返回 DeepSeek-Reasonix
session_lease.go
根目录 / internal / cli / session_lease.go
1 package cli
2
3 import (
4 "fmt"
5 "path/filepath"
6 "strings"
7
8 "reasonix/internal/agent"
9 "reasonix/internal/control"
10 )
11
12 func persistCLIModelSelection(ctrl control.SessionAPI) error {
13 selected, ok := ctrl.(interface {
14 ModelRef() string
15 ModelSelectionIdentity() string
16 })
17 if !ok || selected.ModelSelectionIdentity() == "" || ctrl.SessionPath() == "" {
18 return nil
19 }
20 return agent.SetBranchModelSelectionPreserveUpdated(ctrl.SessionPath(), selected.ModelRef(), selected.ModelSelectionIdentity())
21 }
22
23 // bindAndLoadCLIResume acquires the single-writer lease before reading the
24 // transcript. Loading first leaves a race where the previous writer can append
25 // and release between the read and Rebind, giving the new CLI ownership of a
26 // newer file while its controller resumes an older in-memory snapshot.
27 func bindAndLoadCLIResume(leases *control.SessionLeaseKeeper, path string, load func(string) (*agent.Session, error)) (*agent.Session, error) {
28 if leases != nil {
29 if err := leases.Rebind(path); err != nil {
30 return nil, err
31 }
32 }
33 return load(path)
34 }
35
36 func cliControllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {
37 if ctrl == nil {
38 return false
39 }
40 status := ctrl.RuntimeStatus()
41 return status.Running || status.PendingPrompt || status.BackgroundJobs > 0
42 }
43
44 // sessionLeaseResumeRefusal is the startup-time refusal for `reasonix
45 // [--resume|--continue]` and `reasonix run --resume/--continue`: it names the
46 // holder and offers the two ways out (close the holder, or continue in a
47 // duplicated session via --copy).
48 func sessionLeaseResumeRefusal(err error) string {
49 return control.SessionInUseMessage(err) +
50 "; close the other Reasonix window or process, or rerun with --copy to continue in a duplicated session"
51 }
52
53 // sessionLeaseHeldNotice is the in-TUI refusal for /resume and /switch, where
54 // exiting to rerun with --copy is not the natural move.
55 func sessionLeaseHeldNotice(err error) string {
56 return control.SessionInUseMessage(err) + "; " + control.SessionLeaseCloseHint
57 }
58
59 // rebindSessionLease moves the chat TUI's session lease to path before the
60 // controller binds it for writing. A nil keeper (tests, persistence disabled)
61 // gates nothing. On error the keeper still guards the previous session.
62 func (m *chatTUI) rebindSessionLease(path string) error {
63 if m.leases == nil {
64 return nil
65 }
66 handled := false
67 var err error
68 if m.takeover != nil {
69 handled, err = m.takeover.RebindAway(path)
70 }
71 if err != nil {
72 return err
73 }
74 if !handled {
75 err = m.leases.Rebind(path)
76 }
77 if err != nil {
78 return err
79 }
80 return bindChatTUIAuthority(m)
81 }
82
83 // commitSessionSwitch acquires the target lease before loading its transcript
84 // while retaining the source keeper. This is the ordinary counterpart of
85 // /takeover's targeted transaction and also lets a mirrored CLI leave for a
86 // free session without dropping its source before the candidate is authorized.
87 func (m *chatTUI) commitSessionSwitch(path string) error {
88 return m.commitSessionSwitchWithLoader(path, loadResumableSession)
89 }
90
91 func (m *chatTUI) commitSessionSwitchWithLoader(path string, load func(string) (*agent.Session, error)) error {
92 if m == nil {
93 return fmt.Errorf("resume candidate unavailable")
94 }
95 if validator, ok := m.ctrl.(interface{ ValidateSessionModel(string) error }); ok {
96 if err := validator.ValidateSessionModel(path); err != nil {
97 return err
98 }
99 }
100 binding, err := cliAcquireFreeSession(path, m.leases, m.takeover)
101 if err != nil {
102 return err
103 }
104 loaded, err := load(path)
105 if err != nil {
106 _ = cliReturnFailedTakeover(binding, m.leases, m.takeover)
107 return err
108 }
109 if m.leases != nil {
110 if err := m.leases.BindSessionAuthority(loaded); err != nil {
111 _ = cliReturnFailedTakeover(binding, m.leases, m.takeover)
112 return err
113 }
114 }
115 if err := binding.commitPrevious(m.takeover); err != nil {
116 _ = cliReturnFailedTakeover(binding, m.leases, m.takeover)
117 return err
118 }
119 m.ctrl.Resume(loaded, path)
120 if identity, ok := m.ctrl.(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() && m.leases != nil {
121 // The frozen legacy file is no longer the execution store after a
122 // successful import. Release its compatibility lease immediately.
123 if err := m.leases.Rebind(""); err != nil {
124 return err
125 }
126 }
127 return bindChatTUIAuthority(m)
128 }
129
130 // restoreSessionLease re-points the lease at the controller's current session
131 // after a switch attempt moved it but the switch itself then failed.
132 // Best-effort: the old lease was released during the rebind, so in the
133 // (unlikely) case another runtime grabbed it in between this stays silent and
134 // the next write surfaces the conflict.
135 func (m *chatTUI) restoreSessionLease() {
136 if m.leases == nil {
137 return
138 }
139 _ = m.leases.Rebind(m.ctrl.SessionPath())
140 _ = bindChatTUIAuthority(m)
141 }
142
143 // followSessionLease re-points the TUI's session lease at the controller's
144 // current session file after an operation that rotated it to a fresh path
145 // (/new, /clear, /branch, fork). A fresh path cannot be held by anyone else,
146 // so failure is theoretical — but never silent.
147 func (m *chatTUI) followSessionLease() {
148 if m.leases == nil {
149 return
150 }
151 if err := m.leases.Rebind(m.ctrl.SessionPath()); err != nil {
152 m.notice(sessionLeaseHeldNotice(err))
153 return
154 }
155 if err := bindChatTUIAuthority(m); err != nil {
156 m.notice(fmt.Sprintf("session write authority: %v", err))
157 }
158 }
159
160 // cliSessionRecoveredHandler moves the single-session CLI lease during the
161 // controller's recovery commit. The callback runs before Controller changes its
162 // session path, closing the unguarded interval that event-driven follow-up
163 // calls left after ordinary turn-end and mid-turn autosaves.
164 func cliSessionRecoveredHandler(leases *control.SessionLeaseKeeper) func(control.SessionRecoveryInfo) error {
165 return func(info control.SessionRecoveryInfo) error {
166 if err := leases.HandleSessionRecovered(info); err != nil {
167 return err
168 }
169 // Controller pointer is not available here; TUI followSessionLease and
170 // headless post-Rebind bind authority. Recovery commit rebinds the lease
171 // first; the next Snapshot path match is ensured once Bind runs.
172 return nil
173 }
174 }
175
176 func rebindCLIControllerAuthority(leases *control.SessionLeaseKeeper, ctrl *control.Controller) error {
177 if leases == nil || ctrl == nil {
178 return nil
179 }
180 if err := leases.Rebind(ctrl.SessionPath()); err != nil {
181 return err
182 }
183 return leases.BindControllerAuthority(ctrl)
184 }
185
186 func bindChatTUIAuthority(m *chatTUI) error {
187 if m == nil || m.leases == nil {
188 return nil
189 }
190 c, ok := m.ctrl.(*control.Controller)
191 if !ok || c == nil {
192 return nil
193 }
194 return m.leases.BindControllerAuthority(c)
195 }
196
197 // copySessionForWriting duplicates the session at src into a fresh session
198 // file beside it and returns the new path. It backs the --copy escape hatch:
199 // when src is held by another runtime, the copy gives this process a session
200 // it can own. The duplicate is written through Session.SaveIfAbsent, so it is
201 // event-log aware (authoritative event log plus .jsonl checkpoint), cannot
202 // replace a destination another runtime created, and starts with no
203 // lease/lock sidecars of its own; src is only read. When src is being
204 // written concurrently, the copy captures the transcript as of the load — an
205 // append-only prefix, the same view a resume would see.
206 func copySessionForWriting(src string) (string, error) {
207 loaded, err := loadResumableSession(src)
208 if err != nil {
209 return "", err
210 }
211 msgs := loaded.Snapshot()
212
213 var srcMeta agent.BranchMeta
214 if meta, ok, metaErr := agent.LoadBranchMeta(src); metaErr == nil && ok {
215 srcMeta = meta
216 }
217 label := "session"
218 if model, ok := agent.LoadSessionModel(src); ok && strings.TrimSpace(model) != "" {
219 label = model
220 }
221
222 newPath := agent.NewSessionPath(filepath.Dir(src), label)
223 copySess := agent.NewSession("")
224 copySess.Messages = msgs
225 if err := copySess.SaveIfAbsent(newPath); err != nil {
226 return "", fmt.Errorf("copy session: %w", err)
227 }
228 preview, turns := agent.SessionPreviewFromMessages(msgs)
229 meta := agent.BranchMeta{
230 ParentID: agent.BranchID(src),
231 ForkTurn: -1,
232 ForkMessageIndex: len(msgs),
233 Preview: preview,
234 Turns: turns,
235 SchemaVersion: agent.BranchMetaCountsVersion,
236 Model: srcMeta.Model,
237 ModelIdentity: srcMeta.ModelIdentity,
238 }
239 if title := strings.TrimSpace(firstNonEmpty(srcMeta.CustomTitle, srcMeta.TopicTitle)); title != "" {
240 meta.CustomTitle = title + " (copy)"
241 }
242 if err := agent.SaveBranchMeta(newPath, meta); err != nil {
243 return "", fmt.Errorf("copy session meta: %w", err)
244 }
245 return newPath, nil
246 }
247
247 lines GO