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) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-21 14:37:22 -04:00 committed by GitHub
parent 42f23630e7
commit b741090870
2 changed files with 49 additions and 2 deletions

View file

@ -84,12 +84,19 @@ export const COMPLETION_TRANSITION_CODES = new Set<DoctorIssueCode>([
]);
/**
* 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<DoctorIssueCode>([
"orphaned_project_state",
"orphaned_completed_units",
]);
export interface DoctorIssue {

View file

@ -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 */ }