From 2ee0e5ee17774c29d94af2810611dd5d533de065 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Tue, 17 Mar 2026 12:44:42 -0500 Subject: [PATCH] fix: dispatch plan-slice when task plans missing instead of hard-stop (#909) (#915) --- src/resources/extensions/gsd/auto-dispatch.ts | 39 ++++-- .../tests/dispatch-missing-task-plans.test.ts | 132 ++++++++++++++++++ 2 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts diff --git a/src/resources/extensions/gsd/auto-dispatch.ts b/src/resources/extensions/gsd/auto-dispatch.ts index 064687e80..8cd4e2ce6 100644 --- a/src/resources/extensions/gsd/auto-dispatch.ts +++ b/src/resources/extensions/gsd/auto-dispatch.ts @@ -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", diff --git a/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts b/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts new file mode 100644 index 000000000..1c92b64a0 --- /dev/null +++ b/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts @@ -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 { + 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): 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 }); + } +});