fix(agents): skip runtime plugin reload when a registry is already active

ensureRuntimePluginsLoaded rebuilt and activated a replacement plugin
registry on every call site invocation (agent turns, cron isolated-agent
runs, the gateway prewarm sidecar). Activating a replacement retires the
active registry and runs its plugin host cleanup, which cron.remove()s
persistent plugin-scheduled session turns before the replacement's async
re-registration catches up — silently deleting the cron row with only a
low-level 'cron service unavailable' WARN. Plugins using
api.session.workflow.scheduleSessionTurn for recurring jobs lose their
schedule on the next agent turn after boot.

Guard with getActiveRuntimePluginRegistry(): when a registry is already
active every caller's request is satisfied, so return before building a
replacement.
This commit is contained in:
Dan Scarafoni
2026-07-14 16:30:25 -04:00
parent 98e88ab1a6
commit 1de432a978
2 changed files with 28 additions and 0 deletions
+20
View File
@@ -8,12 +8,17 @@ const hoisted = vi.hoisted(() => ({
() => "default",
),
getActivePluginRegistryWorkspaceDir: vi.fn<() => string | undefined>(() => undefined),
getActiveRuntimePluginRegistry: vi.fn<() => unknown>(() => null),
}));
vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: hoisted.getCurrentPluginMetadataSnapshot,
}));
vi.mock("../plugins/active-runtime-registry.js", () => ({
getActiveRuntimePluginRegistry: hoisted.getActiveRuntimePluginRegistry,
}));
vi.mock("../plugins/runtime/standalone-runtime-registry-loader.js", () => ({
ensureStandaloneRuntimePluginRegistryLoaded: hoisted.ensureStandaloneRuntimePluginRegistryLoaded,
}));
@@ -36,6 +41,8 @@ describe("ensureRuntimePluginsLoaded", () => {
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
hoisted.getActivePluginRegistryWorkspaceDir.mockReset();
hoisted.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined);
hoisted.getActiveRuntimePluginRegistry.mockReset();
hoisted.getActiveRuntimePluginRegistry.mockReturnValue(null);
vi.resetModules();
({ ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"));
});
@@ -52,6 +59,19 @@ describe("ensureRuntimePluginsLoaded", () => {
expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledTimes(1);
});
it("skips loading entirely when a runtime plugin registry is already active", () => {
hoisted.getActiveRuntimePluginRegistry.mockReturnValue({});
ensureRuntimePluginsLoaded({
config: {} as never,
workspaceDir: "/tmp/workspace",
allowGatewaySubagentBinding: true,
});
expect(hoisted.getCurrentPluginMetadataSnapshot).not.toHaveBeenCalled();
expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).not.toHaveBeenCalled();
});
it("resolves runtime plugins through the shared runtime helper", () => {
ensureRuntimePluginsLoaded({
config: {} as never,
+8
View File
@@ -3,6 +3,7 @@
* plugin IDs from metadata scope the load when available.
*/
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getActiveRuntimePluginRegistry } from "../plugins/active-runtime-registry.js";
import { normalizePluginsConfig } from "../plugins/config-state.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js";
@@ -41,6 +42,13 @@ export function ensureRuntimePluginsLoaded(params: {
if (params.config && !normalizePluginsConfig(params.config.plugins).enabled) {
return;
}
// Activating a replacement registry retires the active one and runs its
// plugin host cleanup, which cron.remove()s persistent plugin-scheduled
// jobs the replacement hasn't re-registered yet (async, still in flight).
// Once a registry is active there is nothing left for this call to do.
if (getActiveRuntimePluginRegistry()) {
return;
}
const workspaceDir =
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
? resolveUserPath(params.workspaceDir)