diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index c576d820d..dc03fe65e 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -85,6 +85,7 @@ interface PendingAutoStartEntry { basePath: string; milestoneId: string; // the milestone being discussed step?: boolean; // preserve step mode through discuss → auto transition + createdAt: number; // timestamp for staleness detection (#3274) } const pendingAutoStartMap = new Map(); @@ -104,8 +105,8 @@ function _getPendingAutoStart(basePath?: string): PendingAutoStartEntry | 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); +export function setPendingAutoStart(basePath: string, entry: { basePath: string; milestoneId: string; ctx?: ExtensionCommandContext; pi?: ExtensionAPI; step?: boolean; createdAt?: number }): void { + pendingAutoStartMap.set(basePath, { createdAt: Date.now(), ...entry } as PendingAutoStartEntry); } /** @@ -469,7 +470,7 @@ export async function showHeadlessMilestoneCreation( const prompt = buildHeadlessDiscussPrompt(nextId, seedContext, basePath); // Set pending auto start (auto-mode triggers on "Milestone X ready." via checkAutoStartAfterDiscuss) - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, createdAt: Date.now() }); // Dispatch — headless milestone creation is a planning activity await dispatchWorkflow(pi, prompt, "gsd-run", ctx, "plan-milestone"); @@ -650,12 +651,12 @@ export async function showDiscuss( const seed = draftContent ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` : basePrompt; - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: mid, step: false }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: mid, step: false, createdAt: Date.now() }); 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"; - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: mid, step: false }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: mid, step: false, createdAt: Date.now() }); await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`), @@ -664,7 +665,7 @@ export async function showDiscuss( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: false }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: false, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "discuss-milestone"); } return; @@ -1027,7 +1028,7 @@ async function handleMilestoneActions( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath @@ -1154,8 +1155,22 @@ export async function showSmartEntry( // Without this guard, every subsequent /gsd call overwrites the pending auto-start // and fires another dispatchWorkflow, resetting the conversation mid-interview. if (pendingAutoStartMap.has(basePath)) { - ctx.ui.notify("Discussion already in progress — answer the question above to continue.", "info"); - return; + // #3274: If /clear interrupted the discussion, the pending entry is stale. + // Detect staleness: no manifest, no CONTEXT.md, AND entry is older than + // 30s (avoids race between .set() and LLM writing first artifact). + const entry = pendingAutoStartMap.get(basePath)!; + const ageMs = Date.now() - (entry.createdAt || 0); + const manifestExists = existsSync(join(gsdRoot(basePath), "DISCUSSION-MANIFEST.json")); + const milestoneHasContext = existsSync( + join(gsdRoot(basePath), "milestones", entry.milestoneId, `${entry.milestoneId}-CONTEXT.md`), + ); + if (!manifestExists && !milestoneHasContext && ageMs > 30_000) { + // Stale entry from an interrupted discussion — clear and continue + pendingAutoStartMap.delete(basePath); + } else { + ctx.ui.notify("Discussion already in progress — answer the question above to continue.", "info"); + return; + } } const milestoneIds = findMilestoneIds(basePath); @@ -1186,7 +1201,7 @@ export async function showSmartEntry( if (isFirst) { // First ever — skip wizard, just ask directly - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`, basePath @@ -1207,7 +1222,7 @@ export async function showSmartEntry( }); if (choice === "new_milestone") { - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath @@ -1246,7 +1261,7 @@ export async function showSmartEntry( const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath @@ -1297,12 +1312,12 @@ export async function showSmartEntry( const seed = draftContent ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` : basePrompt; - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode, createdAt: Date.now() }); 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"; - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`), @@ -1311,7 +1326,7 @@ export async function showSmartEntry( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath @@ -1364,7 +1379,7 @@ export async function showSmartEntry( }); if (choice === "plan") { - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId, step: stepMode, createdAt: Date.now() }); const planMilestoneTemplates = [ inlineTemplate("roadmap", "Roadmap"), inlineTemplate("plan", "Slice Plan"), @@ -1395,7 +1410,7 @@ export async function showSmartEntry( const milestoneIds = findMilestoneIds(basePath); const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids; const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds); - pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode }); + pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() }); await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath diff --git a/src/resources/extensions/gsd/tests/clear-stale-autostart.test.ts b/src/resources/extensions/gsd/tests/clear-stale-autostart.test.ts new file mode 100644 index 000000000..c5452e6a6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/clear-stale-autostart.test.ts @@ -0,0 +1,41 @@ +/** + * clear-stale-autostart.test.ts — #3667 + * + * Verify that guided-flow.ts adds a createdAt timestamp to pending auto-start + * entries and implements a staleness check (30s age guard) so that /clear + * interrupted discussions don't permanently block future /gsd invocations. + */ + +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const sourceFile = join(__dirname, "..", "guided-flow.ts"); + +describe("clear stale pending auto-start (#3667)", () => { + const source = readFileSync(sourceFile, "utf-8"); + + test("PendingAutoStartEntry interface includes createdAt field", () => { + assert.match(source, /createdAt:\s*number/); + }); + + test("setPendingAutoStart defaults createdAt to Date.now()", () => { + assert.match(source, /createdAt:\s*Date\.now\(\)/); + }); + + test("staleness check uses 30_000ms threshold", () => { + assert.match(source, /30[_]?000/); + }); + + test("stale entry detection checks manifest and context files", () => { + assert.match(source, /DISCUSSION-MANIFEST\.json/); + assert.match(source, /CONTEXT\.md/); + }); + + test("stale entries are deleted from the map", () => { + assert.match(source, /pendingAutoStartMap\.delete\(basePath\)/); + }); +});