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