From ac3853f20ccc903f7aad44ea6e9ef9a1123e0735 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 16 Mar 2026 18:24:08 -0500 Subject: [PATCH] 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. --- src/resources/extensions/gsd/auto-dispatch.ts | 17 ++++- src/resources/extensions/gsd/auto-recovery.ts | 28 +++++++- src/resources/extensions/gsd/index.ts | 17 +++++ .../extensions/gsd/prompts/research-slice.md | 2 +- .../gsd/tests/auto-recovery.test.ts | 70 +++++++++++++++++++ 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index a280a37c8..4f82ff43c 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -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", diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index 4b304d356..ff9c852f7 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -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 diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 346e122f9..8a3cc6d6e 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -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)", diff --git a/src/resources/extensions/gsd/prompts/research-slice.md b/src/resources/extensions/gsd/prompts/research-slice.md index 2a749a669..c440851e1 100644 --- a/src/resources/extensions/gsd/prompts/research-slice.md +++ b/src/resources/extensions/gsd/prompts/research-slice.md @@ -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. diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts index 4ea508ac4..6230ea0e0 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -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); + } +});