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:
parent
d21db9f398
commit
c9e6d50004
2 changed files with 52 additions and 1 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue