Skip to content

Commit bcd6e4a

Browse files
perf(agents/runtime): short-circuit ensureRuntimePluginsLoaded when active registry exists
ensureRuntimePluginsLoaded is called from dispatchReplyFromConfig on every inbound message and was rebuilding the entire plugin registry on each call. Root cause: it builds a 3-field options object (config, workspaceDir, runtimeOptions) and calls resolveRuntimePluginRegistry. That ends up in getCompatibleActivePluginRegistry, which does a strict cacheKey equality check against the active registry's cache key. The boot path (loadGatewayPlugins) populates the active registry with a 9+ field options set (onlyPluginIds, activationSourceConfig, autoEnabledReasons, coreGatewayHandlers, ...). The dispatch-time hash and the boot-time hash always differ, the equality check always fails, and the dispatcher falls through to a full loadOpenClawPlugins — re-importing every plugin module, re-validating manifests, and re-running each plugin's register(). On hosted gateways with plugins.entries populated, that's ~5–6s of wasted wall-clock per inbound message even when the active registry was already a valid answer. The function exists to *ensure* runtime plugins are loaded — if the active registry is already populated, the goal is already met. Plugin reconfiguration (installs / uninstalls / auto-enable changes) already invalidates the active registry through other code paths (setActivePluginRegistry, gateway restart on config write), so a stale active registry is not a concern here. Updates the existing "does not reactivate plugins when a process already has an active registry" test (which was asserting one call — incorrect; the prior code reactivated every time) into a regression test that asserts zero calls when getActivePluginRegistry returns truthy. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3cad579 commit bcd6e4a

2 files changed

Lines changed: 39 additions & 5 deletions

File tree

src/agents/runtime-plugins.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const hoisted = vi.hoisted(() => ({
44
resolveRuntimePluginRegistry: vi.fn(),
5+
getActivePluginRegistry: vi.fn<() => unknown>(() => undefined),
56
getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">(
67
() => "default",
78
),
@@ -12,6 +13,7 @@ vi.mock("../plugins/loader.js", () => ({
1213
}));
1314

1415
vi.mock("../plugins/runtime.js", () => ({
16+
getActivePluginRegistry: hoisted.getActivePluginRegistry,
1517
getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode,
1618
}));
1719

@@ -21,25 +23,34 @@ describe("ensureRuntimePluginsLoaded", () => {
2123
beforeEach(async () => {
2224
hoisted.resolveRuntimePluginRegistry.mockReset();
2325
hoisted.resolveRuntimePluginRegistry.mockReturnValue(undefined);
26+
hoisted.getActivePluginRegistry.mockReset();
27+
hoisted.getActivePluginRegistry.mockReturnValue(undefined);
2428
hoisted.getActivePluginRuntimeSubagentMode.mockReset();
2529
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
2630
vi.resetModules();
2731
({ ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"));
2832
});
2933

30-
it("does not reactivate plugins when a process already has an active registry", async () => {
31-
hoisted.resolveRuntimePluginRegistry.mockReturnValue({});
34+
it("short-circuits without rebuilding load options when an active registry exists", async () => {
35+
// Regression: every inbound dispatch was calling
36+
// resolveRuntimePluginRegistry with a 3-field options set that hashes
37+
// to a different cacheKey than boot's 9+ field set, so
38+
// getCompatibleActivePluginRegistry's strict equality check failed
39+
// and the dispatcher fell through to a full loadOpenClawPlugins
40+
// rebuild — costing ~5–6s per inbound message on hosted gateways even
41+
// though the active registry was already a valid answer.
42+
hoisted.getActivePluginRegistry.mockReturnValue({ plugins: [], channels: [] });
3243

3344
ensureRuntimePluginsLoaded({
3445
config: {} as never,
3546
workspaceDir: "/tmp/workspace",
3647
allowGatewaySubagentBinding: true,
3748
});
3849

39-
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
50+
expect(hoisted.resolveRuntimePluginRegistry).not.toHaveBeenCalled();
4051
});
4152

42-
it("resolves runtime plugins through the shared runtime helper", async () => {
53+
it("resolves runtime plugins through the shared runtime helper when no active registry is present", async () => {
4354
ensureRuntimePluginsLoaded({
4455
config: {} as never,
4556
workspaceDir: "/tmp/workspace",

src/agents/runtime-plugins.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,36 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
22
import { resolveRuntimePluginRegistry } from "../plugins/loader.js";
3-
import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js";
3+
import { getActivePluginRegistry, getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js";
44
import { resolveUserPath } from "../utils.js";
55

66
export function ensureRuntimePluginsLoaded(params: {
77
config?: OpenClawConfig;
88
workspaceDir?: string | null;
99
allowGatewaySubagentBinding?: boolean;
1010
}): void {
11+
// Fast path: if the active plugin registry is already populated (the boot
12+
// path ran `loadGatewayPlugins`), the function's intent — "ensure runtime
13+
// plugins are loaded" — is already satisfied. Skip rebuilding load options
14+
// and re-asking the loader.
15+
//
16+
// Without this short-circuit, every inbound dispatch hits
17+
// `resolveRuntimePluginRegistry` with a 3-field options set
18+
// (`config`, `workspaceDir`, `runtimeOptions`), which derives a different
19+
// `cacheKey` than boot's 9+ field set (which includes `onlyPluginIds`,
20+
// `activationSourceConfig`, `autoEnabledReasons`, etc.).
21+
// `getCompatibleActivePluginRegistry`'s strict `cacheKey` equality fails;
22+
// the call falls through to a full `loadOpenClawPlugins`, re-imports every
23+
// plugin, and re-runs each plugin's `register()` — ~5–6s per inbound
24+
// message on hosted gateways. The rebuild is wasted because the active
25+
// registry is already a valid answer.
26+
//
27+
// Plugin reconfiguration (`installs`/`uninstalls`/auto-enable changes)
28+
// already invalidates the active registry through other code paths
29+
// (`setActivePluginRegistry`, gateway restart on config write), so a stale
30+
// active registry is not a concern here.
31+
if (getActivePluginRegistry()) {
32+
return;
33+
}
1134
const workspaceDir =
1235
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
1336
? resolveUserPath(params.workspaceDir)

0 commit comments

Comments
 (0)