fix(plugins): reuse gateway boot registry for runtime ensures

Co-authored-by: Mark Ramos <6416874+markthebest12@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-04-25 00:14:02 +01:00
parent c735b59043
commit 0270428645
4 changed files with 102 additions and 17 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Slack/Assistant: accept Slack Assistant DM `message_changed` events when their metadata identifies the human sender, while continuing to drop self-authored bot edits. Fixes #55445. Thanks @AlfredPros.
- Agents/failover: stop body-less HTTP 400/422 proxy failures from defaulting to `"format"` classification, so embedded retries surface the opaque provider failure instead of falling into a compaction loop. Fixes #66462. (#67024) Thanks @altaywtf and @HongzhuLiu.
- Plugins/loader: use cached discovery-mode snapshot loads for read-only plugin capability lookups, keep snapshot caches isolated from active Gateway registries, and make same-plugin channel/HTTP route re-registration idempotent so repeated snapshot or hot-reload paths no longer rerun full plugin side effects or accumulate duplicate surfaces. Fixes #51781, #52031, #54181, and #57514. Thanks @livingghost, @okuyam2y, @ShionEria, and @bbshih.
- Plugins/loader: reuse the compatible active Gateway registry for broad runtime plugin ensure calls after a gateway-bindable boot load, so non-bundled plugins no longer re-run `register()` during the same boot path. Fixes #69250. Thanks @markthebest12.
## 2026.4.24

View File

@@ -77,6 +77,10 @@ import {
listImportedRuntimePluginIds,
setActivePluginRegistry,
} from "./runtime.js";
import {
__testing as runtimeRegistryLoaderTesting,
ensurePluginRegistryLoaded,
} from "./runtime/runtime-registry-loader.js";
import type { PluginSdkResolutionPreference } from "./sdk-alias.js";
let cachedBundledTelegramDir = "";
let cachedBundledMemoryDir = "";
@@ -832,6 +836,7 @@ function expectEscapingEntryRejected(params: {
afterEach(() => {
clearRuntimeConfigSnapshot();
runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests();
resetPluginLoaderTestStateForTest();
});
@@ -3518,6 +3523,46 @@ module.exports = { id: "throws-after-import", register() {} };`,
delete (globalThis as Record<string, unknown>)[marker];
});
it("does not re-register non-bundled plugins after gateway-bindable boot loads", () => {
useNoBundledPlugins();
const marker = "__openclawGatewayBootRegisterCount";
const plugin = writePlugin({
id: "costclaw-boot-cache",
filename: "costclaw-boot-cache.cjs",
body: `module.exports = {
id: "costclaw-boot-cache",
register() {
globalThis.${marker} = (globalThis.${marker} || 0) + 1;
},
};`,
});
const config = {
plugins: {
load: { paths: [plugin.file] },
allow: ["costclaw-boot-cache"],
entries: {
"costclaw-boot-cache": { enabled: true },
},
},
};
loadOpenClawPlugins({
workspaceDir: plugin.dir,
config,
runtimeOptions: {
allowGatewaySubagentBinding: true,
},
});
ensurePluginRegistryLoaded({
scope: "all",
workspaceDir: plugin.dir,
config,
});
expect((globalThis as Record<string, unknown>)[marker]).toBe(1);
delete (globalThis as Record<string, unknown>)[marker];
});
it("re-initializes global hook runner when serving registry from cache", () => {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
const plugin = writePlugin({

View File

@@ -3,6 +3,7 @@ import { createEmptyPluginRegistry } from "../registry.js";
const mocks = vi.hoisted(() => ({
loadOpenClawPlugins: vi.fn<typeof import("../loader.js").loadOpenClawPlugins>(),
resolveRuntimePluginRegistry: vi.fn<typeof import("../loader.js").resolveRuntimePluginRegistry>(),
getActivePluginRegistry: vi.fn<typeof import("../runtime.js").getActivePluginRegistry>(),
resolveConfiguredChannelPluginIds:
vi.fn<typeof import("../channel-plugin-ids.js").resolveConfiguredChannelPluginIds>(),
@@ -24,6 +25,8 @@ let resetPluginRegistryLoadedForTests: typeof import("./runtime-registry-loader.
vi.mock("../loader.js", () => ({
loadOpenClawPlugins: (...args: Parameters<typeof mocks.loadOpenClawPlugins>) =>
mocks.loadOpenClawPlugins(...args),
resolveRuntimePluginRegistry: (...args: Parameters<typeof mocks.resolveRuntimePluginRegistry>) =>
mocks.resolveRuntimePluginRegistry(...args),
}));
vi.mock("../runtime.js", () => ({
@@ -61,6 +64,7 @@ describe("ensurePluginRegistryLoaded", () => {
beforeEach(() => {
mocks.loadOpenClawPlugins.mockReset();
mocks.resolveRuntimePluginRegistry.mockReset();
mocks.getActivePluginRegistry.mockReset();
mocks.resolveConfiguredChannelPluginIds.mockReset();
mocks.resolveChannelPluginIds.mockReset();
@@ -70,6 +74,10 @@ describe("ensurePluginRegistryLoaded", () => {
resetPluginRegistryLoadedForTests();
mocks.getActivePluginRegistry.mockReturnValue(createEmptyPluginRegistry());
mocks.loadOpenClawPlugins.mockReturnValue(createEmptyPluginRegistry());
mocks.resolveRuntimePluginRegistry.mockImplementation(
(...args: Parameters<typeof mocks.loadOpenClawPlugins>) => mocks.loadOpenClawPlugins(...args),
);
mocks.applyPluginAutoEnable.mockImplementation((params) => ({
config:
params.config && typeof params.config === "object"
@@ -255,4 +263,26 @@ describe("ensurePluginRegistryLoaded", () => {
(mocks.loadOpenClawPlugins.mock.calls[0]?.[0] as { onlyPluginIds?: string[] }).onlyPluginIds,
).toBeUndefined();
});
it("reuses a compatible active registry instead of forcing a broad reload", () => {
const activeRegistry = createEmptyPluginRegistry();
mocks.resolveRuntimePluginRegistry.mockReturnValue(activeRegistry);
ensurePluginRegistryLoaded({
scope: "all",
config: { plugins: { allow: ["demo"] } } as never,
});
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(
expect.objectContaining({
throwOnLoadError: true,
}),
);
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith(
expect.not.objectContaining({
onlyPluginIds: expect.any(Array),
}),
);
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
});
});

View File

@@ -4,7 +4,7 @@ import {
resolveChannelPluginIds,
resolveConfiguredChannelPluginIds,
} from "../channel-plugin-ids.js";
import { loadOpenClawPlugins } from "../loader.js";
import { loadOpenClawPlugins, resolveRuntimePluginRegistry } from "../loader.js";
import {
hasExplicitPluginIdScope,
hasNonEmptyPluginIdScope,
@@ -73,6 +73,16 @@ function shouldForwardChannelScope(params: {
return !params.scopedLoad && params.scope === "configured-channels";
}
function resolveOrLoadRuntimePluginRegistry(
loadOptions: Parameters<typeof loadOpenClawPlugins>[0],
): void {
// Prefer the runtime resolver so broad ensures can reuse compatible active
// registries, including gateway-bindable startup registries.
if (!resolveRuntimePluginRegistry(loadOptions)) {
loadOpenClawPlugins(loadOptions);
}
}
export function ensurePluginRegistryLoaded(options?: {
scope?: PluginRegistryScope;
config?: OpenClawConfig;
@@ -132,23 +142,22 @@ export function ensurePluginRegistryLoaded(options?: {
pluginIds: expectedChannelPluginIds,
}) ?? context.activationSourceConfig)
: context.activationSourceConfig;
loadOpenClawPlugins(
buildPluginRuntimeLoadOptionsFromValues(
{
...context,
config: scopedConfig,
activationSourceConfig: scopedActivationSourceConfig,
},
{
throwOnLoadError: true,
...(hasExplicitPluginIdScope(requestedPluginIds) ||
shouldForwardChannelScope({ scope, scopedLoad }) ||
hasNonEmptyPluginIdScope(expectedChannelPluginIds)
? { onlyPluginIds: expectedChannelPluginIds }
: {}),
},
),
const loadOptions = buildPluginRuntimeLoadOptionsFromValues(
{
...context,
config: scopedConfig,
activationSourceConfig: scopedActivationSourceConfig,
},
{
throwOnLoadError: true,
...(hasExplicitPluginIdScope(requestedPluginIds) ||
shouldForwardChannelScope({ scope, scopedLoad }) ||
hasNonEmptyPluginIdScope(expectedChannelPluginIds)
? { onlyPluginIds: expectedChannelPluginIds }
: {}),
},
);
resolveOrLoadRuntimePluginRegistry(loadOptions);
if (!scopedLoad) {
pluginRegistryLoaded = scope;
}