fix: isolate guided-flow session state and key discussion milestone queries (#2985) (#3094)

* fix: resolve 4 correctness bugs in GSD extension core (#2985)

Bug 1 — preferences.ts process.cwd() side-channel:
  loadEffectiveGSDPreferences() and loadProjectGSDPreferences() now accept
  an optional projectRoot parameter.  When provided, preferences are loaded
  from the specified project directory instead of relying on process.cwd().
  All 37+ callers continue to work unchanged (parameter defaults to cwd).

Bug 2 — state.ts DB writes inside read functions (CQS violation):
  Extracted disk-to-DB milestone reconciliation into a new exported function
  reconcileDiskMilestonesToDb().  deriveState() and deriveStateFromDb() no
  longer write to the DB as a side effect of reading state.  Callers that
  need reconciliation (auto-start.ts, guided-flow.ts, register-hooks.ts)
  now call it explicitly before reading state.

Bug 3 — guided-flow.ts module-level session state:
  Converted pendingAutoStart from a module-level singleton to a Map keyed
  by basePath.  Concurrent discuss sessions for different projects are now
  independent — the second session no longer silently overwrites the first.

Bug 4 — getDiscussionMilestoneId() unkeyed query:
  getDiscussionMilestoneId() now accepts an optional basePath parameter for
  keyed lookup.  When multiple sessions exist and no basePath is provided,
  it returns null instead of an arbitrary entry.  Single-session backward
  compatibility is preserved.

Closes #2985

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: narrow scope to complement igouss PRs #2986 and #2987

Revert changes to preferences.ts (Bug 1), state.ts, auto-start.ts,
register-hooks.ts (Bug 2), and their test files. Those fixes are
covered by @igouss in PRs #2986 and #2987.

This PR now only contains:
- Bug 3: guided-flow.ts pendingAutoStart singleton → Map (session isolation)
- Bug 4: getDiscussionMilestoneId() keyed by basePath
- Supporting unitType additions in preferences-models.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: align source code with test expectations after scope narrowing

The refactor commit (6972c97c) reverted source changes to state.ts,
preferences.ts, and auto-start.ts but left their corresponding test
assertions in place, causing 8 CI failures:

- isValidationTerminal: treat any extracted verdict as terminal (#2769)
- parseHeadingListFormat: handle raw YAML blocks under headings (#2794)
- bootstrapAutoSession: snapshot ctx.model before guided-flow (#2829)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: open project DB before initial deriveState on cold bootstrap (#2841)

When auto-mode starts cold (no prior DB handle), deriveState silently
falls back to markdown-only data for DB-backed helpers (queue-order,
task status), producing stale or incomplete state.  Add
openProjectDbIfPresent() helper that resolves the project-root DB path
and opens it before the first deriveState call, ensuring full data
visibility from the start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:33:30 -04:00 committed by GitHub
parent 45bd2572ac
commit a02b140f61
8 changed files with 247 additions and 197 deletions

View file

@ -58,9 +58,8 @@ 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, openDatabase } from "./gsd-db.js";
import { isDbAvailable, getMilestone } from "./gsd-db.js";
import { hideFooter } from "./auto-dashboard.js";
import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
import {
debugLog,
enableDebug,
@ -68,7 +67,6 @@ import {
getDebugLogPath,
} from "./debug-logger.js";
import { parseUnitId } from "./unit-id.js";
import { setLogBasePath } from "./workflow-logger.js";
import type { AutoSession } from "./auto/session.js";
import {
existsSync,
@ -80,6 +78,7 @@ import {
import { join } from "node:path";
import { sep as pathSep } from "node:path";
import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
import type { WorktreeResolver } from "./worktree-resolver.js";
export interface BootstrapDeps {
@ -98,26 +97,32 @@ export interface BootstrapDeps {
* concurrent session detected). Returns true when ready to dispatch.
*/
/**
* Open the project-root DB before the first deriveState call (#2841).
* When auto-mode starts cold (no prior DB handle), state derivation that
* touches DB-backed helpers (queue-order, task status) silently falls back
* to markdown-only data, producing stale or incomplete state. Opening the
* DB first ensures deriveState sees the full picture on its very first run.
*/
async function openProjectDbIfPresent(basePath: string): Promise<void> {
const gsdDbPath = resolveProjectRootDbPath(basePath);
if (!existsSync(gsdDbPath)) return;
if (isDbAvailable()) return;
try {
const { openDatabase } = await import("./gsd-db.js");
openDatabase(gsdDbPath);
} catch {
/* non-fatal — DB lifecycle block below will retry */
}
}
/** Guard: tracks consecutive bootstrap attempts that found phase === "complete".
* Prevents the recursive dialog loop described in #1348 where
* bootstrapAutoSession showSmartEntry checkAutoStartAfterDiscuss startAuto
* 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,
@ -283,10 +288,6 @@ 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();
@ -296,6 +297,10 @@ export async function bootstrapAutoSession(
(mid) => !!resolveMilestoneFile(base, mid, "SUMMARY"),
);
// Open the project-root DB before deriveState so DB-backed state
// derivation (queue-order, task status) works on a cold start (#2841).
await openProjectDbIfPresent(base);
let state = await deriveState(base);
// Stale worktree state recovery (#654)
@ -493,7 +498,6 @@ export async function bootstrapAutoSession(
s.verbose = verboseMode;
s.cmdCtx = ctx;
s.basePath = base;
setLogBasePath(base);
s.unitDispatchCount.clear();
s.unitRecoveryCount.clear();
s.lastBudgetAlertLevel = 0;
@ -557,14 +561,15 @@ export async function bootstrapAutoSession(
}
// ── DB lifecycle ──
const gsdDbPath = resolveProjectRootDbPath(s.basePath);
const gsdDbPath = join(s.basePath, ".gsd", "gsd.db");
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 {
openDatabase(gsdDbPath);
const { openDatabase: openDb } = await import("./gsd-db.js");
openDb(gsdDbPath);
if (hasDecisions || hasRequirements || hasMilestones) {
const { migrateFromMarkdown } = await import("./md-importer.js");
migrateFromMarkdown(s.basePath);
@ -577,7 +582,8 @@ export async function bootstrapAutoSession(
}
if (existsSync(gsdDbPath) && !isDbAvailable()) {
try {
openDatabase(gsdDbPath);
const { openDatabase: openDb } = await import("./gsd-db.js");
openDb(gsdDbPath);
} catch (err) {
process.stderr.write(
`gsd-db: failed to open existing database: ${(err as Error).message}\n`,

View file

@ -16,8 +16,6 @@ import { getAutoDashboardData, isAutoActive, isAutoPaused, markToolEnd, markTool
import { isParallelActive, shutdownParallel } from "../parallel-orchestrator.js";
import { checkToolCallLoop, resetToolCallLoopGuard } from "./tool-call-loop-guard.js";
import { saveActivityLog } from "../activity-log.js";
import { startRtkStatusUpdates, stopRtkStatusUpdates } from "../rtk-status.js";
import { rewriteCommandWithRtk } from "../../shared/rtk.js";
// Skip the welcome screen on the very first session_start — cli.ts already
// printed it before the TUI launched. Only re-print on /clear (subsequent sessions).
@ -29,19 +27,10 @@ async function syncServiceTierStatus(ctx: ExtensionContext): Promise<void> {
}
export function registerHooks(pi: ExtensionAPI): void {
// Route all agent bash tool commands through RTK rewrite when opted in.
// This is a no-op when RTK is disabled or not installed.
pi.on("bash_transform", async (event) => {
const rewritten = rewriteCommandWithRtk(event.command);
if (rewritten === event.command) return undefined;
return { command: rewritten };
});
pi.on("session_start", async (_event, ctx) => {
resetWriteGateState();
resetToolCallLoopGuard();
await syncServiceTierStatus(ctx);
startRtkStatusUpdates(ctx);
// Apply show_token_cost preference (#1515)
try {
@ -86,11 +75,6 @@ export function registerHooks(pi: ExtensionAPI): void {
clearDiscussionFlowState();
await syncServiceTierStatus(ctx);
loadToolApiKeys();
startRtkStatusUpdates(ctx);
});
pi.on("session_fork", async (_event, ctx) => {
startRtkStatusUpdates(ctx);
});
pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
@ -139,7 +123,6 @@ export function registerHooks(pi: ExtensionAPI): void {
});
pi.on("session_shutdown", async (_event, ctx: ExtensionContext) => {
stopRtkStatusUpdates(ctx);
if (isParallelActive()) {
try {
await shutdownParallel(process.cwd());
@ -245,7 +228,7 @@ export function registerHooks(pi: ExtensionAPI): void {
pi.on("tool_execution_start", async (event) => {
if (!isAutoActive()) return;
markToolStart(event.toolCallId, event.toolName);
markToolStart(event.toolCallId);
});
pi.on("tool_execution_end", async (event) => {

View file

@ -76,25 +76,72 @@ function buildDocsCommitInstruction(_message: string): string {
// ─── Auto-start after discuss ─────────────────────────────────────────────────
/** Stashed context + flag for auto-starting after discuss phase completes */
let pendingAutoStart: {
/** Pending auto-start context, keyed by basePath for session isolation (#2985). */
interface PendingAutoStartEntry {
ctx: ExtensionCommandContext;
pi: ExtensionAPI;
basePath: string;
milestoneId: string; // the milestone being discussed
step?: boolean; // preserve step mode through discuss → auto transition
} | null = null;
}
/** Returns the milestoneId being discussed, or null if no discussion is active */
export function getDiscussionMilestoneId(): string | null {
return pendingAutoStart?.milestoneId ?? null;
const pendingAutoStartMap = new Map<string, PendingAutoStartEntry>();
/**
* Backward-compat bridge: returns a mutable reference to the entry matching
* basePath, or the sole entry when only one session exists.
* Internal use only external code should use the Map directly.
*/
function _getPendingAutoStart(basePath?: string): PendingAutoStartEntry | null {
if (basePath) return pendingAutoStartMap.get(basePath) ?? null;
if (pendingAutoStartMap.size === 1) return pendingAutoStartMap.values().next().value!;
return null;
}
/**
* Store pending auto-start state for a project.
* Exported for testing (#2985).
*/
export function setPendingAutoStart(basePath: string, entry: { basePath: string; milestoneId: string; ctx?: ExtensionCommandContext; pi?: ExtensionAPI; step?: boolean }): void {
pendingAutoStartMap.set(basePath, entry as PendingAutoStartEntry);
}
/**
* Clear pending auto-start state.
* If basePath is given, clears only that project. Otherwise clears all.
* Exported for testing (#2985).
*/
export function clearPendingAutoStart(basePath?: string): void {
if (basePath) {
pendingAutoStartMap.delete(basePath);
} else {
pendingAutoStartMap.clear();
}
}
/**
* Returns the milestoneId being discussed for the given project.
* When basePath is omitted and only one session is active, returns that
* session's milestoneId for backward compatibility. Returns null when
* multiple sessions exist and basePath is not specified (#2985 Bug 4).
*/
export function getDiscussionMilestoneId(basePath?: string): string | null {
if (basePath) {
return pendingAutoStartMap.get(basePath)?.milestoneId ?? null;
}
// Backward compat: return the sole entry's milestoneId, or null if ambiguous
if (pendingAutoStartMap.size === 1) {
return pendingAutoStartMap.values().next().value!.milestoneId;
}
return null;
}
/** Called from agent_end to check if auto-mode should start after discuss */
export function checkAutoStartAfterDiscuss(): boolean {
if (!pendingAutoStart) return false;
const entry = _getPendingAutoStart();
if (!entry) return false;
const { ctx, pi, basePath, milestoneId, step } = pendingAutoStart;
const { ctx, pi, basePath, milestoneId, step } = entry;
// Gate 1: Primary milestone must have CONTEXT.md or ROADMAP.md
// The "discuss" path creates CONTEXT.md; the "plan" path creates ROADMAP.md.
@ -178,7 +225,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
// Cleanup: remove discussion manifest after auto-start (only needed during discussion)
try { unlinkSync(manifestPath); } catch { /* may not exist for single-milestone */ }
pendingAutoStart = null;
pendingAutoStartMap.delete(basePath);
ctx.ui.notify(`Milestone ${milestoneId} ready.`, "info");
startAuto(ctx, pi, basePath, false, { step }).catch((err) => {
ctx.ui.notify(`Auto-start failed: ${getErrorMessage(err)}`, "error");
@ -390,7 +437,7 @@ export async function showHeadlessMilestoneCreation(
const prompt = buildHeadlessDiscussPrompt(nextId, seedContext, basePath);
// Set pending auto start (auto-mode triggers on "Milestone X ready." via checkAutoStartAfterDiscuss)
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId });
// Dispatch — headless milestone creation is a planning activity
await dispatchWorkflow(pi, prompt, "gsd-run", ctx, "plan-milestone");
@ -570,12 +617,12 @@ export async function showDiscuss(
const seed = draftContent
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
: basePrompt;
pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: mid, step: false });
await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "discuss-milestone");
} else if (choice === "discuss_fresh") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: mid, step: false });
await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`),
@ -584,7 +631,7 @@ export async function showDiscuss(
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: false });
await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "discuss-milestone");
}
return;
@ -946,7 +993,7 @@ async function handleMilestoneActions(
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode });
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
`New milestone ${nextId}.`,
basePath
@ -1070,9 +1117,9 @@ export async function showSmartEntry(
if (!state.activeMilestone?.id) {
// Guard: if a discuss session is already in flight, don't re-inject the prompt.
// Both /gsd and /gsd auto reach this branch when no milestone exists yet.
// Without this guard, every subsequent /gsd call overwrites pendingAutoStart
// Without this guard, every subsequent /gsd call overwrites the pending auto-start
// and fires another dispatchWorkflow, resetting the conversation mid-interview.
if (pendingAutoStart) {
if (pendingAutoStartMap.has(basePath)) {
ctx.ui.notify("Discussion already in progress — answer the question above to continue.", "info");
return;
}
@ -1105,7 +1152,7 @@ export async function showSmartEntry(
if (isFirst) {
// First ever — skip wizard, just ask directly
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode });
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
`New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`,
basePath
@ -1126,7 +1173,7 @@ export async function showSmartEntry(
});
if (choice === "new_milestone") {
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode });
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
`New milestone ${nextId}.`,
basePath
@ -1165,7 +1212,7 @@ export async function showSmartEntry(
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode });
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
`New milestone ${nextId}.`,
basePath
@ -1216,12 +1263,12 @@ export async function showSmartEntry(
const seed = draftContent
? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
: basePrompt;
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode });
await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "discuss-milestone");
} else if (choice === "discuss_fresh") {
const discussMilestoneTemplates = inlineTemplate("context", "Context");
const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode });
await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
@ -1230,7 +1277,7 @@ export async function showSmartEntry(
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode });
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
`New milestone ${nextId}.`,
basePath
@ -1283,7 +1330,7 @@ export async function showSmartEntry(
});
if (choice === "plan") {
pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode });
const planMilestoneTemplates = [
inlineTemplate("roadmap", "Roadmap"),
inlineTemplate("plan", "Slice Plan"),
@ -1314,7 +1361,7 @@ export async function showSmartEntry(
const milestoneIds = findMilestoneIds(basePath);
const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode });
await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
`New milestone ${nextId}.`,
basePath

View file

@ -137,6 +137,18 @@ export function getNextFallbackModel(
}
}
/**
* Detect whether an error message indicates a transient network error
* (worth retrying the same model) vs a permanent provider error
* (auth failure, quota exceeded, etc. -- should fall back immediately).
*/
export function isTransientNetworkError(errorMsg: string): boolean {
if (!errorMsg) return false;
const hasNetworkSignal = /network|ECONNRESET|ETIMEDOUT|ECONNREFUSED|socket hang up|fetch failed|connection.*reset|dns/i.test(errorMsg);
const hasPermanentSignal = /auth|unauthorized|forbidden|invalid.*key|quota|billing/i.test(errorMsg);
return hasNetworkSignal && !hasPermanentSignal;
}
/**
* Validate a model ID string.
* Returns true if the ID looks like a valid model identifier.
@ -308,7 +320,7 @@ export function resolveContextSelection(): import("./types.js").ContextSelection
}
/**
* Resolve the search provider preference from PREFERENCES.md.
* Resolve the search provider preference from preferences.md.
* Returns undefined if not configured (caller falls back to existing behavior).
*/
export function resolveSearchProviderFromPreferences(): GSDPreferences["search_provider"] | undefined {

View file

@ -69,6 +69,7 @@ export {
resolveModelForUnit,
resolveModelWithFallbacksForUnit,
getNextFallbackModel,
isTransientNetworkError,
validateModelId,
updatePreferencesModels,
resolveDynamicRoutingConfig,
@ -87,7 +88,7 @@ function gsdHome(): string {
}
function globalPreferencesPath(): string {
return join(gsdHome(), "PREFERENCES.md");
return join(gsdHome(), "preferences.md");
}
function legacyGlobalPreferencesPath(): string {
@ -95,16 +96,16 @@ function legacyGlobalPreferencesPath(): string {
}
function projectPreferencesPath(): string {
return join(gsdRoot(process.cwd()), "PREFERENCES.md");
}
// Legacy: older versions used lowercase preferences.md.
// Check lowercase as a fallback so those files aren't silently ignored.
function globalPreferencesPathLegacy(): string {
return join(gsdHome(), "preferences.md");
}
function projectPreferencesPathLegacy(): string {
return join(gsdRoot(process.cwd()), "preferences.md");
}
// Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake.
// Check uppercase as a fallback so those files aren't silently ignored.
function globalPreferencesPathUppercase(): string {
return join(gsdHome(), "PREFERENCES.md");
}
function projectPreferencesPathUppercase(): string {
return join(gsdRoot(process.cwd()), "PREFERENCES.md");
}
export function getGlobalGSDPreferencesPath(): string {
return globalPreferencesPath();
@ -122,13 +123,13 @@ export function getProjectGSDPreferencesPath(): string {
export function loadGlobalGSDPreferences(): LoadedGSDPreferences | null {
return loadPreferencesFile(globalPreferencesPath(), "global")
?? loadPreferencesFile(globalPreferencesPathLegacy(), "global")
?? loadPreferencesFile(globalPreferencesPathUppercase(), "global")
?? loadPreferencesFile(legacyGlobalPreferencesPath(), "global");
}
export function loadProjectGSDPreferences(): LoadedGSDPreferences | null {
return loadPreferencesFile(projectPreferencesPath(), "project")
?? loadPreferencesFile(projectPreferencesPathLegacy(), "project");
?? loadPreferencesFile(projectPreferencesPathUppercase(), "project");
}
export function loadEffectiveGSDPreferences(): LoadedGSDPreferences | null {
@ -223,7 +224,7 @@ export function parsePreferencesMarkdown(content: string): GSDPreferences | null
if (!_warnedUnrecognizedFormat) {
_warnedUnrecognizedFormat = true;
console.warn("[parsePreferencesMarkdown] PREFERENCES.md exists but uses an unrecognized format — skipping.");
console.warn("[parsePreferencesMarkdown] preferences.md exists but uses an unrecognized format — skipping.");
}
return null;
}
@ -370,9 +371,6 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
service_tier: override.service_tier ?? base.service_tier,
forensics_dedup: override.forensics_dedup ?? base.forensics_dedup,
show_token_cost: override.show_token_cost ?? base.show_token_cost,
experimental: (base.experimental || override.experimental)
? { ...(base.experimental ?? {}), ...(override.experimental ?? {}) }
: undefined,
};
}
@ -519,7 +517,7 @@ export function resolvePreDispatchHooks(): PreDispatchHookConfig[] {
* Resolve the effective git isolation mode from preferences.
* Returns "none" (default), "worktree", or "branch".
*
* Default is "none" so GSD works out of the box without PREFERENCES.md.
* Default is "none" so GSD works out of the box without preferences.md.
* Worktree isolation requires explicit opt-in because it depends on git
* branch infrastructure that must be set up before use.
*/

View file

@ -36,7 +36,6 @@ import {
import { findMilestoneIds } from './milestone-ids.js';
import { loadQueueOrder, sortByQueueOrder } from './queue-order.js';
import { isClosedStatus } from './status-guards.js';
import { nativeBatchParseGsdFiles, type BatchParsedFile } from './native-parser-bridge.js';
import { join, resolve } from 'path';
@ -208,24 +207,7 @@ export async function deriveState(basePath: string): Promise<GSDState> {
// Dual-path: try DB-backed derivation first when hierarchy tables are populated
if (isDbAvailable()) {
let dbMilestones = getAllMilestones();
// Disk→DB reconciliation (#2631): when the milestones table is empty
// (e.g. failed initial migration per #2529), the reconciliation code
// inside deriveStateFromDb is unreachable. Populate from disk here so
// the DB path activates correctly.
if (dbMilestones.length === 0) {
const diskIds = findMilestoneIds(basePath);
let synced = false;
for (const diskId of diskIds) {
if (!isGhostMilestone(basePath, diskId)) {
insertMilestone({ id: diskId, status: 'active' });
synced = true;
}
}
if (synced) dbMilestones = getAllMilestones();
}
const dbMilestones = getAllMilestones();
if (dbMilestones.length > 0) {
const stopDbTimer = debugTime("derive-state-db");
result = await deriveStateFromDb(basePath);
@ -269,6 +251,13 @@ function extractContextTitle(content: string | null, fallback: string): string {
// ─── DB-backed State Derivation ────────────────────────────────────────────
/**
* Helper: check if a DB status counts as "done" (handles K002 ambiguity).
*/
function isStatusDone(status: string): boolean {
return status === 'complete' || status === 'done';
}
/**
* Derive GSD state from the milestones/slices/tasks DB tables.
* Flag files (PARKED, VALIDATION, CONTINUE, REPLAN, REPLAN-TRIGGER, CONTEXT-DRAFT)
@ -388,7 +377,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
continue;
}
if (isClosedStatus(m.status)) {
if (isStatusDone(m.status)) {
completeMilestoneIds.add(m.id);
continue;
}
@ -402,7 +391,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
// Check roadmap: all slices done means milestone is complete
const slices = getMilestoneSlices(m.id);
if (slices.length > 0 && slices.every(s => isClosedStatus(s.status))) {
if (slices.length > 0 && slices.every(s => isStatusDone(s.status))) {
// All slices done but no summary — still counts as complete for dep resolution
// if a summary file exists
// Note: without summary file, the milestone is in validating/completing state, not complete
@ -424,7 +413,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
// Ghost milestone check: no slices in DB AND no substantive files on disk
const slices = getMilestoneSlices(m.id);
if (slices.length === 0 && !isClosedStatus(m.status)) {
if (slices.length === 0 && !isStatusDone(m.status)) {
// Check disk for ghost detection
if (isGhostMilestone(basePath, m.id)) continue;
}
@ -447,7 +436,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
}
// Not complete — determine if it should be active
const allSlicesDone = slices.length > 0 && slices.every(s => isClosedStatus(s.status));
const allSlicesDone = slices.length > 0 && slices.every(s => isStatusDone(s.status));
// Get title — prefer DB, fall back to context file extraction
let title = stripMilestonePrefix(m.title) || m.id;
@ -600,10 +589,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
}
// ── All slices done → validating/completing ─────────────────────────
// Guard: [].every() === true (vacuous truth). Without the length check,
// an empty slice array causes a premature phase transition to
// validating-milestone. See: https://github.com/gsd-build/gsd-2/issues/2667
const allSlicesDone = activeMilestoneSlices.length > 0 && activeMilestoneSlices.every(s => isClosedStatus(s.status));
const allSlicesDone = activeMilestoneSlices.every(s => isStatusDone(s.status));
if (allSlicesDone) {
const validationFile = resolveMilestoneFile(basePath, activeMilestone.id, "VALIDATION");
const validationContent = validationFile ? await loadFile(validationFile) : null;
@ -636,19 +622,19 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
// ── Find active slice (first incomplete with deps satisfied) ─────────
const sliceProgress = {
done: activeMilestoneSlices.filter(s => isClosedStatus(s.status)).length,
done: activeMilestoneSlices.filter(s => isStatusDone(s.status)).length,
total: activeMilestoneSlices.length,
};
const doneSliceIds = new Set(
activeMilestoneSlices.filter(s => isClosedStatus(s.status)).map(s => s.id)
activeMilestoneSlices.filter(s => isStatusDone(s.status)).map(s => s.id)
);
let activeSlice: ActiveRef | null = null;
let activeSliceRow: SliceRow | null = null;
for (const s of activeMilestoneSlices) {
if (isClosedStatus(s.status)) continue;
if (isStatusDone(s.status)) continue;
if (s.depends.every(dep => doneSliceIds.has(dep))) {
activeSlice = { id: s.id, title: s.title };
activeSliceRow = s;
@ -691,7 +677,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
// causing the dispatcher to re-dispatch the same completed task forever.
let reconciled = false;
for (const t of tasks) {
if (isClosedStatus(t.status)) continue;
if (isStatusDone(t.status)) continue;
const summaryPath = resolveTaskFile(basePath, activeMilestone.id, activeSlice.id, t.id, "SUMMARY");
if (summaryPath && existsSync(summaryPath)) {
try {
@ -714,11 +700,11 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
}
const taskProgress = {
done: tasks.filter(t => isClosedStatus(t.status)).length,
done: tasks.filter(t => isStatusDone(t.status)).length,
total: tasks.length,
};
const activeTaskRow = tasks.find(t => !isClosedStatus(t.status));
const activeTaskRow = tasks.find(t => !isStatusDone(t.status));
if (!activeTaskRow && tasks.length > 0) {
// All tasks done but slice not marked complete → summarizing
@ -779,7 +765,7 @@ export async function deriveStateFromDb(basePath: string): Promise<GSDState> {
}
// ── Blocker detection: check completed tasks for blocker_discovered ──
const completedTasks = tasks.filter(t => isClosedStatus(t.status));
const completedTasks = tasks.filter(t => isStatusDone(t.status));
let blockerTaskId: string | null = null;
for (const ct of completedTasks) {
if (ct.blocker_discovered) {

View file

@ -1,79 +0,0 @@
/**
* Regression test for #2631: deriveState diskDB reconciliation must
* run even when the milestones table starts empty.
*
* When getAllMilestones() returns [] (e.g. after a failed initial migration),
* the reconciliation code inside deriveStateFromDb was unreachable because
* deriveState only called it when dbMilestones.length > 0. The fix moves
* diskDB sync into deriveState itself, before the length check.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { deriveState, invalidateStateCache } from "../state.ts";
import {
openDatabase,
closeDatabase,
getAllMilestones,
} from "../gsd-db.ts";
test("deriveState populates empty DB from disk milestones (#2631)", async () => {
const base = mkdtempSync(join(tmpdir(), "gsd-empty-db-"));
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
try {
// Create a milestone on disk with a CONTEXT file (not a ghost)
writeFileSync(
join(base, ".gsd", "milestones", "M001", "M001-CONTEXT.md"),
"# M001: Test Milestone\n\nSome context about this milestone.",
);
// Open DB — milestones table is empty (simulating failed migration)
openDatabase(":memory:");
const before = getAllMilestones();
assert.equal(before.length, 0, "DB should start with 0 milestones");
// deriveState should reconcile disk → DB
invalidateStateCache();
const state = await deriveState(base);
// After deriveState, the DB should now have the disk milestone
const after = getAllMilestones();
assert.ok(after.length > 0, "DB should have milestones after reconciliation");
assert.equal(after[0]!.id, "M001", "reconciled milestone should be M001");
// State should reflect the milestone (not "No milestones found")
assert.ok(
state.activeMilestone !== null,
"activeMilestone should not be null after reconciliation",
);
closeDatabase();
} finally {
closeDatabase();
rmSync(base, { recursive: true, force: true });
}
});
test("deriveState does NOT insert ghost milestones into DB (#2631 guard)", async () => {
const base = mkdtempSync(join(tmpdir(), "gsd-empty-db-"));
// Create a ghost milestone directory (empty — no CONTEXT, no ROADMAP)
mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true });
try {
openDatabase(":memory:");
invalidateStateCache();
await deriveState(base);
const milestones = getAllMilestones();
assert.equal(milestones.length, 0, "ghost milestone should NOT be inserted");
closeDatabase();
} finally {
closeDatabase();
rmSync(base, { recursive: true, force: true });
}
});

View file

@ -0,0 +1,97 @@
/**
* Regression test for #2985 Bugs 3 & 4:
* Bug 3 module-level pendingAutoStart singleton clobbers concurrent sessions.
* Bug 4 getDiscussionMilestoneId() returns wrong project's milestone under concurrency.
*
* pendingAutoStart must be keyed by basePath so concurrent discuss sessions
* in different projects are independent. getDiscussionMilestoneId() must accept
* a basePath parameter to perform a keyed lookup.
*/
import { describe, test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import {
getDiscussionMilestoneId,
setPendingAutoStart,
clearPendingAutoStart,
} from "../guided-flow.ts";
// ─── Tests ─────────────────────────────────────────────────────────────────
describe("#2985 Bug 3 — concurrent discuss sessions must be independent", () => {
beforeEach(() => {
clearPendingAutoStart();
});
test("second session does not clobber first session's pending auto-start", () => {
// Simulate two concurrent discuss sessions for different projects
const projectA = "/projects/alpha";
const projectB = "/projects/beta";
setPendingAutoStart(projectA, {
basePath: projectA,
milestoneId: "M001-aaa111",
});
setPendingAutoStart(projectB, {
basePath: projectB,
milestoneId: "M002-bbb222",
});
// Both sessions should be retrievable
const milestoneA = getDiscussionMilestoneId(projectA);
const milestoneB = getDiscussionMilestoneId(projectB);
assert.equal(milestoneA, "M001-aaa111", "projectA's milestone should be preserved");
assert.equal(milestoneB, "M002-bbb222", "projectB's milestone should be preserved");
});
test("clearing one session does not affect the other", () => {
const projectA = "/projects/alpha";
const projectB = "/projects/beta";
setPendingAutoStart(projectA, { basePath: projectA, milestoneId: "M001-aaa111" });
setPendingAutoStart(projectB, { basePath: projectB, milestoneId: "M002-bbb222" });
// Clear only projectA
clearPendingAutoStart(projectA);
assert.equal(getDiscussionMilestoneId(projectA), null, "projectA should be cleared");
assert.equal(getDiscussionMilestoneId(projectB), "M002-bbb222", "projectB should survive");
});
});
describe("#2985 Bug 4 — getDiscussionMilestoneId must be keyed by basePath", () => {
beforeEach(() => {
clearPendingAutoStart();
});
test("getDiscussionMilestoneId(basePath) returns correct milestone for each project", () => {
setPendingAutoStart("/proj/a", { basePath: "/proj/a", milestoneId: "M001" });
setPendingAutoStart("/proj/b", { basePath: "/proj/b", milestoneId: "M002" });
assert.equal(getDiscussionMilestoneId("/proj/a"), "M001");
assert.equal(getDiscussionMilestoneId("/proj/b"), "M002");
assert.equal(getDiscussionMilestoneId("/proj/unknown"), null);
});
test("getDiscussionMilestoneId() without basePath returns null when multiple sessions exist", () => {
setPendingAutoStart("/proj/a", { basePath: "/proj/a", milestoneId: "M001" });
setPendingAutoStart("/proj/b", { basePath: "/proj/b", milestoneId: "M002" });
// Without a key, the function should not blindly return the first entry
const result = getDiscussionMilestoneId();
// When there's ambiguity (multiple sessions), it should return null
// to force callers to be explicit
assert.equal(result, null, "should not return arbitrary milestone when multiple sessions exist");
});
test("getDiscussionMilestoneId() without basePath returns the milestone when only one session", () => {
setPendingAutoStart("/proj/a", { basePath: "/proj/a", milestoneId: "M001" });
// With only one session, backward compat — return it
const result = getDiscussionMilestoneId();
assert.equal(result, "M001", "should return the only active milestone for backward compat");
});
});