* 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:
parent
45bd2572ac
commit
a02b140f61
8 changed files with 247 additions and 197 deletions
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* Regression test for #2631: deriveState disk→DB 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
|
||||
* disk→DB 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue