From 1b6fdd6aa6d901f981872eb3fe226699855981df Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sat, 14 Mar 2026 13:34:29 -0600 Subject: [PATCH 1/2] perf: add session-scoped directory listing cache to path resolution resolveDir(), resolveFile(), and resolveTaskFiles() call readdirSync() on every invocation with no caching. These are called dozens of times per dispatch cycle through resolveMilestonePath, resolveSliceFile, relTaskFile, etc. This adds a module-level Map cache for directory listings with an exported clearPathCache() function for invalidation at dispatch cycle boundaries and milestone transitions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/resources/extensions/gsd/paths.ts | 39 ++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index c41ee596e..601e7e1d9 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -9,9 +9,40 @@ * via prefix matching, so existing projects work without migration. */ -import { readdirSync, existsSync } from "node:fs"; +import { readdirSync, existsSync, Dirent } from "node:fs"; import { join } from "node:path"; +// ─── Directory Listing Cache ────────────────────────────────────────────────── + +const dirEntryCache = new Map(); +const dirListCache = new Map(); + +function cachedReaddirWithTypes(dirPath: string): Dirent[] { + const cached = dirEntryCache.get(dirPath); + if (cached) return cached; + const entries = readdirSync(dirPath, { withFileTypes: true }); + dirEntryCache.set(dirPath, entries); + return entries; +} + +function cachedReaddir(dirPath: string): string[] { + const cached = dirListCache.get(dirPath); + if (cached) return cached; + const entries = readdirSync(dirPath); + dirListCache.set(dirPath, entries); + return entries; +} + +/** + * Clear the directory listing cache. + * Call after milestone transitions, file creation in planning directories, + * or at the start/end of a dispatch cycle. + */ +export function clearPathCache(): void { + dirEntryCache.clear(); + dirListCache.clear(); +} + // ─── Name Builders ───────────────────────────────────────────────────────── /** @@ -58,7 +89,7 @@ export function buildTaskFileName(taskId: string, suffix: string): string { export function resolveDir(parentDir: string, idPrefix: string): string | null { if (!existsSync(parentDir)) return null; try { - const entries = readdirSync(parentDir, { withFileTypes: true }); + const entries = cachedReaddirWithTypes(parentDir); // Exact match first (current convention: bare ID) const exact = entries.find(e => e.isDirectory() && e.name === idPrefix); if (exact) return exact.name; @@ -83,7 +114,7 @@ export function resolveFile(dir: string, idPrefix: string, suffix: string): stri if (!existsSync(dir)) return null; const target = `${idPrefix}-${suffix}.md`.toUpperCase(); try { - const entries = readdirSync(dir); + const entries = cachedReaddir(dir); // Direct match: ID-SUFFIX.md const direct = entries.find(e => e.toUpperCase() === target); if (direct) return direct; @@ -113,7 +144,7 @@ export function resolveTaskFiles(tasksDir: string, suffix: string): string[] { const currentPattern = new RegExp(`^T\\d+-${suffix}\\.md$`, "i"); // Legacy convention: T01-INSTALL-PACKAGES-PLAN.md const legacyPattern = new RegExp(`^T\\d+-.*-${suffix}\\.md$`, "i"); - return readdirSync(tasksDir) + return cachedReaddir(tasksDir) .filter(f => currentPattern.test(f) || legacyPattern.test(f)) .sort(); } catch { From d7faf8a4e5f37375728a7e27ddcbaf97f07a180e Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Sat, 14 Mar 2026 14:14:40 -0600 Subject: [PATCH 2/2] fix(tests): invalidate path cache between deriveState calls that expect fresh disk state Tests that write files and immediately call deriveState() got stale results because the path resolution cache (dirEntryCache/dirListCache) returned cached directory listings that didn't include newly written files. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extensions/gsd/tests/complete-milestone.test.ts | 2 ++ src/resources/extensions/gsd/tests/draft-promotion.test.ts | 4 +++- .../gsd/tests/integration-mixed-milestones.test.ts | 3 +++ src/resources/extensions/gsd/tests/unit-runtime.test.ts | 6 ++++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/tests/complete-milestone.test.ts b/src/resources/extensions/gsd/tests/complete-milestone.test.ts index cdb286792..0055cb862 100644 --- a/src/resources/extensions/gsd/tests/complete-milestone.test.ts +++ b/src/resources/extensions/gsd/tests/complete-milestone.test.ts @@ -3,6 +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'; // loadPrompt reads from ~/.gsd/agent/extensions/gsd/prompts/ (main checkout). // In a worktree the file may not exist there yet, so we resolve prompts @@ -179,6 +180,7 @@ async function main(): Promise { // Now add the summary and verify it transitions to complete writeMilestoneSummary(base, "M001", "# M001 Summary\n\nDone."); + clearPathCache(); 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 0fb7160cd..1037d9809 100644 --- a/src/resources/extensions/gsd/tests/draft-promotion.test.ts +++ b/src/resources/extensions/gsd/tests/draft-promotion.test.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { deriveState } from "../state.js"; -import { resolveMilestoneFile } from "../paths.js"; +import { resolveMilestoneFile, clearPathCache } from "../paths.js"; let passed = 0; let failed = 0; @@ -40,6 +40,7 @@ assert( const contextPath = join(gsd, "milestones", "M001", "M001-CONTEXT.md"); writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n"); +clearPathCache(); const state2 = await deriveState(tmpBase); assert( state2.phase === "pre-planning", @@ -65,6 +66,7 @@ assert( ); // Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists) +clearPathCache(); const state3 = await deriveState(tmpBase); assert( state3.phase === "pre-planning", diff --git a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts index d5b761075..7e16581eb 100644 --- a/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +++ b/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts @@ -23,6 +23,7 @@ import { parseSliceBranch, switchToMain, } from '../worktree.ts'; +import { clearPathCache } from '../paths.ts'; import { createTestContext } from './test-helpers.ts'; // ─── Assertion Helpers ──────────────────────────────────────────────────── @@ -437,6 +438,7 @@ Built the legacy feature successfully. `); run('git add .', base); run('git commit -m add-m001', base); + clearPathCache(); // M001 (seq=1) < M001-abc123 (seq=1) — but M001 has incomplete S02 // Since M001 seq=1 and M002-abc123 seq=2, blocker should reference M001/S02 @@ -459,6 +461,7 @@ Built the legacy feature successfully. `); run('git add .', base); run('git commit -m complete-m001', base); + clearPathCache(); assertEq( getPriorSliceCompletionBlocker(base, 'main', 'plan-slice', 'M002-abc123/S01'), diff --git a/src/resources/extensions/gsd/tests/unit-runtime.test.ts b/src/resources/extensions/gsd/tests/unit-runtime.test.ts index b96439ede..64c7ee49a 100644 --- a/src/resources/extensions/gsd/tests/unit-runtime.test.ts +++ b/src/resources/extensions/gsd/tests/unit-runtime.test.ts @@ -8,6 +8,7 @@ import { readUnitRuntimeRecord, writeUnitRuntimeRecord, } from "../unit-runtime.ts"; +import { clearPathCache } from '../paths.ts'; import { createTestContext } from './test-helpers.ts'; const { assertEq, assertTrue, report } = createTestContext(); @@ -48,6 +49,7 @@ console.log("\n=== execute-task durability inspection ==="); "utf-8", ); writeFileSync(join(base, ".gsd", "STATE.md"), "## Next Action\nExecute T10 for S02: next thing\n", "utf-8"); + clearPathCache(); status = await inspectExecuteTaskDurability(base, "M100/S02/T09"); assertEq(status!.summaryExists, true, "summary found after write"); @@ -128,6 +130,7 @@ console.log("\n=== must-haves: partially mentioned in summary ==="); ); writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S02: next thing\n", "utf-8"); + clearPathCache(); const status = await inspectExecuteTaskDurability(mhBase, "M200/S02/T01"); assertTrue(status !== null, "mh-partial: status exists"); assertEq(status!.mustHaveCount, 3, "mh-partial: mustHaveCount is 3"); @@ -155,6 +158,7 @@ console.log("\n=== must-haves: no task plan file ==="); ); writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S03: next thing\n", "utf-8"); + clearPathCache(); const status = await inspectExecuteTaskDurability(mhBase, "M200/S03/T01"); assertTrue(status !== null, "mh-noplan: status exists"); assertEq(status!.mustHaveCount, 0, "mh-noplan: mustHaveCount is 0 when no task plan"); @@ -179,6 +183,7 @@ console.log("\n=== must-haves: present but no summary file ==="); ); writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T01 for S04: build parser\n", "utf-8"); + clearPathCache(); const status = await inspectExecuteTaskDurability(mhBase, "M200/S04/T01"); assertTrue(status !== null, "mh-nosummary: status exists"); assertEq(status!.mustHaveCount, 2, "mh-nosummary: mustHaveCount is 2"); @@ -210,6 +215,7 @@ console.log("\n=== must-haves: substring matching (no backtick tokens) ==="); ); writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S05: next thing\n", "utf-8"); + clearPathCache(); const status = await inspectExecuteTaskDurability(mhBase, "M200/S05/T01"); assertTrue(status !== null, "mh-substr: status exists"); assertEq(status!.mustHaveCount, 3, "mh-substr: mustHaveCount is 3");