diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 3bffee4b8..e0a10b4cd 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -39,7 +39,7 @@ import { } from "./auto-recovery.js"; import { regenerateIfMissing } from "./workflow-projections.js"; import { syncStateToProjectRoot } from "./auto-worktree.js"; -import { isDbAvailable, getTask, getSlice, getMilestone, updateTaskStatus, _getAdapter } from "./gsd-db.js"; +import { isDbAvailable, getTask, getSlice, getMilestone, updateTaskStatus, updateSliceStatus, _getAdapter } from "./gsd-db.js"; import { renderPlanCheckboxes } from "./markdown-renderer.js"; import { consumeSignal } from "./session-status-io.js"; import { @@ -161,7 +161,14 @@ export function detectRogueFileWrites( const dbRow = getSlice(mid, sid); if (!dbRow || dbRow.status !== "complete") { - rogues.push({ path: summaryPath, unitType, unitId }); + // Auto-remediate: SUMMARY exists on disk but DB is stale — sync DB to + // match filesystem instead of reporting as rogue (#3633). + try { + updateSliceStatus(mid, sid, "complete", new Date().toISOString()); + } catch { + // If DB update fails, fall back to rogue detection so the issue is visible + rogues.push({ path: summaryPath, unitType, unitId }); + } } } else if (unitType === "plan-milestone") { if (!mid) return []; diff --git a/src/resources/extensions/gsd/tests/auto-remediate-slice-status.test.ts b/src/resources/extensions/gsd/tests/auto-remediate-slice-status.test.ts new file mode 100644 index 000000000..552096d00 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-remediate-slice-status.test.ts @@ -0,0 +1,56 @@ +/** + * Regression test for #3673 — auto-remediate stale slice DB status + * + * When complete-slice fails after writing SUMMARY.md but before calling + * updateSliceStatus(), the DB stays stale and the post-unit check + * previously reported this as a "rogue" artifact, causing infinite + * re-dispatch. The fix calls updateSliceStatus() to sync the DB. + * + * This structural test verifies updateSliceStatus is imported and called + * in the complete-slice branch of auto-post-unit.ts. + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const source = readFileSync(join(__dirname, '..', 'auto-post-unit.ts'), 'utf-8'); + +describe('auto-remediate stale slice status (#3673)', () => { + test('updateSliceStatus is imported from gsd-db', () => { + assert.match(source, /import\s*\{[^}]*updateSliceStatus[^}]*\}\s*from\s*["']\.\/gsd-db/, + 'updateSliceStatus should be imported from gsd-db'); + }); + + test('updateSliceStatus is called with "complete" status', () => { + assert.match(source, /updateSliceStatus\(mid,\s*sid,\s*["']complete["']/, + 'updateSliceStatus should be called with "complete" status'); + }); + + test('remediation is wrapped in try-catch for fallback to rogue detection', () => { + // The updateSliceStatus call should be in a try block with a catch + // that falls back to rogues.push + const updateIdx = source.indexOf('updateSliceStatus(mid, sid'); + assert.ok(updateIdx > 0, 'updateSliceStatus call should exist'); + + // Find surrounding try-catch + const before = source.slice(Math.max(0, updateIdx - 200), updateIdx); + assert.match(before, /try\s*\{/, + 'updateSliceStatus should be inside a try block'); + + const after = source.slice(updateIdx, updateIdx + 300); + assert.match(after, /catch/, + 'try block should have a catch for fallback'); + }); + + test('rogue detection still exists as fallback', () => { + // rogues.push should appear in the catch block + assert.match(source, /rogues\.push\(\{.*path:\s*summaryPath/, + 'rogues.push fallback should still exist'); + }); +}); diff --git a/src/resources/extensions/gsd/tests/rogue-file-detection.test.ts b/src/resources/extensions/gsd/tests/rogue-file-detection.test.ts index e0fd6c00e..09110adf7 100644 --- a/src/resources/extensions/gsd/tests/rogue-file-detection.test.ts +++ b/src/resources/extensions/gsd/tests/rogue-file-detection.test.ts @@ -149,7 +149,7 @@ test("rogue detection: DB not available → returns empty array (graceful degrad } }); -test("rogue detection: slice summary on disk, no DB row → detected as rogue", () => { +test("rogue detection: slice summary on disk, no DB row → auto-remediated (not rogue)", () => { const basePath = createTmpBase(); const dbPath = join(basePath, ".gsd", "gsd.db"); mkdirSync(join(basePath, ".gsd"), { recursive: true }); @@ -160,11 +160,10 @@ test("rogue detection: slice summary on disk, no DB row → detected as rogue", const summaryPath = createSliceSummaryOnDisk(basePath, "M001", "S01"); assert.ok(existsSync(summaryPath), "Slice summary file should exist on disk"); + // Fix #3633: stale slice DB status is auto-remediated via updateSliceStatus() + // instead of being reported as rogue, so rogues array should be empty. const rogues = detectRogueFileWrites("complete-slice", "M001/S01", basePath); - assert.equal(rogues.length, 1, "Should detect one rogue slice file"); - assert.equal(rogues[0].path, summaryPath); - assert.equal(rogues[0].unitType, "complete-slice"); - assert.equal(rogues[0].unitId, "M001/S01"); + assert.equal(rogues.length, 0, "Should auto-remediate stale slice, not report as rogue"); } finally { closeDatabase(); rmSync(basePath, { recursive: true, force: true });