返回 DeepSeek-Reasonix
legacy_empty_session_cleanup.go
根目录 / desktop / legacy_empty_session_cleanup.go
1 package main
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "os"
13 "path/filepath"
14 "slices"
15 "sync"
16 "time"
17
18 "reasonix/desktop/internal/legacycleanup"
19 "reasonix/desktop/internal/workspacestate"
20 "reasonix/internal/agent"
21 "reasonix/internal/filelock"
22 "reasonix/internal/session"
23 "reasonix/internal/store"
24 )
25
26 const legacyEmptySessionCleanupEvent = "legacy-empty-session-cleanup:changed"
27
28 var errLegacyCleanupStateChanged = errors.New("legacy cleanup candidate changed after inspection")
29
30 type LegacyEmptySessionCleanupItem struct {
31 ID string `json:"id"`
32 Kind string `json:"kind"`
33 WorkspaceID string `json:"workspaceId,omitempty"`
34 SessionID string `json:"sessionId,omitempty"`
35 TopicID string `json:"topicId,omitempty"`
36 Title string `json:"title,omitempty"`
37 Phase string `json:"phase"`
38 Classification string `json:"classification,omitempty"`
39 Reason string `json:"reason,omitempty"`
40 }
41
42 type LegacyEmptySessionCleanupStatus struct {
43 Version int `json:"version"`
44 BatchID string `json:"batchId,omitempty"`
45 State string `json:"state"`
46 Removed int `json:"removed"`
47 Pending int `json:"pending"`
48 Busy int `json:"busy"`
49 Unknown int `json:"unknown"`
50 Protected int `json:"protected"`
51 HasContent int `json:"hasContent"`
52 Items []LegacyEmptySessionCleanupItem `json:"items"`
53 }
54
55 type legacyCleanupDecision struct {
56 classification string
57 reason string
58 info session.SessionInfo
59 snapshot session.Snapshot
60 }
61
62 type legacyCleanupWorkerState struct {
63 mu sync.Mutex
64 running bool
65 // beforeArchive is a deterministic race-test hook. Set before cleanup and
66 // never mutate it concurrently.
67 beforeArchive func()
68 }
69
70 func legacyCleanupBatchID(state workspacestate.State) string {
71 ids := make([]string, 0, len(state.SessionStates))
72 for id := range state.SessionStates {
73 ids = append(ids, id)
74 }
75 slices.Sort(ids)
76 body, _ := json.Marshal(struct {
77 Generation uint64
78 IDs []string
79 At int64
80 }{state.Generation, ids, time.Now().UTC().UnixNano()})
81 sum := sha256.Sum256(body)
82 return hex.EncodeToString(sum[:12])
83 }
84
85 func legacyCleanupSourcePaths(sessionPath string) ([]string, error) {
86 paths := make([]string, 0, 24)
87 for _, artifact := range sessionTrashArtifacts(sessionPath, filepath.Base(sessionPath)) {
88 paths = append(paths, artifact.src)
89 }
90 subagents, err := agent.ListSubagentsByParent(filepath.Dir(sessionPath), agent.BranchID(sessionPath))
91 if err != nil {
92 return nil, err
93 }
94 for _, artifact := range subagents {
95 paths = append(paths, artifact.SessionPath, artifact.MetaPath)
96 paths = append(paths, store.SessionSidecarFiles(artifact.SessionPath)...)
97 paths = append(paths,
98 store.SessionCheckpointDir(artifact.SessionPath),
99 store.SessionJobsDir(artifact.SessionPath),
100 store.SessionInboxDir(artifact.SessionPath),
101 )
102 }
103 paths = uniqueStrings(paths)
104 slices.Sort(paths)
105 return paths, nil
106 }
107
108 // legacyCleanupSourceFingerprint covers every durable artifact that can make a
109 // legacy session recoverable. It deliberately excludes lease and lock files.
110 func legacyCleanupSourceFingerprint(sessionPath string) (string, error) {
111 paths, err := legacyCleanupSourcePaths(sessionPath)
112 if err != nil {
113 return "", err
114 }
115 h := sha256.New()
116 found := false
117 for _, path := range paths {
118 info, err := os.Lstat(path)
119 if os.IsNotExist(err) {
120 continue
121 }
122 if err != nil {
123 return "", err
124 }
125 if info.Mode()&os.ModeSymlink != 0 {
126 return "", errors.New("legacy cleanup source contains a symbolic link")
127 }
128 found = true
129 root := filepath.Dir(sessionPath)
130 if info.IsDir() {
131 err = filepath.WalkDir(path, func(child string, entry os.DirEntry, walkErr error) error {
132 if walkErr != nil {
133 return walkErr
134 }
135 childInfo, infoErr := entry.Info()
136 if infoErr != nil {
137 return infoErr
138 }
139 if childInfo.Mode()&os.ModeSymlink != 0 {
140 return errors.New("legacy cleanup source contains a symbolic link")
141 }
142 rel, relErr := filepath.Rel(root, child)
143 if relErr != nil {
144 return relErr
145 }
146 fmt.Fprintf(h, "%s\x00%d\x00", filepath.ToSlash(rel), childInfo.Size())
147 if entry.IsDir() {
148 return nil
149 }
150 if !childInfo.Mode().IsRegular() {
151 return errors.New("legacy cleanup source contains a non-regular file")
152 }
153 file, openErr := os.Open(child)
154 if openErr != nil {
155 return openErr
156 }
157 _, copyErr := io.Copy(h, file)
158 closeErr := file.Close()
159 return errors.Join(copyErr, closeErr)
160 })
161 if err != nil {
162 return "", err
163 }
164 continue
165 }
166 if !info.Mode().IsRegular() {
167 return "", errors.New("legacy cleanup source contains a non-regular file")
168 }
169 rel, err := filepath.Rel(root, path)
170 if err != nil {
171 return "", err
172 }
173 fmt.Fprintf(h, "%s\x00%d\x00", filepath.ToSlash(rel), info.Size())
174 file, err := os.Open(path)
175 if err != nil {
176 return "", err
177 }
178 _, copyErr := io.Copy(h, file)
179 closeErr := file.Close()
180 if err := errors.Join(copyErr, closeErr); err != nil {
181 return "", err
182 }
183 }
184 if !found {
185 return "", os.ErrNotExist
186 }
187 return hex.EncodeToString(h.Sum(nil)), nil
188 }
189
190 // initializeLegacyEmptySessionCleanupBatch freezes identities before the
191 // renderer can create a new draft-backed session. Content inspection remains a
192 // background operation after migration and draft recovery.
193 func (a *App) initializeLegacyEmptySessionCleanupBatch() error {
194 if a == nil || a.legacyCleanup == nil {
195 return errors.New("legacy cleanup store is unavailable")
196 }
197 if _, err := a.legacyCleanup.Load(a.bootContext()); err == nil {
198 return nil
199 } else if !errors.Is(err, legacycleanup.ErrNotInitialized) {
200 return err
201 }
202 state, err := a.workspaceRegistry().Load(a.bootContext())
203 if err != nil {
204 return err
205 }
206 builder := newLegacyCleanupBatchBuilder(a, state)
207 builder.registerCanonicalSessions(a.bootContext())
208 projects := loadProjectsFile()
209 builder.registerTopics("global", "", workspacestate.GlobalWorkspaceID, projects.GlobalTopics, projects.GlobalPinnedTopics, projects.GlobalGroups)
210 for _, project := range projects.Projects {
211 workspaceID := builder.workspaceIDForRoot(project.Root)
212 if workspaceID != "" {
213 builder.registerTopics("project", project.Root, workspaceID, project.Topics, project.PinnedTopics, project.Groups)
214 }
215 }
216 _, _, err = a.legacyCleanup.Initialize(a.bootContext(), legacycleanup.State{
217 BatchID: legacyCleanupBatchID(state),
218 Items: builder.items,
219 })
220 return err
221 }
222
223 func (a *App) GetLegacyEmptySessionCleanupStatus() (LegacyEmptySessionCleanupStatus, error) {
224 state, err := a.legacyCleanup.Load(a.bootContext())
225 if errors.Is(err, legacycleanup.ErrNotInitialized) {
226 return LegacyEmptySessionCleanupStatus{Version: 1, State: "not_initialized", Items: []LegacyEmptySessionCleanupItem{}}, nil
227 }
228 if err != nil {
229 return LegacyEmptySessionCleanupStatus{}, err
230 }
231 return legacyCleanupStatus(state), nil
232 }
233
234 func legacyCleanupStatus(state legacycleanup.State) LegacyEmptySessionCleanupStatus {
235 out := LegacyEmptySessionCleanupStatus{Version: state.Version, BatchID: state.BatchID, State: "complete", Items: []LegacyEmptySessionCleanupItem{}}
236 for _, item := range legacycleanup.SortedItems(state) {
237 out.Items = append(out.Items, LegacyEmptySessionCleanupItem{ID: item.ID, Kind: item.Kind, WorkspaceID: item.WorkspaceID, SessionID: item.SessionID, TopicID: item.TopicID, Title: item.Title, Phase: item.Phase, Classification: item.Classification, Reason: item.Reason})
238 switch item.Phase {
239 case "archived":
240 out.Removed++
241 case "busy":
242 out.Busy++
243 out.Pending++
244 case "unknown", "registered", "verified", "archive_pending":
245 out.Unknown++
246 out.Pending++
247 case "protected", "restored":
248 out.Protected++
249 case "has_content":
250 out.HasContent++
251 }
252 }
253 if out.Pending > 0 {
254 out.State = "pending"
255 }
256 return out
257 }
258
259 func (a *App) RetryLegacyEmptySessionCleanup() (LegacyEmptySessionCleanupStatus, error) {
260 a.registerLegacyCleanupUpgradeBatch()
261 status, err := a.GetLegacyEmptySessionCleanupStatus()
262 if err != nil {
263 return LegacyEmptySessionCleanupStatus{}, err
264 }
265 a.goSafe("retryLegacyEmptySessionCleanup", func() {
266 a.runLegacyEmptySessionCleanup(true)
267 })
268 return status, nil
269 }
270
271 func (a *App) runLegacyEmptySessionCleanup(includeUnknown bool) {
272 if a == nil || a.legacyCleanup == nil {
273 return
274 }
275 a.legacyCleanupWorker.mu.Lock()
276 if a.legacyCleanupWorker.running {
277 a.legacyCleanupWorker.mu.Unlock()
278 return
279 }
280 a.legacyCleanupWorker.running = true
281 a.legacyCleanupWorker.mu.Unlock()
282 defer func() {
283 a.legacyCleanupWorker.mu.Lock()
284 a.legacyCleanupWorker.running = false
285 a.legacyCleanupWorker.mu.Unlock()
286 }()
287 releaseWorker, err := a.legacyCleanup.TryAcquireWorker()
288 if err != nil {
289 if !errors.Is(err, filelock.ErrHeld) {
290 slog.Warn("desktop: legacy empty session cleanup worker unavailable", "err", err)
291 }
292 return
293 }
294 defer releaseWorker()
295 if a.desktopMigrationDone != nil {
296 select {
297 case <-a.desktopMigrationDone:
298 case <-a.bootContext().Done():
299 return
300 }
301 }
302 a.mu.RLock()
303 tabsRestored := a.tabsRestored
304 a.mu.RUnlock()
305 if tabsRestored != nil {
306 select {
307 case <-tabsRestored:
308 case <-a.bootContext().Done():
309 return
310 }
311 }
312 state, err := a.legacyCleanup.Load(a.bootContext())
313 if err != nil {
314 if !errors.Is(err, legacycleanup.ErrNotInitialized) {
315 slog.Warn("desktop: legacy empty session cleanup disabled", "err", err)
316 }
317 return
318 }
319 before := legacyCleanupStatus(state).Removed
320 for _, item := range legacycleanup.SortedItems(state) {
321 if item.Restored || item.Phase == "archived" || item.Phase == "has_content" || item.Phase == "protected" {
322 continue
323 }
324 if item.Phase == "unknown" && !includeUnknown {
325 continue
326 }
327 switch item.Kind {
328 case "session":
329 a.processLegacyCleanupSession(item)
330 case "legacy":
331 a.processLegacyCleanupSource(item)
332 case "topic":
333 a.processLegacyCleanupTopic(item)
334 }
335 }
336 after, err := a.GetLegacyEmptySessionCleanupStatus()
337 if err == nil {
338 a.emitRuntimeEvent(legacyEmptySessionCleanupEvent, after)
339 if after.Removed > before {
340 a.emitProjectTreeChanged()
341 }
342 }
343 }
344
345 func (a *App) reconcileLegacyCleanupArchivedOperation(item legacycleanup.Candidate, sessionID string) bool {
346 state, err := a.workspaceRegistry().Load(a.bootContext())
347 if err != nil {
348 return false
349 }
350 op, ok := state.PendingOperations[item.OperationID]
351 if !ok || op.Kind != "archive" || op.Phase != "committed" || !slices.Contains(op.SessionIDs, sessionID) ||
352 state.SessionStates[sessionID].Lifecycle != workspacestate.Archived {
353 return false
354 }
355 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
356 next.SessionID = sessionID
357 next.Phase, next.Classification, next.Reason = "archived", "empty", ""
358 if next.ArchivedAt == 0 {
359 next.ArchivedAt = state.SessionStates[sessionID].ArchivedAt
360 if next.ArchivedAt == 0 {
361 next.ArchivedAt = time.Now().UTC().UnixMilli()
362 }
363 }
364 })
365 return true
366 }
367
368 func (a *App) updateLegacyCleanupItem(id string, update func(*legacycleanup.Candidate)) {
369 _, err := a.legacyCleanup.Update(a.bootContext(), func(state *legacycleanup.State) error {
370 item, ok := state.Items[id]
371 if !ok || item.Restored {
372 return errLegacyCleanupStateChanged
373 }
374 update(&item)
375 state.Items[id] = item
376 return nil
377 })
378 if err != nil && !errors.Is(err, errLegacyCleanupStateChanged) {
379 slog.Warn("desktop: legacy cleanup state update failed", "err", err)
380 }
381 }
382
383 func (a *App) processLegacyCleanupSession(item legacycleanup.Candidate) {
384 if a.reconcileLegacyCleanupArchivedOperation(item, item.SessionID) {
385 return
386 }
387 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: item.SessionID}
388 decision := a.classifyLegacyCleanupSession(a.bootContext(), ref, item)
389 if decision.classification != "empty" {
390 phase := decision.classification
391 if phase == "empty" || phase == "" {
392 phase = "unknown"
393 }
394 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
395 next.Phase, next.Classification, next.Reason = phase, decision.classification, decision.reason
396 })
397 return
398 }
399 if a.legacyCleanupWorker.beforeArchive != nil {
400 a.legacyCleanupWorker.beforeArchive()
401 }
402 release, ok := a.tryLockRuntimeMutation("legacy empty session cleanup")
403 if !ok {
404 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
405 next.Phase, next.Classification, next.Reason = "busy", "busy", "runtime_mutation"
406 })
407 return
408 }
409 defer release()
410 verify := func(ctx context.Context, latest workspacestate.State) error {
411 fresh := a.classifyLegacyCleanupSession(ctx, ref, item)
412 if fresh.classification != "empty" {
413 return fmt.Errorf("%w: %s", errLegacyCleanupStateChanged, fresh.classification)
414 }
415 return nil
416 }
417 err := a.archiveSessionRefsWithOperationConditional([]session.SessionRef{ref}, item.OperationID, verify)
418 if err != nil {
419 classification, reason := "unknown", "archive_failed"
420 if errors.Is(err, errTopicHasActiveWork) || errors.Is(err, errTopicArchiveBusy) {
421 classification, reason = "busy", "runtime_active"
422 } else if errors.Is(err, errLegacyCleanupStateChanged) || errors.Is(err, workspacestate.ErrMutationConflict) {
423 classification, reason = "protected", "state_changed"
424 }
425 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
426 next.Phase, next.Classification, next.Reason = classification, classification, reason
427 })
428 return
429 }
430 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
431 next.Phase, next.Classification, next.Reason, next.ArchivedAt = "archived", "empty", "", time.Now().UTC().UnixMilli()
432 })
433 }
434
434 lines GO