Skip to content

Commit 7eac710

Browse files
committed
fix(codex): cache plugin inventory to prevent repeated disk I/O (#99071)
During complex multi-turn tasks, buildCodexPluginThreadConfig calls readCodexPluginInventory up to 4 times, and each call issues a plugin/list RPC to the Codex app-server. The app-server then scans ~180 plugin.json files from disk on every call, producing 1.3–1.4 GB of disk reads in 30 seconds and driving disk utilization to 99% on resource-constrained cloud servers. The existing CodexAppInventoryCache only caches app/list responses. The plugin/list and plugin/read RPC paths had no cache at all. This commit introduces CodexPluginListCache — a process-local in-memory cache for plugin/list responses with a 5-minute TTL and coalesced refreshes. The cache is wired into: - readCodexPluginInventory (plugin-inventory.ts): plugin/list calls now use readOrRefresh() which returns a cached fresh snapshot instead of hitting the server - buildCodexPluginThreadConfig (plugin-thread-config.ts): all 4 readCodexPluginInventory calls now pass the shared plugin list cache - attempt-startup.ts: the build callback passes defaultCodexPluginListCache alongside the existing app inventory cache - plugin-activation.ts: the cache is invalidated on plugin install and during refreshCodexPluginRuntimeState so post-install state changes are always observed The activation path (ensureCodexPluginActivation) intentionally does NOT use the cache for its initial plugin/list read — it needs fresh install/enable state to decide whether plugin/install is needed. Tests: - 10 new tests in plugin-list-cache.test.ts covering cache reads, refreshes, coalescing, invalidation, forced refetch, error propagation, and revision tracking - 2 new tests in plugin-inventory.test.ts verifying that repeated readCodexPluginInventory calls use the cached plugin/list response and that forcePluginListRefetch bypasses the cache - plugin-thread-config.test.ts clears defaultCodexPluginListCache in beforeEach to prevent cross-test cache pollution Fixes #99071
1 parent 0011a18 commit 7eac710

8 files changed

Lines changed: 597 additions & 4 deletions

extensions/codex/src/app-server/attempt-startup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
buildCodexAppServerRuntimeFingerprint,
3636
buildCodexPluginAppCacheKey,
3737
} from "./plugin-app-cache-key.js";
38+
import { defaultCodexPluginListCache, type CodexPluginListCache } from "./plugin-list-cache.js";
3839
import {
3940
buildCodexPluginThreadConfig,
4041
buildCodexPluginThreadConfigInputFingerprint,
@@ -349,6 +350,8 @@ export async function startCodexAttemptThread(params: {
349350
configCwd: startupExecutionCwd,
350351
appCache: defaultCodexAppInventoryCache,
351352
appCacheKey: pluginAppCacheKey,
353+
pluginListCache: defaultCodexPluginListCache,
354+
pluginListCacheKey: pluginAppCacheKey,
352355
}),
353356
}
354357
: undefined,

extensions/codex/src/app-server/plugin-activation.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type CodexPluginMarketplaceRef,
1313
type CodexPluginRuntimeRequest,
1414
} from "./plugin-inventory.js";
15+
import type { CodexPluginListCache } from "./plugin-list-cache.js";
1516
import type { v2 } from "./protocol.js";
1617

1718
/** Terminal reason reported after trying to activate one Codex plugin policy. */
@@ -46,6 +47,8 @@ export type EnsureCodexPluginActivationParams = {
4647
request: CodexPluginRuntimeRequest;
4748
appCache?: CodexAppInventoryCache;
4849
appCacheKey?: string;
50+
pluginListCache?: CodexPluginListCache;
51+
pluginListCacheKey?: string;
4952
installEvenIfActive?: boolean;
5053
targetAppIds?: readonly string[];
5154
};
@@ -65,6 +68,11 @@ export async function ensureCodexPluginActivation(
6568
});
6669
}
6770

71+
const pluginListCache = params.pluginListCache;
72+
const pluginListCacheKey = params.pluginListCacheKey;
73+
// Activation needs fresh plugin/list state to check current install/enable
74+
// status. Do not use the cache here — the cache is invalidated below after
75+
// install, and the initial pre-install read must see the latest server state.
6876
const listed = (await params.request("plugin/list", {
6977
cwds: [],
7078
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
@@ -103,13 +111,19 @@ export async function ensureCodexPluginActivation(
103111
: params.identity.pluginName,
104112
) satisfies v2.PluginInstallParams,
105113
)) as v2.PluginInstallResponse;
114+
// Invalidate the plugin list cache so the next read sees the new install state.
115+
if (pluginListCache && pluginListCacheKey) {
116+
pluginListCache.invalidate(pluginListCacheKey, "plugin installed");
117+
}
106118
const refreshDiagnostics: CodexPluginActivationDiagnostic[] = [];
107119
let refreshFailed = false;
108120
try {
109121
const refreshResult = await refreshCodexPluginRuntimeState({
110122
request: params.request,
111123
appCache: params.appCache,
112124
appCacheKey: params.appCacheKey,
125+
pluginListCache,
126+
pluginListCacheKey,
113127
targetAppIds: params.targetAppIds,
114128
});
115129
refreshDiagnostics.push(...refreshResult.diagnostics);
@@ -149,9 +163,16 @@ export async function refreshCodexPluginRuntimeState(params: {
149163
request: CodexPluginRuntimeRequest;
150164
appCache?: CodexAppInventoryCache;
151165
appCacheKey?: string;
166+
pluginListCache?: CodexPluginListCache;
167+
pluginListCacheKey?: string;
152168
targetAppIds?: readonly string[];
153169
}): Promise<CodexPluginRuntimeRefreshResult> {
154170
const diagnostics: CodexPluginActivationDiagnostic[] = [];
171+
// Invalidate plugin list cache before reloading so the next read picks up
172+
// any install/enable state changes from the plugin/install call.
173+
if (params.pluginListCache && params.pluginListCacheKey) {
174+
params.pluginListCache.invalidate(params.pluginListCacheKey, "plugin runtime refresh");
175+
}
155176
await params.request("plugin/list", {
156177
cwds: [],
157178
} satisfies v2.PluginListParams);

extensions/codex/src/app-server/plugin-inventory.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
33
import { CodexAppInventoryCache } from "./app-inventory-cache.js";
44
import { CODEX_PLUGINS_MARKETPLACE_NAME } from "./config.js";
55
import { findOpenAiCuratedPluginSummary, readCodexPluginInventory } from "./plugin-inventory.js";
6+
import { CodexPluginListCache } from "./plugin-list-cache.js";
67
import type { v2 } from "./protocol.js";
78

89
describe("Codex plugin inventory", () => {
@@ -350,6 +351,143 @@ describe("Codex plugin inventory", () => {
350351
"app_inventory_missing",
351352
]);
352353
});
354+
355+
it("uses the plugin list cache to avoid repeated plugin/list calls", async () => {
356+
const appCache = new CodexAppInventoryCache();
357+
await appCache.refreshNow({
358+
key: "runtime",
359+
nowMs: 0,
360+
request: async () => ({
361+
data: [appInfo("google-calendar-app", true)],
362+
nextCursor: null,
363+
}),
364+
});
365+
const pluginListCache = new CodexPluginListCache({ ttlMs: 1_000 });
366+
const calls: string[] = [];
367+
const request = async (method: string, params?: unknown) => {
368+
calls.push(method);
369+
if (method === "plugin/list") {
370+
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
371+
}
372+
if (method === "plugin/read") {
373+
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
374+
}
375+
throw new Error(`unexpected request ${method}`);
376+
};
377+
378+
// First call populates the plugin list cache.
379+
await readCodexPluginInventory({
380+
pluginConfig: {
381+
codexPlugins: {
382+
enabled: true,
383+
plugins: {
384+
"google-calendar": {
385+
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
386+
pluginName: "google-calendar",
387+
},
388+
},
389+
},
390+
},
391+
appCache,
392+
appCacheKey: "runtime",
393+
pluginListCache,
394+
pluginListCacheKey: "runtime",
395+
nowMs: 1,
396+
request,
397+
});
398+
const firstCallCount = calls.length;
399+
expect(calls.filter((c) => c === "plugin/list")).toHaveLength(1);
400+
401+
// Second call should use the cached plugin/list — no new plugin/list call.
402+
await readCodexPluginInventory({
403+
pluginConfig: {
404+
codexPlugins: {
405+
enabled: true,
406+
plugins: {
407+
"google-calendar": {
408+
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
409+
pluginName: "google-calendar",
410+
},
411+
},
412+
},
413+
},
414+
appCache,
415+
appCacheKey: "runtime",
416+
pluginListCache,
417+
pluginListCacheKey: "runtime",
418+
nowMs: 2,
419+
request,
420+
});
421+
expect(calls.filter((c) => c === "plugin/list")).toHaveLength(1);
422+
expect(calls.length).toBe(firstCallCount + 1); // only plugin/read is added
423+
});
424+
425+
it("forces a plugin list refetch when forcePluginListRefetch is true", async () => {
426+
const appCache = new CodexAppInventoryCache();
427+
await appCache.refreshNow({
428+
key: "runtime",
429+
nowMs: 0,
430+
request: async () => ({
431+
data: [appInfo("google-calendar-app", true)],
432+
nextCursor: null,
433+
}),
434+
});
435+
const pluginListCache = new CodexPluginListCache({ ttlMs: 1_000 });
436+
let pluginListCalls = 0;
437+
const request = async (method: string) => {
438+
if (method === "plugin/list") {
439+
pluginListCalls += 1;
440+
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
441+
}
442+
if (method === "plugin/read") {
443+
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
444+
}
445+
throw new Error(`unexpected request ${method}`);
446+
};
447+
448+
await readCodexPluginInventory({
449+
pluginConfig: {
450+
codexPlugins: {
451+
enabled: true,
452+
plugins: {
453+
"google-calendar": {
454+
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
455+
pluginName: "google-calendar",
456+
},
457+
},
458+
},
459+
},
460+
appCache,
461+
appCacheKey: "runtime",
462+
pluginListCache,
463+
pluginListCacheKey: "runtime",
464+
nowMs: 1,
465+
request,
466+
});
467+
expect(pluginListCalls).toBe(1);
468+
469+
await readCodexPluginInventory({
470+
pluginConfig: {
471+
codexPlugins: {
472+
enabled: true,
473+
plugins: {
474+
"google-calendar": {
475+
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
476+
pluginName: "google-calendar",
477+
},
478+
},
479+
},
480+
},
481+
appCache,
482+
appCacheKey: "runtime",
483+
pluginListCache,
484+
pluginListCacheKey: "runtime",
485+
nowMs: 2,
486+
forcePluginListRefetch: true,
487+
request,
488+
});
489+
expect(pluginListCalls).toBe(2);
490+
});
353491
});
354492

355493
function pluginList(

extensions/codex/src/app-server/plugin-inventory.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type ResolvedCodexPluginPolicy,
1515
type ResolvedCodexPluginsPolicy,
1616
} from "./config.js";
17+
import type { CodexPluginListCache, CodexPluginListRequest } from "./plugin-list-cache.js";
1718
import type { v2 } from "./protocol.js";
1819

1920
const CODEX_PLUGINS_REMOTE_MARKETPLACE_NAME = `${CODEX_PLUGINS_MARKETPLACE_NAME}-remote`;
@@ -83,9 +84,12 @@ export type ReadCodexPluginInventoryParams = {
8384
request: CodexPluginRuntimeRequest;
8485
appCache?: CodexAppInventoryCache;
8586
appCacheKey?: string;
87+
pluginListCache?: CodexPluginListCache;
88+
pluginListCacheKey?: string;
8689
nowMs?: number;
8790
readPluginDetails?: boolean;
8891
suppressAppInventoryRefresh?: boolean;
92+
forcePluginListRefetch?: boolean;
8993
};
9094

9195
/** Reads configured Codex plugin state and maps owned apps to readiness diagnostics. */
@@ -107,9 +111,7 @@ export async function readCodexPluginInventory(
107111
}
108112

109113
const appInventory = readCachedAppInventory(params);
110-
const listed = (await params.request("plugin/list", {
111-
cwds: [],
112-
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
114+
const listed = await readPluginList(params);
113115
const marketplaceEntry = listed.marketplaces.find(isOpenAiCuratedMarketplace);
114116
if (!marketplaceEntry) {
115117
return {
@@ -260,6 +262,25 @@ function readCachedAppInventory(
260262
});
261263
}
262264

265+
async function readPluginList(
266+
params: ReadCodexPluginInventoryParams,
267+
): Promise<v2.PluginListResponse> {
268+
if (params.pluginListCache && params.pluginListCacheKey) {
269+
const request: CodexPluginListRequest = async (method, requestParams) =>
270+
(await params.request(method, requestParams)) as v2.PluginListResponse;
271+
const snapshot = await params.pluginListCache.readOrRefresh({
272+
key: params.pluginListCacheKey,
273+
request,
274+
nowMs: params.nowMs,
275+
forceRefetch: params.forcePluginListRefetch,
276+
});
277+
return snapshot.response;
278+
}
279+
return (await params.request("plugin/list", {
280+
cwds: [],
281+
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
282+
}
283+
263284
async function readPluginDetail(
264285
params: ReadCodexPluginInventoryParams,
265286
marketplace: CodexPluginMarketplaceRef,

0 commit comments

Comments
 (0)