Skip to content

Commit d678bcf

Browse files
fix: hot reload plugin management changes (#75976)
Summary: - The PR changes Gateway reload planning, CLI plugin install-index writes, plugin runtime/cache cleanup, docs, changelog, and tests so plugin enable/disable hot reloads while install/update/uninstall stay restart-backed. - Reproducibility: yes. The earlier blocker has a source-level reproduction: run an external plugin install/up ... watches config and only the managed plugin index changes; the PR now tests that path and queues a restart. ClawSweeper fixups: - Included follow-up commit: fix: hot reload plugin management changes - Included follow-up commit: fix(clawsweeper): address review for automerge-openclaw-openclaw-7597… - Ran the ClawSweeper repair loop before final review. Validation: - ClawSweeper review passed for head 860594f. - Required merge gates passed before the squash merge. Prepared head SHA: 860594f Review: #75976 (comment) Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent c9fa7b6 commit d678bcf

31 files changed

Lines changed: 958 additions & 50 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ Docs: https://docs.openclaw.ai
174174
- 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.
175175
- 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.
176176
- 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.
177+
- 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.
177178
- 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.
178179
- 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.
179180
- Gateway/diagnostics: keep idle liveness samples in telemetry instead of visible warning logs unless diagnostic work is active, waiting, or queued. Thanks @vincentkoc.

docs/tools/plugin.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ temporary set of OpenClaw-owned plugin packages while that migration finishes.
5555

5656
</Step>
5757

58+
<Step title="Chat-native management">
59+
In a running Gateway, owner-only `/plugins enable` and `/plugins disable`
60+
trigger the Gateway config reloader. The Gateway reloads plugin runtime
61+
surfaces in process, and new agent turns rebuild their tool list from the
62+
refreshed registry. `/plugins install` changes plugin source code, so the
63+
Gateway requests a restart instead of pretending the current process can
64+
safely reload already-imported modules.
65+
66+
</Step>
67+
5868
<Step title="Verify the plugin">
5969
```bash
6070
openclaw plugins inspect <plugin-id> --runtime --json
@@ -251,20 +261,19 @@ tool name. If a tool allowlist references plugin tools, add the owning plugin id
251261
to `plugins.allow` or remove `plugins.allow`; `openclaw doctor` warns about this
252262
shape.
253263

254-
Config changes **require a gateway restart**. If the Gateway is running with config
255-
watch + in-process restart enabled (the default `openclaw gateway` path), that
256-
restart is usually performed automatically a moment after the config write lands.
257-
There is no supported hot-reload path for native plugin runtime code or lifecycle
258-
hooks; restart the Gateway process that is serving the live channel before
259-
expecting updated `register(api)` code, `api.on(...)` hooks, tools, services, or
260-
provider/runtime hooks to run.
264+
Config changes made through `/plugins enable` or `/plugins disable` trigger an
265+
in-process Gateway plugin reload. New agent turns rebuild their tool list from
266+
the refreshed plugin registry. Source-changing operations such as install,
267+
update, and uninstall still restart the Gateway process because already-imported
268+
plugin modules cannot be safely replaced in place.
261269

262270
`openclaw plugins list` is a local plugin registry/config snapshot. An
263271
`enabled` plugin there means the persisted registry and current config allow the
264272
plugin to participate. It does not prove that an already-running remote Gateway
265-
child has restarted into the same plugin code. On VPS/container setups with
266-
wrapper processes, send restarts to the actual `openclaw gateway run` process,
267-
or use `openclaw gateway restart` against the running Gateway.
273+
has reloaded or restarted into the same plugin code. On VPS/container setups
274+
with wrapper processes, send restarts or reload-triggering writes to the actual
275+
`openclaw gateway run` process, or use `openclaw gateway restart` against the
276+
running Gateway when the reload reports a failure.
268277

269278
<Accordion title="Plugin states: disabled vs missing vs invalid">
270279
- **Disabled**: plugin exists but enablement rules turned it off. Config is preserved.

docs/tools/slash-commands.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,8 @@ User-invocable skills are also exposed as slash commands:
250250
- In multi-account channels, config-targeted `/allowlist --account <id>` and `/config set channels.<provider>.accounts.<id>...` also honor the target account's `configWrites`.
251251
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
252252
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
253-
- `/plugins install <spec>` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, `git:<repo>`, or `clawhub:<pkg>`.
254-
- `/plugins enable|disable` updates plugin config and may prompt for a restart.
253+
- `/plugins install <spec>` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, `git:<repo>`, or `clawhub:<pkg>`, then requests a Gateway restart because plugin source modules changed.
254+
- `/plugins enable|disable` updates plugin config and triggers Gateway plugin reload for new agent turns.
255255

256256
</Accordion>
257257
<Accordion title="Channel-specific behavior">
@@ -429,8 +429,9 @@ Examples:
429429

430430
<Note>
431431
- `/plugins list` and `/plugins show` use real plugin discovery against the current workspace plus on-disk config.
432+
- `/plugins install` installs from ClawHub, npm, git, local directories, and archives.
432433
- `/plugins enable|disable` updates plugin config only; it does not install or uninstall plugins.
433-
- After enable/disable changes, restart the gateway to apply them.
434+
- Enable and disable changes hot-reload Gateway plugin runtime surfaces for new agent turns; install requests a Gateway restart because plugin source modules changed.
434435

435436
</Note>
436437

src/auto-reply/reply/commands-plugins.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
424424
return {
425425
shouldContinue: false,
426426
reply: {
427-
text: `🔌 Installed plugin "${installed.pluginId}". Restart the gateway to load plugins.`,
427+
text: `🔌 Installed plugin "${installed.pluginId}". Gateway restart will load the new plugin source.`,
428428
},
429429
};
430430
}
@@ -531,7 +531,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
531531
shouldContinue: false,
532532
reply: {
533533
text:
534-
`🔌 Plugin "${plugin.id}" ${pluginsCommand.action}d in ${loaded.path}. Restart the gateway to apply.` +
534+
`🔌 Plugin "${plugin.id}" ${pluginsCommand.action}d in ${loaded.path}. Gateway reload will apply it to new agent turns.` +
535535
(registryWarning ? `\n${registryWarning}` : ""),
536536
},
537537
};

src/cli/plugins-cli-test-helpers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const buildPluginDiagnosticsReport: UnknownMock = vi.fn();
6666
const buildPluginCompatibilityNotices: UnknownMock = vi.fn();
6767
export const inspectPluginRegistry: AsyncUnknownMock = vi.fn();
6868
export const refreshPluginRegistry: AsyncUnknownMock = vi.fn();
69+
export const clearPluginRegistryLoadCache: UnknownMock = vi.fn();
6970
export const applyExclusiveSlotSelection: UnknownMock = vi.fn();
7071
export const planPluginUninstall: UnknownMock = vi.fn();
7172
export const applyPluginUninstallDirectoryRemoval: AsyncUnknownMock = vi.fn();
@@ -353,6 +354,13 @@ vi.mock("../plugins/plugin-registry.js", () => ({
353354
)) as (typeof import("../plugins/plugin-registry.js"))["refreshPluginRegistry"],
354355
}));
355356

357+
vi.mock("../plugins/loader.js", () => ({
358+
clearPluginRegistryLoadCache: ((...args: unknown[]) =>
359+
invokeMock<unknown[], unknown>(clearPluginRegistryLoadCache, ...args)) as (
360+
...args: unknown[]
361+
) => unknown,
362+
}));
363+
356364
vi.mock("../plugins/slots.js", async (importOriginal) => {
357365
const actual = await importOriginal<typeof import("../plugins/slots.js")>();
358366
return {
@@ -599,6 +607,7 @@ export function resetPluginsCliTestState() {
599607
buildPluginCompatibilityNotices.mockReset();
600608
inspectPluginRegistry.mockReset();
601609
refreshPluginRegistry.mockReset();
610+
clearPluginRegistryLoadCache.mockReset();
602611
applyExclusiveSlotSelection.mockReset();
603612
planPluginUninstall.mockReset();
604613
applyPluginUninstallDirectoryRemoval.mockReset();

src/cli/plugins-install-persist.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import type { OpenClawConfig } from "../config/config.js";
33
import {
44
applyExclusiveSlotSelection,
55
buildPluginDiagnosticsReport,
6+
clearPluginRegistryLoadCache,
67
enablePluginInConfig,
78
loadPluginManifestRegistry,
9+
replaceConfigFile,
810
refreshPluginRegistry,
911
resetPluginsCliTestState,
12+
runtimeLogs,
1013
writeConfigFile,
1114
writePersistedInstalledPluginIndexInstallRecords,
1215
} from "./plugins-cli-test-helpers.js";
@@ -60,6 +63,14 @@ describe("persistPluginInstall", () => {
6063
}),
6164
});
6265
expect(writeConfigFile).toHaveBeenCalledWith(enabledConfig);
66+
expect(replaceConfigFile).toHaveBeenCalledWith({
67+
nextConfig: enabledConfig,
68+
baseHash: "config-1",
69+
writeOptions: {
70+
afterWrite: { mode: "restart", reason: "plugin source changed" },
71+
unsetPaths: [["plugins", "installs"]],
72+
},
73+
});
6374
expect(refreshPluginRegistry).toHaveBeenCalledWith({
6475
config: enabledConfig,
6576
installRecords: {
@@ -71,6 +82,82 @@ describe("persistPluginInstall", () => {
7182
},
7283
reason: "source-changed",
7384
});
85+
expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1);
86+
});
87+
88+
it("persists installs even when runtime cache invalidation fails", async () => {
89+
const { persistPluginInstall } = await import("./plugins-install-persist.js");
90+
const baseConfig = {
91+
plugins: {
92+
entries: {},
93+
},
94+
} as OpenClawConfig;
95+
const enabledConfig = {
96+
plugins: {
97+
entries: {
98+
alpha: { enabled: true },
99+
},
100+
},
101+
} as OpenClawConfig;
102+
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
103+
clearPluginRegistryLoadCache.mockImplementation(() => {
104+
throw new Error("cache unavailable");
105+
});
106+
107+
const next = await persistPluginInstall({
108+
snapshot: {
109+
config: baseConfig,
110+
baseHash: "config-1",
111+
},
112+
pluginId: "alpha",
113+
install: {
114+
source: "npm",
115+
spec: "alpha@1.0.0",
116+
installPath: "/tmp/alpha",
117+
},
118+
});
119+
120+
expect(next).toEqual(enabledConfig);
121+
expect(refreshPluginRegistry).toHaveBeenCalled();
122+
expect(
123+
runtimeLogs.some((line) => line.includes("Plugin runtime cache invalidation failed")),
124+
).toBe(true);
125+
});
126+
127+
it("invalidates runtime cache even when registry refresh fails", async () => {
128+
const { persistPluginInstall } = await import("./plugins-install-persist.js");
129+
const baseConfig = {
130+
plugins: {
131+
entries: {},
132+
},
133+
} as OpenClawConfig;
134+
const enabledConfig = {
135+
plugins: {
136+
entries: {
137+
alpha: { enabled: true },
138+
},
139+
},
140+
} as OpenClawConfig;
141+
enablePluginInConfig.mockReturnValue({ config: enabledConfig });
142+
refreshPluginRegistry.mockRejectedValueOnce(new Error("registry unavailable"));
143+
144+
const next = await persistPluginInstall({
145+
snapshot: {
146+
config: baseConfig,
147+
baseHash: "config-1",
148+
},
149+
pluginId: "alpha",
150+
install: {
151+
source: "npm",
152+
spec: "alpha@1.0.0",
153+
installPath: "/tmp/alpha",
154+
},
155+
});
156+
157+
expect(next).toEqual(enabledConfig);
158+
expect(refreshPluginRegistry).toHaveBeenCalled();
159+
expect(clearPluginRegistryLoadCache).toHaveBeenCalledTimes(1);
160+
expect(runtimeLogs.some((line) => line.includes("Plugin registry refresh failed"))).toBe(true);
74161
});
75162

76163
it("removes stale denylist entries before enabling installed plugins", async () => {

src/cli/plugins-install-persist.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ export async function persistPluginInstall(params: {
107107
nextInstallRecords,
108108
nextConfig: next,
109109
baseHash: params.snapshot.baseHash,
110+
writeOptions: {
111+
afterWrite: { mode: "restart", reason: "plugin source changed" },
112+
},
110113
}),
111114
{ command: "install" },
112115
);

src/cli/plugins-install-record-commit.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ describe("commitConfigWithPendingPluginInstalls", () => {
7878
},
7979
baseHash: "config-1",
8080
writeOptions: {
81+
afterWrite: { mode: "restart", reason: "plugin source changed" },
8182
unsetPaths: [["plugins", "installs"]],
8283
},
8384
});
@@ -97,6 +98,33 @@ describe("commitConfigWithPendingPluginInstalls", () => {
9798
});
9899
});
99100

101+
it("does not add restart intent when pending records match the plugin index", async () => {
102+
const existingRecords: Record<string, PluginInstallRecord> = {
103+
demo: {
104+
source: "npm",
105+
spec: "demo@1.0.0",
106+
},
107+
};
108+
mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue(existingRecords);
109+
110+
await commitConfigWithPendingPluginInstalls({
111+
nextConfig: {
112+
plugins: {
113+
installs: existingRecords,
114+
},
115+
},
116+
baseHash: "config-1",
117+
});
118+
119+
expect(mocks.replaceConfigFile).toHaveBeenCalledWith({
120+
nextConfig: {},
121+
baseHash: "config-1",
122+
writeOptions: {
123+
unsetPaths: [["plugins", "installs"]],
124+
},
125+
});
126+
});
127+
100128
it("rolls back plugin index writes when the config write fails", async () => {
101129
const existingRecords: Record<string, PluginInstallRecord> = {
102130
existing: {

src/cli/plugins-install-record-commit.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isDeepStrictEqual } from "node:util";
12
import { replaceConfigFile } from "../config/config.js";
23
import type { ConfigWriteOptions } from "../config/io.js";
34
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -18,6 +19,7 @@ function mergeUnsetPaths(
1819
}
1920

2021
type ConfigCommit = (config: OpenClawConfig, writeOptions?: ConfigWriteOptions) => Promise<void>;
22+
const PLUGIN_SOURCE_CHANGED_RESTART_REASON = "plugin source changed";
2123

2224
async function commitPluginInstallRecordsWithWriter(params: {
2325
previousInstallRecords?: Record<string, PluginInstallRecord>;
@@ -30,8 +32,15 @@ async function commitPluginInstallRecordsWithWriter(params: {
3032
params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
3133
await writePersistedInstalledPluginIndexInstallRecords(params.nextInstallRecords);
3234
try {
35+
const installRecordsChanged = !isDeepStrictEqual(
36+
previousInstallRecords,
37+
params.nextInstallRecords,
38+
);
3339
await params.commit(params.nextConfig, {
3440
...params.writeOptions,
41+
...(installRecordsChanged && params.writeOptions?.afterWrite === undefined
42+
? { afterWrite: { mode: "restart", reason: PLUGIN_SOURCE_CHANGED_RESTART_REASON } }
43+
: {}),
3544
unsetPaths: mergeUnsetPaths(params.writeOptions?.unsetPaths, [
3645
Array.from(PLUGIN_INSTALLS_CONFIG_PATH),
3746
]),

src/cli/plugins-registry-refresh.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,16 @@ export async function refreshPluginRegistryAfterConfigMutation(params: {
4343
} catch (error) {
4444
params.logger?.warn?.(`Plugin registry refresh failed: ${formatErrorMessage(error)}`);
4545
}
46+
await invalidatePluginRuntimeDiscoveryAfterConfigMutation(params);
47+
}
48+
49+
async function invalidatePluginRuntimeDiscoveryAfterConfigMutation(params: {
50+
logger?: PluginRegistryRefreshLogger;
51+
}): Promise<void> {
52+
try {
53+
const { clearPluginRegistryLoadCache } = await import("../plugins/loader.js");
54+
clearPluginRegistryLoadCache();
55+
} catch (error) {
56+
params.logger?.warn?.(`Plugin runtime cache invalidation failed: ${formatErrorMessage(error)}`);
57+
}
4658
}

0 commit comments

Comments
 (0)