diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index bcf9c6582..4fd9f2dad 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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; diff --git a/src/resources/extensions/gsd/state.ts b/src/resources/extensions/gsd/state.ts index 662545093..9aa14e85c 100644 --- a/src/resources/extensions/gsd/state.ts +++ b/src/resources/extensions/gsd/state.ts @@ -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 { + // 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 { const milestoneIds = findMilestoneIds(basePath); // ── Batch-parse file cache ──────────────────────────────────────────────