Merge pull request #413 from gsd-build/perf/memoize-derive-state
perf: memoize deriveState() per dispatch cycle
This commit is contained in:
commit
6d8445b201
4 changed files with 50 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
// ─── 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<void> {
|
|||
// 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");
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue