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