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 ────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tests/complete-milestone.test.ts b/src/resources/extensions/gsd/tests/complete-milestone.test.ts index 0055cb862..af7389701 100644 --- a/src/resources/extensions/gsd/tests/complete-milestone.test.ts +++ b/src/resources/extensions/gsd/tests/complete-milestone.test.ts @@ -4,6 +4,7 @@ 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'; // loadPrompt reads from ~/.gsd/agent/extensions/gsd/prompts/ (main checkout). // In a worktree the file may not exist there yet, so we resolve prompts @@ -147,7 +148,7 @@ async function main(): Promise { // ─── deriveState integration: completing-milestone dispatches correctly ─ console.log("\n=== deriveState completing-milestone integration ==="); { - const { deriveState, isMilestoneComplete } = await import("../state.ts"); + const { deriveState, isMilestoneComplete, invalidateStateCache } = await import("../state.ts"); const { parseRoadmap } = await import("../files.ts"); const base = createFixtureBase(); @@ -181,6 +182,7 @@ async function main(): Promise { // Now add the summary and verify it transitions to complete writeMilestoneSummary(base, "M001", "# M001 Summary\n\nDone."); clearPathCache(); + invalidateStateCache(); 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 1037d9809..4ea6f976c 100644 --- a/src/resources/extensions/gsd/tests/draft-promotion.test.ts +++ b/src/resources/extensions/gsd/tests/draft-promotion.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node: import { join } from "node:path"; import { tmpdir } from "node:os"; -import { deriveState } from "../state.js"; +import { deriveState, invalidateStateCache } from "../state.js"; import { resolveMilestoneFile, clearPathCache } from "../paths.js"; let passed = 0; @@ -41,6 +41,7 @@ const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md"); writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n"); clearPathCache(); +invalidateStateCache(); const state2 = await deriveState(tmpBase); assert( state2.phase === "pre-planning", @@ -67,6 +68,7 @@ assert( // Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists) clearPathCache(); +invalidateStateCache(); const state3 = await deriveState(tmpBase); assert( state3.phase === "pre-planning",