Merge pull request #3667 from Tibsfox/fix/clear-stale-pending-autostart

fix(gsd): clear stale pendingAutoStart after /clear interrupts discussion
This commit is contained in:
Jeremy McSpadden 2026-04-07 07:08:53 -05:00 committed by GitHub
commit 85a578265b
2 changed files with 73 additions and 17 deletions

View file

@ -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<string, PendingAutoStartEntry>();
@ -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

View file

@ -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\)/);
});
});