From 1de432a978a8743bbe93df8c87e673c3f8da2efd Mon Sep 17 00:00:00 2001 From: Dan Scarafoni Date: Tue, 14 Jul 2026 16:30:25 -0400 Subject: [PATCH] fix(agents): skip runtime plugin reload when a registry is already active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/agents/runtime-plugins.test.ts | 20 ++++++++++++++++++++ src/agents/runtime-plugins.ts | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index 659de14fd050..74af31517dd4 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -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, diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index 45049a7173da..650b48094463 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -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)