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:
parent
605fa6803a
commit
accb327552
4 changed files with 152 additions and 0 deletions
|
|
@ -839,6 +839,7 @@ function buildLoopDeps(): LoopDeps {
|
|||
// State and cache
|
||||
invalidateAllCaches,
|
||||
deriveState,
|
||||
rebuildState,
|
||||
loadEffectiveGSDPreferences,
|
||||
|
||||
// Pre-dispatch health gate
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue