Merge pull request #3656 from Tibsfox/fix/auto-dispatch-planning-stuck

This commit is contained in:
Jeremy McSpadden 2026-04-07 04:22:49 -05:00 committed by GitHub
commit 69007b594f
3 changed files with 82 additions and 7 deletions

View file

@ -55,6 +55,7 @@ import {
getSlice,
insertMilestone,
insertSlice,
insertTask,
updateTaskStatus,
getPendingSliceGateCount,
type MilestoneRow,
@ -738,6 +739,44 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
// ── 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

View file

@ -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/);
});
});

View file

@ -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)");
});
});