From e39dc7976cd8d032c4d44a22b1a5ed275d88b4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Tue, 24 Mar 2026 22:26:39 -0600 Subject: [PATCH] fix(gsd): insert DB row when generating milestone ID (#2416) gsd_milestone_generate_id creates a minimal DB row (status: 'queued') via INSERT OR IGNORE when generating an ID. This ensures milestones created via /gsd queue or multi-milestone discuss are visible to the state machine from the moment they get an ID, rather than relying on the safety-net reconciliation in deriveStateFromDb(). Co-authored-by: Claude Opus 4.6 (1M context) --- .../extensions/gsd/bootstrap/db-tools.ts | 19 +++++++++++++ .../gsd/tests/derive-state-db.test.ts | 27 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index 759bfe256..70edc4e30 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -248,6 +248,7 @@ export function registerDbTools(pi: ExtensionAPI): void { // This guarantees the ID shown in the UI matches the one materialised on disk. const reserved = claimReservedId(); if (reserved) { + await ensureMilestoneDbRow(reserved); return { content: [{ type: "text" as const, text: reserved }], details: { operation: "generate_milestone_id", id: reserved, source: "reserved" } as any, @@ -259,6 +260,7 @@ export function registerDbTools(pi: ExtensionAPI): void { const uniqueEnabled = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const allIds = [...new Set([...existingIds, ...getReservedMilestoneIds()])]; const newId = nextMilestoneId(allIds, uniqueEnabled); + await ensureMilestoneDbRow(newId); return { content: [{ type: "text" as const, text: newId }], details: { operation: "generate_milestone_id", id: newId, existingCount: existingIds.length, uniqueEnabled } as any, @@ -272,6 +274,23 @@ export function registerDbTools(pi: ExtensionAPI): void { } }; + /** + * Insert a minimal DB row for a milestone ID so it's visible to the state + * machine. Uses INSERT OR IGNORE — safe to call even if gsd_plan_milestone + * later writes the full row. Silently skips if the DB isn't available yet + * (pre-migration). + */ + async function ensureMilestoneDbRow(milestoneId: string): Promise { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) return; + try { + const { insertMilestone } = await import("../gsd-db.js"); + insertMilestone({ id: milestoneId, status: "queued" }); + } catch { + // Non-fatal — the safety-net in deriveStateFromDb will catch this + } + } + const milestoneGenerateIdTool = { name: "gsd_milestone_generate_id", label: "Generate Milestone ID", diff --git a/src/resources/extensions/gsd/tests/derive-state-db.test.ts b/src/resources/extensions/gsd/tests/derive-state-db.test.ts index 2b8d304fb..307a51c29 100644 --- a/src/resources/extensions/gsd/tests/derive-state-db.test.ts +++ b/src/resources/extensions/gsd/tests/derive-state-db.test.ts @@ -11,6 +11,7 @@ import { insertArtifact, isDbAvailable, insertMilestone, + getAllMilestones, insertSlice, insertTask, } from '../gsd-db.ts'; @@ -995,4 +996,30 @@ describe('derive-state-db', async () => { cleanup(base); } }); + + // ─── Queued milestone row not clobbered by later plan (#2416 root cause) ── + test('derive-state-db: queued milestone row survives gsd_plan_milestone INSERT OR IGNORE', async () => { + try { + openDatabase(':memory:'); + + // Simulates gsd_milestone_generate_id inserting a minimal queued row + insertMilestone({ id: 'M001', status: 'queued' }); + + const before = getAllMilestones(); + assert.equal(before.length, 1, 'queued-row: one row after generate_id'); + assert.equal(before[0]!.status, 'queued', 'queued-row: status is queued'); + + // Simulates gsd_plan_milestone calling insertMilestone (INSERT OR IGNORE) + insertMilestone({ id: 'M001', title: 'Planned Title', status: 'active' }); + + const after = getAllMilestones(); + assert.equal(after.length, 1, 'queued-row: still one row after plan'); + // INSERT OR IGNORE keeps the original row — status stays 'queued' + assert.equal(after[0]!.status, 'queued', 'queued-row: INSERT OR IGNORE preserves original status'); + + closeDatabase(); + } finally { + closeDatabase(); + } + }); });