fix: auto-heal STATE.md missing in preDispatchHealthGate (#862)
This commit is contained in:
parent
c209dd1118
commit
d06e9ca12e
4 changed files with 188 additions and 11 deletions
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue