返回 DeepSeek-Reasonix
session_lease_keeper.go
根目录 / internal / control / session_lease_keeper.go
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
141 lines GO