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