返回 DeepSeek-Reasonix
recovery_lineage.go
根目录 / desktop / recovery_lineage.go
1 package main
2
3 import (
4 "errors"
5 "path/filepath"
6
7 "reasonix/internal/agent"
8 "reasonix/internal/control"
9 "reasonix/internal/sessioncatalog"
10 )
11
12 type RecoveryLineageMember struct {
13 Path string `json:"path"`
14 VersionKind string `json:"versionKind,omitempty"`
15 VersionState string `json:"versionState,omitempty"`
16 ParentVersionID string `json:"parentVersionId,omitempty"`
17 Role string `json:"role"`
18 Canonical bool `json:"canonical"`
19 Turns int `json:"turns"`
20 Open bool `json:"open"`
21 Running bool `json:"running"`
22 VersionNote string `json:"versionNote,omitempty"`
23 Preview string `json:"preview,omitempty"`
24 CreatedAt int64 `json:"createdAt,omitempty"`
25 LastActivityAt int64 `json:"lastActivityAt,omitempty"`
26 // Head fields are set when the version is a head inside one schema-2 log.
27 HeadID string `json:"headId,omitempty"`
28 HeadKind string `json:"headKind,omitempty"`
29 HeadName string `json:"headName,omitempty"`
30 Selected bool `json:"selected,omitempty"`
31 }
32
33 type RecoveryLineageView struct {
34 GroupID string `json:"groupId"`
35 State string `json:"state"`
36 BranchCount int `json:"branchCount"`
37 Unresolved int `json:"unresolved"`
38 CleanupEligible int `json:"cleanupEligible"`
39 Members []RecoveryLineageMember `json:"members"`
40 }
41
42 type SessionVersionStateView struct {
43 ConversationID string `json:"conversationId,omitempty"`
44 ActiveVersionID string `json:"activeVersionId,omitempty"`
45 ActivePath string `json:"activePath,omitempty"`
46 RecoveryVersionID string `json:"recoveryVersionId,omitempty"`
47 CanContinue bool `json:"canContinue"`
48 RequiresChoice bool `json:"requiresChoice"`
49 Lineage RecoveryLineageView `json:"lineage"`
50 }
51
52 // GetSessionVersionState exposes the logical conversation and its physical
53 // recovery versions without making the physical paths ordinary sessions.
54 func (a *App) GetSessionVersionState(key ProjectTopicKey) SessionVersionStateView {
55 view := a.GetRecoveryLineage(key)
56 if view.Members == nil {
57 view.Members = []RecoveryLineageMember{}
58 }
59 out := SessionVersionStateView{Lineage: view, CanContinue: true}
60 out.ConversationID = key.TopicID
61 for _, member := range view.Members {
62 if member.Canonical {
63 out.ActivePath = member.Path
64 out.ActiveVersionID = agent.BranchID(member.Path)
65 if member.HeadID != "" {
66 out.ActiveVersionID = member.HeadID
67 }
68 break
69 }
70 }
71 if key.Path != "" {
72 out.ActivePath = key.Path
73 if view.State != sessionHeadLineageState {
74 out.ActiveVersionID = agent.BranchID(key.Path)
75 }
76 }
77 out.RequiresChoice = view.State == "diverged" && view.Unresolved > 0
78 if out.ActivePath != "" {
79 for _, member := range view.Members {
80 if sameRecoveryLineagePath(member.Path, out.ActivePath) && member.Role == sessioncatalog.RecoveryRoleDiverged {
81 out.RecoveryVersionID = agent.BranchID(member.Path)
82 }
83 }
84 }
85 return out
86 }
87
88 // ReconcileRecoveryVersions refreshes one logical conversation and applies the
89 // existing covered-copy sweep. It is idempotent and keeps diverged content.
90 func (a *App) ReconcileRecoveryVersions(key ProjectTopicKey) error {
91 catalog := a.sessionCatalog.Load()
92 if catalog == nil {
93 return errors.New("session catalog is unavailable")
94 }
95 topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID})
96 if err != nil || !ok {
97 return errors.New("session version lineage is unavailable")
98 }
99 _, dir, ok := recoveryLineageSelection(topic, key.Path)
100 if !ok {
101 return nil
102 }
103 target := sessioncatalog.DirectoryTarget{Path: dir, Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot}
104 if err := catalog.ReconcileDirectory(a.bootContext(), target); err != nil {
105 return err
106 }
107 a.sweepExcessRecoveryCopies(catalog, target)
108 a.emitProjectTreeChangedForSessionDirs(dir)
109 return nil
110 }
111
112 // SetActiveSessionVersion selects and opens a recovery version on the existing
113 // topic tab. It rejects subagent transcripts and preserves the logical topic.
114 func (a *App) SetActiveSessionVersion(req RecoveryPreferenceRequest) error {
115 a.sessionVersionActivationMu.Lock()
116 defer a.sessionVersionActivationMu.Unlock()
117 if req.HeadID != "" {
118 if err := a.chooseSessionHead(req); err != nil {
119 return err
120 }
121 a.emitRuntimeEvent("session:active-version-changed", sessionRecoveryEvent{
122 ConversationID: req.TopicID, ActiveVersionID: req.HeadID, RecoveryVersionID: req.HeadID,
123 Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, TopicID: req.TopicID, CanContinue: true,
124 })
125 return nil
126 }
127 meta, ok, err := agent.LoadBranchMeta(req.Path)
128 if err != nil || !ok {
129 return errors.New("session version is unavailable")
130 }
131 if meta.EffectiveVersionKind() == agent.VersionSubagent {
132 return errors.New("subagent transcripts cannot become the active conversation version")
133 }
134 catalog := a.sessionCatalog.Load()
135 if catalog == nil {
136 return errors.New("session catalog is unavailable")
137 }
138 topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, TopicID: req.TopicID})
139 if err != nil || !ok {
140 return errors.New("recovery lineage is unavailable")
141 }
142 groupID, _, ok := recoveryLineageSelection(topic, req.Path)
143 if !ok || groupID == "" {
144 return errors.New("selected version is outside the recovery lineage")
145 }
146 memberFound := false
147 for _, member := range topic.Sessions {
148 if recoveryRecordBelongsToGroup(member, groupID) && sameRecoveryLineagePath(member.Path, req.Path) && member.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
149 memberFound = true
150 break
151 }
152 }
153 if !memberFound {
154 return errors.New("selected version is outside the recovery lineage")
155 }
156 a.mu.RLock()
157 var tabID string
158 for _, tab := range a.runtimeTabsLocked() {
159 if tab == nil || tab.TopicID != req.TopicID || tab.Scope != req.Scope ||
160 (req.Scope == "project" && tab.WorkspaceRoot != req.WorkspaceRoot) {
161 continue
162 }
163 tabID = tab.ID
164 break
165 }
166 a.mu.RUnlock()
167 if tabID != "" {
168 if _, err := a.ResumeSessionForTab(tabID, req.Path); err != nil {
169 return err
170 }
171 }
172 if err := a.ChooseRecoveryBranch(req); err != nil {
173 return err
174 }
175 a.emitRuntimeEvent("session:active-version-changed", sessionRecoveryEvent{
176 ConversationID: req.TopicID, ActiveVersionID: agent.BranchID(req.Path),
177 RecoveryVersionID: agent.BranchID(req.Path), Scope: req.Scope,
178 WorkspaceRoot: req.WorkspaceRoot, TopicID: req.TopicID,
179 CanContinue: true, RequiresChoice: false,
180 })
181 return nil
182 }
183
184 // RetrySessionRecovery re-arms a pending recovery version after its lease
185 // owner has gone away, then routes through the same validated activation path.
186 func (a *App) RetrySessionRecovery(req RecoveryPreferenceRequest) error {
187 meta, ok, err := agent.LoadBranchMeta(req.Path)
188 if err != nil || !ok || meta.EffectiveVersionKind() != agent.VersionRecovery {
189 return errors.New("session recovery version is unavailable")
190 }
191 if err := agent.UpdateBranchMeta(req.Path, false, func(next *agent.BranchMeta) error {
192 next.VersionKind = agent.VersionRecovery
193 next.VersionState = agent.VersionActive
194 return nil
195 }); err != nil {
196 return err
197 }
198 if err := a.SetActiveSessionVersion(req); err != nil {
199 _ = agent.UpdateBranchMeta(req.Path, false, func(next *agent.BranchMeta) error {
200 next.VersionState = agent.VersionPending
201 return nil
202 })
203 return err
204 }
205 return nil
206 }
207
208 type RecoveryCleanupRequest struct {
209 Scope string `json:"scope"`
210 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
211 TopicID string `json:"topicId"`
212 Apply bool `json:"apply"`
213 }
214
215 type RecoveryPreferenceRequest struct {
216 Scope string `json:"scope"`
217 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
218 TopicID string `json:"topicId"`
219 Path string `json:"path"`
220 HeadID string `json:"headId,omitempty"` // a head of the schema-2 log at Path
221 }
222
223 type RecoveryCleanupItem struct {
224 Path string `json:"path"`
225 HeadID string `json:"headId,omitempty"`
226 Status string `json:"status"`
227 Error string `json:"error,omitempty"`
228 }
229
230 type RecoveryCleanupResult struct {
231 Eligible int `json:"eligible"`
232 Moved int `json:"moved"`
233 Busy int `json:"busy"`
234 Kept int `json:"kept"`
235 DryRun bool `json:"dryRun"`
236 Items []RecoveryCleanupItem `json:"items"`
237 }
238
239 // GetRecoveryLineage lists a conversation's versions: the heads of its
240 // schema-2 log, the schema-1 recovery copies of its lineage, or both for a
241 // family whose root was upgraded after copies had been made.
242 func (a *App) GetRecoveryLineage(key ProjectTopicKey) RecoveryLineageView {
243 out := RecoveryLineageView{Members: []RecoveryLineageMember{}}
244 if a.catalogRebuilding.Load() {
245 out.State = "repairing"
246 return out
247 }
248 catalog := a.sessionCatalog.Load()
249 if catalog == nil {
250 return out
251 }
252 topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: key.Scope, WorkspaceRoot: key.WorkspaceRoot, TopicID: key.TopicID})
253 if err != nil || !ok {
254 return out
255 }
256 out = a.fileRecoveryLineage(catalog, topic, key.Path)
257 if heads, ok := a.sessionHeadLineage(topic, key.Path); ok {
258 out = mergeHeadLineage(out, heads)
259 }
260 if key.RecordClassification {
261 recordRecoveryLineageClassification(key.Path, out)
262 }
263 return out
264 }
265
266 func (a *App) fileRecoveryLineage(catalog *sessioncatalog.Catalog, topic sessioncatalog.TopicRecord, selectedPath string) RecoveryLineageView {
267 out := RecoveryLineageView{Members: []RecoveryLineageMember{}}
268 groupID, directory, ok := recoveryLineageSelection(topic, selectedPath)
269 if !ok {
270 return out
271 }
272 groups, err := catalog.ListRecoveryGroups(a.bootContext(), directory)
273 if err != nil {
274 return out
275 }
276 groupFound := false
277 for _, group := range groups {
278 if group.ID == groupID {
279 out.State = group.State
280 groupFound = true
281 break
282 }
283 }
284 if !groupFound {
285 return RecoveryLineageView{Members: []RecoveryLineageMember{}}
286 }
287 out.GroupID = groupID
288 _, overlays := a.catalogRuntimeOverlays()
289 representativeInGroup := false
290 for _, record := range topic.Sessions {
291 if recoveryRecordBelongsToGroup(record, groupID) && sameRecoveryLineagePath(record.Path, topic.RepresentativePath) {
292 representativeInGroup = true
293 break
294 }
295 }
296 for _, record := range topic.Sessions {
297 if !recoveryRecordBelongsToGroup(record, groupID) {
298 continue
299 }
300 overlay := overlays[sessionRuntimeKey(record.Path)]
301 versionNote := record.CustomTitle
302 versionKind := "recovery"
303 versionState := "active"
304 parentVersionID := record.ParentID
305 if meta, ok, err := agent.LoadBranchMeta(record.Path); err == nil && ok {
306 versionNote = meta.CustomTitle
307 versionKind = string(meta.EffectiveVersionKind())
308 versionState = string(meta.EffectiveVersionState())
309 parentVersionID = meta.ParentVersionID
310 }
311 canonical := record.RecoveryCanonical
312 if representativeInGroup {
313 canonical = sameRecoveryLineagePath(record.Path, topic.RepresentativePath)
314 }
315 out.Members = append(out.Members, RecoveryLineageMember{
316 Path: record.Path, VersionKind: versionKind, VersionState: versionState,
317 ParentVersionID: parentVersionID, Role: record.RecoveryRole, Canonical: canonical,
318 Turns: record.Turns, Open: overlay.open, Running: overlay.running,
319 VersionNote: versionNote, Preview: record.Preview,
320 CreatedAt: record.CreatedAt, LastActivityAt: record.LastActivityAt,
321 })
322 out.BranchCount++
323 if record.RecoveryRole == sessioncatalog.RecoveryRoleDiverged {
324 out.Unresolved++
325 }
326 if record.RecoveryRole == sessioncatalog.RecoveryRoleCoveredCopy {
327 out.CleanupEligible++
328 }
329 }
330 if out.State == "" {
331 out.State = topic.RecoveryState
332 }
333 if out.State == "preferred" {
334 out.Unresolved = 0
335 }
336 // The lower-level group API historically calls an all-covered lineage
337 // "repairing". Expose its stable state so event consumers can clear pending
338 // recovery notifications without polling forever.
339 if recoveryLineageIsCovered(out) {
340 out.State = "covered"
341 }
342 return out
343 }
344
345 func recordRecoveryLineageClassification(selectedPath string, view RecoveryLineageView) {
346 outcome := ""
347 switch view.State {
348 case "covered", "adopted", "preferred", "diverged":
349 outcome = "classified_" + view.State
350 default:
351 return
352 }
353 path := ""
354 for _, member := range view.Members {
355 if sameRecoveryLineagePath(member.Path, selectedPath) {
356 path = member.Path
357 break
358 }
359 if path == "" || member.Canonical {
360 path = member.Path
361 }
362 }
363 control.RecordRecoveryLifecycle(path, outcome)
364 }
365
366 func recoveryLineageSelection(topic sessioncatalog.TopicRecord, selectedPath string) (string, string, bool) {
367 if sessioncatalog.PathIdentityKey(selectedPath) != "" {
368 for _, record := range topic.Sessions {
369 if !sameRecoveryLineagePath(record.Path, selectedPath) {
370 continue
371 }
372 groupID := record.RecoveryGroupID
373 if !record.Recovered {
374 groupID = agent.BranchID(record.Path)
375 }
376 if groupID != "" && recoveryTopicHasGroup(topic, groupID) {
377 return groupID, filepath.Dir(record.Path), true
378 }
379 return "", "", false
380 }
381 return "", "", false
382 }
383
384 groupID, directory := "", ""
385 for _, record := range topic.Sessions {
386 if !record.Recovered || record.RecoveryGroupID == "" {
387 continue
388 }
389 if groupID != "" && groupID != record.RecoveryGroupID {
390 // An older frontend cannot safely choose between multiple groups.
391 return "", "", false
392 }
393 groupID, directory = record.RecoveryGroupID, filepath.Dir(record.Path)
394 }
395 return groupID, directory, groupID != "" && directory != ""
396 }
397
398 func sameRecoveryLineagePath(left, right string) bool {
399 leftKey := sessioncatalog.PathIdentityKey(left)
400 return leftKey != "" && leftKey == sessioncatalog.PathIdentityKey(right)
401 }
402
403 func recoveryTopicHasGroup(topic sessioncatalog.TopicRecord, groupID string) bool {
404 for _, record := range topic.Sessions {
405 if record.Recovered && record.RecoveryGroupID == groupID {
406 return true
407 }
408 }
409 return false
410 }
411
412 func recoveryRecordBelongsToGroup(record sessioncatalog.SessionRecord, groupID string) bool {
413 if record.Recovered {
414 return record.RecoveryGroupID == groupID
415 }
416 return agent.BranchID(record.Path) == groupID
417 }
418
419 func recoveryLineageIsCovered(view RecoveryLineageView) bool {
420 if view.State != "repairing" || view.CleanupEligible == 0 {
421 return false
422 }
423 for _, member := range view.Members {
424 if member.Role != sessioncatalog.RecoveryRoleNormal && member.Role != sessioncatalog.RecoveryRoleCoveredCopy {
425 return false
426 }
427 }
428 return true
429 }
430
431 // ChooseRecoveryBranch changes only the default open target. Diverged content
432 // remains on disk and is never made cleanup-eligible by this choice.
433 func (a *App) ChooseRecoveryBranch(req RecoveryPreferenceRequest) error {
434 if req.HeadID != "" {
435 return a.chooseSessionHead(req)
436 }
437 catalog := a.sessionCatalog.Load()
438 if catalog == nil {
439 return errors.New("session catalog is unavailable")
440 }
441 topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, TopicID: req.TopicID})
442 if err != nil || !ok {
443 return errors.New("recovery lineage is unavailable")
444 }
445 groupID, dir, ok := recoveryLineageSelection(topic, req.Path)
446 if !ok {
447 return errors.New("selected branch is outside the recovery lineage")
448 }
449 groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir)
450 if err != nil {
451 return errors.New("recovery lineage is unavailable")
452 }
453 paths := []string{}
454 chosen := ""
455 foundGroup := false
456 for _, group := range groups {
457 if group.ID == groupID {
458 foundGroup = true
459 break
460 }
461 }
462 if !foundGroup {
463 return errors.New("recovery lineage is unavailable")
464 }
465 for _, member := range topic.Sessions {
466 if !recoveryRecordBelongsToGroup(member, groupID) {
467 continue
468 }
469 paths = append(paths, member.Path)
470 if sameRecoveryLineagePath(member.Path, req.Path) && member.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
471 chosen = member.Path
472 }
473 }
474 if chosen == "" {
475 return errors.New("selected branch is outside the recovery lineage")
476 }
477 if err := func() error {
478 defer a.lockRuntimeMutation("choose-recovery-branch")()
479 a.sessionRemovalMu.Lock()
480 defer a.sessionRemovalMu.Unlock()
481 return agent.SetRecoveryPreferred(paths, chosen)
482 }(); err != nil {
483 return errors.New("could not save the recovery branch choice")
484 }
485 // The rescan reads session files and rewrites only the catalog projection,
486 // so it needs neither barrier; only the preference write above must stay
487 // atomic with respect to session removal.
488 if err := catalog.ReconcileDirectory(a.bootContext(), sessioncatalog.DirectoryTarget{Path: dir, Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot}); err != nil {
489 return errors.New("the branch choice was saved but the session catalog could not refresh")
490 }
491 a.emitProjectTreeChangedForSessionDirs(dir)
492 return nil
493 }
494
495 // CleanRecoveryLineage performs one backend-owned, revalidated cleanup batch.
496 // It never purges and never moves diverged content.
497 func (a *App) CleanRecoveryLineage(req RecoveryCleanupRequest) RecoveryCleanupResult {
498 result := RecoveryCleanupResult{DryRun: !req.Apply, Items: []RecoveryCleanupItem{}}
499 catalog := a.sessionCatalog.Load()
500 if catalog == nil {
501 return result
502 }
503 topic, ok, err := catalog.GetTopic(a.bootContext(), sessioncatalog.TopicKey{Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot, TopicID: req.TopicID})
504 if err != nil || !ok {
505 return result
506 }
507 if heads, ok := a.cleanTopicHeads(req, topic); ok {
508 return heads
509 }
510 canonical, rootID := recoveryCleanupCanonical(topic)
511 if canonical == "" || rootID == "" {
512 return result
513 }
514 dir := filepath.Dir(canonical)
515 groups, err := catalog.ListRecoveryGroups(a.bootContext(), dir)
516 if err != nil {
517 return result
518 }
519 members := []sessioncatalog.SessionRecord{}
520 for _, group := range groups {
521 if group.ID == rootID {
522 members = group.Members
523 break
524 }
525 }
526 candidates := []sessioncatalog.SessionRecord{}
527 for _, record := range members {
528 if record.Path == canonical || record.RecoveryRole != sessioncatalog.RecoveryRoleCoveredCopy {
529 continue
530 }
531 result.Eligible++
532 candidates = append(candidates, record)
533 result.Items = append(result.Items, RecoveryCleanupItem{Path: record.Path, Status: "eligible"})
534 }
535 if !req.Apply || len(candidates) == 0 {
536 return result
537 }
538 defer a.lockRuntimeMutation("clean-recovery-lineage")()
539 a.sessionRemovalMu.Lock()
540 defer a.sessionRemovalMu.Unlock()
541 if a.sessionOpenInAnyTab(canonical) || agent.SessionLeaseHeld(canonical) {
542 for index := range result.Items {
543 result.Items[index].Status = "busy"
544 result.Busy++
545 }
546 return result
547 }
548 if err := agent.ReparentRecoveryCanonical(canonical, rootID, dir); err != nil {
549 for index := range result.Items {
550 if errors.Is(err, agent.ErrSessionLeaseHeld) {
551 result.Items[index].Status = "busy"
552 result.Busy++
553 } else {
554 result.Items[index].Status = "kept"
555 result.Items[index].Error = "recovery branch changed and was kept"
556 result.Kept++
557 }
558 }
559 return result
560 }
561 for index, record := range candidates {
562 item := &result.Items[index]
563 if a.sessionOpenInAnyTab(record.Path) || agent.SessionLeaseHeld(record.Path) {
564 item.Status = "busy"
565 result.Busy++
566 continue
567 }
568 if err := agent.TrashRecoveryBranchCoveredBy(record.Path, canonical, dir); err != nil {
569 item.Status = "kept"
570 if errors.Is(err, agent.ErrSessionLeaseHeld) {
571 item.Status = "busy"
572 result.Busy++
573 } else {
574 item.Error = "recovery branch changed and was kept"
575 result.Kept++
576 }
577 } else {
578 item.Status = "moved"
579 result.Moved++
580 a.removeSessionCatalogPath(record.Path, "recovery_lineage_cleaned")
581 }
582 }
583 if result.Moved > 0 {
584 a.emitProjectTreeChangedForSessionDirs(dir)
585 a.invalidatePromptHistoryCache()
586 }
587 return result
588 }
589
590 func recoveryCleanupCanonical(topic sessioncatalog.TopicRecord) (canonical, rootID string) {
591 for _, record := range topic.Sessions {
592 if record.RecoveryCanonical && (record.RecoveryRole == sessioncatalog.RecoveryRoleAdopted || record.RecoveryRole == sessioncatalog.RecoveryRolePreferred) {
593 return record.Path, record.RecoveryGroupID
594 }
595 }
596 return "", ""
597 }
598
598 lines GO