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 <noreply@anthropic.com>
This commit is contained in:
parent
0964c97931
commit
d25dbb15fd
2 changed files with 265 additions and 1 deletions
|
|
@ -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<GSDState> {
|
|||
}
|
||||
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
|
||||
|
|
|
|||
233
src/resources/extensions/gsd/tests/slice-disk-reconcile.test.ts
Normal file
233
src/resources/extensions/gsd/tests/slice-disk-reconcile.test.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
console.log("\n=== #2533: deriveStateFromDb reconciles disk slices ===");
|
||||
|
||||
await testMissingSlicesCauseBlock();
|
||||
await testSliceReconciliationIdempotent();
|
||||
await testNoRoadmapSkipsReconciliation();
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue