diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index ca9543edf..de76458dc 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -18,7 +18,7 @@ import type { import { deriveState, invalidateStateCache } from "./state.js"; import type { BudgetEnforcementMode, GSDState } from "./types.js"; -import { loadFile, parseRoadmap, getManifestStatus, clearParseCache } from "./files.js"; +import { loadFile, parseRoadmap, getManifestStatus } from "./files.js"; export { inlinePriorMilestoneSummary } from "./files.js"; import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; import { @@ -27,8 +27,8 @@ import { relMilestoneFile, relSliceFile, relSlicePath, relMilestonePath, milestonesDir, buildMilestoneFileName, buildSliceFileName, buildTaskFileName, - clearPathCache, } from "./paths.js"; +import { invalidateAllCaches } from "./cache.js"; import { saveActivityLog } from "./activity-log.js"; import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js"; import { writeLock, clearLock, readCrashLock, formatCrashInfo, isLockProcessAlive } from "./crash-recovery.js"; @@ -492,9 +492,7 @@ export async function startAuto( } catch { /* non-fatal */ } // Self-heal: clear stale runtime records where artifacts already exist await selfHealRuntimeRecords(base, ctx, completedKeySet); - invalidateStateCache(); - clearParseCache(); - clearPathCache(); + invalidateAllCaches(); await dispatchNextUnit(ctx, pi); return; } @@ -763,11 +761,9 @@ export async function handleAgentEnd( // Unit completed — clear its timeout clearUnitTimeout(); - // Invalidate deriveState() cache — the unit just completed and may have + // Invalidate all caches — the unit just completed and may have // written planning files (task summaries, roadmap checkboxes, etc.) - invalidateStateCache(); - clearParseCache(); - clearPathCache(); + invalidateAllCaches(); // Small delay to let files settle (git commits, file writes) await new Promise(r => setTimeout(r, 500)); @@ -1120,11 +1116,10 @@ async function dispatchNextUnit( await new Promise(r => setTimeout(r, 200)); } - // Clear stale directory listing cache so deriveState sees fresh disk state (#431) - clearPathCache(); - // Clear parsed roadmap/plan cache — doctor may have re-populated it with + // Clear all caches so deriveState sees fresh disk state (#431). + // Parse cache is also cleared — doctor may have re-populated it with // stale data between handleAgentEnd and this dispatch call (Path B fix). - clearParseCache(); + invalidateAllCaches(); let state = await deriveState(basePath); let mid = state.activeMilestone?.id; @@ -1171,9 +1166,7 @@ async function dispatchNextUnit( // ── Mid-merge safety check: detect leftover merge state from a prior session ── if (reconcileMergeState(basePath, ctx)) { - invalidateStateCache(); - clearParseCache(); - clearPathCache(); + invalidateAllCaches(); state = await deriveState(basePath); mid = state.activeMilestone?.id; midTitle = state.activeMilestone?.title; diff --git a/src/resources/extensions/gsd/cache.ts b/src/resources/extensions/gsd/cache.ts new file mode 100644 index 000000000..0dcef5b4f --- /dev/null +++ b/src/resources/extensions/gsd/cache.ts @@ -0,0 +1,27 @@ +// GSD Extension — Unified Cache Invalidation +// +// Three module-scoped caches exist across the GSD extension: +// 1. State cache (state.ts) — memoized deriveState() result +// 2. Path cache (paths.ts) — directory listing results (readdirSync) +// 3. Parse cache (files.ts) — parsed markdown file results +// +// After any file write that changes .gsd/ contents, all three must be +// invalidated together to prevent stale reads. This module provides a +// single function that clears all three atomically. + +import { invalidateStateCache } from './state.js'; +import { clearPathCache } from './paths.js'; +import { clearParseCache } from './files.js'; + +/** + * Invalidate all GSD runtime caches in one call. + * + * Call this after file writes, milestone transitions, merge reconciliation, + * or any operation that changes .gsd/ contents on disk. Forgetting to clear + * any single cache causes stale reads (see #431). + */ +export function invalidateAllCaches(): void { + invalidateStateCache(); + clearPathCache(); + clearParseCache(); +} diff --git a/src/resources/extensions/gsd/tests/complete-milestone.test.ts b/src/resources/extensions/gsd/tests/complete-milestone.test.ts index af7389701..8037ef317 100644 --- a/src/resources/extensions/gsd/tests/complete-milestone.test.ts +++ b/src/resources/extensions/gsd/tests/complete-milestone.test.ts @@ -3,8 +3,7 @@ import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { createTestContext } from './test-helpers.ts'; -import { clearPathCache } from '../paths.ts'; -import { invalidateStateCache } from '../state.ts'; +import { invalidateAllCaches } from '../cache.ts'; // loadPrompt reads from ~/.gsd/agent/extensions/gsd/prompts/ (main checkout). // In a worktree the file may not exist there yet, so we resolve prompts @@ -148,7 +147,8 @@ async function main(): Promise { // ─── deriveState integration: completing-milestone dispatches correctly ─ console.log("\n=== deriveState completing-milestone integration ==="); { - const { deriveState, isMilestoneComplete, invalidateStateCache } = await import("../state.ts"); + const { deriveState, isMilestoneComplete } = await import("../state.ts"); + const { invalidateAllCaches: invalidateAllCachesDynamic } = await import("../cache.ts"); const { parseRoadmap } = await import("../files.ts"); const base = createFixtureBase(); @@ -181,8 +181,7 @@ async function main(): Promise { // Now add the summary and verify it transitions to complete writeMilestoneSummary(base, "M001", "# M001 Summary\n\nDone."); - clearPathCache(); - invalidateStateCache(); + invalidateAllCachesDynamic(); const stateAfter = await deriveState(base); assertEq(stateAfter.phase, "complete", "deriveState returns complete after summary exists"); assertEq(stateAfter.registry[0]?.status, "complete", "registry shows complete status"); diff --git a/src/resources/extensions/gsd/tests/draft-promotion.test.ts b/src/resources/extensions/gsd/tests/draft-promotion.test.ts index 0ce24ed50..aaad0e2da 100644 --- a/src/resources/extensions/gsd/tests/draft-promotion.test.ts +++ b/src/resources/extensions/gsd/tests/draft-promotion.test.ts @@ -2,8 +2,9 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node: import { join } from "node:path"; import { tmpdir } from "node:os"; -import { deriveState, invalidateStateCache } from "../state.js"; -import { resolveMilestoneFile, clearPathCache } from "../paths.js"; +import { deriveState } from "../state.js"; +import { resolveMilestoneFile } from "../paths.js"; +import { invalidateAllCaches } from "../cache.js"; let passed = 0; let failed = 0; @@ -40,8 +41,7 @@ assert( const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md"); writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n"); -clearPathCache(); -invalidateStateCache(); +invalidateAllCaches(); const state2 = await deriveState(tmpBase); assert( state2.phase === "pre-planning", @@ -67,8 +67,7 @@ assert( ); // Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists) -clearPathCache(); -invalidateStateCache(); +invalidateAllCaches(); const state3 = await deriveState(tmpBase); assert( state3.phase === "pre-planning",