fix: auto-heal STATE.md missing in preDispatchHealthGate (#862)

This commit is contained in:
Jeremy McSpadden 2026-03-17 09:20:10 -05:00 committed by GitHub
parent c209dd1118
commit d06e9ca12e
4 changed files with 188 additions and 11 deletions

View file

@ -2102,7 +2102,7 @@ async function dispatchNextUnit(
// Lightweight check for critical issues that would cause the next unit
// to fail or corrupt state. Auto-heals what it can, blocks on the rest.
try {
const healthGate = preDispatchHealthGate(basePath);
const healthGate = await preDispatchHealthGate(basePath);
if (healthGate.fixesApplied.length > 0) {
ctx.ui.notify(`Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info");
}

View file

@ -19,6 +19,7 @@ import { join } from "node:path";
import { gsdRoot, resolveGsdRootFile } from "./paths.js";
import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js";
import { abortAndReset } from "./git-self-heal.js";
import { rebuildState } from "./doctor.js";
// ── Health Score Tracking ──────────────────────────────────────────────────
@ -131,7 +132,7 @@ export interface PreDispatchHealthResult {
*
* Returns { proceed: true } if dispatch should continue.
*/
export function preDispatchHealthGate(basePath: string): PreDispatchHealthResult {
export async function preDispatchHealthGate(basePath: string): Promise<PreDispatchHealthResult> {
const issues: string[] = [];
const fixesApplied: string[] = [];
@ -172,17 +173,17 @@ export function preDispatchHealthGate(basePath: string): PreDispatchHealthResult
}
// ── STATE.md existence check ──
// If STATE.md is missing, deriveState will still work but the LLM
// may get confused. Rebuild it silently.
// If STATE.md is missing, rebuild it now so the next unit has accurate
// context. Non-blocking — if the rebuild throws, dispatch continues anyway.
try {
const stateFile = resolveGsdRootFile(basePath, "STATE");
const milestonesDir = join(gsdRoot(basePath), "milestones");
if (existsSync(milestonesDir) && !existsSync(stateFile)) {
issues.push("STATE.md missing — will rebuild after this unit");
// Don't block dispatch — rebuilding happens in post-hook
await rebuildState(basePath);
fixesApplied.push("rebuilt missing STATE.md before dispatch");
}
} catch {
// Non-fatal
// Non-fatal — dispatch continues without STATE.md if rebuild fails
}
// If we had critical issues that couldn't be auto-healed, block dispatch

View file

@ -188,7 +188,7 @@ async function main(): Promise<void> {
cleanups.push(dir);
mkdirSync(join(dir, ".gsd"), { recursive: true });
const result = preDispatchHealthGate(dir);
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate passes on clean state");
assertEq(result.issues.length, 0, "no issues on clean state");
}
@ -206,7 +206,7 @@ async function main(): Promise<void> {
unitStartedAt: "2026-03-10T00:01:00Z", completedUnits: 3,
}));
const result = preDispatchHealthGate(dir);
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate passes after auto-clearing stale lock");
assertTrue(result.fixesApplied.some(f => f.includes("cleared stale auto.lock")), "reports lock cleared");
assertTrue(!existsSync(join(dir, ".gsd", "auto.lock")), "lock file removed");
@ -222,7 +222,7 @@ async function main(): Promise<void> {
const headHash = run("git rev-parse HEAD", dir);
writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n");
const result = preDispatchHealthGate(dir);
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate passes after auto-healing merge state");
assertTrue(result.fixesApplied.some(f => f.includes("cleaned merge state")), "reports merge state cleaned");
assertTrue(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed");
@ -231,6 +231,26 @@ async function main(): Promise<void> {
console.log(" (skipped on Windows)");
}
console.log("\n=== health gate: STATE.md missing — auto-healed ===");
{
const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-proactive-")));
cleanups.push(dir);
// Minimal .gsd structure: milestones dir exists but no STATE.md
mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true });
const stateFile = join(dir, ".gsd", "STATE.md");
assertTrue(!existsSync(stateFile), "STATE.md does not exist before gate");
const result = await preDispatchHealthGate(dir);
assertTrue(result.proceed, "gate passes after rebuilding STATE.md");
assertTrue(
result.fixesApplied.some(f => f.includes("rebuilt missing STATE.md")),
"reports STATE.md rebuilt",
);
assertTrue(existsSync(stateFile), "STATE.md created by auto-heal");
assertTrue(result.issues.length === 0, "no blocking issues after heal");
}
} finally {
resetProactiveHealing();
for (const dir of cleanups) {

View file

@ -13,7 +13,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { delimiter, join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
@ -235,3 +235,159 @@ test("loadStoredEnvKeys does not overwrite existing env vars", async () => {
rmSync(tmp, { recursive: true, force: true });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 6. State derivation — Gap 2
// ═══════════════════════════════════════════════════════════════════════════
test("deriveState returns pre-planning phase for empty .gsd/ directory", async () => {
const { deriveState } = await import("../resources/extensions/gsd/state.ts");
const tmp = mkdtempSync(join(tmpdir(), "gsd-state-smoke-"));
// Create minimal .gsd/ structure with no milestones
mkdirSync(join(tmp, ".gsd"), { recursive: true });
try {
const state = await deriveState(tmp);
assert.equal(state.phase, "pre-planning",
`expected pre-planning phase for empty .gsd/, got: ${state.phase}`);
assert.equal(state.activeMilestone, null, "no active milestone");
assert.equal(state.activeSlice, null, "no active slice");
assert.equal(state.activeTask, null, "no active task");
assert.ok(Array.isArray(state.blockers), "blockers is an array");
assert.ok(Array.isArray(state.registry), "registry is an array");
assert.equal(state.registry.length, 0, "empty registry");
assert.ok(typeof state.nextAction === "string", "nextAction is a string");
assert.ok(state.nextAction.length > 0, "nextAction is non-empty");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState returns pre-planning phase when no .gsd/ directory exists", async () => {
const { deriveState } = await import("../resources/extensions/gsd/state.ts");
// Use a temp dir with no .gsd/ subdirectory at all
const tmp = mkdtempSync(join(tmpdir(), "gsd-state-nogsd-"));
try {
// Should not throw — missing .gsd/ is a valid "no project" state
const state = await deriveState(tmp);
assert.equal(state.phase, "pre-planning",
`expected pre-planning phase when .gsd/ absent, got: ${state.phase}`);
assert.equal(state.activeMilestone, null, "no active milestone");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("deriveState shape is structurally complete", async () => {
const { deriveState } = await import("../resources/extensions/gsd/state.ts");
const tmp = mkdtempSync(join(tmpdir(), "gsd-state-shape-"));
mkdirSync(join(tmp, ".gsd"), { recursive: true });
try {
const state = await deriveState(tmp);
// All required fields present
const requiredFields = [
"phase", "activeMilestone", "activeSlice", "activeTask",
"recentDecisions", "blockers", "nextAction", "registry",
] as const;
for (const field of requiredFields) {
assert.ok(field in state, `state.${field} should be present`);
}
// phase is a known string value
const validPhases = [
"pre-planning", "needs-discussion", "researching", "planning",
"executing", "summarizing", "replanning-slice", "validating-milestone",
"completing-milestone", "complete", "blocked",
];
assert.ok(validPhases.includes(state.phase),
`state.phase '${state.phase}' should be a known phase`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// 7. Doctor health checks — Gap 3
// ═══════════════════════════════════════════════════════════════════════════
test("runGSDDoctor completes without throwing on empty .gsd/ directory", async () => {
const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts");
const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-smoke-"));
mkdirSync(join(tmp, ".gsd"), { recursive: true });
try {
// audit-only mode (fix: false) — should never throw
const report = await runGSDDoctor(tmp, { fix: false });
// Structural assertions on the DoctorReport
assert.ok(typeof report === "object" && report !== null, "report is an object");
assert.ok("ok" in report, "report has ok field");
assert.ok("issues" in report, "report has issues field");
assert.ok("fixesApplied" in report, "report has fixesApplied field");
assert.ok("basePath" in report, "report has basePath field");
assert.ok(Array.isArray(report.issues), "report.issues is an array");
assert.ok(Array.isArray(report.fixesApplied), "report.fixesApplied is an array");
assert.equal(typeof report.ok, "boolean", "report.ok is a boolean");
assert.equal(report.fixesApplied.length, 0, "no fixes applied in audit mode");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("runGSDDoctor issue objects have required fields", async () => {
const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts");
const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-fields-"));
mkdirSync(join(tmp, ".gsd"), { recursive: true });
// Create a milestone dir with no ROADMAP.md to force a missing_roadmap issue
const mDir = join(tmp, ".gsd", "milestones", "M001");
mkdirSync(mDir, { recursive: true });
writeFileSync(join(mDir, "M001-CONTEXT.md"), "# Context\n");
try {
const report = await runGSDDoctor(tmp, { fix: false });
// Should find at least one issue (missing roadmap for M001)
assert.ok(report.issues.length > 0, "expected at least one issue for milestone missing ROADMAP.md");
// Verify structure of each issue
for (const issue of report.issues) {
assert.ok(typeof issue.severity === "string", "issue.severity is a string");
assert.ok(["info", "warning", "error"].includes(issue.severity),
`issue.severity '${issue.severity}' should be info|warning|error`);
assert.ok(typeof issue.code === "string", "issue.code is a string");
assert.ok(typeof issue.message === "string", "issue.message is a string");
assert.ok(issue.message.length > 0, "issue.message is non-empty");
assert.ok(typeof issue.fixable === "boolean", "issue.fixable is a boolean");
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("runGSDDoctor with fix:false never modifies the filesystem", async () => {
const { runGSDDoctor } = await import("../resources/extensions/gsd/doctor.ts");
const tmp = mkdtempSync(join(tmpdir(), "gsd-doctor-readonly-"));
const gsdDir = join(tmp, ".gsd");
mkdirSync(gsdDir, { recursive: true });
// Write a sentinel file — doctor must not delete or modify it
const sentinelPath = join(gsdDir, "SENTINEL.md");
writeFileSync(sentinelPath, "# sentinel\n");
try {
await runGSDDoctor(tmp, { fix: false });
assert.ok(existsSync(sentinelPath), "sentinel file still exists after audit-only run");
const content = readFileSync(sentinelPath, "utf-8");
assert.equal(content, "# sentinel\n", "sentinel file content unchanged");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});