Files
2026-07-13 13:00:08 +08:00

117 lines
3.6 KiB
Go

package control
import (
"errors"
"fmt"
"strings"
"sync"
"reasonix/internal/agent"
)
// SessionLeaseKeeper owns at most one session lease on behalf of a frontend
// that binds session files for writing (the CLI chat/run commands, `reasonix
// serve`, one ACP session). Desktop tabs keep their own per-tab lease
// management; this keeper is the equivalent for the single-session surfaces:
// it follows the active session path across resumes, forks, and fresh-session
// rotations, holding exactly one lease at a time.
//
// The zero value is not ready for use; construct with NewSessionLeaseKeeper.
type SessionLeaseKeeper struct {
mu sync.Mutex
lease *agent.SessionLease
}
func NewSessionLeaseKeeper() *SessionLeaseKeeper {
return &SessionLeaseKeeper{}
}
// Rebind points the keeper at path: it acquires path's session lease and only
// then releases the previously held one, so the outgoing session stays
// protected until the new one is secured. Rebinding to the path already held
// is a no-op; an empty path (session persistence disabled) just releases.
// On failure the keeper is unchanged — the caller still holds its previous
// lease and must not bind path for writing. A held path surfaces as an error
// wrapping agent.ErrSessionLeaseHeld; format it with SessionInUseMessage.
func (k *SessionLeaseKeeper) Rebind(path string) error {
if k == nil {
return nil
}
k.mu.Lock()
defer k.mu.Unlock()
if strings.TrimSpace(path) == "" {
k.releaseLocked()
return nil
}
if k.lease != nil && k.lease.Path() == agent.CanonicalSessionPath(path) {
return nil
}
lease, err := agent.TryAcquireSessionLease(path)
if err != nil {
return err
}
k.releaseLocked()
k.lease = lease
return nil
}
// Release drops the held lease, if any. Idempotent; call it on frontend
// teardown after the controller has finished its final writes.
func (k *SessionLeaseKeeper) Release() {
if k == nil {
return
}
k.mu.Lock()
defer k.mu.Unlock()
k.releaseLocked()
}
// HeldPath reports the canonical session path the keeper currently guards,
// or "" when it holds nothing.
func (k *SessionLeaseKeeper) HeldPath() string {
if k == nil {
return ""
}
k.mu.Lock()
defer k.mu.Unlock()
if k.lease == nil {
return ""
}
return k.lease.Path()
}
func (k *SessionLeaseKeeper) releaseLocked() {
if k.lease != nil {
k.lease.Release()
k.lease = nil
}
}
// SessionLeaseCloseHint is the universal way out of a lease refusal, appended
// by surfaces that have no copy escape hatch (in-TUI switches, serve, ACP).
const SessionLeaseCloseHint = "close the other Reasonix window or process first"
// SessionInUseMessage renders a lease-acquisition failure as the shared
// operator-facing "who is holding this" line used by the CLI, serve, and ACP.
// It names the holder from the lease info when available and degrades to a
// generic line otherwise. The session file path is deliberately omitted — the
// caller already knows which session it asked for.
func SessionInUseMessage(err error) string {
const fallback = "this session is in use by another Reasonix window or process"
var leaseErr *agent.SessionLeaseError
if !errors.As(err, &leaseErr) || leaseErr == nil || leaseErr.Info == nil || leaseErr.Info.PID <= 0 {
return fallback
}
info := leaseErr.Info
var b strings.Builder
fmt.Fprintf(&b, "this session is in use by another Reasonix process (pid %d", info.PID)
if host := strings.TrimSpace(info.Hostname); host != "" {
b.WriteString(" on " + host)
}
if !info.AcquiredAt.IsZero() {
b.WriteString(", since " + info.AcquiredAt.Local().Format("15:04"))
}
b.WriteString(")")
return b.String()
}