fix(gsd): reconcile disk-only milestones into DB in deriveStateFromDb (#2416)

* fix(gsd): reconcile disk-only milestones into DB in deriveStateFromDb

Milestones created via /gsd queue (or by complete-milestone writing a
next CONTEXT.md) are never inserted into the DB because the migration
guard in auto-start.ts only runs when gsd.db does not yet exist.
deriveStateFromDb() called getAllMilestones() (DB-only) with no disk
fallback, so these queued milestones were invisible to the state machine.
When all DB-tracked milestones completed, phase='complete' fired and
auto-mode stopped even though untracked milestones existed on disk.

Fix: add an incremental disk→DB reconciliation step inside
deriveStateFromDb() that compares findMilestoneIds() against DB rows
and calls insertMilestone() (INSERT OR IGNORE) for any non-ghost
directory that has no DB row. Re-queries only when rows were inserted.

Adds a regression test that reproduces the exact scenario from #2416:
M001 complete in DB, M002 queued on disk only → before fix phase was
'complete', after fix phase is 'pre-planning' with both milestones
visible in the registry.

Closes #2416

* fix: add missing closing brace for describe block in test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jeremy McSpadden <jeremy@fluxlabs.net>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-24 22:20:45 -06:00 committed by GitHub
parent d21db9f398
commit c9e6d50004
2 changed files with 52 additions and 1 deletions

View file

@ -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<GSDState> {
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;

View file

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