This commit is contained in:
parent
f5e9b00f47
commit
2ee0e5ee17
2 changed files with 158 additions and 13 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue