fix(gsd): resume cold auto bootstrap from db

Open the project database before the first auto bootstrap derive so cold-start resume uses DB-backed slice state instead of stale markdown fallback state.

Also recognize glyph completion markers in roadmap tables and lock the new bootstrap ordering with regression coverage.

Closes #2841
This commit is contained in:
mastertyko 2026-03-27 18:19:24 +01:00
parent b5715c20bb
commit 09de02adee
5 changed files with 94 additions and 20 deletions

View file

@ -58,8 +58,9 @@ import { initRoutingHistory } from "./routing-history.js";
import { restoreHookState, resetHookState } from "./post-unit-hooks.js";
import { resetProactiveHealing, setLevelChangeCallback } from "./doctor-proactive.js";
import { snapshotSkills } from "./skill-discovery.js";
import { isDbAvailable, getMilestone } from "./gsd-db.js";
import { isDbAvailable, getMilestone, openDatabase } from "./gsd-db.js";
import { hideFooter } from "./auto-dashboard.js";
import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
import {
debugLog,
enableDebug,
@ -103,6 +104,20 @@ export interface BootstrapDeps {
* cycles indefinitely when the discuss workflow doesn't produce a milestone. */
let _consecutiveCompleteBootstraps = 0;
const MAX_CONSECUTIVE_COMPLETE_BOOTSTRAPS = 2;
async function openProjectDbIfPresent(basePath: string): Promise<void> {
const gsdDbPath = resolveProjectRootDbPath(basePath);
if (!existsSync(gsdDbPath) || isDbAvailable()) return;
try {
openDatabase(gsdDbPath);
} catch (err) {
process.stderr.write(
`gsd-db: failed to open existing database: ${(err as Error).message}\n`,
);
}
}
export async function bootstrapAutoSession(
s: AutoSession,
ctx: ExtensionCommandContext,
@ -265,6 +280,10 @@ export async function bootstrapAutoSession(
ctx.ui.notify(`Debug logging enabled → ${getDebugLogPath()}`, "info");
}
// Open the project DB before the first derive so resume uses DB truth
// immediately on cold starts instead of falling back to markdown (#2841).
await openProjectDbIfPresent(base);
// Invalidate caches before initial state derivation
invalidateAllCaches();
@ -535,15 +554,14 @@ export async function bootstrapAutoSession(
}
// ── DB lifecycle ──
const gsdDbPath = join(s.basePath, ".gsd", "gsd.db");
const gsdDbPath = resolveProjectRootDbPath(s.basePath);
const gsdDirPath = join(s.basePath, ".gsd");
if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) {
const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md"));
const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md"));
const hasMilestones = existsSync(join(gsdDirPath, "milestones"));
try {
const { openDatabase: openDb } = await import("./gsd-db.js");
openDb(gsdDbPath);
openDatabase(gsdDbPath);
if (hasDecisions || hasRequirements || hasMilestones) {
const { migrateFromMarkdown } = await import("./md-importer.js");
migrateFromMarkdown(s.basePath);
@ -556,8 +574,7 @@ export async function bootstrapAutoSession(
}
if (existsSync(gsdDbPath) && !isDbAvailable()) {
try {
const { openDatabase: openDb } = await import("./gsd-db.js");
openDb(gsdDbPath);
openDatabase(gsdDbPath);
} catch (err) {
process.stderr.write(
`gsd-db: failed to open existing database: ${(err as Error).message}\n`,

View file

@ -82,6 +82,7 @@ function parseTableSlices(section: string): RoadmapSliceEntry[] {
const fullRow = line.toLowerCase();
const done =
/\[x\]/i.test(line) ||
/[✅☑✓]/.test(line) ||
/\bdone\b/.test(fullRow) ||
/\bcomplete(?:d)?\b/.test(fullRow);

View file

@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createTestContext } from "./test-helpers.ts";
const { assertTrue, report } = createTestContext();
const srcPath = join(import.meta.dirname, "..", "auto-start.ts");
const src = readFileSync(srcPath, "utf-8");
console.log("\n=== #2841: cold DB opened before initial deriveState ===");
const helperIdx = src.indexOf("async function openProjectDbIfPresent");
assertTrue(helperIdx >= 0, "auto-start.ts defines a helper for pre-derive DB open (#2841)");
const helperRegion = helperIdx >= 0 ? src.slice(helperIdx, helperIdx + 500) : "";
assertTrue(
helperRegion.includes("resolveProjectRootDbPath(basePath)"),
"pre-derive DB helper resolves the project-root DB path (#2841)",
);
assertTrue(
helperRegion.includes("openDatabase(gsdDbPath)"),
"pre-derive DB helper opens the resolved DB path (#2841)",
);
const firstDeriveIdx = src.indexOf("let state = await deriveState(base);");
assertTrue(firstDeriveIdx > 0, "auto-start.ts has the initial deriveState(base) call");
const preDeriveRegion = firstDeriveIdx > 0 ? src.slice(0, firstDeriveIdx) : "";
const preDeriveOpenIdx = preDeriveRegion.lastIndexOf("await openProjectDbIfPresent(base);");
assertTrue(
preDeriveOpenIdx > 0,
"bootstrapAutoSession opens the DB before the first deriveState(base) call (#2841)",
);
report();

View file

@ -116,6 +116,23 @@ test("parseRoadmapSlices: table with Status Done/Complete text (#1736)", () => {
assert.equal(slices[2]?.done, true);
});
test("parseRoadmapSlices: table with glyph completion markers (#2841)", () => {
const tableContent = [
"# M003: Glyph Status", "", "## Slices", "",
"| Slice | Title | Risk | Status |", "|---|---|---|---|",
"| S01 | First | Low | ✅ |",
"| S02 | Second | High | Pending |",
"| S03 | Third | Medium | ☑ |",
"| S04 | Fourth | Medium | ✓ |", "",
].join("\n");
const slices = parseRoadmapSlices(tableContent);
assert.equal(slices.length, 4);
assert.equal(slices[0]?.done, true);
assert.equal(slices[1]?.done, false);
assert.equal(slices[2]?.done, true);
assert.equal(slices[3]?.done, true);
});
test("parseRoadmapSlices: table with dependencies column (#1736)", () => {
const tableContent = [
"# M004: Deps", "", "## Slices", "",

View file

@ -33,23 +33,25 @@ assertTrue(dbLifecycleIdx > 0, "auto-start.ts has a DB lifecycle section");
const afterDbLifecycle = src.slice(dbLifecycleIdx);
// Find the second isDbAvailable check — the one AFTER the open attempts.
// The first check at line ~543 tries to open the DB.
// There must be a SECOND check that gates bootstrap if it's still unavailable.
const firstCheck = afterDbLifecycle.indexOf("isDbAvailable()");
assertTrue(firstCheck > 0, "DB lifecycle section has isDbAvailable() check");
const afterFirstCheck = afterDbLifecycle.slice(firstCheck + "isDbAvailable()".length);
const secondCheck = afterFirstCheck.indexOf("isDbAvailable()");
// The DB lifecycle section may contain multiple isDbAvailable() checks now that
// cold-start bootstrap can pre-open the DB earlier in the file. What matters
// for #2419 is the explicit abort gate after the DB open attempts.
assertTrue(
secondCheck > 0,
"auto-start.ts has a SECOND isDbAvailable() check after the open attempt — this is the gate (#2419)",
afterDbLifecycle.includes("!isDbAvailable()"),
"DB lifecycle section still checks for unavailable DB state (#2419)",
);
// The second check must lead to releaseLockAndReturn (abort bootstrap)
if (secondCheck > 0) {
const gateRegion = afterFirstCheck.slice(secondCheck, secondCheck + 500);
const gateMatch = afterDbLifecycle.match(
/if\s*\(existsSync\(gsdDbPath\)\s*&&\s*!isDbAvailable\(\)\)\s*\{[\s\S]*?releaseLockAndReturn\(\);[\s\S]*?\}/,
);
assertTrue(
!!gateMatch,
"auto-start.ts has a hard abort gate when gsd.db exists but SQLite is still unavailable (#2419)",
);
if (gateMatch) {
const gateRegion = gateMatch[0];
assertTrue(
gateRegion.includes("releaseLockAndReturn"),
"The DB availability gate calls releaseLockAndReturn() to abort bootstrap (#2419)",