From b1d9798e301429ef988504a8d02b92f35a7db13e Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 17:51:33 -0700 Subject: [PATCH 1/3] fix(gsd): reconcile plan-file tasks into DB when planner skips persistence (#3600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 on disk contains valid tasks. This causes deriveState to return phase='planning' forever — the auto-mode dispatcher re-dispatches plan-slice in an infinite loop. Add a reconciliation step in deriveStateFromDb: when the DB returns zero tasks but the plan file exists and contains parsed tasks, import them into the DB so the state machine can advance past planning into execution. This mirrors the existing #2514 reconciliation pattern for stale task status. Fixes #3600 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/state.ts | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) 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 From 9475757d9a38c0a66e70a09abb3e3876ef1d55e0 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 22:25:10 -0700 Subject: [PATCH 2/3] test: add regression test for dispatcher stuck-planning reconciliation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/dispatcher-stuck-planning.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/dispatcher-stuck-planning.test.ts 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/); + }); +}); From f8e98de19fd275f3868fd98420c1c4e0ec5844c1 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 22:42:17 -0700 Subject: [PATCH 3/3] fix(test): update stuck-planning test to expect executing after reconciliation The fix reconciles plan-file tasks into the DB when the planner skips persistence, so the phase correctly advances to executing instead of remaining stuck in planning. Update the known-issue test to expect the fixed behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/state-machine-full-walkthrough.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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)"); }); });