fix: rebuild STATE.md and reset completed-units on milestone transition (#1576) (#1775)

After milestone transitions in auto-mode, STATE.md remained stale because
rebuildState() was never called. Additionally, completed-units.json retained
entries from the previous milestone, causing dispatch to skip units in the
new milestone context. This adds rebuildState() to the milestone transition
block (bypassing the 30-second throttle) and resets completed-units tracking
when the active milestone changes.

Closes #1576

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-21 09:33:07 -06:00 committed by GitHub
parent 605fa6803a
commit accb327552
4 changed files with 152 additions and 0 deletions

View file

@ -839,6 +839,7 @@ function buildLoopDeps(): LoopDeps {
// State and cache
invalidateAllCaches,
deriveState,
rebuildState,
loadEffectiveGSDPreferences,
// Pre-dispatch health gate

View file

@ -53,6 +53,7 @@ export interface LoopDeps {
// State and cache functions
invalidateAllCaches: () => void;
deriveState: (basePath: string) => Promise<GSDState>;
rebuildState: (basePath: string) => Promise<void>;
loadEffectiveGSDPreferences: () =>
| { preferences?: GSDPreferences }
| undefined;

View file

@ -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) {

View file

@ -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<void>"),
"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 });
}
});