Skip to content

Commit 559095a

Browse files
committed
perf: cache resourceLoader across embedded runs to eliminate 5-9s reload overhead
DefaultResourceLoader construction triggers packageManager.resolve() which scans skills directories and blocks the event loop for 5-9 seconds on every embedded run. This is pure overhead when the same workspace/agent is reused across consecutive runs (normal session flow). Solution: - Add in-memory cache keyed by (cwd, agentDir) in resource-loader.ts - Cache TTL defaults to 60s, configurable via OPENCLAW_RESOURCE_LOADER_CACHE_TTL_MS - add markResourceLoaderReloaded() to track reload completion - Add pruneResourceLoaderCache() for periodic cleanup - Add invalidateResourceLoaderCache() for targeted or full cache invalidation - Call markResourceLoaderReloaded in attempt.ts and compact.ts after reload() Files changed: - resource-loader.ts: cache logic - resource-loader.test.ts: 10 new test cases - attempt.ts: call markResourceLoaderReloaded after reload - compact.ts: call markResourceLoaderReloaded after reload AI-assisted development
1 parent 6920ec6 commit 559095a

4 files changed

Lines changed: 405 additions & 4 deletions

File tree

src/agents/pi-embedded-runner/compact.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,10 @@ import { buildEmbeddedMessageActionDiscoveryInput } from "./message-action-disco
142142
import { readPiModelContextTokens } from "./model-context-tokens.js";
143143
import { resolveModelAsync } from "./model.js";
144144
import { sanitizeSessionHistory, validateReplayTurns } from "./replay-history.js";
145-
import { createEmbeddedPiResourceLoader } from "./resource-loader.js";
145+
import {
146+
createEmbeddedPiResourceLoader,
147+
markResourceLoaderReloaded,
148+
} from "./resource-loader.js";
146149
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
147150
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
148151
import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js";
@@ -1016,6 +1019,7 @@ async function compactEmbeddedPiSessionDirectOnce(
10161019
extensionFactories,
10171020
});
10181021
await resourceLoader.reload();
1022+
markResourceLoaderReloaded(resolvedWorkspace, agentDir);
10191023
// DefaultResourceLoader.reload() rehydrates settings from disk and can drop OpenClaw
10201024
// compaction overrides applied in createPreparedEmbeddedPiSettingsManager — same
10211025
// rehydration also restores Pi's auto-compaction (openclaw#75799), so re-apply

src/agents/pi-embedded-runner/resource-loader.test.ts

Lines changed: 243 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent";
2-
import { describe, expect, it, vi } from "vitest";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import {
44
createEmbeddedPiResourceLoader,
55
EMBEDDED_PI_RESOURCE_LOADER_DISCOVERY_OPTIONS,
6+
getResourceLoaderCacheSize,
7+
invalidateResourceLoaderCache,
8+
markResourceLoaderReloaded,
9+
pruneResourceLoaderCache,
610
} from "./resource-loader.js";
711

812
vi.mock("@earendil-works/pi-coding-agent", () => ({
@@ -18,6 +22,17 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({
1822
}));
1923

2024
describe("createEmbeddedPiResourceLoader", () => {
25+
beforeEach(() => {
26+
// Clear cache before each test
27+
invalidateResourceLoaderCache();
28+
// Reset mock call count
29+
vi.clearAllMocks();
30+
});
31+
32+
afterEach(() => {
33+
invalidateResourceLoaderCache();
34+
});
35+
2136
it("keeps inline extensions but disables Pi filesystem discovery", () => {
2237
const settingsManager = {};
2338
const extensionFactories = [vi.fn()];
@@ -37,4 +52,231 @@ describe("createEmbeddedPiResourceLoader", () => {
3752
...EMBEDDED_PI_RESOURCE_LOADER_DISCOVERY_OPTIONS,
3853
});
3954
});
55+
56+
it("caches resource loader for repeated calls with same cwd/agentDir", () => {
57+
const settingsManager = {};
58+
59+
// First call creates new loader
60+
const loader1 = createEmbeddedPiResourceLoader({
61+
cwd: "/workspace",
62+
agentDir: "/agent",
63+
settingsManager: settingsManager as never,
64+
extensionFactories: [],
65+
});
66+
67+
// Second call should return cached loader (no new DefaultResourceLoader call)
68+
const loader2 = createEmbeddedPiResourceLoader({
69+
cwd: "/workspace",
70+
agentDir: "/agent",
71+
settingsManager: settingsManager as never,
72+
extensionFactories: [],
73+
});
74+
75+
// Should be same instance
76+
expect(loader1).toBe(loader2);
77+
// Should only create one DefaultResourceLoader
78+
expect(DefaultResourceLoader).toHaveBeenCalledTimes(1);
79+
});
80+
81+
it("creates separate loaders for different workspaces", () => {
82+
const loader1 = createEmbeddedPiResourceLoader({
83+
cwd: "/workspace1",
84+
agentDir: "/agent",
85+
settingsManager: {} as never,
86+
extensionFactories: [],
87+
});
88+
89+
const loader2 = createEmbeddedPiResourceLoader({
90+
cwd: "/workspace2",
91+
agentDir: "/agent",
92+
settingsManager: {} as never,
93+
extensionFactories: [],
94+
});
95+
96+
// Should be different instances
97+
expect(loader1).not.toBe(loader2);
98+
// Should create two DefaultResourceLoader
99+
expect(DefaultResourceLoader).toHaveBeenCalledTimes(2);
100+
});
101+
102+
it("creates separate loaders for different agentDirs", () => {
103+
const loader1 = createEmbeddedPiResourceLoader({
104+
cwd: "/workspace",
105+
agentDir: "/agent1",
106+
settingsManager: {} as never,
107+
extensionFactories: [],
108+
});
109+
110+
const loader2 = createEmbeddedPiResourceLoader({
111+
cwd: "/workspace",
112+
agentDir: "/agent2",
113+
settingsManager: {} as never,
114+
extensionFactories: [],
115+
});
116+
117+
expect(loader1).not.toBe(loader2);
118+
expect(DefaultResourceLoader).toHaveBeenCalledTimes(2);
119+
});
120+
121+
it("cache size tracks number of cached loaders", () => {
122+
expect(getResourceLoaderCacheSize()).toBe(0);
123+
124+
createEmbeddedPiResourceLoader({
125+
cwd: "/workspace1",
126+
agentDir: "/agent",
127+
settingsManager: {} as never,
128+
extensionFactories: [],
129+
});
130+
expect(getResourceLoaderCacheSize()).toBe(1);
131+
132+
createEmbeddedPiResourceLoader({
133+
cwd: "/workspace2",
134+
agentDir: "/agent",
135+
settingsManager: {} as never,
136+
extensionFactories: [],
137+
});
138+
expect(getResourceLoaderCacheSize()).toBe(2);
139+
140+
// Same workspace should not increase cache size
141+
createEmbeddedPiResourceLoader({
142+
cwd: "/workspace1",
143+
agentDir: "/agent",
144+
settingsManager: {} as never,
145+
extensionFactories: [],
146+
});
147+
expect(getResourceLoaderCacheSize()).toBe(2);
148+
});
149+
150+
it("invalidateResourceLoaderCache clears all entries", () => {
151+
createEmbeddedPiResourceLoader({
152+
cwd: "/workspace1",
153+
agentDir: "/agent",
154+
settingsManager: {} as never,
155+
extensionFactories: [],
156+
});
157+
createEmbeddedPiResourceLoader({
158+
cwd: "/workspace2",
159+
agentDir: "/agent",
160+
settingsManager: {} as never,
161+
extensionFactories: [],
162+
});
163+
expect(getResourceLoaderCacheSize()).toBe(2);
164+
165+
invalidateResourceLoaderCache();
166+
expect(getResourceLoaderCacheSize()).toBe(0);
167+
168+
// After invalidate, should create new loader
169+
createEmbeddedPiResourceLoader({
170+
cwd: "/workspace1",
171+
agentDir: "/agent",
172+
settingsManager: {} as never,
173+
extensionFactories: [],
174+
});
175+
expect(DefaultResourceLoader).toHaveBeenCalledTimes(3);
176+
});
177+
178+
it("invalidateResourceLoaderCache with specific cwd/agentDir only removes that entry", () => {
179+
createEmbeddedPiResourceLoader({
180+
cwd: "/workspace1",
181+
agentDir: "/agent1",
182+
settingsManager: {} as never,
183+
extensionFactories: [],
184+
});
185+
createEmbeddedPiResourceLoader({
186+
cwd: "/workspace2",
187+
agentDir: "/agent2",
188+
settingsManager: {} as never,
189+
extensionFactories: [],
190+
});
191+
expect(getResourceLoaderCacheSize()).toBe(2);
192+
193+
invalidateResourceLoaderCache("/workspace1", "/agent1");
194+
expect(getResourceLoaderCacheSize()).toBe(1);
195+
196+
// workspace1 should create new loader
197+
createEmbeddedPiResourceLoader({
198+
cwd: "/workspace1",
199+
agentDir: "/agent1",
200+
settingsManager: {} as never,
201+
extensionFactories: [],
202+
});
203+
expect(DefaultResourceLoader).toHaveBeenCalledTimes(3);
204+
205+
// workspace2 should still use cached loader
206+
createEmbeddedPiResourceLoader({
207+
cwd: "/workspace2",
208+
agentDir: "/agent2",
209+
settingsManager: {} as never,
210+
extensionFactories: [],
211+
});
212+
expect(DefaultResourceLoader).toHaveBeenCalledTimes(3); // No new call
213+
});
214+
215+
it("markResourceLoaderReloaded updates lastReloadAt timestamp", () => {
216+
createEmbeddedPiResourceLoader({
217+
cwd: "/workspace",
218+
agentDir: "/agent",
219+
settingsManager: {} as never,
220+
extensionFactories: [],
221+
});
222+
223+
// Should not throw
224+
markResourceLoaderReloaded("/workspace", "/agent");
225+
});
226+
227+
it("pruneResourceLoaderCache removes expired entries when TTL env is set", async () => {
228+
// Set very short TTL for test
229+
process.env.OPENCLAW_RESOURCE_LOADER_CACHE_TTL_MS = "100";
230+
231+
createEmbeddedPiResourceLoader({
232+
cwd: "/workspace",
233+
agentDir: "/agent",
234+
settingsManager: {} as never,
235+
extensionFactories: [],
236+
});
237+
expect(getResourceLoaderCacheSize()).toBe(1);
238+
239+
// Wait for TTL to expire
240+
await new Promise((resolve) => setTimeout(resolve, 150));
241+
242+
pruneResourceLoaderCache();
243+
expect(getResourceLoaderCacheSize()).toBe(0);
244+
245+
// After prune, should create new loader
246+
createEmbeddedPiResourceLoader({
247+
cwd: "/workspace",
248+
agentDir: "/agent",
249+
settingsManager: {} as never,
250+
extensionFactories: [],
251+
});
252+
expect(DefaultResourceLoader).toHaveBeenCalledTimes(2);
253+
254+
// Clean up env
255+
delete process.env.OPENCLAW_RESOURCE_LOADER_CACHE_TTL_MS;
256+
});
257+
258+
it("respects OPENCLAW_RESOURCE_LOADER_CACHE_TTL_MS env var", () => {
259+
// Set TTL to 10 seconds (minimum)
260+
process.env.OPENCLAW_RESOURCE_LOADER_CACHE_TTL_MS = "10000";
261+
262+
createEmbeddedPiResourceLoader({
263+
cwd: "/workspace",
264+
agentDir: "/agent",
265+
settingsManager: {} as never,
266+
extensionFactories: [],
267+
});
268+
expect(DefaultResourceLoader).toHaveBeenCalledTimes(1);
269+
270+
// Immediate second call should use cache
271+
createEmbeddedPiResourceLoader({
272+
cwd: "/workspace",
273+
agentDir: "/agent",
274+
settingsManager: {} as never,
275+
extensionFactories: [],
276+
});
277+
expect(DefaultResourceLoader).toHaveBeenCalledTimes(1);
278+
279+
// Clean up env
280+
delete process.env.OPENCLAW_RESOURCE_LOADER_CACHE_TTL_MS;
281+
});
40282
});

0 commit comments

Comments
 (0)