From b1d9798e301429ef988504a8d02b92f35a7db13e Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 17:51:33 -0700 Subject: [PATCH] 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