refactor: unify cache invalidation into invalidateAllCaches() (#545)

Three independent caches (state, path, parse) required manual coordination
on every dispatch cycle. Forgetting any one caused stale reads (#431).
Add a single invalidateAllCaches() in cache.ts that clears all three,
and replace grouped call sites in auto.ts and tests.

Individual clear functions are preserved for callers that legitimately
only need to clear one cache.

Closes #527

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-15 17:12:13 -06:00 committed by GitHub
parent 4afcc81382
commit 4184be251f
4 changed files with 45 additions and 27 deletions

View file

@ -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;

View file

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

View file

@ -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<void> {
// ─── 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<void> {
// 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");

View file

@ -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",