Merge pull request #3656 from Tibsfox/fix/auto-dispatch-planning-stuck
This commit is contained in:
commit
69007b594f
3 changed files with 82 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue