diff --git a/CHANGELOG.md b/CHANGELOG.md index 106b52bb1ea..b3865bb091c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,7 @@ Docs: https://docs.openclaw.ai - Telegram: split long default markdown sends and media follow-up text into safe HTML chunks, so outbound messages over Telegram's limit no longer fail as one oversized Bot API request. Fixes #75868. Thanks @zhengsx. - Gateway/chat history: merge Claude CLI transcript imports for Anthropic-routed sessions that still have a Claude CLI binding, so local chat history does not hide CLI JSONL turns. Fixes #75850. Thanks @alfredjbclaw. - Media: trim serialized JSON suffixes after local `MEDIA:` directive file extensions, so generated-image metadata cannot pollute the parsed media path and cause false `ENOENT` delivery failures. Fixes #75182. Thanks @TnzGit and @hclsys. +- Plugins/runtime: hot-reload Gateway plugin runtime surfaces after plugin enable/disable changes while keeping source-changing plugin install, update, and uninstall operations restart-backed so loaded module code is not reused. Fixes #72097. - Cron: make scheduler reload schedule comparison tolerate malformed persisted jobs, so one bad cron entry no longer aborts the whole tick. Fixes #75886. Thanks @samfox-ai. - Doctor/channels: warn after migrations when default Telegram or Discord accounts have no configured token and their env fallback (`TELEGRAM_BOT_TOKEN` or `DISCORD_BOT_TOKEN`) is unavailable, with secret-safe migration docs for checking state-dir `.env`. Fixes #74298. Thanks @lolaopenclaw. - Gateway/diagnostics: keep idle liveness samples in telemetry instead of visible warning logs unless diagnostic work is active, waiting, or queued. Thanks @vincentkoc. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 8dab7e2079e..2619bf30595 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -55,6 +55,16 @@ temporary set of OpenClaw-owned plugin packages while that migration finishes. + + In a running Gateway, owner-only `/plugins enable` and `/plugins disable` + trigger the Gateway config reloader. The Gateway reloads plugin runtime + surfaces in process, and new agent turns rebuild their tool list from the + refreshed registry. `/plugins install` changes plugin source code, so the + Gateway requests a restart instead of pretending the current process can + safely reload already-imported modules. + + + ```bash openclaw plugins inspect --runtime --json @@ -251,20 +261,19 @@ tool name. If a tool allowlist references plugin tools, add the owning plugin id to `plugins.allow` or remove `plugins.allow`; `openclaw doctor` warns about this shape. -Config changes **require a gateway restart**. If the Gateway is running with config -watch + in-process restart enabled (the default `openclaw gateway` path), that -restart is usually performed automatically a moment after the config write lands. -There is no supported hot-reload path for native plugin runtime code or lifecycle -hooks; restart the Gateway process that is serving the live channel before -expecting updated `register(api)` code, `api.on(...)` hooks, tools, services, or -provider/runtime hooks to run. +Config changes made through `/plugins enable` or `/plugins disable` trigger an +in-process Gateway plugin reload. New agent turns rebuild their tool list from +the refreshed plugin registry. Source-changing operations such as install, +update, and uninstall still restart the Gateway process because already-imported +plugin modules cannot be safely replaced in place. `openclaw plugins list` is a local plugin registry/config snapshot. An `enabled` plugin there means the persisted registry and current config allow the plugin to participate. It does not prove that an already-running remote Gateway -child has restarted into the same plugin code. On VPS/container setups with -wrapper processes, send restarts to the actual `openclaw gateway run` process, -or use `openclaw gateway restart` against the running Gateway. +has reloaded or restarted into the same plugin code. On VPS/container setups +with wrapper processes, send restarts or reload-triggering writes to the actual +`openclaw gateway run` process, or use `openclaw gateway restart` against the +running Gateway when the reload reports a failure. - **Disabled**: plugin exists but enablement rules turned it off. Config is preserved. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 4a914179317..bd0b316ba0a 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -250,8 +250,8 @@ User-invocable skills are also exposed as slash commands: - In multi-account channels, config-targeted `/allowlist --account ` and `/config set channels..accounts....` also honor the target account's `configWrites`. - `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs. - `/restart` is enabled by default; set `commands.restart: false` to disable it. - - `/plugins install ` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, `git:`, or `clawhub:`. - - `/plugins enable|disable` updates plugin config and may prompt for a restart. + - `/plugins install ` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, `git:`, or `clawhub:`, then requests a Gateway restart because plugin source modules changed. + - `/plugins enable|disable` updates plugin config and triggers Gateway plugin reload for new agent turns. @@ -429,8 +429,9 @@ Examples: - `/plugins list` and `/plugins show` use real plugin discovery against the current workspace plus on-disk config. +- `/plugins install` installs from ClawHub, npm, git, local directories, and archives. - `/plugins enable|disable` updates plugin config only; it does not install or uninstall plugins. -- After enable/disable changes, restart the gateway to apply them. +- Enable and disable changes hot-reload Gateway plugin runtime surfaces for new agent turns; install requests a Gateway restart because plugin source modules changed. diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 05a3c9a3994..b3807073bd6 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -424,7 +424,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { - text: `🔌 Installed plugin "${installed.pluginId}". Restart the gateway to load plugins.`, + text: `🔌 Installed plugin "${installed.pluginId}". Gateway restart will load the new plugin source.`, }, }; } @@ -531,7 +531,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm shouldContinue: false, reply: { text: - `🔌 Plugin "${plugin.id}" ${pluginsCommand.action}d in ${loaded.path}. Restart the gateway to apply.` + + `🔌 Plugin "${plugin.id}" ${pluginsCommand.action}d in ${loaded.path}. Gateway reload will apply it to new agent turns.` + (registryWarning ? `\n${registryWarning}` : ""), }, }; diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index ffaf253e95b..d572d316966 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -66,6 +66,7 @@ export const buildPluginDiagnosticsReport: UnknownMock = vi.fn(); const buildPluginCompatibilityNotices: UnknownMock = vi.fn(); export const inspectPluginRegistry: AsyncUnknownMock = vi.fn(); export const refreshPluginRegistry: AsyncUnknownMock = vi.fn(); +export const clearPluginRegistryLoadCache: UnknownMock = vi.fn(); export const applyExclusiveSlotSelection: UnknownMock = vi.fn(); export const planPluginUninstall: UnknownMock = vi.fn(); export const applyPluginUninstallDirectoryRemoval: AsyncUnknownMock = vi.fn(); @@ -353,6 +354,13 @@ vi.mock("../plugins/plugin-registry.js", () => ({ )) as (typeof import("../plugins/plugin-registry.js"))["refreshPluginRegistry"], })); +vi.mock("../plugins/loader.js", () => ({ + clearPluginRegistryLoadCache: ((...args: unknown[]) => + invokeMock(clearPluginRegistryLoadCache, ...args)) as ( + ...args: unknown[] + ) => unknown, +})); + vi.mock("../plugins/slots.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -599,6 +607,7 @@ export function resetPluginsCliTestState() { buildPluginCompatibilityNotices.mockReset(); inspectPluginRegistry.mockReset(); refreshPluginRegistry.mockReset(); + clearPluginRegistryLoadCache.mockReset(); applyExclusiveSlotSelection.mockReset(); planPluginUninstall.mockReset(); applyPluginUninstallDirectoryRemoval.mockReset(); diff --git a/src/cli/plugins-install-persist.test.ts b/src/cli/plugins-install-persist.test.ts index b26934b23c2..9d01a58863f 100644 --- a/src/cli/plugins-install-persist.test.ts +++ b/src/cli/plugins-install-persist.test.ts @@ -3,10 +3,13 @@ import type { OpenClawConfig } from "../config/config.js"; import { applyExclusiveSlotSelection, buildPluginDiagnosticsReport, + clearPluginRegistryLoadCache, enablePluginInConfig, loadPluginManifestRegistry, + replaceConfigFile, refreshPluginRegistry, resetPluginsCliTestState, + runtimeLogs, writeConfigFile, writePersistedInstalledPluginIndexInstallRecords, } from "./plugins-cli-test-helpers.js"; @@ -60,6 +63,14 @@ describe("persistPluginInstall", () => { }), }); expect(writeConfigFile).toHaveBeenCalledWith(enabledConfig); + expect(replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: enabledConfig, + baseHash: "config-1", + writeOptions: { + afterWrite: { mode: "restart", reason: "plugin source changed" }, + unsetPaths: [["plugins", "installs"]], + }, + }); expect(refreshPluginRegistry).toHaveBeenCalledWith({ config: enabledConfig, installRecords: { @@ -71,6 +82,82 @@ describe("persistPluginInstall", () => { }, reason: "source-changed", }); + expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1); + }); + + it("persists installs even when runtime cache invalidation fails", async () => { + const { persistPluginInstall } = await import("./plugins-install-persist.js"); + const baseConfig = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + }, + } as OpenClawConfig; + enablePluginInConfig.mockReturnValue({ config: enabledConfig }); + clearPluginRegistryLoadCache.mockImplementation(() => { + throw new Error("cache unavailable"); + }); + + const next = await persistPluginInstall({ + snapshot: { + config: baseConfig, + baseHash: "config-1", + }, + pluginId: "alpha", + install: { + source: "npm", + spec: "alpha@1.0.0", + installPath: "/tmp/alpha", + }, + }); + + expect(next).toEqual(enabledConfig); + expect(refreshPluginRegistry).toHaveBeenCalled(); + expect( + runtimeLogs.some((line) => line.includes("Plugin runtime cache invalidation failed")), + ).toBe(true); + }); + + it("invalidates runtime cache even when registry refresh fails", async () => { + const { persistPluginInstall } = await import("./plugins-install-persist.js"); + const baseConfig = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + }, + } as OpenClawConfig; + enablePluginInConfig.mockReturnValue({ config: enabledConfig }); + refreshPluginRegistry.mockRejectedValueOnce(new Error("registry unavailable")); + + const next = await persistPluginInstall({ + snapshot: { + config: baseConfig, + baseHash: "config-1", + }, + pluginId: "alpha", + install: { + source: "npm", + spec: "alpha@1.0.0", + installPath: "/tmp/alpha", + }, + }); + + expect(next).toEqual(enabledConfig); + expect(refreshPluginRegistry).toHaveBeenCalled(); + expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1); + expect(runtimeLogs.some((line) => line.includes("Plugin registry refresh failed"))).toBe(true); }); it("removes stale denylist entries before enabling installed plugins", async () => { diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index eb4decdcc7c..a9d2775a750 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -107,6 +107,9 @@ export async function persistPluginInstall(params: { nextInstallRecords, nextConfig: next, baseHash: params.snapshot.baseHash, + writeOptions: { + afterWrite: { mode: "restart", reason: "plugin source changed" }, + }, }), { command: "install" }, ); diff --git a/src/cli/plugins-install-record-commit.test.ts b/src/cli/plugins-install-record-commit.test.ts index 23f382cc8c3..a746190554f 100644 --- a/src/cli/plugins-install-record-commit.test.ts +++ b/src/cli/plugins-install-record-commit.test.ts @@ -78,6 +78,7 @@ describe("commitConfigWithPendingPluginInstalls", () => { }, baseHash: "config-1", writeOptions: { + afterWrite: { mode: "restart", reason: "plugin source changed" }, unsetPaths: [["plugins", "installs"]], }, }); @@ -97,6 +98,33 @@ describe("commitConfigWithPendingPluginInstalls", () => { }); }); + it("does not add restart intent when pending records match the plugin index", async () => { + const existingRecords: Record = { + demo: { + source: "npm", + spec: "demo@1.0.0", + }, + }; + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(existingRecords); + + await commitConfigWithPendingPluginInstalls({ + nextConfig: { + plugins: { + installs: existingRecords, + }, + }, + baseHash: "config-1", + }); + + expect(mocks.replaceConfigFile).toHaveBeenCalledWith({ + nextConfig: {}, + baseHash: "config-1", + writeOptions: { + unsetPaths: [["plugins", "installs"]], + }, + }); + }); + it("rolls back plugin index writes when the config write fails", async () => { const existingRecords: Record = { existing: { diff --git a/src/cli/plugins-install-record-commit.ts b/src/cli/plugins-install-record-commit.ts index 93fc4dd9cb9..4cda3f3c36c 100644 --- a/src/cli/plugins-install-record-commit.ts +++ b/src/cli/plugins-install-record-commit.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import { replaceConfigFile } from "../config/config.js"; import type { ConfigWriteOptions } from "../config/io.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -18,6 +19,7 @@ function mergeUnsetPaths( } type ConfigCommit = (config: OpenClawConfig, writeOptions?: ConfigWriteOptions) => Promise; +const PLUGIN_SOURCE_CHANGED_RESTART_REASON = "plugin source changed"; async function commitPluginInstallRecordsWithWriter(params: { previousInstallRecords?: Record; @@ -30,8 +32,15 @@ async function commitPluginInstallRecordsWithWriter(params: { params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords()); await writePersistedInstalledPluginIndexInstallRecords(params.nextInstallRecords); try { + const installRecordsChanged = !isDeepStrictEqual( + previousInstallRecords, + params.nextInstallRecords, + ); await params.commit(params.nextConfig, { ...params.writeOptions, + ...(installRecordsChanged && params.writeOptions?.afterWrite === undefined + ? { afterWrite: { mode: "restart", reason: PLUGIN_SOURCE_CHANGED_RESTART_REASON } } + : {}), unsetPaths: mergeUnsetPaths(params.writeOptions?.unsetPaths, [ Array.from(PLUGIN_INSTALLS_CONFIG_PATH), ]), diff --git a/src/cli/plugins-registry-refresh.ts b/src/cli/plugins-registry-refresh.ts index 64c3eec4e4c..e4471194282 100644 --- a/src/cli/plugins-registry-refresh.ts +++ b/src/cli/plugins-registry-refresh.ts @@ -43,4 +43,16 @@ export async function refreshPluginRegistryAfterConfigMutation(params: { } catch (error) { params.logger?.warn?.(`Plugin registry refresh failed: ${formatErrorMessage(error)}`); } + await invalidatePluginRuntimeDiscoveryAfterConfigMutation(params); +} + +async function invalidatePluginRuntimeDiscoveryAfterConfigMutation(params: { + logger?: PluginRegistryRefreshLogger; +}): Promise { + try { + const { clearPluginRegistryLoadCache } = await import("../plugins/loader.js"); + clearPluginRegistryLoadCache(); + } catch (error) { + params.logger?.warn?.(`Plugin runtime cache invalidation failed: ${formatErrorMessage(error)}`); + } } diff --git a/src/cli/plugins-uninstall-command.ts b/src/cli/plugins-uninstall-command.ts index f633ffd3e0d..8b165a03de7 100644 --- a/src/cli/plugins-uninstall-command.ts +++ b/src/cli/plugins-uninstall-command.ts @@ -167,6 +167,9 @@ export async function runPluginUninstallCommand( nextInstallRecords, nextConfig, ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + writeOptions: { + afterWrite: { mode: "restart", reason: "plugin source changed" }, + }, }), { command: "uninstall" }, ); diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 950d1ddd6b0..ce149013b10 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -111,6 +111,9 @@ export async function runPluginUpdateCommand(params: { nextInstallRecords: nextPluginInstallRecords, nextConfig, baseHash: (await sourceSnapshotPromise)?.hash, + writeOptions: { + afterWrite: { mode: "restart", reason: "plugin source changed" }, + }, }); } else { await replaceConfigFile({ diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index fbd348ef6d0..16e7ab3d01e 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -18,6 +18,7 @@ export type GatewayReloadPlan = { restartCron: boolean; restartHeartbeat: boolean; restartHealthMonitor: boolean; + reloadPlugins: boolean; restartChannels: Set; disposeMcpRuntimes: boolean; noopPaths: string[]; @@ -35,6 +36,7 @@ type ReloadAction = | "restart-cron" | "restart-heartbeat" | "restart-health-monitor" + | "reload-plugins" | "dispose-mcp-runtimes" | `restart-channel:${ChannelId}`; @@ -97,6 +99,8 @@ const BASE_RELOAD_RULES: ReloadRule[] = [ { prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] }, { prefix: "cron", kind: "hot", actions: ["restart-cron"] }, { prefix: "mcp", kind: "hot", actions: ["dispose-mcp-runtimes"] }, + { prefix: "plugins.load", kind: "restart" }, + { prefix: "plugins.installs", kind: "restart" }, ]; const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [ @@ -115,7 +119,7 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [ { prefix: "talk", kind: "none" }, { prefix: "skills", kind: "none" }, { prefix: "secrets", kind: "none" }, - { prefix: "plugins", kind: "restart" }, + { prefix: "plugins", kind: "hot", actions: ["reload-plugins", "dispose-mcp-runtimes"] }, { prefix: "ui", kind: "none" }, { prefix: "gateway", kind: "restart" }, { prefix: "discovery", kind: "restart" }, @@ -163,6 +167,17 @@ function listReloadRules(): ReloadRule[] { ), ), ); + const channelPluginStateRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [ + { + prefix: `plugins.entries.${plugin.id}`, + kind: "hot", + actions: [ + "reload-plugins", + "dispose-mcp-runtimes", + `restart-channel:${plugin.id}` as ReloadAction, + ], + }, + ]); const pluginReloadRules: ReloadRule[] = (registry?.reloads ?? []).flatMap((entry) => (entry.registration.restartPrefixes ?? []) .map( @@ -190,6 +205,7 @@ function listReloadRules(): ReloadRule[] { ...BASE_RELOAD_RULES, ...pluginReloadRules, ...channelReloadRules, + ...channelPluginStateRules, ...BASE_RELOAD_RULES_TAIL, ]; cachedReloadRules = rules; @@ -286,6 +302,7 @@ export function buildGatewayReloadPlan( restartCron: false, restartHeartbeat: false, restartHealthMonitor: false, + reloadPlugins: false, restartChannels: new Set(), disposeMcpRuntimes: false, noopPaths: [], @@ -313,6 +330,9 @@ export function buildGatewayReloadPlan( case "restart-health-monitor": plan.restartHealthMonitor = true; break; + case "reload-plugins": + plan.reloadPlugins = true; + break; case "dispose-mcp-runtimes": plan.disposeMcpRuntimes = true; break; diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 8e4af2faf5b..c657561b8cc 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -11,6 +11,7 @@ import type { ConfigWriteNotification, OpenClawConfig, } from "../config/config.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; import { pinActivePluginChannelRegistry, resetPluginRuntimeStateForTest, @@ -233,6 +234,25 @@ describe("buildGatewayReloadPlan", () => { expect(afterPinPlan.restartChannels).toEqual(new Set(["telegram"])); }); + it("restarts loaded channel plugins when plugin entry state changes", () => { + const plan = buildGatewayReloadPlan(["plugins.entries.telegram.enabled"]); + + expect(plan.restartGateway).toBe(false); + expect(plan.reloadPlugins).toBe(true); + expect(plan.disposeMcpRuntimes).toBe(true); + expect(plan.restartChannels).toEqual(new Set(["telegram"])); + }); + + it("keeps installed channel plugin source changes restart-backed", () => { + const plan = buildGatewayReloadPlan(["plugins.installs.telegram.installPath"]); + + expect(plan.restartGateway).toBe(true); + expect(plan.reloadPlugins).toBe(false); + expect(plan.disposeMcpRuntimes).toBe(false); + expect(plan.restartChannels).toEqual(new Set()); + expect(plan.restartReasons).toEqual(["plugins.installs.telegram.installPath"]); + }); + it("restarts heartbeat when model-related config changes", () => { const plan = buildGatewayReloadPlan([ "models.providers.openai.models", @@ -281,7 +301,7 @@ describe("buildGatewayReloadPlan", () => { ]); }); - it("keeps colliding whole-record plugin install changes as restart reasons", () => { + it("restarts for whole-record plugin install changes", () => { const plan = buildGatewayReloadPlan( ["plugins.installs.lossless.resolvedAt", "plugins.installs.lossless.resolvedAt"], { @@ -291,6 +311,8 @@ describe("buildGatewayReloadPlan", () => { ); expect(plan.restartGateway).toBe(true); + expect(plan.reloadPlugins).toBe(false); + expect(plan.disposeMcpRuntimes).toBe(false); expect(plan.restartReasons).toEqual([ "plugins.installs.lossless.resolvedAt", "plugins.installs.lossless.resolvedAt", @@ -298,6 +320,23 @@ describe("buildGatewayReloadPlan", () => { expect(plan.noopPaths).toEqual([]); }); + it("requires restart when plugin load paths change", () => { + const plan = buildGatewayReloadPlan(["plugins.load.paths.0"]); + + expect(plan.restartGateway).toBe(true); + expect(plan.reloadPlugins).toBe(false); + expect(plan.disposeMcpRuntimes).toBe(false); + expect(plan.restartReasons).toEqual(["plugins.load.paths.0"]); + }); + + it("hot-reloads plugin entry config changes", () => { + const plan = buildGatewayReloadPlan(["plugins.entries.lossless-claw.config.mode"]); + expect(plan.restartGateway).toBe(false); + expect(plan.reloadPlugins).toBe(true); + expect(plan.disposeMcpRuntimes).toBe(true); + expect(plan.hotReasons).toContain("plugins.entries.lossless-claw.config.mode"); + }); + it("lists plugin install metadata and whole-record paths structurally", () => { const prev = { plugins: { @@ -541,6 +580,8 @@ function createReloaderHarness( initialInternalWriteHash?: string | null; recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise; promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise; + initialPluginInstallRecords?: Record; + readPluginInstallRecords?: () => Promise>; onRecovered?: (params: { reason: string; snapshot: ConfigFileSnapshot; @@ -573,6 +614,8 @@ function createReloaderHarness( readSnapshot, recoverSnapshot: options.recoverSnapshot, promoteSnapshot: options.promoteSnapshot, + initialPluginInstallRecords: options.initialPluginInstallRecords ?? {}, + readPluginInstallRecords: options.readPluginInstallRecords ?? (async () => ({})), onRecovered: options.onRecovered, subscribeToWrites, onHotReload, @@ -809,13 +852,14 @@ describe("startGatewayConfigReloader", () => { expect(recoverSnapshot).not.toHaveBeenCalled(); expect(readSnapshot).toHaveBeenCalledTimes(1); - expect(onHotReload).not.toHaveBeenCalled(); - expect(onRestart).toHaveBeenCalledTimes(1); - expect(onRestart).toHaveBeenCalledWith( + expect(onRestart).not.toHaveBeenCalled(); + expect(onHotReload).toHaveBeenCalledTimes(1); + expect(onHotReload).toHaveBeenCalledWith( expect.objectContaining({ changedPaths: ["plugins.entries.lossless-claw.config.cacheAwareCompaction"], - restartGateway: true, - restartReasons: ["plugins.entries.lossless-claw.config.cacheAwareCompaction"], + restartGateway: false, + reloadPlugins: true, + hotReasons: ["plugins.entries.lossless-claw.config.cacheAwareCompaction"], }), expect.objectContaining({ plugins: expect.objectContaining({ @@ -1162,6 +1206,189 @@ describe("startGatewayConfigReloader", () => { expect(harness.onHotReload).not.toHaveBeenCalled(); expect(harness.onRestart).toHaveBeenCalledTimes(1); + expect(harness.onRestart).toHaveBeenCalledWith( + expect.objectContaining({ + changedPaths: [ + "plugins.installs.lossless.resolvedAt", + "plugins.installs.lossless.resolvedAt", + ], + restartGateway: true, + restartReasons: [ + "plugins.installs.lossless.resolvedAt", + "plugins.installs.lossless.resolvedAt", + ], + }), + expect.objectContaining({ + plugins: expect.objectContaining({ + installs: expect.objectContaining({ + "lossless.resolvedAt": expect.objectContaining({ + source: "npm", + }), + }), + }), + }), + ); + + await harness.reloader.stop(); + }); + + it("queues restart when an external plugin source write only changes the managed index", async () => { + const activeConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + plugins: { + allow: ["lossless-claw"], + entries: { + "lossless-claw": { enabled: true }, + }, + }, + }; + const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce( + makeSnapshot({ + sourceConfig: activeConfig, + runtimeConfig: activeConfig, + config: activeConfig, + hash: "external-plugin-index-1", + }), + ); + const readPluginInstallRecords = vi.fn().mockResolvedValueOnce({ + "lossless-claw": { + source: "npm", + spec: "@martian-engineering/lossless-claw", + installPath: "/tmp/openclaw/plugins/lossless-claw", + installedAt: "2026-04-22T00:00:00.000Z", + }, + } satisfies Record); + const harness = createReloaderHarness(readSnapshot, { + initialCompareConfig: activeConfig, + initialPluginInstallRecords: {}, + readPluginInstallRecords, + }); + + harness.watcher.emit("change"); + await vi.runOnlyPendingTimersAsync(); + + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).toHaveBeenCalledTimes(1); + expect(harness.onRestart).toHaveBeenCalledWith( + expect.objectContaining({ + changedPaths: ["plugins.installs.lossless-claw"], + restartGateway: true, + restartReasons: ["plugins.installs.lossless-claw"], + }), + activeConfig, + ); + + await harness.reloader.stop(); + }); + + it("keeps external plugin policy-only writes on the hot reload path", async () => { + const previousConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + plugins: { + entries: { + telegram: { enabled: false }, + }, + }, + }; + const nextConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + plugins: { + entries: { + telegram: { enabled: true }, + }, + }, + }; + const installRecords = { + telegram: { + source: "npm", + spec: "@openclaw/telegram", + installPath: "/tmp/openclaw/plugins/telegram", + }, + } satisfies Record; + const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce( + makeSnapshot({ + sourceConfig: nextConfig, + runtimeConfig: nextConfig, + config: nextConfig, + hash: "external-plugin-policy-1", + }), + ); + const readPluginInstallRecords = vi.fn().mockResolvedValueOnce(installRecords); + const harness = createReloaderHarness(readSnapshot, { + initialCompareConfig: previousConfig, + initialPluginInstallRecords: installRecords, + readPluginInstallRecords, + }); + + harness.watcher.emit("change"); + await vi.runOnlyPendingTimersAsync(); + + expect(harness.onRestart).not.toHaveBeenCalled(); + expect(harness.onHotReload).toHaveBeenCalledTimes(1); + expect(harness.onHotReload).toHaveBeenCalledWith( + expect.objectContaining({ + changedPaths: ["plugins.entries.telegram.enabled"], + restartGateway: false, + reloadPlugins: true, + hotReasons: ["plugins.entries.telegram.enabled"], + }), + nextConfig, + ); + + await harness.reloader.stop(); + }); + + it("queues restart when an external plugin source write also changes plugin config", async () => { + const previousConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + plugins: { + allow: ["lossless-claw"], + }, + }; + const nextConfig: OpenClawConfig = { + gateway: { reload: { debounceMs: 0 } }, + plugins: { + allow: ["lossless-claw"], + entries: { + "lossless-claw": { enabled: true }, + }, + }, + }; + const readSnapshot = vi.fn<() => Promise>().mockResolvedValueOnce( + makeSnapshot({ + sourceConfig: nextConfig, + runtimeConfig: nextConfig, + config: nextConfig, + hash: "external-plugin-source-and-config-1", + }), + ); + const readPluginInstallRecords = vi.fn().mockResolvedValueOnce({ + "lossless-claw": { + source: "npm", + spec: "@martian-engineering/lossless-claw", + installPath: "/tmp/openclaw/plugins/lossless-claw", + installedAt: "2026-04-22T00:00:00.000Z", + }, + } satisfies Record); + const harness = createReloaderHarness(readSnapshot, { + initialCompareConfig: previousConfig, + initialPluginInstallRecords: {}, + readPluginInstallRecords, + }); + + harness.watcher.emit("change"); + await vi.runOnlyPendingTimersAsync(); + + expect(harness.onHotReload).not.toHaveBeenCalled(); + expect(harness.onRestart).toHaveBeenCalledTimes(1); + expect(harness.onRestart).toHaveBeenCalledWith( + expect.objectContaining({ + changedPaths: ["plugins.entries", "plugins.installs.lossless-claw"], + restartGateway: true, + restartReasons: ["plugins.installs.lossless-claw"], + }), + nextConfig, + ); await harness.reloader.stop(); }); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 723b6eef1df..ba2f12e5e84 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -11,7 +11,12 @@ import { import { resolveConfigWriteFollowUp } from "../config/runtime-snapshot.js"; import type { GatewayReloadMode } from "../config/types.gateway.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; import { validateConfigObjectWithPlugins } from "../config/validation.js"; +import { + loadInstalledPluginIndexInstallRecords, + loadInstalledPluginIndexInstallRecordsSync, +} from "../plugins/installed-plugin-index-records.js"; import { isPlainObject } from "../utils.js"; import { buildGatewayReloadPlan, @@ -71,6 +76,7 @@ function isNoopReloadPlan(plan: GatewayReloadPlan): boolean { !plan.restartCron && !plan.restartHeartbeat && !plan.restartHealthMonitor && + !plan.reloadPlugins && !plan.disposeMcpRuntimes && plan.restartChannels.size === 0 ); @@ -158,6 +164,16 @@ type GatewayConfigReloader = { stop: () => Promise; }; +type PluginInstallRecords = Record; + +function asPluginInstallConfig(records: PluginInstallRecords): OpenClawConfig { + return { + plugins: { + installs: records, + }, + }; +} + export function startGatewayConfigReloader(opts: { initialConfig: OpenClawConfig; initialCompareConfig?: OpenClawConfig; @@ -167,6 +183,8 @@ export function startGatewayConfigReloader(opts: { onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise; promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise; + initialPluginInstallRecords?: PluginInstallRecords; + readPluginInstallRecords?: () => Promise; onRecovered?: (params: { reason: string; snapshot: ConfigFileSnapshot; @@ -196,6 +214,10 @@ export function startGatewayConfigReloader(opts: { afterWrite?: ConfigWriteNotification["afterWrite"]; } | null = null; let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null; + let currentPluginInstallRecords = + opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync(); + const readPluginInstallRecords = + opts.readPluginInstallRecords ?? loadInstalledPluginIndexInstallRecords; const scheduleAfter = (wait: number) => { if (stopped) { @@ -294,17 +316,47 @@ export function startGatewayConfigReloader(opts: { nextCompareConfig: OpenClawConfig, afterWrite?: ConfigWriteNotification["afterWrite"], ) => { - const changedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig); - const pluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths( + const configChangedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig); + const configPluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths( currentCompareConfig, nextCompareConfig, ); - const pluginInstallWholeRecordPaths = listPluginInstallWholeRecordPaths( + const configPluginInstallWholeRecordPaths = listPluginInstallWholeRecordPaths( currentCompareConfig, nextCompareConfig, ); + let nextPluginInstallRecords = currentPluginInstallRecords; + try { + nextPluginInstallRecords = await readPluginInstallRecords(); + } catch (err) { + opts.log.warn(`config reload plugin install record check failed: ${String(err)}`); + } + const previousPluginInstallConfig = asPluginInstallConfig(currentPluginInstallRecords); + const nextPluginInstallConfig = asPluginInstallConfig(nextPluginInstallRecords); + const pluginInstallRecordChangedPaths = diffConfigPaths( + previousPluginInstallConfig, + nextPluginInstallConfig, + ); + const pluginInstallRecordTimestampNoopPaths = listPluginInstallTimestampMetadataPaths( + previousPluginInstallConfig, + nextPluginInstallConfig, + ); + const pluginInstallRecordWholeRecordPaths = listPluginInstallWholeRecordPaths( + previousPluginInstallConfig, + nextPluginInstallConfig, + ); + const changedPaths = [...configChangedPaths, ...pluginInstallRecordChangedPaths]; + const pluginInstallTimestampNoopPaths = [ + ...configPluginInstallTimestampNoopPaths, + ...pluginInstallRecordTimestampNoopPaths, + ]; + const pluginInstallWholeRecordPaths = [ + ...configPluginInstallWholeRecordPaths, + ...pluginInstallRecordWholeRecordPaths, + ]; currentConfig = nextConfig; currentCompareConfig = nextCompareConfig; + currentPluginInstallRecords = nextPluginInstallRecords; settings = resolveGatewayReloadSettings(nextConfig); if (changedPaths.length === 0) { return; diff --git a/src/gateway/plugin-channel-reload-targets.test.ts b/src/gateway/plugin-channel-reload-targets.test.ts new file mode 100644 index 00000000000..b6c55c04e42 --- /dev/null +++ b/src/gateway/plugin-channel-reload-targets.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + listChannelPluginConfigTargetIds, + pluginConfigTargetsChanged, +} from "./plugin-channel-reload-targets.js"; + +describe("plugin channel reload targets", () => { + it("matches channel plugin config changes by owning plugin id", () => { + const targets = listChannelPluginConfigTargetIds({ + channelId: "matrix", + pluginId: "acme-chat", + aliases: ["matrix-chat"], + }); + + expect(pluginConfigTargetsChanged(targets, ["plugins.entries.acme-chat.config.mode"])).toBe( + true, + ); + expect(pluginConfigTargetsChanged(targets, ["plugins.installs.acme-chat.source"])).toBe(true); + expect(pluginConfigTargetsChanged(targets, ["plugins.entries.matrix.config.mode"])).toBe(true); + expect(pluginConfigTargetsChanged(targets, ["plugins.entries.matrix-chat.enabled"])).toBe(true); + expect(pluginConfigTargetsChanged(targets, ["plugins.entries.other.enabled"])).toBe(false); + }); +}); diff --git a/src/gateway/plugin-channel-reload-targets.ts b/src/gateway/plugin-channel-reload-targets.ts new file mode 100644 index 00000000000..4bad088f674 --- /dev/null +++ b/src/gateway/plugin-channel-reload-targets.ts @@ -0,0 +1,40 @@ +import type { ChannelId } from "../channels/plugins/index.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; + +export type ChannelPluginReloadTarget = { + channelId: ChannelId; + pluginId?: string | null; + aliases?: readonly string[] | null; +}; + +function addNormalizedTarget(targets: Set, value: string | null | undefined): void { + const normalized = normalizeOptionalString(value); + if (normalized) { + targets.add(normalized); + } +} + +export function listChannelPluginConfigTargetIds( + target: ChannelPluginReloadTarget, +): ReadonlySet { + const targets = new Set(); + addNormalizedTarget(targets, target.channelId); + addNormalizedTarget(targets, target.pluginId); + for (const alias of target.aliases ?? []) { + addNormalizedTarget(targets, alias); + } + return targets; +} + +export function pluginConfigTargetsChanged( + targetIds: Iterable, + changedPaths: readonly string[], +): boolean { + const prefixes = Array.from(targetIds, (id) => [ + `plugins.entries.${id}`, + `plugins.installs.${id}`, + ]).flat(); + return changedPaths.some((path) => + prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}.`)), + ); +} diff --git a/src/gateway/server-aux-handlers.test.ts b/src/gateway/server-aux-handlers.test.ts index 6a7b867a406..b3f320dff88 100644 --- a/src/gateway/server-aux-handlers.test.ts +++ b/src/gateway/server-aux-handlers.test.ts @@ -24,6 +24,7 @@ function createReloadPlan(overrides?: Partial): GatewayReload restartCron: overrides?.restartCron ?? false, restartHeartbeat: overrides?.restartHeartbeat ?? false, restartHealthMonitor: overrides?.restartHealthMonitor ?? false, + reloadPlugins: overrides?.reloadPlugins ?? false, restartChannels: overrides?.restartChannels ?? new Set(), disposeMcpRuntimes: overrides?.disposeMcpRuntimes ?? false, noopPaths: overrides?.noopPaths ?? [], diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index ac7fd771bed..dfb875d8182 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -7,6 +7,7 @@ import type { PluginDiagnostic } from "../plugins/types.js"; import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js"; const loadOpenClawPlugins = vi.hoisted(() => vi.fn()); +const clearActivatedPluginRuntimeState = vi.hoisted(() => vi.fn()); const loadPluginLookUpTable = vi.hoisted(() => vi.fn(() => ({ startup: { @@ -34,6 +35,7 @@ const handleGatewayRequest = vi.hoisted(() => ); vi.mock("../plugins/loader.js", () => ({ + clearActivatedPluginRuntimeState, loadOpenClawPlugins, })); @@ -307,6 +309,7 @@ beforeAll(async () => { }); beforeEach(() => { + clearActivatedPluginRuntimeState.mockClear(); loadOpenClawPlugins.mockReset(); loadPluginLookUpTable.mockReset().mockReturnValue({ startup: { @@ -629,6 +632,7 @@ describe("loadGatewayPlugins", () => { baseMethods: ["sessions.get"], }); + expect(clearActivatedPluginRuntimeState).toHaveBeenCalledTimes(1); expect(loadOpenClawPlugins).not.toHaveBeenCalled(); expect(result.pluginRegistry.plugins).toEqual([]); expect(result.gatewayMethods).toEqual(["sessions.get"]); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index a3d3e622bea..78ee3a68e5e 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -3,7 +3,7 @@ import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; -import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { clearActivatedPluginRuntimeState, loadOpenClawPlugins } from "../plugins/loader.js"; import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; @@ -575,6 +575,7 @@ export function loadGatewayPlugins(params: { ).startup.pluginIds, ]; if (pluginIds.length === 0) { + clearActivatedPluginRuntimeState(); const pluginRegistry = createEmptyPluginRegistry(); setActivePluginRegistry(pluginRegistry, undefined, "gateway-bindable", params.workspaceDir); return { diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index 7b37b390719..8a539109082 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -5,7 +5,9 @@ import { setActiveEmbeddedRun, type EmbeddedPiQueueHandle, } from "../agents/pi-embedded-runner/runs.js"; -import { __testing } from "./server-reload-handlers.js"; +import type { ChannelKind } from "./config-reload-plan.js"; +import type { GatewayPluginReloadResult } from "./server-reload-handlers.js"; +import { __testing, createGatewayReloadHandlers } from "./server-reload-handlers.js"; describe("gateway reload recovery handlers", () => { afterEach(() => { @@ -49,3 +51,107 @@ describe("gateway reload recovery handlers", () => { expect(logReload.warn).not.toHaveBeenCalled(); }); }); + +describe("gateway plugin hot reload handlers", () => { + it("stops removed channel plugins from broad activation before swapping plugin runtime", async () => { + const previousSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS; + const previousSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS; + delete process.env.OPENCLAW_SKIP_CHANNELS; + delete process.env.OPENCLAW_SKIP_PROVIDERS; + const cron = { start: vi.fn(async () => {}), stop: vi.fn() }; + const heartbeatRunner = { + stop: vi.fn(), + updateConfig: vi.fn(), + }; + const setState = vi.fn(); + const startChannel = vi.fn(async () => {}); + const events: string[] = []; + const stopChannel = vi.fn(async () => { + events.push("stop"); + }); + const reloadPlugins = vi.fn( + async (params: { + beforeReplace: (channels: ReadonlySet) => Promise; + }): Promise => { + events.push("reload:start"); + await params.beforeReplace(new Set(["discord"])); + events.push("registry:replace"); + return { + restartChannels: new Set(), + activeChannels: new Set(), + }; + }, + ); + const { applyHotReload } = createGatewayReloadHandlers({ + deps: {} as never, + broadcast: vi.fn(), + getState: () => ({ + hooksConfig: {} as never, + hookClientIpConfig: {} as never, + heartbeatRunner: heartbeatRunner as never, + cronState: { cron, storePath: "/tmp/cron.json", cronEnabled: false } as never, + channelHealthMonitor: null, + }), + setState, + startChannel, + stopChannel, + reloadPlugins, + logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + logChannels: { info: vi.fn(), error: vi.fn() }, + logCron: { error: vi.fn() }, + logReload: { info: vi.fn(), warn: vi.fn() }, + createHealthMonitor: () => null, + }); + + try { + await applyHotReload( + { + changedPaths: ["plugins.enabled"], + restartGateway: false, + restartReasons: [], + hotReasons: ["plugins.enabled"], + reloadHooks: false, + restartGmailWatcher: false, + restartCron: false, + restartHeartbeat: false, + restartHealthMonitor: false, + reloadPlugins: true, + restartChannels: new Set(), + disposeMcpRuntimes: false, + noopPaths: [], + }, + { + plugins: { + enabled: false, + }, + }, + ); + } finally { + if (previousSkipChannels === undefined) { + delete process.env.OPENCLAW_SKIP_CHANNELS; + } else { + process.env.OPENCLAW_SKIP_CHANNELS = previousSkipChannels; + } + if (previousSkipProviders === undefined) { + delete process.env.OPENCLAW_SKIP_PROVIDERS; + } else { + process.env.OPENCLAW_SKIP_PROVIDERS = previousSkipProviders; + } + } + + expect(reloadPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + nextConfig: { + plugins: { + enabled: false, + }, + }, + changedPaths: ["plugins.enabled"], + }), + ); + expect(stopChannel).toHaveBeenCalledWith("discord"); + expect(startChannel).not.toHaveBeenCalled(); + expect(events).toEqual(["reload:start", "stop", "registry:replace"]); + expect(setState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 77d53ae1943..624d0355856 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -58,6 +58,11 @@ type GatewayReloadLog = { warn: (msg: string) => void; }; +export type GatewayPluginReloadResult = { + restartChannels: ReadonlySet; + activeChannels: ReadonlySet; +}; + const MCP_RUNTIME_RELOAD_DISPOSE_TIMEOUT_MS = 5_000; const CHANNEL_RELOAD_DEFERRAL_POLL_MS = 500; const CHANNEL_RELOAD_STILL_PENDING_WARN_MS = 30_000; @@ -109,6 +114,11 @@ type GatewayReloadHandlerParams = { setState: (state: GatewayHotReloadState) => void; startChannel: (name: ChannelKind) => Promise; stopChannel: (name: ChannelKind) => Promise; + reloadPlugins: (params: { + nextConfig: OpenClawConfig; + changedPaths: readonly string[]; + beforeReplace: (channels: ReadonlySet) => Promise; + }) => Promise; logHooks: { info: (msg: string) => void; warn: (msg: string) => void; @@ -260,6 +270,41 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) resetDirectoryCache(); + const channelsToRestart = new Set(plan.restartChannels); + const channelsStoppedBeforePluginReload = new Set(); + let activePluginChannelsAfterReload: ReadonlySet | null = null; + const shouldSkipChannelRestart = () => + isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) || + isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS); + if (plan.reloadPlugins) { + const stopChannelsBeforePluginReplace = async (channels: ReadonlySet) => { + for (const channel of channels) { + channelsToRestart.add(channel); + } + if (channelsToRestart.size === 0 || shouldSkipChannelRestart()) { + return; + } + await waitForActiveWorkBeforeChannelReload(channelsToRestart, nextConfig); + for (const channel of channelsToRestart) { + if (channelsStoppedBeforePluginReload.has(channel)) { + continue; + } + params.logChannels.info(`stopping ${channel} channel before plugin reload`); + await params.stopChannel(channel); + channelsStoppedBeforePluginReload.add(channel); + } + }; + const pluginReloadResult = await params.reloadPlugins({ + nextConfig, + changedPaths: plan.changedPaths, + beforeReplace: stopChannelsBeforePluginReplace, + }); + for (const channel of pluginReloadResult.restartChannels) { + channelsToRestart.add(channel); + } + activePluginChannelsAfterReload = pluginReloadResult.activeChannels; + } + if (plan.restartCron) { state.cronState.cron.stop(); nextState.cronState = buildGatewayCronService({ @@ -303,22 +348,26 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) }); } - if (plan.restartChannels.size > 0) { - if ( - isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) || - isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS) - ) { + if (channelsToRestart.size > 0) { + if (shouldSkipChannelRestart()) { params.logChannels.info( "skipping channel reload (OPENCLAW_SKIP_CHANNELS=1 or OPENCLAW_SKIP_PROVIDERS=1)", ); } else { - await waitForActiveWorkBeforeChannelReload(plan.restartChannels, nextConfig); + if (!plan.reloadPlugins) { + await waitForActiveWorkBeforeChannelReload(channelsToRestart, nextConfig); + } const restartChannel = async (name: ChannelKind) => { + if (plan.reloadPlugins && activePluginChannelsAfterReload?.has(name) === false) { + return; + } params.logChannels.info(`restarting ${name} channel`); - await params.stopChannel(name); + if (!channelsStoppedBeforePluginReload.has(name)) { + await params.stopChannel(name); + } await params.startChannel(name); }; - for (const channel of plan.restartChannels) { + for (const channel of channelsToRestart) { await restartChannel(channel); } } @@ -421,6 +470,7 @@ export function startManagedGatewayConfigReloader(params: ManagedGatewayConfigRe setState: params.setState, startChannel: params.startChannel, stopChannel: params.stopChannel, + reloadPlugins: params.reloadPlugins, logHooks: params.logHooks, logChannels: params.logChannels, logCron: params.logCron, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index ed6273b7a04..86c809f0cfb 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -4,6 +4,7 @@ import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry. import type { CanvasHostServer } from "../canvas-host/server.js"; import type { ChannelRuntimeSurface } from "../channels/plugins/channel-runtime-surface.types.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; +import { getLoadedChannelPluginEntryById } from "../channels/plugins/registry-loaded.js"; import { createDefaultDeps } from "../cli/deps.js"; import { isRestartEnabled } from "../config/commands.flags.js"; import { @@ -58,6 +59,10 @@ import { } from "../tasks/task-registry.maintenance.js"; import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; import { resolveGatewayAuth } from "./auth.js"; +import { + listChannelPluginConfigTargetIds, + pluginConfigTargetsChanged, +} from "./plugin-channel-reload-targets.js"; import { createGatewayAuxHandlers } from "./server-aux-handlers.js"; import { createChannelManager } from "./server-channels.js"; import { resolveGatewayControlUiRootState } from "./server-control-ui-root.js"; @@ -69,6 +74,7 @@ import { loadGatewayModelCatalog } from "./server-model-catalog.js"; import { bootstrapGatewayNetworkRuntime } from "./server-network-runtime.js"; import { createGatewayNodeSessionRuntime } from "./server-node-session-runtime.js"; import { setFallbackGatewayContextResolver } from "./server-plugins.js"; +import type { GatewayPluginReloadResult } from "./server-reload-handlers.js"; import { createGatewayRequestContext } from "./server-request-context.js"; import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; import { @@ -1041,6 +1047,109 @@ export async function startGatewayServer( logDiscovery.warn(`gateway discovery refresh failed after plugin load: ${String(err)}`); } }; + const listAttachedChannelConfigTargets = () => + new Map( + listChannelPlugins().map((plugin) => [ + plugin.id, + listChannelPluginConfigTargetIds({ + channelId: plugin.id, + pluginId: getLoadedChannelPluginEntryById(plugin.id)?.pluginId, + aliases: plugin.meta.aliases, + }), + ]), + ); + const reloadAttachedGatewayPlugins = async (params: { + nextConfig: OpenClawConfig; + changedPaths: readonly string[]; + beforeReplace: (channels: ReadonlySet) => Promise; + }): Promise => { + const beforeChannelTargets = listAttachedChannelConfigTargets(); + const beforeChannelIds = new Set(beforeChannelTargets.keys()); + const [{ loadPluginLookUpTable }, { prepareGatewayPluginLoad }, { startPluginServices }] = + await Promise.all([ + import("../plugins/plugin-lookup-table.js"), + import("./server-plugin-bootstrap.js"), + import("../plugins/services.js"), + ]); + const nextPluginLookUpTable = loadPluginLookUpTable({ + config: params.nextConfig, + workspaceDir: defaultWorkspaceDir, + env: process.env, + activationSourceConfig: params.nextConfig, + }); + const nextStartupPluginIds = new Set(nextPluginLookUpTable.startup.pluginIds); + const nextStartupChannelIds = new Set(); + for (const plugin of nextPluginLookUpTable.manifestRegistry.plugins) { + if (!nextStartupPluginIds.has(plugin.id)) { + continue; + } + if (plugin.channels.length === 0) { + nextStartupChannelIds.add(plugin.id); + continue; + } + for (const channelId of plugin.channels) { + nextStartupChannelIds.add(channelId); + } + } + const channelsToStopBeforeReplace = new Set(); + for (const channelId of beforeChannelIds) { + const targetIds = beforeChannelTargets.get(channelId) ?? new Set([channelId]); + if ( + !nextStartupChannelIds.has(channelId) || + pluginConfigTargetsChanged(targetIds, params.changedPaths) + ) { + channelsToStopBeforeReplace.add(channelId); + } + } + await params.beforeReplace(channelsToStopBeforeReplace); + setCurrentPluginMetadataSnapshot(nextPluginLookUpTable, { config: params.nextConfig }); + const loaded = prepareGatewayPluginLoad({ + cfg: params.nextConfig, + workspaceDir: defaultWorkspaceDir, + log, + coreGatewayMethodNames: baseMethods, + baseMethods, + pluginLookUpTable: nextPluginLookUpTable, + }); + const previousPluginServices = runtimeState.pluginServices; + runtimeState.pluginServices = null; + if (previousPluginServices) { + await previousPluginServices.stop().catch((err) => { + log.warn(`plugin services stop failed during reload: ${String(err)}`); + }); + } + replaceAttachedPluginRuntime(loaded); + await refreshAttachedGatewayDiscovery(loaded.pluginRegistry); + try { + runtimeState.pluginServices = await startPluginServices({ + registry: loaded.pluginRegistry, + config: params.nextConfig, + workspaceDir: defaultWorkspaceDir, + }); + } catch (err) { + log.warn(`plugin services failed to start after reload: ${String(err)}`); + } + const afterChannelTargets = listAttachedChannelConfigTargets(); + const afterChannelIds = new Set(afterChannelTargets.keys()); + const restartChannels = new Set(); + for (const channelId of new Set([...beforeChannelIds, ...afterChannelIds])) { + const targetIds = + afterChannelTargets.get(channelId) ?? + beforeChannelTargets.get(channelId) ?? + new Set([channelId]); + if ( + afterChannelIds.has(channelId) && + (beforeChannelIds.has(channelId) !== afterChannelIds.has(channelId) || + pluginConfigTargetsChanged(targetIds, params.changedPaths)) + ) { + restartChannels.add(channelId); + } + } + return { + restartChannels, + activeChannels: afterChannelIds, + }; + }; const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port; @@ -1287,6 +1396,7 @@ export async function startGatewayServer( }, startChannel, stopChannel, + reloadPlugins: reloadAttachedGatewayPlugins, logHooks, logChannels, logCron, diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index 45db86744b9..170c18ff7c4 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -890,6 +890,40 @@ describe("gateway hot reload", () => { }); }); + it("reloads plugin runtime surfaces and disposes MCP runtimes on plugin config hot reloads", async () => { + await withNonMinimalGatewayServer(async () => { + const onHotReload = hoisted.getOnHotReload(); + expect(onHotReload).toBeTypeOf("function"); + + await onHotReload?.( + { + changedPaths: ["plugins.entries.discord.enabled"], + restartGateway: false, + restartReasons: [], + hotReasons: ["plugins.entries.discord.enabled"], + reloadHooks: false, + restartGmailWatcher: false, + restartCron: false, + restartHeartbeat: false, + restartHealthMonitor: false, + reloadPlugins: true, + restartChannels: new Set(), + disposeMcpRuntimes: true, + noopPaths: [], + }, + { + plugins: { + entries: { + discord: { enabled: false }, + }, + }, + }, + ); + + expect(hoisted.disposeAllSessionMcpRuntimes).toHaveBeenCalledTimes(1); + }); + }); + it("serves secrets.reload immediately after startup without race failures", async () => { await writeEnvRefConfig(); process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 036a9360e77..6902f71b172 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -273,7 +273,7 @@ describe("createGatewayPluginRequestHandler", () => { expect(routeHandler).toHaveBeenCalledTimes(1); }); - it("falls back to the provided registry when the pinned route registry is empty", async () => { + it("does not fall back to stale routes when the pinned route registry is empty", async () => { const explicitRouteHandler = vi.fn(async (_req, res: ServerResponse) => { res.statusCode = 200; return true; @@ -293,8 +293,8 @@ describe("createGatewayPluginRequestHandler", () => { const { res } = makeMockHttpResponse(); const handled = await handler({ url: "/demo" } as IncomingMessage, res); - expect(handled).toBe(true); - expect(explicitRouteHandler).toHaveBeenCalledTimes(1); + expect(handled).toBe(false); + expect(explicitRouteHandler).not.toHaveBeenCalled(); }); it("handles routes registered into the pinned startup registry after the active registry changes", async () => { diff --git a/src/plugins/loader-cache-state.test.ts b/src/plugins/loader-cache-state.test.ts index 99cd30d16da..362bfad281d 100644 --- a/src/plugins/loader-cache-state.test.ts +++ b/src/plugins/loader-cache-state.test.ts @@ -41,4 +41,18 @@ describe("PluginLoaderCacheState", () => { expect(cache.isLoadInFlight("demo")).toBe(false); expect(cache.hasOpenAllowlistWarning("demo-warning")).toBe(false); }); + + it("clears cached registries without dropping in-flight load guards", () => { + const cache = new PluginLoaderCacheState(2); + + cache.set("demo", "registry"); + cache.beginLoad("demo"); + cache.recordOpenAllowlistWarning("demo-warning"); + + cache.clearCachedRegistries(); + + expect(cache.get("demo")).toBeUndefined(); + expect(cache.isLoadInFlight("demo")).toBe(true); + expect(cache.hasOpenAllowlistWarning("demo-warning")).toBe(false); + }); }); diff --git a/src/plugins/loader-cache-state.ts b/src/plugins/loader-cache-state.ts index 840c0b2f549..16b48eb07a3 100644 --- a/src/plugins/loader-cache-state.ts +++ b/src/plugins/loader-cache-state.ts @@ -33,6 +33,11 @@ export class PluginLoaderCacheState { this.#openAllowlistWarningCache.clear(); } + clearCachedRegistries(): void { + this.#registryCache.clear(); + this.#openAllowlistWarningCache.clear(); + } + get(cacheKey: string): T | undefined { return this.#registryCache.get(cacheKey); } diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 846aaf9a1b7..eeff5987471 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, it } from "vitest"; +import { getCompactionProvider, registerCompactionProvider } from "./compaction-provider.js"; import { __testing, clearPluginLoaderCache, + clearPluginRegistryLoadCache, loadOpenClawPlugins, resolveRuntimePluginRegistry, } from "./loader.js"; @@ -624,3 +626,53 @@ describe("clearPluginLoaderCache", () => { expect(getMemoryEmbeddingProvider("stale")).toBeUndefined(); }); }); + +describe("loadOpenClawPlugins active runtime clearing", () => { + it("clears plugin-owned global providers before activating a new registry", () => { + registerCompactionProvider({ + id: "stale-compaction", + label: "Stale Compaction", + summarize: async () => "stale", + }); + registerMemoryEmbeddingProvider({ + id: "stale-memory", + create: async () => ({ provider: null }), + }); + + loadOpenClawPlugins({ onlyPluginIds: [] }); + + expect(getCompactionProvider("stale-compaction")).toBeUndefined(); + expect(getMemoryEmbeddingProvider("stale-memory")).toBeUndefined(); + }); +}); + +describe("clearPluginRegistryLoadCache", () => { + it("preserves plugin-owned runtime registries while invalidating load snapshots", () => { + registerMemoryEmbeddingProvider({ + id: "still-live", + create: async () => ({ provider: null }), + }); + registerMemoryPromptSection(() => ["still live"]); + + clearPluginRegistryLoadCache(); + + expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual(["still live"]); + expect(getMemoryEmbeddingProvider("still-live")).toBeDefined(); + }); + + it("invalidates full-workspace load snapshots", () => { + const loadOptions = { + config: { + plugins: { + allow: ["demo"], + }, + }, + workspaceDir: "/tmp/workspace-a", + }; + const registry = loadOpenClawPlugins(loadOptions); + + clearPluginRegistryLoadCache(); + + expect(loadOpenClawPlugins(loadOptions)).not.toBe(registry); + }); +}); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 27ca6a881a7..ab127678da1 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -285,6 +285,10 @@ function createPluginCandidatesFromManifestRegistry( export function clearPluginLoaderCache(): void { pluginLoaderCacheState.clear(); fullWorkspacePluginLoaderCacheState.clear(); + clearActivatedPluginRuntimeState(); +} + +export function clearActivatedPluginRuntimeState(): void { clearAgentHarnesses(); clearPluginCommands(); clearCompactionProviders(); @@ -294,6 +298,11 @@ export function clearPluginLoaderCache(): void { clearMemoryPluginState(); } +export function clearPluginRegistryLoadCache(): void { + pluginLoaderCacheState.clearCachedRegistries(); + fullWorkspacePluginLoaderCacheState.clearCachedRegistries(); +} + const defaultLogger = () => createSubsystemLogger("plugins"); function isPromiseLike(value: unknown): value is PromiseLike { @@ -1338,11 +1347,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (requestedOnlyPluginIdSet && requestedOnlyPluginIdSet.size === 0) { const emptyRegistry = createEmptyPluginRegistry(); if (options.activate !== false) { - clearAgentHarnesses(); - clearPluginCommands(); - clearPluginInteractiveHandlers(); - clearDetachedTaskLifecycleRuntimeRegistration(); - clearMemoryPluginState(); + clearActivatedPluginRuntimeState(); activatePluginRegistry( emptyRegistry, `empty-plugin-scope::${resolveRuntimeSubagentMode(options.runtimeOptions)}::${options.workspaceDir ?? ""}`, @@ -1415,11 +1420,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi // Clear previously registered plugin state before reloading. // Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins. if (shouldActivate) { - clearAgentHarnesses(); - clearPluginCommands(); - clearPluginInteractiveHandlers(); - clearDetachedTaskLifecycleRuntimeRegistration(); - clearMemoryPluginState(); + clearActivatedPluginRuntimeState(); } // Lazy: avoid creating module loaders when all plugins are disabled (common in unit tests). diff --git a/src/plugins/runtime.test.ts b/src/plugins/runtime.test.ts index 9b796357f69..a2541fb60c5 100644 --- a/src/plugins/runtime.test.ts +++ b/src/plugins/runtime.test.ts @@ -117,10 +117,10 @@ describe("plugin runtime route registry", () => { it.each([ { - name: "falls back to the provided registry when the pinned route registry has no routes", + name: "keeps an explicitly pinned empty route registry authoritative", pinnedRegistry: createEmptyPluginRegistry(), explicitRegistry: createRegistryWithRoute("/demo"), - expected: "explicit", + expected: "pinned", }, { name: "prefers the pinned route registry when it already owns routes", diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 0b2e6201b82..d831a21eaed 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -204,6 +204,9 @@ export function resolveActivePluginHttpRouteRegistry(fallback: PluginRegistry): if (!routeRegistry) { return fallback; } + if (state.httpRoute.pinned) { + return routeRegistry; + } const routeCount = routeRegistry.httpRoutes?.length ?? 0; const fallbackRouteCount = fallback.httpRoutes?.length ?? 0; if (routeCount === 0 && fallbackRouteCount > 0) {