From b741090870a35b9a198b0879107fbd1b18eea10c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 21 Mar 2026 14:37:22 -0400 Subject: [PATCH] fix(doctor): prevent cleanup from deleting user work files (#1825) The doctor's orphaned_completed_units check was running at fixLevel="task" during every auto-mode post-unit cycle. When artifact files were temporarily unreachable (worktree sync timing, path resolution), the doctor removed their completion keys from completed-units.json. This caused deriveState to consider those tasks incomplete, reverting the user to an earlier slice and discarding all work past that point. Add orphaned_completed_units to GLOBAL_STATE_CODES so it is never auto-fixed at fixLevel="task". Only explicit manual doctor runs (fixLevel="all") can now remove completion keys. Fixes #1809 Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/doctor-types.ts | 11 ++++- .../gsd/tests/doctor-runtime.test.ts | 40 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts index af7bb1c8e..a53057dc0 100644 --- a/src/resources/extensions/gsd/doctor-types.ts +++ b/src/resources/extensions/gsd/doctor-types.ts @@ -84,12 +84,19 @@ export const COMPLETION_TRANSITION_CODES = new Set([ ]); /** - * Issue codes that represent global (cross-project) state. + * Issue codes that represent global or completion-critical state. * These must NOT be auto-fixed when fixLevel is "task" — automated - * post-task health checks must never delete external project state directories. + * post-task health checks must never delete external project state directories + * or remove completed-unit keys (which causes state reversion / data loss). + * + * orphaned_completed_units: Removing completed-unit keys causes deriveState to + * consider those tasks incomplete, reverting the user to an earlier slice and + * effectively discarding all work past that point (#1809). This must only be + * fixed by an explicit manual doctor run (fixLevel="all"). */ export const GLOBAL_STATE_CODES = new Set([ "orphaned_project_state", + "orphaned_completed_units", ]); export interface DoctorIssue { diff --git a/src/resources/extensions/gsd/tests/doctor-runtime.test.ts b/src/resources/extensions/gsd/tests/doctor-runtime.test.ts index 8277bea03..216ce9084 100644 --- a/src/resources/extensions/gsd/tests/doctor-runtime.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-runtime.test.ts @@ -346,6 +346,46 @@ node_modules/ console.log("\n=== stranded_lock_directory (skipped on Windows) ==="); } + // ─── Test: orphaned_completed_units NOT auto-fixed at fixLevel="task" (#1809) ── + // Regression: task-level doctor was removing completed-unit keys whose artifacts + // were temporarily missing, causing deriveState to revert the user to S01 and + // effectively discarding hours of work. + console.log("\n=== orphaned_completed_units protected at fixLevel=task (#1809) ==="); + { + const dir = createMinimalProject(); + cleanups.push(dir); + + // Write completed-units.json with keys that reference non-existent artifacts. + // At fixLevel="task" (auto-mode post-unit), these must NOT be removed. + const completedKeys = [ + "execute-task/M001/S01/T99", // artifact missing + "complete-slice/M001/S99", // artifact missing + ]; + writeFileSync(join(dir, ".gsd", "completed-units.json"), JSON.stringify(completedKeys)); + + // fixLevel="task" — the level used by auto-post-unit after every task + const taskLevelFix = await runGSDDoctor(dir, { fix: true, fixLevel: "task" }); + const taskLevelOrphan = taskLevelFix.issues.filter(i => i.code === "orphaned_completed_units"); + assertTrue(taskLevelOrphan.length > 0, "orphaned_completed_units detected at task fixLevel"); + + // Verify keys were NOT removed — the fix must be suppressed at task level + const afterTaskFix = JSON.parse(readFileSync(join(dir, ".gsd", "completed-units.json"), "utf-8")); + assertEq(afterTaskFix.length, 2, "completed-unit keys preserved at fixLevel=task (data loss prevention)"); + assertTrue( + !taskLevelFix.fixesApplied.some(f => f.includes("orphaned")), + "no orphaned-units fix applied at fixLevel=task", + ); + + // fixLevel="all" (explicit manual doctor) — fix SHOULD apply + const allLevelFix = await runGSDDoctor(dir, { fix: true, fixLevel: "all" }); + assertTrue( + allLevelFix.fixesApplied.some(f => f.includes("orphaned")), + "orphaned-units fix applied at fixLevel=all (manual doctor)", + ); + const afterAllFix = JSON.parse(readFileSync(join(dir, ".gsd", "completed-units.json"), "utf-8")); + assertEq(afterAllFix.length, 0, "orphaned keys removed at fixLevel=all"); + } + } finally { for (const dir of cleanups) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }