diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index bc41ab6ed..08c63026b 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -55,6 +55,7 @@ import { getSlice, insertMilestone, insertSlice, + insertTask, updateTaskStatus, getPendingSliceGateCount, type MilestoneRow, @@ -738,6 +739,44 @@ export async function deriveStateFromDb(basePath: string): Promise { // ── Get tasks from DB ──────────────────────────────────────────────── let tasks = getSliceTasks(activeMilestone.id, activeSlice.id); + // ── Reconcile missing tasks: plan file has tasks but DB is empty (#3600) ── + // When the planning agent writes S##-PLAN.md with task entries but never + // calls the gsd_plan_slice persistence tool, the DB has zero task rows + // even though the plan file contains valid tasks. Without this reconciliation, + // deriveState returns phase='planning' forever — the dispatcher re-dispatches + // plan-slice in an infinite loop. + if (tasks.length === 0 && planFile) { + try { + const planContent = await loadFile(planFile); + if (planContent) { + const diskPlan = parsePlan(planContent); + if (diskPlan.tasks.length > 0) { + for (let i = 0; i < diskPlan.tasks.length; i++) { + const t = diskPlan.tasks[i]; + try { + insertTask({ + id: t.id, + sliceId: activeSlice.id, + milestoneId: activeMilestone.id, + title: t.title, + status: t.done ? 'complete' : 'pending', + sequence: i + 1, + }); + } catch (insertErr) { + // Task may already exist from a partial previous import — skip + logWarning("reconcile", `failed to insert task ${t.id} from plan file: ${insertErr instanceof Error ? insertErr.message : String(insertErr)}`); + } + } + tasks = getSliceTasks(activeMilestone.id, activeSlice.id); + logWarning("reconcile", `imported ${tasks.length} tasks from plan file for ${activeMilestone.id}/${activeSlice.id} — DB was empty (#3600)`, { mid: activeMilestone.id, sid: activeSlice.id }); + } + } + } catch (err) { + // Non-fatal — fall through to the existing "empty plan" logic + logError("reconcile", `plan-file task import failed for ${activeMilestone.id}/${activeSlice.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + // ── Reconcile stale task status (#2514) ────────────────────────────── // When a session disconnects after the agent writes SUMMARY + VERIFY // artifacts but before postUnitPostVerification updates the DB, tasks diff --git a/src/resources/extensions/gsd/tests/dispatcher-stuck-planning.test.ts b/src/resources/extensions/gsd/tests/dispatcher-stuck-planning.test.ts new file mode 100644 index 000000000..27dd0686a --- /dev/null +++ b/src/resources/extensions/gsd/tests/dispatcher-stuck-planning.test.ts @@ -0,0 +1,37 @@ +/** + * dispatcher-stuck-planning.test.ts — #3656 + * + * Verify that state.ts contains the disk-to-DB task reconciliation logic + * that prevents the dispatcher from getting stuck in an infinite planning + * loop when the planner writes a PLAN.md but never calls the persistence + * tool, leaving the DB with zero task rows. + */ + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const sourceFile = join(__dirname, "..", "state.ts"); + +describe("dispatcher stuck-planning reconciliation (#3656)", () => { + const source = readFileSync(sourceFile, "utf-8"); + + test("imports insertTask from gsd-db", () => { + assert.match(source, /import\s*\{[^}]*insertTask[^}]*\}\s*from/); + }); + + test("contains plan-file task reconciliation block", () => { + assert.match(source, /tasks\.length\s*===\s*0\s*&&\s*planFile/); + }); + + test("calls insertTask for each disk plan task", () => { + assert.match(source, /insertTask\(\{/); + }); + + test("references issue #3600 in reconciliation comment", () => { + assert.match(source, /#3600/); + }); +}); diff --git a/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts b/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts index b9d14baa6..f1b66acb9 100644 --- a/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts +++ b/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts @@ -1040,8 +1040,8 @@ describe("state-machine-full-walkthrough", () => { // FAILURE MODES: What happens when things go wrong // ═══════════════════════════════════════════════════════════════════════════ - describe("Failure: DB has slice but no task rows (partial migration)", () => { - test("DB tasks empty but PLAN on disk has tasks → wrong phase (planning)", async () => { + describe("Recovery: DB has slice but no task rows (partial migration)", () => { + test("DB tasks empty but PLAN on disk has tasks → reconciles to executing", async () => { const base = createFixtureBase(); const dbPath = join(base, ".gsd", "gsd.db"); openDatabase(dbPath); @@ -1056,11 +1056,10 @@ describe("state-machine-full-walkthrough", () => { invalidateStateCache(); const state = await deriveStateFromDb(base); - // BUG: Returns "planning" because getSliceTasks() returns [] - // and line 703 treats empty tasks as "no tasks defined". - // PLAN file on disk has T01/T02 but DB doesn't know about them. - assert.equal(state.phase, "planning", - "KNOWN ISSUE: DB empty tasks → planning even though PLAN has tasks on disk"); + // FIX (#3600): plan-file tasks are now reconciled into the DB, + // so the phase correctly advances to executing instead of planning. + assert.equal(state.phase, "executing", + "reconciled plan-file tasks → executing (not stuck in planning)"); }); });