Merge pull request #748 from jeremymcs/fix/739-epipe-stale-research-state

fix(auto): prevent runaway execute-task when task plan missing after failed research (#739)
This commit is contained in:
TÂCHES 2026-03-16 20:02:27 -06:00 committed by GitHub
commit ebde7501dd
5 changed files with 137 additions and 6 deletions

View file

@ -14,7 +14,7 @@ import type { GSDPreferences } from "./preferences.js";
import type { UatType } from "./files.js";
import { loadFile, extractUatType, loadActiveOverrides } from "./files.js";
import {
resolveMilestoneFile, resolveMilestonePath, resolveSliceFile,
resolveMilestoneFile, resolveMilestonePath, resolveSliceFile, resolveTaskFile,
relSliceFile, buildMilestoneFileName,
} from "./paths.js";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
@ -249,6 +249,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";
@ -161,6 +160,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

@ -343,7 +343,8 @@ test("verifyExpectedArtifact accepts plan-slice with actual tasks", () => {
const base = makeTmpBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
const tasksDir = join(sliceDir, "tasks");
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
"# S01: Test Slice",
"",
@ -352,6 +353,8 @@ test("verifyExpectedArtifact accepts plan-slice with actual tasks", () => {
"- [ ] **T01: Implement feature** `est:2h`",
"- [ ] **T02: Write tests** `est:1h`",
].join("\n"));
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan");
assert.strictEqual(
verifyExpectedArtifact("plan-slice", "M001/S01", base),
true,
@ -366,7 +369,8 @@ test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => {
const base = makeTmpBase();
try {
const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01");
mkdirSync(sliceDir, { recursive: true });
const tasksDir = join(sliceDir, "tasks");
mkdirSync(tasksDir, { recursive: true });
writeFileSync(join(sliceDir, "S01-PLAN.md"), [
"# S01: Test Slice",
"",
@ -375,6 +379,8 @@ test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => {
"- [x] **T01: Implement feature** `est:2h`",
"- [ ] **T02: Write tests** `est:1h`",
].join("\n"));
writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan");
writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan");
assert.strictEqual(
verifyExpectedArtifact("plan-slice", "M001/S01", base),
true,
@ -384,3 +390,73 @@ test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => {
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 fails for plan with no tasks (#699)", () => {
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, false, "should fail when plan has no task entries (empty scaffold, #699)");
} finally {
cleanup(base);
}
});