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:
parent
d10412bb1e
commit
ac3853f20c
5 changed files with 130 additions and 4 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue