返回 DeepSeek-Reasonix
legacy_empty_session_cleanup_sources.go
根目录 / desktop / legacy_empty_session_cleanup_sources.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "path/filepath"
12 "slices"
13 "strings"
14 "time"
15
16 "reasonix/desktop/internal/legacycleanup"
17 "reasonix/desktop/internal/workspacestate"
18 "reasonix/internal/agent"
19 "reasonix/internal/provider"
20 "reasonix/internal/session"
21 "reasonix/internal/store"
22 "reasonix/internal/transcript"
23 )
24
25 type legacyCleanupSourceTarget struct {
26 state workspacestate.State
27 mapping workspacestate.SourceMapping
28 ref session.SessionRef
29 frozen legacycleanup.Candidate
30 }
31
32 func (a *App) processLegacyCleanupSource(item legacycleanup.Candidate) {
33 target, classification, reason, ok := a.resolveLegacyCleanupSourceTarget(item)
34 if !ok {
35 a.setLegacyCleanupSourceOutcome(item.ID, "", classification, reason)
36 return
37 }
38 if a.reconcileLegacyCleanupArchivedOperation(item, target.mapping.SessionID) {
39 return
40 }
41 target, classification, reason, ok = a.freezeLegacyCleanupSourceTarget(item, target)
42 if !ok {
43 a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, classification, reason)
44 return
45 }
46 decision := a.classifyLegacyCleanupSession(a.bootContext(), target.ref, target.frozen)
47 if decision.classification != "empty" {
48 a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, decision.classification, decision.reason)
49 return
50 }
51 if a.legacyCleanupWorker.beforeArchive != nil {
52 a.legacyCleanupWorker.beforeArchive()
53 }
54 a.archiveLegacyCleanupSource(item, target)
55 }
56
57 func (a *App) resolveLegacyCleanupSourceTarget(item legacycleanup.Candidate) (legacyCleanupSourceTarget, string, string, bool) {
58 classification, reason, _ := classifyLegacyCleanupSource(item.SourcePath, item.SourceHeadID, item.SourceFingerprint)
59 if classification != "empty" {
60 return legacyCleanupSourceTarget{}, classification, reason, false
61 }
62 state, err := a.workspaceRegistry().Load(a.bootContext())
63 if err != nil {
64 return legacyCleanupSourceTarget{}, "unknown", "workspace_unavailable", false
65 }
66 mapping, ok := state.SourceMappings[desktopSourceKey(item.SourcePath, item.SourceHeadID)]
67 if !ok {
68 return legacyCleanupSourceTarget{}, "unknown", "legacy_session_requires_migration", false
69 }
70 if !sameDesktopPath(mapping.Path, item.SourcePath) || mapping.HeadID != item.SourceHeadID || mapping.WorkspaceID != item.WorkspaceID {
71 return legacyCleanupSourceTarget{}, "protected", "migration_identity_changed", false
72 }
73 return legacyCleanupSourceTarget{state: state, mapping: mapping}, "", "", true
74 }
75
76 func (a *App) freezeLegacyCleanupSourceTarget(item legacycleanup.Candidate, target legacyCleanupSourceTarget) (legacyCleanupSourceTarget, string, string, bool) {
77 status, ok := target.state.SessionStates[target.mapping.SessionID]
78 if !ok || status.Lifecycle != workspacestate.Active {
79 return target, "protected", "lifecycle_changed", false
80 }
81 target.ref = session.SessionRef{HostID: localDesktopHostID, SessionID: target.mapping.SessionID}
82 info, err := a.desktopSessionService("").Query().Stat(a.bootContext(), target.ref)
83 if err != nil || info.MetadataStatus == session.MetadataFailed {
84 return target, "unknown", "session_metadata_unavailable", false
85 }
86 target.frozen = item
87 if item.SessionID == "" {
88 target.frozen.SessionID = target.mapping.SessionID
89 target.frozen.WorkspaceID = target.mapping.WorkspaceID
90 target.frozen.TitleSequence = info.TitleSequence
91 target.frozen.EventSequence = info.EventSequence
92 target.frozen.LifecycleGeneration = status.Generation
93 } else if item.SessionID != target.mapping.SessionID || item.WorkspaceID != target.mapping.WorkspaceID {
94 return target, "protected", "migration_binding_changed", false
95 }
96 return target, "", "", true
97 }
98
99 func (a *App) archiveLegacyCleanupSource(item legacycleanup.Candidate, target legacyCleanupSourceTarget) {
100 releaseRuntime, ok := a.tryLockRuntimeMutation("legacy empty source cleanup")
101 if !ok {
102 a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, "busy", "runtime_mutation")
103 return
104 }
105 defer releaseRuntime()
106 sourceGuard, err := acquireSessionRemovalGuard(item.SourcePath)
107 if err != nil {
108 classification, reason := "unknown", "legacy_source_lock_failed"
109 if errors.Is(err, agent.ErrSessionLeaseHeld) {
110 classification, reason = "busy", "legacy_source_busy"
111 }
112 a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, classification, reason)
113 return
114 }
115 defer sourceGuard.Release()
116 verify := func(ctx context.Context, latest workspacestate.State) error {
117 current, exists := latest.SourceMappings[desktopSourceKey(item.SourcePath, item.SourceHeadID)]
118 if !exists || current.SessionID != target.mapping.SessionID || current.WorkspaceID != target.mapping.WorkspaceID ||
119 !sameDesktopPath(current.Path, item.SourcePath) || current.HeadID != item.SourceHeadID {
120 return fmt.Errorf("%w: migration mapping changed", errLegacyCleanupStateChanged)
121 }
122 if sourceClass, _, _ := classifyLegacyCleanupSource(item.SourcePath, item.SourceHeadID, item.SourceFingerprint); sourceClass != "empty" {
123 return fmt.Errorf("%w: legacy source is %s", errLegacyCleanupStateChanged, sourceClass)
124 }
125 fresh := a.classifyLegacyCleanupSession(ctx, target.ref, target.frozen)
126 if fresh.classification != "empty" {
127 return fmt.Errorf("%w: canonical session is %s", errLegacyCleanupStateChanged, fresh.classification)
128 }
129 return nil
130 }
131 err = a.archiveSessionRefsWithOperationConditional([]session.SessionRef{target.ref}, item.OperationID, verify)
132 if err != nil {
133 classification, reason := legacyCleanupArchiveError(err)
134 a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, classification, reason)
135 return
136 }
137 a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) {
138 next.SessionID = target.mapping.SessionID
139 next.Phase, next.Classification, next.Reason, next.ArchivedAt = "archived", "empty", "", time.Now().UTC().UnixMilli()
140 })
141 }
142
143 func legacyCleanupArchiveError(err error) (string, string) {
144 if errors.Is(err, errTopicHasActiveWork) || errors.Is(err, errTopicArchiveBusy) || errors.Is(err, agent.ErrSessionLeaseHeld) {
145 return "busy", "runtime_active"
146 }
147 if errors.Is(err, errLegacyCleanupStateChanged) || errors.Is(err, workspacestate.ErrMutationConflict) {
148 return "protected", "state_changed"
149 }
150 return "unknown", "archive_failed"
151 }
152
153 func (a *App) setLegacyCleanupSourceOutcome(id, sessionID, classification, reason string) {
154 a.updateLegacyCleanupItem(id, func(next *legacycleanup.Candidate) {
155 if sessionID != "" {
156 next.SessionID = sessionID
157 }
158 next.Phase, next.Classification, next.Reason = classification, classification, reason
159 })
160 }
161 func (a *App) bindLegacyCleanupMigration(ctx context.Context, path, headID, sessionID, workspaceID string) error {
162 if a == nil || a.legacyCleanup == nil || strings.TrimSpace(sessionID) == "" {
163 return nil
164 }
165 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID}
166 info, err := a.desktopSessionService("").Query().Stat(ctx, ref)
167 if err != nil {
168 return err
169 }
170 state, err := a.workspaceRegistry().Load(ctx)
171 if err != nil {
172 return err
173 }
174 status, ok := state.SessionStates[sessionID]
175 if !ok || status.Lifecycle != workspacestate.Active {
176 return workspacestate.ErrMutationConflict
177 }
178 id := "legacy:" + desktopSourceKey(path, headID)
179 _, err = a.legacyCleanup.Update(ctx, func(cleanup *legacycleanup.State) error {
180 item, exists := cleanup.Items[id]
181 if !exists {
182 return nil
183 }
184 if item.Kind != "legacy" || !sameDesktopPath(item.SourcePath, path) || item.SourceHeadID != headID || item.WorkspaceID != workspaceID {
185 return errLegacyCleanupStateChanged
186 }
187 if item.SessionID != "" && item.SessionID != sessionID {
188 return errLegacyCleanupStateChanged
189 }
190 item.SessionID = sessionID
191 item.TitleSequence = info.TitleSequence
192 item.EventSequence = info.EventSequence
193 item.LifecycleGeneration = status.Generation
194 cleanup.Items[id] = item
195 return nil
196 })
197 return err
198 }
199
200 func (a *App) classifyLegacyCleanupSession(ctx context.Context, ref session.SessionRef, frozen legacycleanup.Candidate) legacyCleanupDecision {
201 state, decision, ok := a.classifyLegacyCleanupRegistry(ctx, ref, frozen)
202 if !ok {
203 return decision
204 }
205 if a.legacyCleanupSessionIsOpen(ref.SessionID) {
206 return legacyCleanupDecision{"busy", "session_open", session.SessionInfo{}, session.Snapshot{}}
207 }
208 service := a.desktopSessionService("")
209 if _, live := service.Runtime(ref); live {
210 return legacyCleanupDecision{"busy", "runtime_open", session.SessionInfo{}, session.Snapshot{}}
211 }
212 return classifyLegacyCleanupCanonicalStorage(ctx, service, state, ref, frozen)
213 }
214
215 func (a *App) classifyLegacyCleanupRegistry(ctx context.Context, ref session.SessionRef, frozen legacycleanup.Candidate) (workspacestate.State, legacyCleanupDecision, bool) {
216 state, err := a.workspaceRegistry().Load(ctx)
217 if err != nil {
218 return state, legacyCleanupDecision{"unknown", "workspace_unavailable", session.SessionInfo{}, session.Snapshot{}}, false
219 }
220 status, ok := state.SessionStates[ref.SessionID]
221 if !ok || status.Lifecycle != workspacestate.Active || status.Generation != frozen.LifecycleGeneration {
222 return state, legacyCleanupDecision{"protected", "lifecycle_changed", session.SessionInfo{}, session.Snapshot{}}, false
223 }
224 workspace, ok := state.Workspaces[frozen.WorkspaceID]
225 if !ok || !slices.Contains(workspace.SessionIDs, ref.SessionID) {
226 return state, legacyCleanupDecision{"protected", "workspace_changed", session.SessionInfo{}, session.Snapshot{}}, false
227 }
228 if state.Presentation[ref.SessionID].Pinned {
229 return state, legacyCleanupDecision{"has_content", "session_pinned", session.SessionInfo{}, session.Snapshot{}}, false
230 }
231 for _, pending := range state.PendingCreates {
232 if pending.SessionID == ref.SessionID || pending.ParentSessionID == ref.SessionID {
233 return state, legacyCleanupDecision{"protected", "create_or_derivation", session.SessionInfo{}, session.Snapshot{}}, false
234 }
235 }
236 if decision, owned := classifyLegacyCleanupPersistentOwner(state, ref, frozen); owned {
237 return state, decision, false
238 }
239 if decision, valid := classifyLegacyCleanupSourceMappings(state, ref, frozen); !valid {
240 return state, decision, false
241 }
242 ops, err := a.draftStore().PendingOperations(ctx)
243 if err != nil {
244 return state, legacyCleanupDecision{"unknown", "draft_state_unavailable", session.SessionInfo{}, session.Snapshot{}}, false
245 }
246 for _, op := range ops {
247 if op.SessionID == ref.SessionID {
248 return state, legacyCleanupDecision{"protected", "draft_operation", session.SessionInfo{}, session.Snapshot{}}, false
249 }
250 }
251 return state, legacyCleanupDecision{}, true
252 }
253
254 func classifyLegacyCleanupSourceMappings(state workspacestate.State, ref session.SessionRef, frozen legacycleanup.Candidate) (legacyCleanupDecision, bool) {
255 for _, source := range frozen.Sources {
256 mapping, exists := state.SourceMappings[desktopSourceKey(source.Path, source.HeadID)]
257 if !exists || mapping.SessionID != ref.SessionID || mapping.WorkspaceID != frozen.WorkspaceID || mapping.Format != "legacy" ||
258 !sameDesktopPath(mapping.Path, source.Path) || mapping.HeadID != source.HeadID {
259 return legacyCleanupDecision{"protected", "legacy_mapping_changed", session.SessionInfo{}, session.Snapshot{}}, false
260 }
261 classification, reason, _ := classifyLegacyCleanupSource(source.Path, source.HeadID, source.Fingerprint)
262 if classification != "empty" {
263 return legacyCleanupDecision{classification, reason, session.SessionInfo{}, session.Snapshot{}}, false
264 }
265 }
266 return legacyCleanupDecision{}, true
267 }
268
269 func (a *App) legacyCleanupSessionIsOpen(sessionID string) bool {
270 a.mu.RLock()
271 defer a.mu.RUnlock()
272 for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
273 for _, tab := range tabs {
274 if tab != nil && tab.SessionID == sessionID {
275 return true
276 }
277 }
278 }
279 return false
280 }
281
282 func classifyLegacyCleanupCanonicalStorage(ctx context.Context, service *session.Service, state workspacestate.State, ref session.SessionRef, frozen legacycleanup.Candidate) legacyCleanupDecision {
283 info, err := service.Query().Stat(ctx, ref)
284 if err != nil || info.MetadataStatus == session.MetadataFailed {
285 return legacyCleanupDecision{"unknown", "session_metadata_unavailable", info, session.Snapshot{}}
286 }
287 title := state.Presentation[ref.SessionID].Title
288 if info.TitleSequence > 0 || strings.TrimSpace(info.Title) != "" {
289 title = info.Title
290 }
291 if !isDefaultTopicTitle(title) || info.TitleSequence != frozen.TitleSequence || info.EventSequence != frozen.EventSequence {
292 return legacyCleanupDecision{"protected", "title_or_content_changed", info, session.Snapshot{}}
293 }
294 if info.ParentSessionID != "" || info.Origin == session.SessionOriginFork {
295 return legacyCleanupDecision{"protected", "derived_session", info, session.Snapshot{}}
296 }
297 snapshot, err := service.Query().Snapshot(ctx, ref)
298 if err != nil || snapshot.PersistenceStatus != session.PersistenceReady {
299 return legacyCleanupDecision{"unknown", "session_content_unavailable", info, snapshot}
300 }
301 if info.ResultSequence > 0 || canonicalProjectionHasUserContent(snapshot.Projection) {
302 return legacyCleanupDecision{"has_content", "session_content", info, snapshot}
303 }
304 if classification, reason := canonicalSessionDurableEvidence(ctx, info); classification != "empty" {
305 return legacyCleanupDecision{classification, reason, info, snapshot}
306 }
307 if classification, reason := classifyLegacyCleanupArtifacts(info.Path); classification != "empty" {
308 return legacyCleanupDecision{classification, reason, info, snapshot}
309 }
310 if classification, reason := classifyCanonicalOwnedDirectories(info.Path); classification != "empty" {
311 return legacyCleanupDecision{classification, reason, info, snapshot}
312 }
313 return legacyCleanupDecision{"empty", "", info, snapshot}
314 }
315
316 func classifyCanonicalOwnedDirectories(sessionPath string) (string, string) {
317 for _, owned := range []struct {
318 name string
319 reason string
320 }{
321 {name: "attachments", reason: "session_attachment"},
322 {name: "assets", reason: "session_asset"},
323 } {
324 nonempty, err := directoryHasDurableEntries(filepath.Join(sessionPath, owned.name), nil)
325 if err != nil {
326 return "unknown", owned.reason + "_unavailable"
327 }
328 if nonempty {
329 return "has_content", owned.reason
330 }
331 }
332 return "empty", ""
333 }
334 func canonicalProjectionHasUserContent(projection session.Projection) bool {
335 for _, message := range projection.Messages {
336 if message.Role == provider.RoleUser || message.Role == provider.RoleAssistant || message.Role == provider.RoleTool {
337 return true
338 }
339 }
340 if body := strings.TrimSpace(string(projection.GoalState)); body != "" && body != "{}" && body != "null" {
341 return true
342 }
343 return len(projection.Turns) > 0 || projection.TurnID != "" || len(projection.Todos) > 0 || projection.TodoWritten ||
344 len(projection.Interactions) > 0 || len(projection.StartedTools) > 0 || len(projection.ActiveSteps) > 0 || projection.Recovery != nil
345 }
346
347 type legacyCleanupEvidence struct {
348 classification string
349 reason string
350 }
351
352 func (e *legacyCleanupEvidence) mark(classification, reason string) {
353 if e.classification == "has_content" || classification == "empty" {
354 return
355 }
356 if classification == "has_content" || e.classification == "" || e.classification == "empty" {
357 e.classification, e.reason = classification, reason
358 }
359 }
360
361 func canonicalSessionDurableEvidence(ctx context.Context, info session.SessionInfo) (string, string) {
362 evidence := legacyCleanupEvidence{classification: "empty"}
363 err := session.VisitCommits(ctx, info.Path, func(commit session.Commit) error {
364 for _, event := range commit.Events {
365 classification, reason := classifyCanonicalSessionEvent(commit, event)
366 evidence.mark(classification, reason)
367 }
368 return nil
369 })
370 if err != nil {
371 return "unknown", "session_event_log_unavailable"
372 }
373 return evidence.classification, evidence.reason
374 }
375
376 func classifyCanonicalSessionEvent(commit session.Commit, event session.Event) (string, string) {
377 switch event.Kind {
378 case "session/title", "diagnostic":
379 return "empty", ""
380 case "session/config":
381 return classifyCanonicalConfigEvent(commit)
382 case "message/complete", "message/upsert":
383 return classifyCanonicalMessageEvent(event.Payload)
384 case "model/context-replace", "history/replace":
385 return classifyCanonicalContextEvent(commit, event.Payload)
386 case "legacy/import":
387 return classifyCanonicalLegacyImportEvent(event.Payload)
388 case "submission/accepted", "message/retract", "assistant/attempt", "tool/call", "tool/start", "tool/result",
389 "turn/start", "turn/end", "step/start", "step/end", "todo/write", "interaction/created", "interaction/resolved",
390 "plan/state", "goal/state", "compaction", "runtime/recovery":
391 return "has_content", "execution_event"
392 default:
393 return "unknown", "unsupported_session_event"
394 }
395 }
396
397 func classifyCanonicalConfigEvent(commit session.Commit) (string, string) {
398 if commit.OperationID == "session-create" || strings.HasPrefix(commit.OperationID, "legacy-import:") ||
399 strings.HasPrefix(commit.OperationID, "legacy-import-config:") || strings.HasPrefix(commit.OperationID, "prototype-import-config:") {
400 return "empty", ""
401 }
402 if strings.HasPrefix(commit.OperationID, "session-model:") {
403 return "has_content", "explicit_session_config"
404 }
405 return "unknown", "unclassified_session_config"
406 }
407
408 func classifyCanonicalMessageEvent(payload json.RawMessage) (string, string) {
409 var body struct {
410 Message *provider.Message `json:"message"`
411 }
412 if err := json.Unmarshal(payload, &body); err != nil || body.Message == nil {
413 return "unknown", "message_event_unreadable"
414 }
415 if isLegacyCleanupContentRole(body.Message.Role) {
416 return "has_content", "message_event"
417 }
418 if body.Message.Role != provider.RoleSystem {
419 return "unknown", "unknown_message_role"
420 }
421 return "empty", ""
422 }
423
424 func classifyCanonicalContextEvent(commit session.Commit, raw json.RawMessage) (string, string) {
425 var payload struct {
426 Messages []provider.Message `json:"messages"`
427 Reason string `json:"reason"`
428 }
429 if err := json.Unmarshal(raw, &payload); err != nil {
430 return "unknown", "context_event_unreadable"
431 }
432 initialization := strings.HasPrefix(commit.OperationID, "legacy-import:") || payload.Reason == "system-prompt-refresh"
433 if !onlySystemMessages(payload.Messages) || !initialization {
434 return "has_content", "context_event"
435 }
436 return "empty", ""
437 }
438
439 func classifyCanonicalLegacyImportEvent(raw json.RawMessage) (string, string) {
440 var payload struct {
441 Messages []provider.Message `json:"messages"`
442 Goal json.RawMessage `json:"goal"`
443 }
444 if err := json.Unmarshal(raw, &payload); err != nil || payload.Messages == nil {
445 return "unknown", "legacy_import_unreadable"
446 }
447 for _, message := range payload.Messages {
448 if isLegacyCleanupContentRole(message.Role) {
449 return "has_content", "legacy_import_message"
450 }
451 if message.Role != provider.RoleSystem {
452 return "unknown", "legacy_import_message_role"
453 }
454 }
455 goal := bytes.TrimSpace(payload.Goal)
456 if len(goal) > 0 && !bytes.Equal(goal, []byte("null")) && !bytes.Equal(goal, []byte("{}")) {
457 return "has_content", "legacy_import_goal"
458 }
459 return "empty", ""
460 }
461
462 func onlySystemMessages(messages []provider.Message) bool {
463 for _, message := range messages {
464 if message.Role != provider.RoleSystem {
465 return false
466 }
467 }
468 return true
469 }
470
471 func isLegacyCleanupContentRole(role provider.Role) bool {
472 return role == provider.RoleUser || role == provider.RoleAssistant || role == provider.RoleTool
473 }
474 func classifyLegacyCleanupSource(path, headID, frozenFingerprint string) (string, string, string) {
475 currentFingerprint, err := legacyCleanupSourceFingerprint(path)
476 if err != nil {
477 return "unknown", "legacy_source_unavailable", ""
478 }
479 if frozenFingerprint == "" {
480 return "unknown", "legacy_source_was_unreadable", currentFingerprint
481 }
482 if currentFingerprint != frozenFingerprint {
483 return "protected", "legacy_source_changed", currentFingerprint
484 }
485 if agent.IsCleanupPending(path) {
486 return "protected", "legacy_cleanup_pending", currentFingerprint
487 }
488 var legacySession *agent.Session
489 if strings.TrimSpace(headID) == "" {
490 legacySession, err = agent.LoadSession(path)
491 } else {
492 legacySession, err = agent.LoadSessionHeadReadOnly(path, headID)
493 }
494 if err != nil {
495 return "unknown", "legacy_transcript_unavailable", currentFingerprint
496 }
497 if classification, reason := classifyLegacyEventLog(path); classification != "empty" {
498 return classification, reason, currentFingerprint
499 }
500 for _, message := range legacySession.Snapshot() {
501 if message.Role == provider.RoleUser || message.Role == provider.RoleAssistant || message.Role == provider.RoleTool {
502 return "has_content", "legacy_message", currentFingerprint
503 }
504 if message.Role != provider.RoleSystem {
505 return "unknown", "legacy_message_role", currentFingerprint
506 }
507 }
508 if meta, exists, err := agent.LoadBranchMeta(path); err != nil {
509 return "unknown", "legacy_metadata_unavailable", currentFingerprint
510 } else if exists {
511 if meta.ParentID != "" || meta.ParentConversationID != "" || meta.ParentVersionID != "" || meta.Recovered ||
512 meta.EffectiveVersionKind() != agent.VersionNormal || meta.InFlightTurn != nil {
513 return "protected", "legacy_derivation_or_recovery", currentFingerprint
514 }
515 if strings.TrimSpace(meta.Goal) != "" {
516 return "has_content", "legacy_goal", currentFingerprint
517 }
518 if meta.Model != "" || meta.ModelIdentity != "" || meta.TokenMode != "" || meta.AgentPreset != "" ||
519 meta.QualityFloor != "" || meta.Mode != "" || meta.ToolApprovalMode != "" {
520 return "unknown", "legacy_explicit_configuration", currentFingerprint
521 }
522 }
523 if classification, reason := classifyLegacyCleanupArtifacts(path); classification != "empty" {
524 return classification, reason, currentFingerprint
525 }
526 return "empty", "", currentFingerprint
527 }
528
529 func classifyLegacyEventLog(path string) (string, string) {
530 file, err := os.Open(store.SessionEventLog(path))
531 if os.IsNotExist(err) {
532 return "empty", ""
533 }
534 if err != nil {
535 return "unknown", "event_log_unavailable"
536 }
537 defer file.Close()
538 info, err := file.Stat()
539 if err != nil || info.IsDir() {
540 return "unknown", "event_log_unavailable"
541 }
542 if info.Size() == 0 {
543 return "empty", ""
544 }
545 var header struct {
546 SchemaVersion int `json:"schema_version"`
547 Type string `json:"type"`
548 }
549 if err := json.NewDecoder(io.LimitReader(file, 1<<20)).Decode(&header); err != nil || header.SchemaVersion < 1 || header.SchemaVersion > 2 || strings.TrimSpace(header.Type) == "" {
550 return "unknown", "event_log_unreadable"
551 }
552 // agent.LoadSession above already replayed and validated supported native
553 // records. The log itself is not additional content beyond that projection.
554 return "empty", ""
555 }
556
557 func classifyLegacyCleanupArtifacts(path string) (string, string) {
558 if state, err := loadPinnedContextState(path); err != nil {
559 return "unknown", "pinned_context_unavailable"
560 } else if len(state.Files) > 0 {
561 return "has_content", "pinned_context"
562 }
563 if classification, reason := classifyLegacyJSONSidecar(store.SessionGoalState(path), "goal"); classification != "empty" {
564 return classification, reason
565 }
566 for _, target := range []struct {
567 path string
568 reason string
569 }{
570 {store.SessionRecoveryState(path), "recovery_state"},
571 {store.SessionTurnEventLog(path), "turn_ledger"},
572 {store.SessionTurnEventLogDamaged(path), "damaged_turn_ledger"},
573 {store.SessionEventLogDamaged(path), "damaged_event_log"},
574 {store.SessionConflictLog(path), "conflict_log"},
575 {sessionTelemetryPath(path), "session_telemetry"},
576 } {
577 info, err := os.Stat(target.path)
578 if os.IsNotExist(err) {
579 continue
580 }
581 if err != nil || info.IsDir() {
582 return "unknown", target.reason + "_unavailable"
583 }
584 if info.Size() > 0 {
585 return "has_content", target.reason
586 }
587 }
588 if info, err := os.Stat(store.SessionEventLogRotating(path)); err == nil {
589 if info.IsDir() || info.Size() > 0 {
590 return "unknown", "rotating_event_log_requires_recovery"
591 }
592 } else if !os.IsNotExist(err) {
593 return "unknown", "rotating_event_log_unavailable"
594 }
595 if classification, reason := classifyTranscriptCheckpoint(path); classification != "empty" {
596 return classification, reason
597 }
598 if classification, reason := classifyContextCheckpoint(path); classification != "empty" {
599 return classification, reason
600 }
601 for _, target := range []struct {
602 path string
603 reason string
604 }{
605 {store.SessionCheckpointDir(path), "checkpoint"},
606 {store.SessionJobsDir(path), "background_job"},
607 } {
608 nonempty, err := directoryHasDurableEntries(target.path, nil)
609 if err != nil {
610 return "unknown", target.reason + "_unavailable"
611 }
612 if nonempty {
613 return "has_content", target.reason
614 }
615 }
616 if classification, reason := classifyLegacyInbox(path); classification != "empty" {
617 return classification, reason
618 }
619 if subagents, err := agent.ListSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
620 return "unknown", "subagent_state_unavailable"
621 } else if len(subagents) > 0 {
622 return "has_content", "subagent_state"
623 }
624 return "empty", ""
625 }
626
627 func classifyTranscriptCheckpoint(sessionPath string) (string, string) {
628 path := store.SessionTranscriptProjection(sessionPath)
629 body, err := os.ReadFile(path)
630 if os.IsNotExist(err) {
631 return "empty", ""
632 }
633 if err != nil {
634 return "unknown", "transcript_projection_unavailable"
635 }
636 var raw map[string]json.RawMessage
637 var checkpoint transcript.Checkpoint
638 if json.Unmarshal(body, &raw) != nil || json.Unmarshal(body, &checkpoint) != nil || checkpoint.Version != transcript.ProtocolVersion {
639 return "unknown", "transcript_projection_unreadable"
640 }
641 allowed := map[string]bool{
642 "version": true, "identity": true, "coveredThroughSeq": true, "transcriptDigest": true,
643 "providerCount": true, "records": true, "runtime": true, "activeAttempts": true, "completion": true,
644 }
645 for field := range raw {
646 if !allowed[field] {
647 return "unknown", "transcript_projection_requires_verification"
648 }
649 }
650 runtime := checkpoint.Runtime
651 if len(checkpoint.Records) > 0 || len(checkpoint.ActiveAttempts) > 0 || checkpoint.Completion != nil ||
652 runtime.FinalMessageID != "" || runtime.DurationMs != 0 || runtime.SamplingCount != 0 || runtime.ToolCount != 0 ||
653 runtime.TurnID != "" || runtime.SubmissionID != "" || runtime.Status != "" || runtime.Phase != "" || runtime.StartedAt != 0 ||
654 len(runtime.PendingEvents) > 0 || runtime.CompletionSummary != nil || runtime.TurnUsage != nil {
655 return "has_content", "transcript_projection"
656 }
657 return "empty", ""
658 }
659
660 func classifyContextCheckpoint(sessionPath string) (string, string) {
661 path := store.SessionContext(sessionPath)
662 body, err := os.ReadFile(path)
663 if os.IsNotExist(err) {
664 return "empty", ""
665 }
666 if err != nil {
667 return "unknown", "session_context_unavailable"
668 }
669 var raw map[string]json.RawMessage
670 if json.Unmarshal(body, &raw) != nil {
671 return "unknown", "session_context_unreadable"
672 }
673 allowed := map[string]bool{
674 "schema_version": true, "transcript_version": true, "projection": true, "prompt_cache_key": true,
675 "last_cache_state": true, "last_trigger": true, "last_mode": true, "last_source_tokens": true,
676 "last_result_tokens": true, "last_compaction_cost": true, "generation": true, "last_receipt": true,
677 "blocked_input_hash": true, "blocked_reason": true, "native_context_editing_accepted": true,
678 "context_editing_fallback_local": true, "updated_at": true,
679 }
680 for field := range raw {
681 if !allowed[field] {
682 return "unknown", "session_context_requires_verification"
683 }
684 }
685 state, ok, err := agent.LoadCompactionState(sessionPath)
686 if err != nil || !ok {
687 return "unknown", "session_context_unreadable"
688 }
689 if compactionStateHasContent(state) {
690 return "has_content", "session_context"
691 }
692 return "empty", ""
693 }
694
695 func compactionStateHasContent(state agent.CompactionState) bool {
696 return contextProjectionHasContent(state.Projection) || state.TranscriptVersion != 0 ||
697 state.PromptCacheKey != "" || state.LastCacheState != "" || state.LastTrigger != "" || state.LastMode != "" ||
698 state.LastSourceTokens != 0 || state.LastResultTokens != 0 || state.LastCompactionCost != 0 || state.Generation != 0 ||
699 state.LastReceipt != nil || state.BlockedInputHash != "" || state.BlockedReason != "" ||
700 state.NativeContextEditingAccepted || state.ContextEditingFallbackLocal
701 }
702
703 func contextProjectionHasContent(projection agent.ContextProjection) bool {
704 return projection.TranscriptVersion != 0 || projection.ProjectionVersion != 0 || projection.CoveredCount != 0 ||
705 projection.CoveredPrefixHash != "" || projection.PinnedContextHash != "" || projection.SummaryHash != "" ||
706 projection.SourceTokens != 0 || projection.ProjectionTokens != 0 || projection.ViewInputHash != "" ||
707 projection.ViewOutputHash != "" || len(projection.Messages) > 0
708 }
709
710 func classifyLegacyJSONSidecar(path, field string) (string, string) {
711 body, err := os.ReadFile(path)
712 if os.IsNotExist(err) {
713 return "empty", ""
714 }
715 if err != nil {
716 return "unknown", field + "_state_unavailable"
717 }
718 if len(bytes.TrimSpace(body)) == 0 {
719 return "unknown", field + "_state_unreadable"
720 }
721 var object map[string]json.RawMessage
722 if err := json.Unmarshal(body, &object); err != nil {
723 return "unknown", field + "_state_unreadable"
724 }
725 if raw, ok := object[field]; ok {
726 var text string
727 if err := json.Unmarshal(raw, &text); err != nil {
728 return "unknown", field + "_state_unreadable"
729 }
730 if strings.TrimSpace(text) != "" {
731 return "has_content", field + "_state"
732 }
733 }
734 return "empty", ""
735 }
736
737 func directoryHasDurableEntries(path string, ignored map[string]bool) (bool, error) {
738 entries, err := os.ReadDir(path)
739 if os.IsNotExist(err) {
740 return false, nil
741 }
742 if err != nil {
743 return false, err
744 }
745 for _, entry := range entries {
746 if ignored != nil && ignored[entry.Name()] {
747 continue
748 }
749 return true, nil
750 }
751 return false, nil
752 }
753
754 func classifyLegacyInbox(path string) (string, string) {
755 dir := store.SessionInboxDir(path)
756 manifestPath := filepath.Join(dir, "manifest.json")
757 body, err := os.ReadFile(manifestPath)
758 if os.IsNotExist(err) {
759 nonempty, readErr := directoryHasDurableEntries(dir, map[string]bool{"transaction.lock": true})
760 if readErr != nil {
761 return "unknown", "inbox_unavailable"
762 }
763 if nonempty {
764 return "unknown", "inbox_manifest_missing"
765 }
766 return "empty", ""
767 }
768 if err != nil {
769 return "unknown", "inbox_unavailable"
770 }
771 var manifest struct {
772 SchemaVersion int `json:"schemaVersion"`
773 Paused bool `json:"paused"`
774 Recovered bool `json:"recovered"`
775 RecoveredCount int `json:"recoveredCount"`
776 Items []json.RawMessage `json:"items"`
777 Idempotency map[string]string `json:"idempotency"`
778 IdempotencyHashes map[string]string `json:"idempotencyHashes"`
779 Receipts map[string]json.RawMessage `json:"receipts"`
780 }
781 if err := json.Unmarshal(body, &manifest); err != nil || manifest.SchemaVersion < 1 || manifest.SchemaVersion > 2 {
782 return "unknown", "inbox_unreadable"
783 }
784 if len(manifest.Items) > 0 || manifest.Paused || manifest.Recovered || manifest.RecoveredCount > 0 ||
785 len(manifest.Idempotency) > 0 || len(manifest.IdempotencyHashes) > 0 || len(manifest.Receipts) > 0 {
786 return "has_content", "inbox_items"
787 }
788 nonempty, err := directoryHasDurableEntries(dir, map[string]bool{"transaction.lock": true, "manifest.json": true})
789 if err != nil {
790 return "unknown", "inbox_unavailable"
791 }
792 if nonempty {
793 return "unknown", "inbox_orphan_artifacts"
794 }
795 return "empty", ""
796 }
797
797 lines GO