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) <noreply@anthropic.com>
This commit is contained in:
parent
af2bd4d45f
commit
7e9434dec1
1 changed files with 21 additions and 19 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue