fix: reconcile disk milestones missing from DB in deriveStateFromDb (#2416) (#2422)

After migration to DB-backed state, milestones on disk that were never
imported into the DB became invisible. deriveStateFromDb now scans the
milestones directory and injects synthetic entries for any disk-only
milestones, then re-sorts to maintain canonical order.

Fixes #2416

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-25 02:06:47 -04:00 committed by GitHub
parent aa3ac89bf8
commit be4037be90
2 changed files with 143 additions and 0 deletions

View file

@ -277,6 +277,28 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
}
if (synced) allMilestones = getAllMilestones();
// Reconcile: discover milestones that exist on disk but are missing from
// the DB. This happens when milestones were created before the DB migration
// or were manually added to the filesystem. Without this, disk-only
// milestones are invisible after migration (#2416).
const dbMilestoneIds = new Set(allMilestones.map(m => m.id));
const diskMilestoneIds = findMilestoneIds(basePath);
for (const diskId of diskMilestoneIds) {
if (!dbMilestoneIds.has(diskId)) {
// Synthesize a minimal MilestoneRow for the disk-only milestone.
// Title and status will be resolved from disk files in the loop below.
allMilestones.push({
id: diskId,
title: diskId,
status: 'active',
depends_on: [] as string[],
created_at: new Date().toISOString(),
} as MilestoneRow);
}
}
// Re-sort so milestones are in canonical order after injection
allMilestones.sort((a, b) => milestoneIdSort(a.id, b.id));
// Parallel worker isolation: when locked, filter to just the locked milestone
const milestoneLock = process.env.GSD_MILESTONE_LOCK;
const milestones = milestoneLock

View file

@ -0,0 +1,121 @@
/**
* derive-state-db-disk-reconcile.test.ts #2416
*
* After migration to DB-backed state, milestones that exist on disk
* (in .gsd/milestones/) but were never imported into the DB become
* invisible to deriveStateFromDb(). This test verifies that
* deriveStateFromDb reconciles disk milestones with DB milestones.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { deriveStateFromDb, invalidateStateCache } from "../state.ts";
import {
openDatabase,
closeDatabase,
insertMilestone,
insertSlice,
insertTask,
} from "../gsd-db.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
function createFixtureBase(): string {
const base = mkdtempSync(join(tmpdir(), "gsd-disk-reconcile-"));
mkdirSync(join(base, ".gsd", "milestones"), { recursive: true });
return base;
}
function writeFile(base: string, relativePath: string, content: string): void {
const full = join(base, ".gsd", relativePath);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, content);
}
function cleanup(base: string): void {
rmSync(base, { recursive: true, force: true });
}
const CONTEXT_CONTENT = `# M002: Disk-Only Milestone
This milestone exists on disk but not in the DB.
## Must-Haves
- Something important
`;
const ROADMAP_CONTENT = `# M002: Disk-Only Milestone
**Vision:** Test disk reconciliation.
## Slices
- [ ] **S01: First Slice** \`risk:low\` \`depends:[]\`
> Do something.
`;
async function main(): Promise<void> {
console.log("\n=== #2416: deriveStateFromDb reconciles disk milestones ===");
// Set up: M001 in DB, M002 on disk only
const base = createFixtureBase();
const dbPath = join(base, ".gsd", "gsd.db");
try {
openDatabase(dbPath);
// M001 is in the DB with a complete status
insertMilestone({ id: "M001", title: "M001: DB Milestone", status: "complete", depends_on: [] });
insertSlice({ id: "S01", milestoneId: "M001", title: "S01: Done Slice", status: "complete", depends: [] });
// Write M001 summary on disk (marks it complete on filesystem too)
writeFile(base, "milestones/M001/SUMMARY.md", "# M001: DB Milestone\n\nDone.");
// M002 exists ONLY on disk, not in DB
writeFile(base, "milestones/M002/CONTEXT.md", CONTEXT_CONTENT);
writeFile(base, "milestones/M002/ROADMAP.md", ROADMAP_CONTENT);
invalidateStateCache();
const state = await deriveStateFromDb(base);
// M002 should be visible in the registry
const m002Entry = state.registry.find((m) => m.id === "M002");
assertTrue(
m002Entry !== undefined,
"M002 (disk-only milestone) should appear in state.registry (#2416)",
);
// M001 should still be in the registry
const m001Entry = state.registry.find((m) => m.id === "M001");
assertTrue(
m001Entry !== undefined,
"M001 (DB milestone) should still appear in state.registry",
);
// The active milestone should be M002 (since M001 is complete)
assertTrue(
state.activeMilestone !== null,
"There should be an active milestone",
);
if (state.activeMilestone) {
assertEq(
state.activeMilestone.id,
"M002",
"Active milestone should be M002 (disk-only, not complete) (#2416)",
);
}
} finally {
closeDatabase();
cleanup(base);
}
report();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});