diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index b92f5dcf5..42c7cac97 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -12,7 +12,7 @@ import { parseUnitId } from "./unit-id.js"; import { atomicWriteSync } from "./atomic-write.js"; import { clearParseCache } from "./files.js"; import { parseRoadmap as parseLegacyRoadmap, parsePlan as parseLegacyPlan } from "./parsers-legacy.js"; -import { isDbAvailable, getTask, getSlice, getSliceTasks } from "./gsd-db.js"; +import { isDbAvailable, getTask, getSlice, getSliceTasks, updateTaskStatus } from "./gsd-db.js"; import { isValidationTerminal } from "./state.js"; import { nativeConflictFiles, @@ -425,6 +425,20 @@ export function writeBlockerPlaceholder( `Review and replace this file before relying on downstream artifacts.`, ].join("\n"); writeFileSync(absPath, content, "utf-8"); + + // Mark the task as complete in the DB so verifyExpectedArtifact passes. + // Without this, the DB status stays "pending" and the dispatch loop + // re-derives the same task indefinitely (#2531). + if (unitType === "execute-task" && isDbAvailable()) { + const parts = unitId.split("/"); + const mid = parts[0]; + const sid = parts[1]; + const tid = parts[2]; + if (mid && sid && tid) { + try { updateTaskStatus(mid, sid, tid, "complete", new Date().toISOString()); } catch { /* non-fatal */ } + } + } + return diagnoseExpectedArtifact(unitType, unitId, base); } diff --git a/src/resources/extensions/gsd/tests/idle-recovery.test.ts b/src/resources/extensions/gsd/tests/idle-recovery.test.ts index 664d1480a..f8940dc61 100644 --- a/src/resources/extensions/gsd/tests/idle-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/idle-recovery.test.ts @@ -294,3 +294,66 @@ test('verifyExpectedArtifact: hook types always return true', () => { } }); + +test('writeBlockerPlaceholder: updates DB task status for execute-task (#2531)', async () => { + const base = createFixtureBase(); + try { + const { openDatabase, closeDatabase, insertMilestone, insertSlice, insertTask, getTask, isDbAvailable } = + await import("../gsd-db.ts"); + + const dbPath = join(base, ".gsd", "gsd.db"); + // Create the tasks directory (required for artifact path resolution) + mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); + + openDatabase(dbPath); + try { + insertMilestone({ id: "M001", title: "Test", status: "active" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "active" }); + insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", title: "Task", status: "pending" }); + + // Before fix: writeBlockerPlaceholder wrote the file but left DB as "pending" + writeBlockerPlaceholder("execute-task", "M001/S01/T01", base, "idle recovery exhausted"); + + const task = getTask("M001", "S01", "T01"); + assert.equal(task?.status, "complete", + "writeBlockerPlaceholder must update DB task status to 'complete' so verifyExpectedArtifact passes"); + + // Verify the full chain works: verifyExpectedArtifact should return true + const verified = verifyExpectedArtifact("execute-task", "M001/S01/T01", base); + assert.equal(verified, true, + "verifyExpectedArtifact should pass after writeBlockerPlaceholder updates DB status"); + } finally { + if (isDbAvailable()) closeDatabase(); + } + } finally { + cleanup(base); + } +}); + +test('writeBlockerPlaceholder: does NOT update DB for non-execute-task types', async () => { + const base = createFixtureBase(); + try { + const { openDatabase, closeDatabase, insertMilestone, insertSlice, getSlice, isDbAvailable } = + await import("../gsd-db.ts"); + + const dbPath = join(base, ".gsd", "gsd.db"); + mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01"), { recursive: true }); + + openDatabase(dbPath); + try { + insertMilestone({ id: "M001", title: "Test", status: "active" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Slice", status: "active" }); + + // research-slice is NOT execute-task — DB should NOT be updated + writeBlockerPlaceholder("research-slice", "M001/S01", base, "idle recovery exhausted"); + + const slice = getSlice("M001", "S01"); + assert.equal(slice?.status, "active", + "writeBlockerPlaceholder should not change DB status for non-execute-task types"); + } finally { + if (isDbAvailable()) closeDatabase(); + } + } finally { + cleanup(base); + } +});