返回 DeepSeek-Reasonix
recovery_copy_sweep.go
根目录 / desktop / recovery_copy_sweep.go
1 package main
2
3 import (
4 "context"
5 "log/slog"
6 "os"
7 "sort"
8 "time"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/config"
12 "reasonix/internal/control"
13 "reasonix/internal/sessioncatalog"
14 )
15
16 // Recovery-copy count sweep (#8525/#8750/#9109). Catalog v4 folds recovery
17 // lineages into one ordinary list row, so copies multiply on disk even though
18 // the sidebar shows one conversation. Once a lineage piles up
19 // recoveryCopySweepThreshold or more covered copies under one root, the sweep
20 // keeps the newest recoveryCopySweepKeep and moves the rest to the recoverable
21 // session trash (never hard-delete; restore via History). Only recovery_copy=1
22 // members qualify; coverage is re-proven under removal guards before every
23 // move, and diverged copies (unique content) are never auto-swept. The sweep
24 // runs from existing reconcile hooks and is gated by
25 // recovery_cleanup.auto_enabled (default on).
26 const (
27 recoveryCopySweepThreshold = 3
28 recoveryCopySweepKeep = 1
29 )
30
31 // sweepExcessRecoveryCopies runs the count sweep for one reconciled directory.
32 func (a *App) sweepExcessRecoveryCopies(catalog *sessioncatalog.Catalog, target sessioncatalog.DirectoryTarget) {
33 if !config.RecoveryCopyAutoCleanupEnabled() {
34 return
35 }
36 a.sweepExcessRecoveryCopiesIn(catalog, target, time.Now(), agent.RecoveryGCStartupGracePeriod)
37 }
38
39 func (a *App) sweepExcessRecoveryCopiesIn(catalog *sessioncatalog.Catalog, target sessioncatalog.DirectoryTarget, now time.Time, grace time.Duration) int {
40 if a == nil || catalog == nil || a.shuttingDown.Load() {
41 return 0
42 }
43 ctx, cancel := context.WithTimeout(a.bootContext(), sessionCatalogReadTimeout)
44 groups, err := catalog.ListRecoveryGroups(ctx, target.Path)
45 cancel()
46 if err != nil {
47 slog.Warn("desktop: list recovery lineages for copy sweep", "err", err)
48 return 0
49 }
50 moved := 0
51 for _, group := range groups {
52 moved += a.sweepRecoveryGroupCopies(group, now, grace)
53 }
54 if moved > 0 {
55 slog.Info("desktop: moved excess recovery copies to the session trash", "count", moved)
56 }
57 return moved
58 }
59
60 func (a *App) sweepRecoveryGroupCopies(group sessioncatalog.RecoveryGroup, now time.Time, grace time.Duration) int {
61 copies := make([]sessioncatalog.SessionRecord, 0, len(group.Members))
62 for _, member := range group.Members {
63 // Only proven-covered copies are redundant; diverged leaves and the
64 // canonical representative always stay.
65 if member.RecoveryCopy && member.Path != group.CanonicalPath {
66 copies = append(copies, member)
67 }
68 }
69 if len(copies) < recoveryCopySweepThreshold {
70 return 0
71 }
72 sort.SliceStable(copies, func(i, j int) bool {
73 if copies[i].LastActivityAt != copies[j].LastActivityAt {
74 return copies[i].LastActivityAt > copies[j].LastActivityAt
75 }
76 return copies[i].Path < copies[j].Path
77 })
78 for _, kept := range copies[:recoveryCopySweepKeep] {
79 recordRecoveryCleanupOutcome(group, kept.Path, "cleanup_kept")
80 }
81 moved := 0
82 for _, copy := range copies[recoveryCopySweepKeep:] {
83 if grace > 0 {
84 // A fresh copy may still be part of an active conflict flow.
85 info, err := os.Stat(copy.Path)
86 if err != nil || now.Sub(info.ModTime()) < grace {
87 continue
88 }
89 }
90 if a.trashSweptRecoveryCopy(group, copy.Path) {
91 moved++
92 }
93 }
94 return moved
95 }
96
97 // trashSweptRecoveryCopy moves one redundant copy to the recoverable session
98 // trash under the same guards the explicit UI delete uses. Any coverage or
99 // liveness doubt skips the copy untouched; it can be retried by a later sweep.
100 func (a *App) trashSweptRecoveryCopy(group sessioncatalog.RecoveryGroup, path string) bool {
101 defer a.lockRuntimeMutation("recovery-copy-sweep")()
102 a.sessionRemovalMu.Lock()
103 defer a.sessionRemovalMu.Unlock()
104 if a.sessionOpenInAnyTab(path) || agent.SessionLeaseHeld(path) {
105 recordRecoveryCleanupOutcome(group, path, "cleanup_skipped_in_use")
106 return false
107 }
108 var err error
109 if group.CanonicalPath != "" {
110 err = agent.TrashRecoveryBranchCoveredBy(path, group.CanonicalPath, group.Directory)
111 } else {
112 err = agent.TrashCoveredRecoveryBranch(path, group.Directory)
113 }
114 if err != nil {
115 recordRecoveryCleanupOutcome(group, path, "cleanup_revalidation_failed")
116 slog.Debug("desktop: sweep skipped recovery copy", "reason", err)
117 return false
118 }
119 recordRecoveryCleanupOutcome(group, path, "cleanup_moved")
120 a.removeSessionCatalogPath(path, "recovery_copy_sweep")
121 slog.Info("desktop: swept excess recovery copy to trash")
122 return true
123 }
124
125 func recordRecoveryCleanupOutcome(group sessioncatalog.RecoveryGroup, path, outcome string) {
126 anchor := group.CanonicalPath
127 if anchor == "" {
128 anchor = path
129 }
130 control.RecordRecoveryLifecycle(anchor, outcome)
131 }
132
132 lines GO