fix: memoize plugin descriptor config keys (#76240)

This commit is contained in:
Josh Avant
2026-05-02 15:25:00 -05:00
committed by GitHub
parent 10448a0ad1
commit b779c45a78
4 changed files with 130 additions and 7 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
- Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting.
- Heartbeat/scheduler: make heartbeat phase scheduling active-hours-aware so the scheduler seeks forward to the first in-window phase slot instead of arming timers for quiet-hours slots and relying solely on the runtime guard. Non-UTC `activeHours.timezone` values (e.g. `Asia/Shanghai`) now correctly influence when the next heartbeat timer fires, avoiding wasted quiet-hours ticks and long dormant gaps after gateway restarts. Fixes #75487. Thanks @amknight.
- Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky.
- Gateway: avoid repeated plugin tool descriptor config hashing so large runtime configs do not block reply startup and trigger reconnect/timeouts. (#75944) Thanks @joshavant.
- Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc.
- Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof.
- Gateway/status: add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway, so frozen or port-conflict reports include the data needed for root-cause triage. Refs #49012. Thanks @vincentkoc.

View File

@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => ({
resolveRuntimeConfigCacheKey: vi.fn((value: unknown) => {
const id =
value && typeof value === "object" && "id" in value
? String((value as { id?: unknown }).id)
: "config";
return `config:${id}`;
}),
}));
vi.mock("../config/runtime-snapshot.js", () => ({
resolveRuntimeConfigCacheKey: hoisted.resolveRuntimeConfigCacheKey,
}));
import {
buildPluginToolDescriptorCacheKey,
createPluginToolDescriptorConfigCacheKeyMemo,
resetPluginToolDescriptorCache,
} from "./tool-descriptor-cache.js";
describe("plugin tool descriptor cache keys", () => {
afterEach(() => {
hoisted.resolveRuntimeConfigCacheKey.mockClear();
resetPluginToolDescriptorCache();
});
it("memoizes config cache keys across plugin descriptor keys in one resolution pass", () => {
const config = {
id: "runtime",
plugins: {
entries: {
demo: { enabled: true },
},
},
} as never;
const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo();
for (let index = 0; index < 25; index += 1) {
buildPluginToolDescriptorCacheKey({
pluginId: `plugin-${index}`,
source: `/tmp/plugin-${index}.js`,
contractToolNames: [`tool_${index}`],
ctx: {
config,
runtimeConfig: config,
workspaceDir: "/tmp/workspace",
agentDir: "/tmp/agent",
agentId: "main",
sessionKey: "agent:main",
sessionId: "session",
},
currentRuntimeConfig: config,
configCacheKeyMemo,
});
}
expect(hoisted.resolveRuntimeConfigCacheKey).toHaveBeenCalledTimes(1);
});
it("keeps distinct config objects distinct within the memo", () => {
const firstConfig = { id: "first" } as never;
const secondConfig = { id: "second" } as never;
const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo();
const firstKey = buildPluginToolDescriptorCacheKey({
pluginId: "demo",
source: "/tmp/demo.js",
contractToolNames: ["demo"],
ctx: {
config: firstConfig,
runtimeConfig: firstConfig,
},
currentRuntimeConfig: firstConfig,
configCacheKeyMemo,
});
const secondKey = buildPluginToolDescriptorCacheKey({
pluginId: "demo",
source: "/tmp/demo.js",
contractToolNames: ["demo"],
ctx: {
config: secondConfig,
runtimeConfig: secondConfig,
},
currentRuntimeConfig: secondConfig,
configCacheKeyMemo,
});
expect(hoisted.resolveRuntimeConfigCacheKey).toHaveBeenCalledTimes(2);
expect(firstKey).not.toBe(secondKey);
});
});

View File

@@ -19,6 +19,12 @@ const descriptorCache = new Map<string, CachedPluginToolDescriptor[]>();
let descriptorCacheObjectIds = new WeakMap<object, number>();
let nextDescriptorCacheObjectId = 1;
export type PluginToolDescriptorConfigCacheKeyMemo = WeakMap<object, string | number | null>;
export function createPluginToolDescriptorConfigCacheKeyMemo(): PluginToolDescriptorConfigCacheKeyMemo {
return new WeakMap();
}
export function resetPluginToolDescriptorCache(): void {
descriptorCache.clear();
descriptorCacheObjectIds = new WeakMap();
@@ -49,26 +55,38 @@ function getDescriptorCacheObjectId(value: object | null | undefined): number |
function getDescriptorConfigCacheKey(
value: PluginLoadOptions["config"] | null | undefined,
memo?: PluginToolDescriptorConfigCacheKeyMemo,
): string | number | null {
if (!value) {
return null;
}
try {
return resolveRuntimeConfigCacheKey(value);
} catch {
return getDescriptorCacheObjectId(value);
const cached = memo?.get(value);
if (cached !== undefined) {
return cached;
}
let resolved: string | number | null;
try {
resolved = resolveRuntimeConfigCacheKey(value);
} catch {
resolved = getDescriptorCacheObjectId(value);
}
memo?.set(value, resolved);
return resolved;
}
function buildDescriptorContextCacheKey(params: {
ctx: OpenClawPluginToolContext;
currentRuntimeConfig?: PluginLoadOptions["config"] | null;
configCacheKeyMemo?: PluginToolDescriptorConfigCacheKeyMemo;
}): string {
const { ctx } = params;
return JSON.stringify({
config: getDescriptorConfigCacheKey(ctx.config),
runtimeConfig: getDescriptorConfigCacheKey(ctx.runtimeConfig),
currentRuntimeConfig: getDescriptorConfigCacheKey(params.currentRuntimeConfig),
config: getDescriptorConfigCacheKey(ctx.config, params.configCacheKeyMemo),
runtimeConfig: getDescriptorConfigCacheKey(ctx.runtimeConfig, params.configCacheKeyMemo),
currentRuntimeConfig: getDescriptorConfigCacheKey(
params.currentRuntimeConfig,
params.configCacheKeyMemo,
),
fsPolicy: ctx.fsPolicy ?? null,
workspaceDir: ctx.workspaceDir ?? null,
agentDir: ctx.agentDir ?? null,
@@ -92,6 +110,7 @@ export function buildPluginToolDescriptorCacheKey(params: {
contractToolNames: readonly string[];
ctx: OpenClawPluginToolContext;
currentRuntimeConfig?: PluginLoadOptions["config"] | null;
configCacheKeyMemo?: PluginToolDescriptorConfigCacheKeyMemo;
}): string {
return JSON.stringify({
version: PLUGIN_TOOL_DESCRIPTOR_CACHE_VERSION,
@@ -103,6 +122,7 @@ export function buildPluginToolDescriptorCacheKey(params: {
context: buildDescriptorContextCacheKey({
ctx: params.ctx,
currentRuntimeConfig: params.currentRuntimeConfig,
configCacheKeyMemo: params.configCacheKeyMemo,
}),
});
}

View File

@@ -21,8 +21,10 @@ import { findUndeclaredPluginToolNames } from "./tool-contracts.js";
import {
buildPluginToolDescriptorCacheKey,
capturePluginToolDescriptor,
createPluginToolDescriptorConfigCacheKeyMemo,
readCachedPluginToolDescriptors,
type CachedPluginToolDescriptor,
type PluginToolDescriptorConfigCacheKeyMemo,
writeCachedPluginToolDescriptors,
} from "./tool-descriptor-cache.js";
import type { OpenClawPluginToolContext } from "./types.js";
@@ -395,6 +397,7 @@ function buildPluginDescriptorCacheKey(params: {
plugin: PluginManifestRecord;
ctx: OpenClawPluginToolContext;
currentRuntimeConfig?: PluginLoadOptions["config"] | null;
configCacheKeyMemo?: PluginToolDescriptorConfigCacheKeyMemo;
}): string {
return buildPluginToolDescriptorCacheKey({
pluginId: params.plugin.id,
@@ -403,6 +406,7 @@ function buildPluginDescriptorCacheKey(params: {
contractToolNames: params.plugin.contracts?.tools ?? [],
ctx: params.ctx,
currentRuntimeConfig: params.currentRuntimeConfig,
configCacheKeyMemo: params.configCacheKeyMemo,
});
}
@@ -493,6 +497,7 @@ function resolveCachedPluginTools(params: {
loadContext: ReturnType<typeof resolvePluginRuntimeLoadContext>;
runtimeOptions: PluginLoadOptions["runtimeOptions"];
currentRuntimeConfig?: PluginLoadOptions["config"] | null;
configCacheKeyMemo: PluginToolDescriptorConfigCacheKeyMemo;
}): { tools: AnyAgentTool[]; handledPluginIds: Set<string> } {
const tools: AnyAgentTool[] = [];
const handledPluginIds = new Set<string>();
@@ -535,6 +540,7 @@ function resolveCachedPluginTools(params: {
plugin,
ctx: params.ctx,
currentRuntimeConfig: params.currentRuntimeConfig,
configCacheKeyMemo: params.configCacheKeyMemo,
}),
);
if (
@@ -714,6 +720,7 @@ export function resolvePluginTools(params: {
const existing = params.existingToolNames ?? new Set<string>();
const existingNormalized = new Set(Array.from(existing, (tool) => normalizeToolName(tool)));
const allowlist = normalizeAllowlist(params.toolAllowlist);
const configCacheKeyMemo = createPluginToolDescriptorConfigCacheKeyMemo();
let currentRuntimeConfigForDescriptorCache: PluginLoadOptions["config"] | null | undefined =
params.context.runtimeConfig;
if (currentRuntimeConfigForDescriptorCache === undefined && params.context.getRuntimeConfig) {
@@ -737,6 +744,7 @@ export function resolvePluginTools(params: {
loadContext: context,
runtimeOptions,
currentRuntimeConfig: currentRuntimeConfigForDescriptorCache,
configCacheKeyMemo,
});
tools.push(...cached.tools);
const runtimePluginIds = onlyPluginIds.filter(
@@ -922,6 +930,7 @@ export function resolvePluginTools(params: {
plugin: manifestPlugin,
ctx: params.context,
currentRuntimeConfig: currentRuntimeConfigForDescriptorCache,
configCacheKeyMemo,
}),
descriptors,
});