diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index dc37405f7..a3694c61d 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -48,6 +48,7 @@ import { getSliceTasks, getReplanHistory, getSlice, + insertMilestone, type MilestoneRow, type SliceRow, type TaskRow, @@ -257,7 +258,24 @@ function isStatusDone(status: string): boolean { export async function deriveStateFromDb(basePath: string): Promise { const requirements = parseRequirementCounts(await loadFile(resolveGsdRootFile(basePath, "REQUIREMENTS"))); - const allMilestones = getAllMilestones(); + let allMilestones = getAllMilestones(); + + // Incremental disk→DB sync: milestone directories created outside the DB + // write path (via /gsd queue, manual mkdir, or complete-milestone writing the + // next CONTEXT.md) are never inserted by the initial migration guard in + // auto-start.ts because that guard only runs when gsd.db doesn't exist yet. + // Reconcile here so deriveStateFromDb never silently misses queued milestones. + // insertMilestone uses INSERT OR IGNORE, so this is safe to call every time. + const dbIdSet = new Set(allMilestones.map(m => m.id)); + const diskIds = findMilestoneIds(basePath); + let synced = false; + for (const diskId of diskIds) { + if (!dbIdSet.has(diskId) && !isGhostMilestone(basePath, diskId)) { + insertMilestone({ id: diskId, status: 'active' }); + synced = true; + } + } + if (synced) allMilestones = getAllMilestones(); // Parallel worker isolation: when locked, filter to just the locked milestone const milestoneLock = process.env.GSD_MILESTONE_LOCK; 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 f50618f89..2b8d304fb 100644 --- a/src/resources/extensions/gsd/tests/derive-state-db.test.ts +++ b/src/resources/extensions/gsd/tests/derive-state-db.test.ts @@ -962,4 +962,37 @@ describe('derive-state-db', async () => { cleanup(base); } }); + + // ─── Regression: disk-only milestones synced into DB (#2416) ───────── + test('derive-state-db: disk-only milestone auto-synced into DB (#2416)', async () => { + const base = createFixtureBase(); + try { + // M001 is complete and exists in DB. M002 was queued on disk only — no DB row. + writeFile(base, 'milestones/M001/M001-SUMMARY.md', '# M001 Summary\n\nDone.'); + writeFile(base, 'milestones/M002/M002-CONTEXT.md', '# M002: Queued\n\nQueued milestone.'); + + openDatabase(':memory:'); + // Only insert M001 — simulates the state after migration guard ran then /gsd queue added M002 + insertMilestone({ id: 'M001', title: 'First', status: 'complete' }); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + // Before the fix, M002 was invisible: getAllMilestones() returned only M001 + // (complete) → phase='complete' → auto-mode stopped. + // After the fix, deriveStateFromDb reconciles disk dirs and inserts M002. + assert.deepStrictEqual(state.phase, 'pre-planning', 'disk-sync-2416: phase is pre-planning, not complete'); + assert.deepStrictEqual(state.registry.length, 2, 'disk-sync-2416: both milestones visible in registry'); + assert.deepStrictEqual(state.registry[0]?.id, 'M001', 'disk-sync-2416: registry[0] is M001'); + assert.deepStrictEqual(state.registry[0]?.status, 'complete', 'disk-sync-2416: M001 is complete'); + assert.deepStrictEqual(state.registry[1]?.id, 'M002', 'disk-sync-2416: registry[1] is M002'); + assert.deepStrictEqual(state.registry[1]?.status, 'active', 'disk-sync-2416: M002 is active'); + assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'disk-sync-2416: activeMilestone is M002'); + + closeDatabase(); + } finally { + closeDatabase(); + cleanup(base); + } + }); });