perf: memoize deriveState() per dispatch cycle

deriveState() was called ~7 times per dispatch cycle, each call re-reading
the entire .gsd/milestones/ tree from disk (~50-60 file reads per call,
~350-420 redundant reads per cycle). Add a 100ms TTL cache keyed by
basePath so repeated calls within the same dispatch cycle return the
cached result. Expose invalidateStateCache() and call it at every
mutation boundary in auto.ts: handleAgentEnd start, post-merge
re-derivations, and resume-from-pause.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-14 13:35:47 -06:00
parent 86f705c8e4
commit e739c4cab7
2 changed files with 44 additions and 1 deletions

View file

@ -16,7 +16,7 @@ import type {
ExtensionCommandContext,
} from "@gsd/pi-coding-agent";
import { deriveState } from "./state.js";
import { deriveState, invalidateStateCache } from "./state.js";
import type { GSDState } from "./types.js";
import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary, getManifestStatus } from "./files.js";
export { inlinePriorMilestoneSummary };
@ -574,6 +574,7 @@ export async function startAuto(
} catch { /* non-fatal */ }
// Self-heal: clear stale runtime records where artifacts already exist
await selfHealRuntimeRecords(base, ctx);
invalidateStateCache();
await dispatchNextUnit(ctx, pi);
return;
}
@ -763,6 +764,10 @@ export async function handleAgentEnd(
// Unit completed — clear its timeout
clearUnitTimeout();
// Invalidate deriveState() cache — the unit just completed and may have
// written planning files (task summaries, roadmap checkboxes, etc.)
invalidateStateCache();
// Small delay to let files settle (git commits, file writes)
await new Promise(r => setTimeout(r, 500));
@ -1332,6 +1337,7 @@ async function dispatchNextUnit(
}
}
// Re-derive state from the now-merged working tree
invalidateStateCache();
state = await deriveState(basePath);
mid = state.activeMilestone?.id;
midTitle = state.activeMilestone?.title;
@ -1396,6 +1402,7 @@ async function dispatchNextUnit(
"info",
);
// Re-derive state from main so downstream logic sees merged state
invalidateStateCache();
state = await deriveState(basePath);
mid = state.activeMilestone?.id;
midTitle = state.activeMilestone?.title;

View file

@ -53,6 +53,27 @@ export function isMilestoneComplete(roadmap: Roadmap): boolean {
// ─── State Derivation ──────────────────────────────────────────────────────
// ── deriveState memoization ─────────────────────────────────────────────────
// Cache the most recent deriveState() result keyed by basePath. Within a single
// dispatch cycle (~100ms window), repeated calls return the cached value instead
// of re-reading the entire .gsd/ tree from disk.
interface StateCache {
basePath: string;
result: GSDState;
timestamp: number;
}
const CACHE_TTL_MS = 100;
let _stateCache: StateCache | null = null;
/**
* Invalidate the deriveState() cache. Call this whenever planning files on disk
* may have changed (unit completion, merges, file writes).
*/
export function invalidateStateCache(): void {
_stateCache = null;
}
/**
* Returns the ID of the first incomplete milestone, or null if all are complete.
@ -86,6 +107,21 @@ export async function getActiveMilestoneId(basePath: string): Promise<string | n
* Falls back to sequential JS file reads when the native module is absent.
*/
export async function deriveState(basePath: string): Promise<GSDState> {
// Return cached result if within the TTL window for the same basePath
if (
_stateCache &&
_stateCache.basePath === basePath &&
Date.now() - _stateCache.timestamp < CACHE_TTL_MS
) {
return _stateCache.result;
}
const result = await _deriveStateImpl(basePath);
_stateCache = { basePath, result, timestamp: Date.now() };
return result;
}
async function _deriveStateImpl(basePath: string): Promise<GSDState> {
const milestoneIds = findMilestoneIds(basePath);
// ── Batch-parse file cache ──────────────────────────────────────────────