diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 58a900f7f..4d76027c9 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -839,6 +839,7 @@ function buildLoopDeps(): LoopDeps { // State and cache invalidateAllCaches, deriveState, + rebuildState, loadEffectiveGSDPreferences, // Pre-dispatch health gate diff --git a/src/resources/extensions/gsd/auto/loop-deps.ts b/src/resources/extensions/gsd/auto/loop-deps.ts index 83efeec5e..17d8083d6 100644 --- a/src/resources/extensions/gsd/auto/loop-deps.ts +++ b/src/resources/extensions/gsd/auto/loop-deps.ts @@ -53,6 +53,7 @@ export interface LoopDeps { // State and cache functions invalidateAllCaches: () => void; deriveState: (basePath: string) => Promise; + rebuildState: (basePath: string) => Promise; loadEffectiveGSDPreferences: () => | { preferences?: GSDPreferences } | undefined; diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 322875304..0d02ad777 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -275,6 +275,25 @@ export async function runPreDispatch( ) .map((m: { id: string }) => m.id); deps.pruneQueueOrder(s.basePath, pendingIds); + + // Reset completed-units tracking for the new milestone — stale entries + // from the previous milestone cause the dispatch loop to skip units + // that haven't actually been completed in the new milestone's context. + s.completedUnits = []; + try { + const completedKeysPath = join(gsdRoot(s.basePath), "completed-units.json"); + atomicWriteSync(completedKeysPath, JSON.stringify([], null, 2)); + } catch { /* non-fatal */ } + + // Rebuild STATE.md immediately so it reflects the new active milestone. + // This bypasses the 30-second throttle in the normal rebuild path — + // milestone transitions are rare and important enough to warrant an + // immediate write. + try { + await deps.rebuildState(s.basePath); + } catch { + // Non-fatal — STATE.md will be rebuilt on the next regular cycle + } } if (mid) { diff --git a/src/resources/extensions/gsd/tests/milestone-transition-state-rebuild.test.ts b/src/resources/extensions/gsd/tests/milestone-transition-state-rebuild.test.ts new file mode 100644 index 000000000..f76788deb --- /dev/null +++ b/src/resources/extensions/gsd/tests/milestone-transition-state-rebuild.test.ts @@ -0,0 +1,131 @@ +/** + * milestone-transition-state-rebuild.test.ts — Tests for #1576 fix. + * + * Verifies that: + * 1. rebuildState() is called after milestone transitions so STATE.md + * reflects the new active milestone. + * 2. completed-units.json is reset when the active milestone changes, + * preventing stale entries from causing dispatch skips. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, realpathSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// ─── Source-level checks ────────────────────────────────────────────────────── + +test("auto/phases.ts milestone transition block calls rebuildState", () => { + const phasesSrc = readFileSync( + join(__dirname, "..", "auto", "phases.ts"), + "utf-8", + ); + + // rebuildState must be called within the milestone transition block + assert.ok( + phasesSrc.includes("deps.rebuildState(s.basePath)"), + "auto/phases.ts should call deps.rebuildState(s.basePath) during milestone transition", + ); + + // The rebuildState call must appear AFTER the pruneQueueOrder call + // (i.e. after all transition cleanup is done) + const pruneIdx = phasesSrc.indexOf("deps.pruneQueueOrder(s.basePath, pendingIds)"); + const rebuildIdx = phasesSrc.indexOf("deps.rebuildState(s.basePath)"); + assert.ok(pruneIdx > 0, "pruneQueueOrder should exist in phases.ts"); + assert.ok(rebuildIdx > 0, "rebuildState should exist in phases.ts"); + assert.ok( + rebuildIdx > pruneIdx, + "rebuildState should be called after pruneQueueOrder in the milestone transition block", + ); +}); + +test("auto/phases.ts milestone transition block resets completed-units.json", () => { + const phasesSrc = readFileSync( + join(__dirname, "..", "auto", "phases.ts"), + "utf-8", + ); + + // completed-units.json must be cleared during milestone transition + // Look for the reset pattern within the transition block + const transitionStart = phasesSrc.indexOf("Milestone transition"); + const transitionResetSection = phasesSrc.indexOf( + "s.completedUnits = []", + transitionStart, + ); + assert.ok( + transitionResetSection > 0, + "auto/phases.ts should reset s.completedUnits to [] during milestone transition", + ); + + // The disk file should also be cleared + assert.ok( + phasesSrc.includes('atomicWriteSync(completedKeysPath, JSON.stringify([], null, 2))'), + "auto/phases.ts should write empty array to completed-units.json during milestone transition", + ); +}); + +test("auto/loop-deps.ts LoopDeps interface includes rebuildState", () => { + const loopDepsSrc = readFileSync( + join(__dirname, "..", "auto", "loop-deps.ts"), + "utf-8", + ); + + assert.ok( + loopDepsSrc.includes("rebuildState: (basePath: string) => Promise"), + "LoopDeps interface should declare rebuildState method", + ); +}); + +test("auto.ts buildLoopDeps wires rebuildState", () => { + const autoSrc = readFileSync( + join(__dirname, "..", "auto.ts"), + "utf-8", + ); + + // rebuildState should be in the LoopDeps object literal + const buildLoopDepsIdx = autoSrc.indexOf("function buildLoopDeps()"); + assert.ok(buildLoopDepsIdx > 0, "buildLoopDeps function should exist"); + + const afterBuild = autoSrc.slice(buildLoopDepsIdx); + assert.ok( + afterBuild.includes("rebuildState,") || afterBuild.includes("rebuildState:"), + "buildLoopDeps should include rebuildState in the returned deps object", + ); +}); + +// ─── Functional test: completed-units.json reset ───────────────────────────── + +test("completed-units.json is cleared on milestone transition (functional)", () => { + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-cu-reset-"))); + try { + // Create .gsd directory with a populated completed-units.json + const gsdDir = join(tempDir, ".gsd"); + mkdirSync(gsdDir, { recursive: true }); + + const completedKeysPath = join(gsdDir, "completed-units.json"); + const staleEntries = [ + "context-gather/M001", + "roadmap-plan/M001", + "plan-slice/S01", + "execute-task/T01", + ]; + writeFileSync(completedKeysPath, JSON.stringify(staleEntries, null, 2)); + + // Verify stale entries exist + const before = JSON.parse(readFileSync(completedKeysPath, "utf-8")); + assert.equal(before.length, 4, "Should have 4 stale entries before reset"); + + // Simulate what phases.ts does: write empty array + writeFileSync(completedKeysPath, JSON.stringify([], null, 2)); + + // Verify reset + const after = JSON.parse(readFileSync(completedKeysPath, "utf-8")); + assert.deepEqual(after, [], "completed-units.json should be empty after milestone transition"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +});