From d25dbb15fd52275d9e8edeb29069b8a61b1a20a7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 30 Mar 2026 15:50:13 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20add=20disk=E2=86=92DB=20slice=20reconcil?= =?UTF-8?q?iation=20in=20deriveStateFromDb=20(#2533)=20(#3262)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slices defined in ROADMAP.md but missing from the SQLite database caused permanent "No slice eligible — check dependency ordering" blocks. The dependency resolver only considered DB rows, so disk-only slices were invisible. This adds a reconciliation step (mirroring the existing milestone reconciliation) that parses each milestone's ROADMAP.md, compares against getMilestoneSlices(), and inserts missing slices with correct status based on SUMMARY file presence. Co-authored-by: Claude Opus 4.6 --- src/resources/extensions/gsd/state.ts | 33 ++- .../gsd/tests/slice-disk-reconcile.test.ts | 233 ++++++++++++++++++ 2 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/gsd/tests/slice-disk-reconcile.test.ts diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index e6ff91895..bc7b1b9b2 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -40,7 +40,7 @@ import { isClosedStatus } from './status-guards.js'; import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js'; import { join, resolve } from 'path'; -import { existsSync, readdirSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { debugCount, debugTime } from './debug-logger.js'; import { extractVerdict } from './verdict-parser.js'; @@ -52,6 +52,7 @@ import { getReplanHistory, getSlice, insertMilestone, + insertSlice, updateTaskStatus, getPendingSliceGateCount, type MilestoneRow, @@ -298,6 +299,36 @@ export async function deriveStateFromDb(basePath: string): Promise { } if (synced) allMilestones = getAllMilestones(); + // Disk→DB slice reconciliation (#2533): slices defined in ROADMAP.md but + // missing from the DB cause permanent "No slice eligible" blocks because + // the dependency resolver only sees DB rows. Parse each milestone's roadmap + // and insert any missing slices, checking SUMMARY files to set correct status. + // insertSlice uses INSERT OR IGNORE, so existing rows are never overwritten. + for (const mid of diskIds) { + if (isGhostMilestone(basePath, mid)) continue; + const roadmapPath = resolveMilestoneFile(basePath, mid, "ROADMAP"); + if (!roadmapPath) continue; + + const dbSlices = getMilestoneSlices(mid); + const dbSliceIds = new Set(dbSlices.map(s => s.id)); + + let roadmapContent: string; + try { roadmapContent = readFileSync(roadmapPath, "utf-8"); } + catch { continue; } + + const parsed = parseRoadmap(roadmapContent); + for (const s of parsed.slices) { + if (dbSliceIds.has(s.id)) continue; + const summaryPath = resolveSliceFile(basePath, mid, s.id, "SUMMARY"); + const sliceStatus = (s.done || summaryPath) ? "complete" : "pending"; + insertSlice({ + id: s.id, milestoneId: mid, title: s.title, + status: sliceStatus, risk: s.risk, + depends: s.depends, demo: s.demo, + }); + } + } + // 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 diff --git a/src/resources/extensions/gsd/tests/slice-disk-reconcile.test.ts b/src/resources/extensions/gsd/tests/slice-disk-reconcile.test.ts new file mode 100644 index 000000000..a40822d29 --- /dev/null +++ b/src/resources/extensions/gsd/tests/slice-disk-reconcile.test.ts @@ -0,0 +1,233 @@ +/** + * slice-disk-reconcile.test.ts — #2533 + * + * Slices that exist on disk (in ROADMAP.md) but are missing from the SQLite + * database cause permanent "No slice eligible — check dependency ordering" + * blocks. deriveStateFromDb must reconcile disk slices into the DB, just as + * it already does for milestones (#2416). + * + * Scenario: M001 has a ROADMAP with S01-S04. S01 and S02 have SUMMARY files + * (complete on disk). S03 depends on S01. Only S04 is in the DB (depends on + * S03). Without slice reconciliation, S01-S03 are invisible and S04 is + * permanently blocked. + */ + +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, + getMilestoneSlices, +} from "../gsd-db.ts"; +import { createTestContext } from "./test-helpers.ts"; + +const { assertEq, assertTrue, report } = createTestContext(); + +function createFixtureBase(): string { + const base = mkdtempSync(join(tmpdir(), "gsd-slice-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 = `# M001: Test Milestone + +This milestone tests slice reconciliation. + +## Must-Haves +- Something important +`; + +// Roadmap with 4 slices: S01 (no deps), S02 (no deps), S03 (depends S01), S04 (depends S03) +const ROADMAP_CONTENT = `# M001: Test Milestone + +**Vision:** Test slice disk→DB reconciliation. + +## Slices + +- [x] **S01: Foundation** \`risk:low\` \`depends:[]\` + > Set up project structure. +- [x] **S02: Core Utils** \`risk:low\` \`depends:[]\` + > Build utility functions. +- [ ] **S03: Integration** \`risk:medium\` \`depends:[S01]\` + > Integrate components. +- [ ] **S04: Final Assembly** \`risk:high\` \`depends:[S03]\` + > Assemble everything. +`; + +async function testMissingSlicesCauseBlock(): Promise { + console.log("\n--- Test: missing DB slices cause permanent block (pre-fix) ---"); + + const base = createFixtureBase(); + const dbPath = join(base, ".gsd", "gsd.db"); + + try { + openDatabase(dbPath); + + // M001 in DB + insertMilestone({ id: "M001", title: "M001: Test Milestone", status: "active", depends_on: [] }); + + // Only S04 is in the DB — S01-S03 are missing + insertSlice({ id: "S04", milestoneId: "M001", title: "S04: Final Assembly", status: "pending", risk: "high", depends: ["S03"] }); + + // Write disk files — S01 and S02 have SUMMARY (complete on disk) + writeFile(base, "milestones/M001/CONTEXT.md", CONTEXT_CONTENT); + writeFile(base, "milestones/M001/ROADMAP.md", ROADMAP_CONTENT); + writeFile(base, "milestones/M001/S01/PLAN.md", "# S01 Plan\n"); + writeFile(base, "milestones/M001/S01/SUMMARY.md", "# S01 Summary\nDone."); + writeFile(base, "milestones/M001/S02/PLAN.md", "# S02 Plan\n"); + writeFile(base, "milestones/M001/S02/SUMMARY.md", "# S02 Summary\nDone."); + writeFile(base, "milestones/M001/S03/PLAN.md", "# S03 Plan\n"); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + // After the fix, slices S01-S03 should be reconciled into DB + const dbSlices = getMilestoneSlices("M001"); + assertTrue( + dbSlices.length === 4, + `All 4 roadmap slices should be in DB after reconciliation, got ${dbSlices.length}`, + ); + + // S01 and S02 should be marked complete (have SUMMARY files) + const s01 = dbSlices.find(s => s.id === "S01"); + assertTrue(s01 !== undefined, "S01 should exist in DB after reconciliation"); + if (s01) { + assertEq(s01.status, "complete", "S01 should be 'complete' (has SUMMARY on disk)"); + } + + const s02 = dbSlices.find(s => s.id === "S02"); + assertTrue(s02 !== undefined, "S02 should exist in DB after reconciliation"); + if (s02) { + assertEq(s02.status, "complete", "S02 should be 'complete' (has SUMMARY on disk)"); + } + + // S03 should be pending (no SUMMARY) + const s03 = dbSlices.find(s => s.id === "S03"); + assertTrue(s03 !== undefined, "S03 should exist in DB after reconciliation"); + if (s03) { + assertEq(s03.status, "pending", "S03 should be 'pending' (no SUMMARY on disk)"); + } + + // The state should NOT be blocked — S03 should be eligible (S01 dep satisfied) + assertTrue( + state.phase !== "blocked", + `Phase should not be 'blocked' after reconciliation, got '${state.phase}'`, + ); + + // Active slice should be S03 (S01 dep met, S03 is first incomplete with satisfied deps) + assertTrue( + state.activeSlice !== null, + "There should be an active slice after reconciliation", + ); + if (state.activeSlice) { + assertEq( + state.activeSlice.id, + "S03", + "Active slice should be S03 (its dependency S01 is complete) (#2533)", + ); + } + } finally { + closeDatabase(); + cleanup(base); + } +} + +async function testSliceReconciliationIdempotent(): Promise { + console.log("\n--- Test: slice reconciliation is idempotent ---"); + + const base = createFixtureBase(); + const dbPath = join(base, ".gsd", "gsd.db"); + + try { + openDatabase(dbPath); + + insertMilestone({ id: "M001", title: "M001: Test", status: "active", depends_on: [] }); + // S01 already in DB with correct status + insertSlice({ id: "S01", milestoneId: "M001", title: "S01: Foundation", status: "complete", depends: [] }); + + writeFile(base, "milestones/M001/CONTEXT.md", CONTEXT_CONTENT); + writeFile(base, "milestones/M001/ROADMAP.md", ROADMAP_CONTENT); + writeFile(base, "milestones/M001/S01/PLAN.md", "# S01 Plan\n"); + writeFile(base, "milestones/M001/S01/SUMMARY.md", "# S01 Summary\nDone."); + writeFile(base, "milestones/M001/S02/PLAN.md", "# S02 Plan\n"); + writeFile(base, "milestones/M001/S02/SUMMARY.md", "# S02 Summary\nDone."); + + invalidateStateCache(); + await deriveStateFromDb(base); + + // S01 should still be complete (not overwritten) + const dbSlices = getMilestoneSlices("M001"); + const s01 = dbSlices.find(s => s.id === "S01"); + assertTrue(s01 !== undefined, "S01 should still exist in DB"); + if (s01) { + assertEq(s01.status, "complete", "S01 status should remain 'complete' (not overwritten)"); + } + + // S02-S04 should have been added + assertTrue( + dbSlices.length === 4, + `Should have 4 slices after reconciliation (existing + new), got ${dbSlices.length}`, + ); + } finally { + closeDatabase(); + cleanup(base); + } +} + +async function testNoRoadmapSkipsReconciliation(): Promise { + console.log("\n--- Test: no ROADMAP file skips slice reconciliation ---"); + + const base = createFixtureBase(); + const dbPath = join(base, ".gsd", "gsd.db"); + + try { + openDatabase(dbPath); + + insertMilestone({ id: "M001", title: "M001: No Roadmap", status: "active", depends_on: [] }); + + // Only a CONTEXT file, no ROADMAP + writeFile(base, "milestones/M001/CONTEXT.md", CONTEXT_CONTENT); + + invalidateStateCache(); + const state = await deriveStateFromDb(base); + + const dbSlices = getMilestoneSlices("M001"); + assertEq(dbSlices.length, 0, "No slices should be added when no ROADMAP exists"); + + // Should be in pre-planning (no roadmap) + assertEq(state.phase, "pre-planning", "Phase should be pre-planning with no roadmap"); + } finally { + closeDatabase(); + cleanup(base); + } +} + +async function main(): Promise { + console.log("\n=== #2533: deriveStateFromDb reconciles disk slices ==="); + + await testMissingSlicesCauseBlock(); + await testSliceReconciliationIdempotent(); + await testNoRoadmapSkipsReconciliation(); + + report(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});