fix: dispatch plan-slice when task plans missing instead of hard-stop (#909) (#915)

This commit is contained in:
Jeremy McSpadden 2026-03-17 12:44:42 -05:00 committed by GitHub
parent f5e9b00f47
commit 2ee0e5ee17
2 changed files with 158 additions and 13 deletions

View file

@ -241,6 +241,32 @@ const DISPATCH_RULES: DispatchRule[] = [
};
},
},
{
name: "executing → execute-task (recover missing task plan → plan-slice)",
match: async ({ state, mid, midTitle, basePath }) => {
if (state.phase !== "executing" || !state.activeTask) return null;
const sid = state.activeSlice!.id;
const sTitle = state.activeSlice!.title;
const tid = state.activeTask.id;
// Guard: if the slice plan exists but the individual task plan files are
// missing, the planner created S##-PLAN.md with task entries but never
// wrote the tasks/ directory files. Dispatch plan-slice to regenerate
// them rather than hard-stopping — fixes the infinite-loop described in
// issue #909.
const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN");
if (!taskPlanPath || !existsSync(taskPlanPath)) {
return {
action: "dispatch",
unitType: "plan-slice",
unitId: `${mid}/${sid}`,
prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath),
};
}
return null;
},
},
{
name: "executing → execute-task",
match: async ({ state, mid, basePath }) => {
@ -250,19 +276,6 @@ const DISPATCH_RULES: DispatchRule[] = [
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

@ -0,0 +1,132 @@
/**
* Regression test for issue #909.
*
* When S##-PLAN.md exists (causing deriveState phase:'executing') but the
* individual task plan files (tasks/T01-PLAN.md, etc.) are absent, the dispatch
* table must recover by re-running plan-slice NOT hard-stop.
*
* Prior behaviour: action:"stop" infinite loop on restart.
* Fixed behaviour: action:"dispatch" unitType:"plan-slice".
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { resolveDispatch } from "../auto-dispatch.ts";
import type { DispatchContext } from "../auto-dispatch.ts";
import type { GSDState } from "../types.ts";
function makeState(overrides: Partial<GSDState> = {}): GSDState {
return {
activeMilestone: { id: "M002", title: "Test Milestone" },
activeSlice: { id: "S03", title: "Third Slice" },
activeTask: { id: "T01", title: "First Task" },
phase: "executing",
recentDecisions: [],
blockers: [],
nextAction: "",
registry: [],
...overrides,
};
}
function makeContext(basePath: string, stateOverrides?: Partial<GSDState>): DispatchContext {
return {
basePath,
mid: "M002",
midTitle: "Test Milestone",
state: makeState(stateOverrides),
prefs: undefined,
};
}
// ─── Scaffold helpers ──────────────────────────────────────────────────────
function scaffoldSlicePlan(basePath: string, mid: string, sid: string): void {
const dir = join(basePath, ".gsd", "milestones", mid, "slices", sid);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${sid}-PLAN.md`), [
`# ${sid}: Third Slice`,
"",
"## Tasks",
"- [ ] **T01: Do something** `est:1h`",
"- [ ] **T02: Do another thing** `est:30m`",
"",
].join("\n"));
}
function scaffoldTaskPlan(basePath: string, mid: string, sid: string, tid: string): void {
const dir = join(basePath, ".gsd", "milestones", mid, "slices", sid, "tasks");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${tid}-PLAN.md`), [
`# ${tid}: Do something`,
"",
"## Steps",
"- [ ] Step 1",
"",
].join("\n"));
}
// ─── Tests ─────────────────────────────────────────────────────────────────
test("dispatch: missing task plan triggers plan-slice (not stop) — issue #909", async () => {
const tmp = mkdtempSync(join(tmpdir(), "gsd-909-"));
try {
// Slice plan exists with tasks, but tasks/ directory is empty
scaffoldSlicePlan(tmp, "M002", "S03");
const ctx = makeContext(tmp);
const result = await resolveDispatch(ctx);
assert.equal(result.action, "dispatch", "should dispatch, not stop");
assert.ok(result.action === "dispatch" && result.unitType === "plan-slice",
`unitType should be plan-slice, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03",
`unitId should be M002/S03, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("dispatch: present task plan proceeds to execute-task normally", async () => {
const tmp = mkdtempSync(join(tmpdir(), "gsd-909-ok-"));
try {
scaffoldSlicePlan(tmp, "M002", "S03");
scaffoldTaskPlan(tmp, "M002", "S03", "T01");
const ctx = makeContext(tmp);
const result = await resolveDispatch(ctx);
assert.equal(result.action, "dispatch");
assert.ok(result.action === "dispatch" && result.unitType === "execute-task",
`unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`);
assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01",
`unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async () => {
// Simulate: plan-slice ran but T01-PLAN.md is still missing (e.g. agent crashed mid-write).
// Dispatch should still re-dispatch plan-slice, not hard-stop.
const tmp = mkdtempSync(join(tmpdir(), "gsd-909-loop-"));
try {
scaffoldSlicePlan(tmp, "M002", "S03");
const ctx = makeContext(tmp);
const r1 = await resolveDispatch(ctx);
assert.equal(r1.action, "dispatch");
assert.ok(r1.action === "dispatch" && r1.unitType === "plan-slice");
// Still no task plan written — dispatch again
const r2 = await resolveDispatch(ctx);
assert.equal(r2.action, "dispatch");
assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice",
"should keep dispatching plan-slice until task plans appear");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});