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:
commit
85a578265b
2 changed files with 73 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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\)/);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue