| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "log/slog" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | |
| 10 | "reasonix/internal/agent" |
| 11 | ) |
| 12 | |
| 13 | // SessionLeaseKeeper owns at most one session lease on behalf of a frontend |
| 14 | // that binds session files for writing (the CLI chat/run commands, `reasonix |
| 15 | // serve`, one ACP session). Desktop tabs keep their own per-tab lease |
| 16 | // management; this keeper is the equivalent for the single-session surfaces: |
| 17 | // it follows the active session path across resumes, forks, and fresh-session |
| 18 | // rotations, holding exactly one lease at a time. |
| 19 | // |
| 20 | // The zero value is not ready for use; construct with NewSessionLeaseKeeper. |
| 21 | type SessionLeaseKeeper struct { |
| 22 | mu sync.Mutex |
| 23 | lease *agent.SessionLease |
| 24 | } |
| 25 | |
| 26 | func NewSessionLeaseKeeper() *SessionLeaseKeeper { |
| 27 | return &SessionLeaseKeeper{} |
| 28 | } |
| 29 | |
| 30 | // Rebind points the keeper at path: it acquires path's session lease and only |
| 31 | // then releases the previously held one, so the outgoing session stays |
| 32 | // protected until the new one is secured. Rebinding to the path already held |
| 33 | // is a no-op; an empty path (session persistence disabled) just releases. |
| 34 | // On failure the keeper is unchanged — the caller still holds its previous |
| 35 | // lease and must not bind path for writing. A held path surfaces as an error |
| 36 | // wrapping agent.ErrSessionLeaseHeld; format it with SessionInUseMessage. |
| 37 | func (k *SessionLeaseKeeper) Rebind(path string) error { |
| 38 | if k == nil { |
| 39 | return nil |
| 40 | } |
| 41 | k.mu.Lock() |
| 42 | defer k.mu.Unlock() |
| 43 | if strings.TrimSpace(path) == "" { |
| 44 | k.releaseLocked() |
| 45 | return nil |
| 46 | } |
| 47 | if k.lease != nil && k.lease.Path() == agent.CanonicalSessionPath(path) { |
| 48 | return nil |
| 49 | } |
| 50 | lease, err := agent.TryAcquireSessionLease(path) |
| 51 | if err != nil { |
| 52 | return err |
| 53 | } |
| 54 | k.releaseLocked() |
| 55 | k.lease = lease |
| 56 | return nil |
| 57 | } |
| 58 | |
| 59 | // HandleSessionRecovered moves the single-session frontend lease before a |
| 60 | // controller commits to a recovery branch. It is suitable for |
| 61 | // Options.OnSessionRecovered in CLI chat/run/serve surfaces. Rebind acquires the |
| 62 | // recovery path before releasing the original lease, so a failed handoff keeps |
| 63 | // the previous session protected. |
| 64 | func (k *SessionLeaseKeeper) HandleSessionRecovered(info SessionRecoveryInfo) error { |
| 65 | recoveryPath := strings.TrimSpace(info.RecoveryPath) |
| 66 | if k == nil || recoveryPath == "" { |
| 67 | return nil |
| 68 | } |
| 69 | if err := k.Rebind(recoveryPath); err != nil { |
| 70 | if errors.Is(err, agent.ErrSessionLeaseHeld) { |
| 71 | return fmt.Errorf("bind recovery session: %s; %s", |
| 72 | SessionInUseMessage(err), SessionLeaseCloseHint) |
| 73 | } |
| 74 | // The detailed error can contain a machine-local path. Keep it in |
| 75 | // diagnostics and return path-free text to every frontend. |
| 76 | slog.Error("control: bind recovery session lease", "err", err) |
| 77 | return fmt.Errorf("bind recovery session: unable to secure recovered transcript") |
| 78 | } |
| 79 | return nil |
| 80 | } |
| 81 | |
| 82 | // Release drops the held lease, if any. Idempotent; call it on frontend |
| 83 | // teardown after the controller has finished its final writes. |
| 84 | func (k *SessionLeaseKeeper) Release() { |
| 85 | if k == nil { |
| 86 | return |
| 87 | } |
| 88 | k.mu.Lock() |
| 89 | defer k.mu.Unlock() |
| 90 | k.releaseLocked() |
| 91 | } |
| 92 | |
| 93 | // HeldPath reports the canonical session path the keeper currently guards, |
| 94 | // or "" when it holds nothing. |
| 95 | func (k *SessionLeaseKeeper) HeldPath() string { |
| 96 | if k == nil { |
| 97 | return "" |
| 98 | } |
| 99 | k.mu.Lock() |
| 100 | defer k.mu.Unlock() |
| 101 | if k.lease == nil { |
| 102 | return "" |
| 103 | } |
| 104 | return k.lease.Path() |
| 105 | } |
| 106 | |
| 107 | func (k *SessionLeaseKeeper) releaseLocked() { |
| 108 | if k.lease != nil { |
| 109 | k.lease.Release() |
| 110 | k.lease = nil |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | // SessionLeaseCloseHint is the universal way out of a lease refusal, appended |
| 115 | // by surfaces that have no copy escape hatch (in-TUI switches, serve, ACP). |
| 116 | const SessionLeaseCloseHint = "close the other Reasonix window or process first" |
| 117 | |
| 118 | // SessionInUseMessage renders a lease-acquisition failure as the shared |
| 119 | // operator-facing "who is holding this" line used by the CLI, serve, and ACP. |
| 120 | // It names the holder from the lease info when available and degrades to a |
| 121 | // generic line otherwise. The session file path is deliberately omitted — the |
| 122 | // caller already knows which session it asked for. |
| 123 | func SessionInUseMessage(err error) string { |
| 124 | const fallback = "this session is in use by another Reasonix window or process" |
| 125 | var leaseErr *agent.SessionLeaseError |
| 126 | if !errors.As(err, &leaseErr) || leaseErr == nil || leaseErr.Info == nil || leaseErr.Info.PID <= 0 { |
| 127 | return fallback |
| 128 | } |
| 129 | info := leaseErr.Info |
| 130 | var b strings.Builder |
| 131 | fmt.Fprintf(&b, "this session is in use by another Reasonix process (pid %d", info.PID) |
| 132 | if host := strings.TrimSpace(info.Hostname); host != "" { |
| 133 | b.WriteString(" on " + host) |
| 134 | } |
| 135 | if !info.AcquiredAt.IsZero() { |
| 136 | b.WriteString(", since " + info.AcquiredAt.Local().Format("15:04")) |
| 137 | } |
| 138 | b.WriteString(")") |
| 139 | return b.String() |
| 140 | } |
| 141 |