返回 DeepSeek-Reasonix
resume.go
根目录 / internal / cli / resume.go
1 package cli
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "path/filepath"
8 "sort"
9 "strconv"
10 "strings"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/control"
14 "reasonix/internal/i18n"
15 "reasonix/internal/session"
16 )
17
18 const resumeListCap = 10
19
20 // resumeEntry is one picker row: a session plus, for cross-project rows, the
21 // project it belongs to. The current directory's sessions keep project empty
22 // so existing labels are unchanged. The target carries whether the row is a
23 // legacy transcript or a final-format session identity.
24 type resumeEntry struct {
25 session agent.SessionInfo
26 project string
27 target cliResumeTarget
28 }
29
30 const resumeOtherProjectsCap = 5
31
32 // resumeEntries lists the current directory's resumable conversations — both
33 // legacy transcripts and final-format catalog rows — then the newest session
34 // of other known projects. A user who worked on this machine over SSH resumes
35 // from any directory, not only the workspace root (#9477), and must see the
36 // same conversations the desktop tree shows after a migration.
37 func resumeEntries(dir string) []resumeEntry {
38 base := mergedResumeEntries(dir, resumeListCap)
39 out := make([]resumeEntry, 0, len(base)+resumeOtherProjectsCap)
40 out = append(out, base...)
41 out = append(out, otherProjectResumeEntries(dir)...)
42 return out
43 }
44
45 func otherProjectResumeEntries(excludeDir string) []resumeEntry {
46 type target struct {
47 path string
48 root string
49 }
50 var targets []target
51 for _, t := range defaultSessionCatalogTargets() {
52 if t.Scope != "project" || t.Path == "" {
53 continue
54 }
55 targets = append(targets, target{path: t.Path, root: t.WorkspaceRoot})
56 }
57 exclude := filepath.Clean(excludeDir)
58 var out []resumeEntry
59 for _, t := range targets {
60 if filepath.Clean(t.path) == exclude {
61 continue
62 }
63 // Cross-project rows are legacy transcripts only: a canonical identity
64 // belongs to another project's session service. Migrated sources stay
65 // hidden so a row always points at a still-live transcript.
66 rows := foreignProjectResumeRows(t.path)
67 if len(rows) == 0 {
68 continue
69 }
70 name := filepath.Base(strings.TrimRight(t.root, string(filepath.Separator)))
71 if name == "" || name == "." {
72 name = t.root
73 }
74 out = append(out, resumeEntry{session: rows[0], project: name, target: cliResumeTarget{path: rows[0].Path}})
75 }
76 sort.SliceStable(out, func(i, j int) bool {
77 return out[i].session.ModTime.After(out[j].session.ModTime)
78 })
79 if len(out) > resumeOtherProjectsCap {
80 out = out[:resumeOtherProjectsCap]
81 }
82 return out
83 }
84
85 // orderResumeSessions keeps conflict-recovery copies next to the session they
86 // came from. Groups remain newest-first, while the newest visible leaf is first
87 // within each group so interactive picker and numbered resume surfaces present
88 // the most likely writable continuation before its ancestors.
89 func orderResumeSessions(sessions []agent.SessionInfo) []agent.SessionInfo {
90 if len(sessions) < 2 {
91 return sessions
92 }
93 byID := make(map[string]agent.SessionInfo, len(sessions))
94 for _, session := range sessions {
95 byID[agent.BranchID(session.Path)] = session
96 }
97 type resumeGroup struct {
98 items []agent.SessionInfo
99 newest int
100 activity int64
101 }
102 groups := make(map[string]*resumeGroup, len(sessions))
103 order := make([]*resumeGroup, 0, len(sessions))
104 for i, session := range sessions {
105 key := recoveryResumeGroupKey(session, byID)
106 group := groups[key]
107 if group == nil {
108 group = &resumeGroup{newest: i}
109 groups[key] = group
110 order = append(order, group)
111 }
112 group.items = append(group.items, session)
113 if stamp := session.ModTime.UnixNano(); stamp > group.activity {
114 group.activity = stamp
115 }
116 }
117 sort.SliceStable(order, func(i, j int) bool {
118 if order[i].activity == order[j].activity {
119 return order[i].newest < order[j].newest
120 }
121 return order[i].activity > order[j].activity
122 })
123
124 out := make([]agent.SessionInfo, 0, len(sessions))
125 for _, group := range order {
126 children := make(map[string]bool, len(group.items))
127 members := make(map[string]bool, len(group.items))
128 for _, session := range group.items {
129 members[agent.BranchID(session.Path)] = true
130 }
131 for _, session := range group.items {
132 parentID := strings.TrimSpace(session.ParentID)
133 if members[parentID] {
134 children[parentID] = true
135 }
136 }
137 sort.SliceStable(group.items, func(i, j int) bool {
138 iLeaf := !children[agent.BranchID(group.items[i].Path)]
139 jLeaf := !children[agent.BranchID(group.items[j].Path)]
140 if iLeaf != jLeaf {
141 return iLeaf
142 }
143 return group.items[i].ModTime.After(group.items[j].ModTime)
144 })
145 out = append(out, group.items...)
146 }
147 return out
148 }
149
150 func recoveryResumeGroupKey(session agent.SessionInfo, byID map[string]agent.SessionInfo) string {
151 id := agent.BranchID(session.Path)
152 if !session.Recovered {
153 return id
154 }
155 seen := map[string]bool{id: true}
156 current := session
157 for {
158 parentID := strings.TrimSpace(current.ParentID)
159 if parentID == "" {
160 return agent.BranchID(current.Path)
161 }
162 if seen[parentID] {
163 return "recovery-cycle:" + parentID
164 }
165 seen[parentID] = true
166 parent, ok := byID[parentID]
167 if !ok {
168 return "recovery-parent:" + parentID
169 }
170 if !parent.Recovered {
171 return parentID
172 }
173 current = parent
174 }
175 }
176
177 // runResumeCommand handles "/resume": with no argument it opens the recent
178 // session picker; "/resume <n>" loads that
179 // session into the running controller in place — keeping the current model and
180 // replaying the transcript into scrollback.
181 func (m *chatTUI) runResumeCommand(input string) {
182 args := tokenizeArgs(input) // args[0] == "/resume"
183 if len(args) < 2 {
184 m.openResumePicker()
185 return
186 }
187 // Never run recovery GC between displaying a numeric index and resolving it
188 // here: dropping an earlier row would silently retarget the number the user
189 // already picked. Bare /resume and startup do that cleanup instead.
190 entries := resumeEntries(m.ctrl.SessionDir())
191 if len(entries) == 0 {
192 m.notice(i18n.M.NoSessionToResume)
193 return
194 }
195 if m.ctrl.Running() {
196 m.notice(i18n.M.ResumeBusy)
197 return
198 }
199 idx, err := strconv.Atoi(strings.TrimSpace(args[1]))
200 if err != nil || idx < 1 || idx > len(entries) {
201 m.notice(fmt.Sprintf(i18n.M.ResumeBadIndexFmt, len(entries)))
202 return
203 }
204 target := entries[idx-1]
205 if resumeEntryIsActive(m.ctrl, target) {
206 m.notice(i18n.M.ResumeAlreadyActive)
207 return
208 }
209 detached := m.sessionReclaimed || m.takeover != nil && m.takeover.Returned()
210 if !detached {
211 // Persist the conversation we're leaving so switching back later restores it.
212 // Snapshot before moving the lease: the outgoing session must be written
213 // while this process still owns it.
214 if err := m.ctrl.Snapshot(); err != nil {
215 m.notice("resume: snapshot current session: " + err.Error())
216 return
217 }
218 m.followSessionLease()
219 }
220 if target.target.canonical() {
221 if err := m.commitCanonicalSessionSwitch(target.target.ref); err != nil {
222 if !detached {
223 m.restoreSessionLease()
224 }
225 if errors.Is(err, session.ErrWriterOwned) {
226 m.pendingTakeoverPath = cliCanonicalRoute(target.target.ref.SessionID)
227 m.notice("resume: " + sessionWriterHeldNotice())
228 m.notice("run /takeover to take this session over")
229 return
230 }
231 m.notice("resume: " + err.Error())
232 return
233 }
234 } else if err := m.commitSessionSwitch(target.session.Path); err != nil {
235 m.notice("resume: " + sessionLeaseHeldNotice(err))
236 if cliSessionTakeoverCandidate(err) {
237 m.pendingTakeoverPath = target.session.Path
238 m.notice("run /takeover to take this session over")
239 }
240 return
241 }
242 m.resumeAfterReclaim()
243 m.replayActiveBranch(i18n.M.ResumedTitle)
244 }
245
246 // resumeEntryIsActive reports whether a picker row is the controller's current
247 // conversation. Canonical rows carry no live path, so they compare session
248 // identities instead of transcript files.
249 func resumeEntryIsActive(ctrl control.SessionAPI, entry resumeEntry) bool {
250 if entry.target.canonical() {
251 if identity, ok := ctrl.(control.IdentityLifecycle); ok {
252 if ref, bound := identity.SessionRef(); bound && ref == entry.target.ref {
253 return true
254 }
255 }
256 return false
257 }
258 return entry.session.Path == ctrl.SessionPath()
259 }
260
261 // commitCanonicalSessionSwitch attaches the controller to an existing
262 // final-format session identity. Writer ownership is enforced by the session
263 // service's directory lease, so unlike a legacy switch there is no transcript
264 // path lease to move: the outgoing legacy lease is released and authority
265 // follows the controller binding. The order matches the legacy switch: secure
266 // the target, return the mirror of the session being left, then publish.
267 func (m *chatTUI) commitCanonicalSessionSwitch(ref session.SessionRef) error {
268 identity, ok := m.ctrl.(control.IdentityLifecycle)
269 if !ok || !identity.UsesExclusiveSession() {
270 return errors.New("final-format session resume requires the session engine")
271 }
272 service := identity.SessionService()
273 if service == nil {
274 return errors.New("session service unavailable")
275 }
276 ctx := context.Background()
277 // The probe grant secures the target writer before anything changes hands,
278 // so a held target (ErrWriterOwned) leaves this session, its lease and its
279 // mirror untouched. A retired codec has no writer; OpenSession upgrades it.
280 probe, err := service.Open(ctx, ref)
281 if err != nil && !errors.Is(err, session.ErrUnsupportedVersion) {
282 return err
283 }
284 if m.takeover != nil {
285 if err := m.takeover.leaveMirror("", nil); err != nil {
286 _ = probe.Release(ctx)
287 return err
288 }
289 }
290 if _, err := identity.OpenSession(ctx, ref); err != nil {
291 _ = probe.Release(ctx)
292 return err
293 }
294 // The controller now holds its own grant on the runtime; the probe's
295 // release cannot retire it.
296 if err := probe.Release(ctx); err != nil {
297 return fmt.Errorf("release session probe grant: %w", err)
298 }
299 if m.leases != nil {
300 if err := m.leases.Rebind(""); err != nil {
301 return err
302 }
303 }
304 return bindChatTUIAuthority(m)
305 }
306
307 // runTakeoverCommand handles "/takeover": it force-takes the last refused
308 // resume target (or an explicit index/path argument) from the resident serve
309 // on this machine, then resumes it.
310 func (m *chatTUI) runTakeoverCommand(input string) {
311 m.echoLocalCommand(input)
312 args := tokenizeArgs(input) // args[0] == "/takeover"
313 target := strings.TrimSpace(m.pendingTakeoverPath)
314 if len(args) >= 2 {
315 target = strings.TrimSpace(args[1])
316 if idx, err := strconv.Atoi(target); err == nil {
317 entries := resumeEntries(m.ctrl.SessionDir())
318 if idx < 1 || idx > len(entries) {
319 m.notice(fmt.Sprintf(i18n.M.ResumeBadIndexFmt, len(entries)))
320 return
321 }
322 picked := entries[idx-1]
323 if picked.target.canonical() {
324 target = cliCanonicalRoute(picked.target.ref.SessionID)
325 } else {
326 target = picked.session.Path
327 }
328 }
329 }
330 if target == "" && m.sessionReclaimed {
331 // The reclaim notice promises "/takeover takes it back": the session
332 // the desktop re-claimed is remembered in reclaimedTarget, not in
333 // pendingTakeoverPath (which only a refused resume populates).
334 if m.reclaimedTarget.canonical() {
335 target = cliCanonicalRoute(m.reclaimedTarget.ref.SessionID)
336 } else {
337 target = m.reclaimedTarget.path
338 }
339 }
340 if target == "" {
341 m.notice("takeover: no refused session; run /resume <n> first or pass an index")
342 return
343 }
344 if isCLICanonicalRoute(target) {
345 m.runCanonicalTakeoverCommand(target)
346 return
347 }
348 if m.ctrl.Running() {
349 m.notice(i18n.M.ResumeBusy)
350 return
351 }
352 _, err := loadResumableSession(target)
353 if err != nil {
354 m.notice("takeover: " + err.Error())
355 return
356 }
357 // A reclaimed session's runtime is already released; snapshotting it
358 // would fail and there is no lease of ours to follow.
359 detached := m.sessionDetached()
360 if !detached {
361 if err := m.ctrl.Snapshot(); err != nil {
362 m.notice("takeover: snapshot current session: " + err.Error())
363 return
364 }
365 m.followSessionLease()
366 }
367 binding, bindErr := cliAcquireFreeSession(target, m.leases, m.takeover)
368 if bindErr != nil {
369 if !cliSessionTakeoverCandidate(bindErr) {
370 m.notice("takeover: " + sessionLeaseHeldNotice(bindErr))
371 return
372 }
373 m.notice("taking the session over from the resident serve…")
374 binding, err = cliTakeoverHeldSession(target, bindErr, m.leases, m.takeover)
375 if err != nil {
376 m.notice("takeover: " + err.Error())
377 return
378 }
379 }
380 loaded, err := cliPrepareTakeoverCandidate(binding, m.leases)
381 if err != nil {
382 _ = cliReturnFailedTakeover(binding, m.leases, m.takeover)
383 m.notice("takeover: " + err.Error())
384 return
385 }
386 if err := binding.commitPrevious(m.takeover); err != nil {
387 _ = cliReturnFailedTakeover(binding, m.leases, m.takeover)
388 m.notice("takeover: " + err.Error())
389 return
390 }
391 m.ctrl.Resume(loaded, target)
392 if err := bindChatTUIAuthority(m); err != nil {
393 m.notice("takeover: " + err.Error())
394 return
395 }
396 m.pendingTakeoverPath = ""
397 m.resumeAfterReclaim()
398 m.replayActiveBranch(i18n.M.ResumedTitle)
399 if m.takeover != nil && binding.grant.MirrorID != "" {
400 m.takeover.AttachController(m.ctrl)
401 m.takeover.Activate(binding)
402 m.notice("session taken over; the remote side is now read-only and can take it back")
403 return
404 }
405 m.notice("session resumed; no other runtime held it")
406 }
407
408 // resumeArgItems completes the index argument of "/resume <n>": once past the
409 // command word it lists recent sessions, inserting the 1-based index and
410 // showing timestamp + turn count + preview as the hint. Indices match
411 // the picker because both window through recentSessions.
412 func (m *chatTUI) resumeArgItems(val string) ([]compItem, int, bool) {
413 cmdEnd := strings.IndexAny(val, " \t")
414 if cmdEnd < 0 || val[:cmdEnd] != "/resume" {
415 return nil, 0, false
416 }
417 from := strings.LastIndexAny(val, " \t") + 1
418 if len(strings.Fields(val[:from])) != 1 || m.ctrl == nil {
419 return nil, from, true
420 }
421 cur := val[from:]
422 var out []compItem
423 for i, entry := range resumeEntries(m.ctrl.SessionDir()) {
424 idx := strconv.Itoa(i + 1)
425 if cur != "" && !strings.HasPrefix(idx, cur) {
426 continue
427 }
428 hint := fmt.Sprintf("%s · %s", entry.session.ModTime.Local().Format("01-02 15:04"), sessionSummary(entry.session))
429 if entry.project != "" {
430 hint = fmt.Sprintf("[%s] %s", entry.project, hint)
431 }
432 out = append(out, compItem{label: idx, insert: idx, hint: hint})
433 }
434 return out, from, true
435 }
436
437 // sessionSummary is the "N turns · display title" line shared by the /resume
438 // list and its argument completion. Explicit session renames win, then topic
439 // titles, then the raw preview so the user can identify sessions at a glance.
440 func sessionSummary(s agent.SessionInfo) string {
441 preview := s.CustomTitle
442 if preview == "" {
443 preview = s.TopicTitle
444 }
445 if preview == "" {
446 preview = s.Preview
447 }
448 if preview == "" {
449 preview = "(no user message yet)"
450 }
451 return recoverySessionBadge(s) + fmt.Sprintf("%d turns · %s", s.Turns, preview)
452 }
453
454 func recoverySessionBadge(s agent.SessionInfo) string {
455 if !s.Recovered {
456 return ""
457 }
458 parent := strings.TrimSpace(s.ParentID)
459 if len(parent) > 8 {
460 parent = parent[:8]
461 }
462 if parent == "" {
463 parent = "?"
464 }
465 return fmt.Sprintf(i18n.M.ResumeRecoveryBadgeFmt, parent) + " "
466 }
467
467 lines GO