Merge pull request #3550 from Tibsfox/fix/stale-state-md-guided-flow
fix(gsd): rebuild STATE.md before guided-flow dispatch
This commit is contained in:
commit
c3c0fb782a
3 changed files with 124 additions and 2 deletions
|
|
@ -87,7 +87,8 @@ function validatePreferenceShape(preferences: GSDPreferences): string[] {
|
|||
return issues;
|
||||
}
|
||||
|
||||
function buildStateMarkdown(state: Awaited<ReturnType<typeof deriveState>>): string {
|
||||
/** Build STATE.md content from derived state. Exported for guided-flow pre-dispatch rebuild (#3475). */
|
||||
export function buildStateMarkdown(state: Awaited<ReturnType<typeof deriveState>>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# GSD State", "");
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent";
|
||||
import { showNextAction } from "../shared/tui.js";
|
||||
import { loadFile } from "./files.js";
|
||||
import { loadFile, saveFile } from "./files.js";
|
||||
import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js";
|
||||
import { parseRoadmapSlices } from "./roadmap-slices.js";
|
||||
import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
|
||||
|
|
@ -596,6 +596,16 @@ export async function showDiscuss(
|
|||
|
||||
const state = await deriveState(basePath);
|
||||
|
||||
// Rebuild STATE.md from derived state before any dispatch (#3475).
|
||||
// Without this, guided prompts read a stale STATE.md cache and the
|
||||
// agent bootstraps from the wrong milestone.
|
||||
try {
|
||||
const { buildStateMarkdown } = await import("./doctor.js");
|
||||
await saveFile(resolveGsdRootFile(basePath, "STATE"), buildStateMarkdown(state));
|
||||
} catch (err) {
|
||||
logWarning("guided", `STATE.md rebuild failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// No active milestone (or corrupted milestone with undefined id) —
|
||||
// check for pending milestones to discuss instead
|
||||
if (!state.activeMilestone?.id) {
|
||||
|
|
@ -1149,6 +1159,14 @@ export async function showSmartEntry(
|
|||
|
||||
const state = await deriveState(basePath);
|
||||
|
||||
// Rebuild STATE.md from derived state before any dispatch (#3475).
|
||||
try {
|
||||
const { buildStateMarkdown } = await import("./doctor.js");
|
||||
await saveFile(resolveGsdRootFile(basePath, "STATE"), buildStateMarkdown(state));
|
||||
} catch (err) {
|
||||
logWarning("guided", `STATE.md rebuild failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
if (!state.activeMilestone?.id) {
|
||||
// Guard: if a discuss session is already in flight, don't re-inject the prompt.
|
||||
// Both /gsd and /gsd auto reach this branch when no milestone exists yet.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Regression test for #3475: guided-flow must rebuild STATE.md from derived
|
||||
* state before dispatching workflows.
|
||||
*
|
||||
* Verifies that buildStateMarkdown() produces content matching the derived
|
||||
* state (not a stale on-disk cache), and that the rebuild helper is wired
|
||||
* correctly from doctor.ts.
|
||||
*/
|
||||
|
||||
import { describe, test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { deriveState, invalidateStateCache } from "../state.ts";
|
||||
import { buildStateMarkdown, rebuildState } from "../doctor.ts";
|
||||
import { resolveGsdRootFile } from "../paths.ts";
|
||||
import {
|
||||
openDatabase,
|
||||
closeDatabase,
|
||||
insertMilestone,
|
||||
insertSlice,
|
||||
insertTask,
|
||||
} from "../gsd-db.ts";
|
||||
|
||||
function createFixtureBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-guided-state-"));
|
||||
mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
|
||||
return base;
|
||||
}
|
||||
|
||||
function writeFile(base: string, relativePath: string, content: string): void {
|
||||
const full = join(base, ".gsd", relativePath);
|
||||
mkdirSync(join(full, ".."), { recursive: true });
|
||||
writeFileSync(full, content);
|
||||
}
|
||||
|
||||
describe("guided-flow STATE.md rebuild (#3475)", () => {
|
||||
let base: string;
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (base) rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("rebuildState writes STATE.md matching derived state, not stale cache", async () => {
|
||||
base = createFixtureBase();
|
||||
openDatabase(":memory:");
|
||||
|
||||
// Set up real active milestone M010
|
||||
insertMilestone({ id: "M010", title: "Real Active", status: "active" });
|
||||
insertSlice({ id: "S03", milestoneId: "M010", title: "Slice Three", status: "active", risk: "low", depends: [] });
|
||||
insertTask({ id: "T05", sliceId: "S03", milestoneId: "M010", title: "Task Five", status: "pending" });
|
||||
writeFile(base, "milestones/M010/M010-CONTEXT.md", "# M010: Real Active\n\nReal work here.");
|
||||
writeFile(base, "milestones/M010/M010-ROADMAP.md", "# M010\n\n## Slices\n\n- [ ] **S03: Slice Three**");
|
||||
|
||||
// Write a STALE STATE.md pointing to wrong milestone
|
||||
writeFile(base, "STATE.md", [
|
||||
"# GSD State",
|
||||
"",
|
||||
"**Active Milestone:** M008: Old Queued",
|
||||
"**Active Slice:** None",
|
||||
"**Phase:** pre-planning",
|
||||
"",
|
||||
"## Next Action",
|
||||
"Milestone M008 has a roadmap but no slices defined.",
|
||||
].join("\n"));
|
||||
|
||||
// Derive state — should return M010
|
||||
invalidateStateCache();
|
||||
const state = await deriveState(base);
|
||||
assert.equal(state.activeMilestone?.id, "M010", "Derived state should be M010");
|
||||
|
||||
// Rebuild STATE.md
|
||||
await rebuildState(base);
|
||||
|
||||
// Read the rebuilt STATE.md
|
||||
const statePath = resolveGsdRootFile(base, "STATE");
|
||||
const rebuilt = readFileSync(statePath, "utf-8");
|
||||
|
||||
// Should contain M010, NOT M008
|
||||
assert.ok(rebuilt.includes("M010"), "Rebuilt STATE.md should reference M010");
|
||||
assert.ok(!rebuilt.includes("M008"), "Rebuilt STATE.md should NOT reference stale M008");
|
||||
});
|
||||
|
||||
test("buildStateMarkdown produces correct active milestone from GSDState", async () => {
|
||||
base = createFixtureBase();
|
||||
openDatabase(":memory:");
|
||||
|
||||
insertMilestone({ id: "M070", title: "Current Work", status: "active" });
|
||||
insertSlice({ id: "S01", milestoneId: "M070", title: "First Slice", status: "active", risk: "low", depends: [] });
|
||||
writeFile(base, "milestones/M070/M070-CONTEXT.md", "# M070: Current Work");
|
||||
writeFile(base, "milestones/M070/M070-ROADMAP.md", "# M070\n\n## Slices\n\n- [ ] **S01: First Slice**");
|
||||
|
||||
invalidateStateCache();
|
||||
const state = await deriveState(base);
|
||||
const md = buildStateMarkdown(state);
|
||||
|
||||
assert.ok(md.includes("M070"), "State markdown should include active milestone M070");
|
||||
assert.ok(md.includes("Current Work") || md.includes("M070"), "State markdown should include milestone title or ID");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue