From af2bd4d45f8349ed7d158ee4abbaf0230b1f3287 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 18:27:45 -0700 Subject: [PATCH 1/3] fix(gsd): clear stale pendingAutoStart after /clear interrupts discussion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the pending auto-start guard fires, check if the discussion is actually still in progress by verifying the discussion manifest or milestone context exists on disk. If neither exists, the entry is stale from an interrupted session — clear it and allow re-entry. Fixes #3274 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/guided-flow.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index d0f400448..a62ec2504 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -1143,8 +1143,21 @@ 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 this by checking if the discussion manifest still exists — it's + // only present while a discuss flow is actively in progress. + const manifestExists = existsSync(join(gsdRoot(basePath), "DISCUSSION-MANIFEST.json")); + const entry = pendingAutoStartMap.get(basePath)!; + const milestoneHasContext = existsSync( + join(gsdRoot(basePath), "milestones", entry.milestoneId, `${entry.milestoneId}-CONTEXT.md`), + ); + if (!manifestExists && !milestoneHasContext) { + // 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); From 7e9434dec129df1c4f976ad08a7d082e41de8ecc Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 18:30:29 -0700 Subject: [PATCH 2/3] fix(gsd): add createdAt timestamp and 30s age guard to staleness check Prevent race where a freshly-set pending entry (before LLM writes artifacts) could be falsely detected as stale. Only clear entries older than 30 seconds with no manifest or CONTEXT.md on disk. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/guided-flow.ts | 40 +++++++++++---------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index a62ec2504..5cb6503a5 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); } /** @@ -460,7 +461,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"); @@ -640,12 +641,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`), @@ -654,7 +655,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; @@ -1016,7 +1017,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 @@ -1144,14 +1145,15 @@ export async function showSmartEntry( // and fires another dispatchWorkflow, resetting the conversation mid-interview. if (pendingAutoStartMap.has(basePath)) { // #3274: If /clear interrupted the discussion, the pending entry is stale. - // Detect this by checking if the discussion manifest still exists — it's - // only present while a discuss flow is actively in progress. - const manifestExists = existsSync(join(gsdRoot(basePath), "DISCUSSION-MANIFEST.json")); + // 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) { + if (!manifestExists && !milestoneHasContext && ageMs > 30_000) { // Stale entry from an interrupted discussion — clear and continue pendingAutoStartMap.delete(basePath); } else { @@ -1188,7 +1190,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 @@ -1209,7 +1211,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 @@ -1248,7 +1250,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 @@ -1299,12 +1301,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`), @@ -1313,7 +1315,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 @@ -1366,7 +1368,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"), @@ -1397,7 +1399,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 From 2e4c19eb1648fbc0481db5de78c7aebf943c93cf Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 6 Apr 2026 22:27:04 -0700 Subject: [PATCH 3/3] test: add regression test for stale pending auto-start cleanup Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gsd/tests/clear-stale-autostart.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/clear-stale-autostart.test.ts 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\)/); + }); +});