fix(auto): prevent runaway execute-task when task plan missing after failed research (#739)

Four-part fix for the failure chain reported in #739:

1. **Dispatch guard** (auto-dispatch.ts): refuse to dispatch execute-task
   when T{tid}-PLAN.md is missing on disk. Emits a stop action with a
   clear error message instead of sending the agent in blind with a
   missing plan, which was the proximate cause of the runaway session
   and eventual EPIPE crash.

2. **verifyExpectedArtifact for plan-slice** (auto-recovery.ts): after
   verifying S{sid}-PLAN.md exists, also check that every task listed in
   the plan has a corresponding T{tid}-PLAN.md. A plan-slice that wrote
   the slice plan but omitted task plans was previously considered
   complete, allowing the dispatch guard above to be bypassed on
   idempotency replay.

3. **EPIPE guard** (index.ts): register an uncaughtException handler at
   extension load time that catches EPIPE (broken stdio pipe) and exits
   cleanly instead of crashing with an unhandled exception. The crash in
   #739 was triggered by process.stderr.write() calls to a closed pipe
   during LSP diagnostics in the execute-task session.

4. **Prompt hardening** (prompts/research-slice.md): explicitly note that
   the research template is already inlined in the prompt and must not be
   read from disk. The agent in #739 hallucinated a read of
   templates/SLICE-RESEARCH.md (ENOENT), causing the subagent to abort,
   which left no S03-RESEARCH.md and poisoned the downstream plan-slice.
This commit is contained in:
Jeremy McSpadden 2026-03-16 18:24:08 -05:00
parent d10412bb1e
commit ac3853f20c
5 changed files with 130 additions and 4 deletions

View file

@ -14,9 +14,10 @@ import type { GSDPreferences } from "./preferences.js";
import type { UatType } from "./files.js";
import { loadFile, extractUatType, loadActiveOverrides } from "./files.js";
import {
resolveMilestoneFile, resolveSliceFile,
resolveMilestoneFile, resolveSliceFile, resolveTaskFile,
relSliceFile,
} from "./paths.js";
import { existsSync } from "node:fs";
import {
buildResearchMilestonePrompt,
buildPlanMilestonePrompt,
@ -246,6 +247,20 @@ const DISPATCH_RULES: DispatchRule[] = [
const sTitle = state.activeSlice!.title;
const tid = state.activeTask.id;
const tTitle = state.activeTask.title;
// Guard: refuse to dispatch execute-task when the task plan file is missing.
// This prevents the agent from running blind after a failed plan-slice that
// wrote S{sid}-PLAN.md but omitted the individual T{tid}-PLAN.md files.
// (See issue #739 — missing task plan caused runaway execution and EPIPE crash.)
const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN");
if (!taskPlanPath || !existsSync(taskPlanPath)) {
return {
action: "stop",
reason: `Task plan ${tid}-PLAN.md is missing for ${mid}/${sid}/${tid}. Re-run plan-slice to regenerate task plans, or create the file manually and resume.`,
level: "error",
};
}
return {
action: "dispatch",
unitType: "execute-task",

View file

@ -11,7 +11,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent";
import {
clearUnitRuntimeRecord,
} from "./unit-runtime.js";
import { clearParseCache } from "./files.js";
import { clearParseCache, parseRoadmap, parsePlan } from "./files.js";
import {
nativeConflictFiles,
nativeCommit,
@ -36,7 +36,6 @@ import {
clearPathCache,
resolveGsdRootFile,
} from "./paths.js";
import { parseRoadmap } from "./files.js";
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
import { dirname, join } from "node:path";
@ -147,6 +146,31 @@ export function verifyExpectedArtifact(unitType: string, unitId: string, base: s
}
}
// plan-slice must also produce individual task plan files for every task listed
// in the slice plan. Without this check, a plan-slice that wrote S{sid}-PLAN.md
// but omitted T{tid}-PLAN.md files would be marked complete, causing execute-task
// to dispatch with a missing task plan (see issue #739).
if (unitType === "plan-slice") {
const parts = unitId.split("/");
const mid = parts[0];
const sid = parts[1];
if (mid && sid) {
try {
const planContent = readFileSync(absPath, "utf-8");
const plan = parsePlan(planContent);
const tasksDir = resolveTasksDir(base, mid, sid);
if (plan.tasks.length > 0 && tasksDir) {
for (const task of plan.tasks) {
const taskPlanFile = join(tasksDir, `${task.id}-PLAN.md`);
if (!existsSync(taskPlanFile)) return false;
}
}
} catch {
// Parse failure — don't block; slice plan may have non-standard format
}
}
}
// complete-slice must also produce a UAT file AND mark the slice [x] in the roadmap.
// Without the roadmap check, a crash after writing SUMMARY+UAT but before updating
// the roadmap causes an infinite skip loop: the idempotency key says "done" but the

View file

@ -129,6 +129,23 @@ export default function (pi: ExtensionAPI) {
registerWorktreeCommand(pi);
registerExitCommand(pi);
// ── EPIPE guard — prevent crash when stdout/stderr pipe closes unexpectedly ──
// Node.js throws a fatal `Error: write EPIPE` when the parent process closes
// its end of the stdio pipe (e.g. during shell/IPC teardown) while auto-mode
// is still writing diagnostics. Catching this here gives auto-mode a clean
// chance to persist state and pause instead of crashing (see issue #739).
if (!process.listeners("uncaughtException").some(l => l.name === "_gsdEpipeGuard")) {
const _gsdEpipeGuard = (err: Error): void => {
if ((err as NodeJS.ErrnoException).code === "EPIPE") {
// Pipe closed — nothing we can write; just exit cleanly
process.exit(0);
}
// Re-throw anything that isn't EPIPE so real crashes still surface
throw err;
};
process.on("uncaughtException", _gsdEpipeGuard);
}
// ── /kill — immediate exit (bypass cleanup) ─────────────────────────────
pi.registerCommand("kill", {
description: "Exit GSD immediately (no cleanup)",

View file

@ -46,7 +46,7 @@ Research what this slice needs. Narrate key findings and surprises as you go —
2. **Skill Discovery ({{skillDiscoveryMode}}):**{{skillDiscoveryInstructions}}
3. Explore relevant code for this slice's scope. For targeted exploration, use `rg`, `find`, and reads. For broad or unfamiliar subsystems, use `scout` to map the relevant area first.
4. Use `resolve_library` / `get_library_docs` for unfamiliar libraries — skip this for libraries already used in the codebase
5. Use the **Research** output template from the inlined context above — include only sections that have real content
5. Use the **Research** output template from the inlined context above — include only sections that have real content. The template is already inlined above; do NOT attempt to read any template file from disk (there is no `templates/SLICE-RESEARCH.md` — the correct template is already present in this prompt).
6. Write `{{outputPath}}`
The slice directory already exists at `{{slicePath}}/`. Do NOT mkdir — just write the file.

View file

@ -320,3 +320,73 @@ test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", ()
cleanup(base);
}
});
// ─── verifyExpectedArtifact: plan-slice task plan check (#739) ────────────
test("verifyExpectedArtifact plan-slice passes when all task plan files exist", () => {
const base = makeTmpBase();
try {
const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
const planContent = [
"# S01: Test Slice",
"",
"## Tasks",
"",
"- [ ] **T01: First task** `est:1h`",
"- [ ] **T02: Second task** `est:2h`",
].join("\n");
writeFileSync(planPath, planContent);
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.");
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan\n\nDo the other thing.");
const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
assert.equal(result, true, "should pass when all task plan files exist");
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact plan-slice fails when a task plan file is missing (#739)", () => {
const base = makeTmpBase();
try {
const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks");
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
const planContent = [
"# S01: Test Slice",
"",
"## Tasks",
"",
"- [ ] **T01: First task** `est:1h`",
"- [ ] **T02: Second task** `est:2h`",
].join("\n");
writeFileSync(planPath, planContent);
// Only write T01-PLAN.md — T02 is missing
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing.");
const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
assert.equal(result, false, "should fail when T02-PLAN.md is missing");
} finally {
cleanup(base);
}
});
test("verifyExpectedArtifact plan-slice passes for plan with no tasks", () => {
const base = makeTmpBase();
try {
const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
const planContent = [
"# S01: Test Slice",
"",
"## Goal",
"",
"Just some documentation updates, no tasks.",
].join("\n");
writeFileSync(planPath, planContent);
const result = verifyExpectedArtifact("plan-slice", "M001/S01", base);
assert.equal(result, true, "should pass when plan has no tasks");
} finally {
cleanup(base);
}
});