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 860594f722.
- Required merge gates passed before the squash merge.

Prepared head SHA: 860594f722
Review: https://github.com/openclaw/openclaw/pull/75976#issuecomment-4363168379

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-05-02 14:19:24 +01:00
committed by GitHub
parent c9fa7b61f1
commit d678bcfcc7
31 changed files with 958 additions and 50 deletions

View File

@@ -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}` : ""),
},
};

View File

@@ -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<unknown[], unknown>(clearPluginRegistryLoadCache, ...args)) as (
...args: unknown[]
) => unknown,
}));
vi.mock("../plugins/slots.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/slots.js")>();
return {
@@ -599,6 +607,7 @@ export function resetPluginsCliTestState() {
buildPluginCompatibilityNotices.mockReset();
inspectPluginRegistry.mockReset();
refreshPluginRegistry.mockReset();
clearPluginRegistryLoadCache.mockReset();
applyExclusiveSlotSelection.mockReset();
planPluginUninstall.mockReset();
applyPluginUninstallDirectoryRemoval.mockReset();

View File

@@ -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 () => {

View File

@@ -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" },
);

View File

@@ -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<string, PluginInstallRecord> = {
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<string, PluginInstallRecord> = {
existing: {

View File

@@ -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<void>;
const PLUGIN_SOURCE_CHANGED_RESTART_REASON = "plugin source changed";
async function commitPluginInstallRecordsWithWriter(params: {
previousInstallRecords?: Record<string, PluginInstallRecord>;
@@ -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),
]),

View File

@@ -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<void> {
try {
const { clearPluginRegistryLoadCache } = await import("../plugins/loader.js");
clearPluginRegistryLoadCache();
} catch (error) {
params.logger?.warn?.(`Plugin runtime cache invalidation failed: ${formatErrorMessage(error)}`);
}
}

View File

@@ -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" },
);

View File

@@ -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({

View File

@@ -18,6 +18,7 @@ export type GatewayReloadPlan = {
restartCron: boolean;
restartHeartbeat: boolean;
restartHealthMonitor: boolean;
reloadPlugins: boolean;
restartChannels: Set<ChannelKind>;
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;

View File

@@ -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<boolean>;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
initialPluginInstallRecords?: Record<string, PluginInstallRecord>;
readPluginInstallRecords?: () => Promise<Record<string, PluginInstallRecord>>;
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<ConfigFileSnapshot>>().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<string, PluginInstallRecord>);
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<string, PluginInstallRecord>;
const readSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>().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<ConfigFileSnapshot>>().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<string, PluginInstallRecord>);
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();
});

View File

@@ -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<void>;
};
type PluginInstallRecords = Record<string, PluginInstallRecord>;
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<void>;
recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
initialPluginInstallRecords?: PluginInstallRecords;
readPluginInstallRecords?: () => Promise<PluginInstallRecords>;
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;

View File

@@ -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);
});
});

View File

@@ -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<string>, value: string | null | undefined): void {
const normalized = normalizeOptionalString(value);
if (normalized) {
targets.add(normalized);
}
}
export function listChannelPluginConfigTargetIds(
target: ChannelPluginReloadTarget,
): ReadonlySet<string> {
const targets = new Set<string>();
addNormalizedTarget(targets, target.channelId);
addNormalizedTarget(targets, target.pluginId);
for (const alias of target.aliases ?? []) {
addNormalizedTarget(targets, alias);
}
return targets;
}
export function pluginConfigTargetsChanged(
targetIds: Iterable<string>,
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}.`)),
);
}

View File

@@ -24,6 +24,7 @@ function createReloadPlan(overrides?: Partial<GatewayReloadPlan>): 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 ?? [],

View File

@@ -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"]);

View File

@@ -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 {

View File

@@ -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<ChannelKind>) => Promise<void>;
}): Promise<GatewayPluginReloadResult> => {
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);
});
});

View File

@@ -58,6 +58,11 @@ type GatewayReloadLog = {
warn: (msg: string) => void;
};
export type GatewayPluginReloadResult = {
restartChannels: ReadonlySet<ChannelKind>;
activeChannels: ReadonlySet<ChannelKind>;
};
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<void>;
stopChannel: (name: ChannelKind) => Promise<void>;
reloadPlugins: (params: {
nextConfig: OpenClawConfig;
changedPaths: readonly string[];
beforeReplace: (channels: ReadonlySet<ChannelKind>) => Promise<void>;
}) => Promise<GatewayPluginReloadResult>;
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<ChannelKind>();
let activePluginChannelsAfterReload: ReadonlySet<ChannelKind> | null = null;
const shouldSkipChannelRestart = () =>
isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS);
if (plan.reloadPlugins) {
const stopChannelsBeforePluginReplace = async (channels: ReadonlySet<ChannelKind>) => {
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,

View File

@@ -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<ChannelId>) => Promise<void>;
}): Promise<GatewayPluginReloadResult> => {
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<ChannelId>();
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<ChannelId>();
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<ChannelId>();
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,

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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<string>(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);
});
});

View File

@@ -33,6 +33,11 @@ export class PluginLoaderCacheState<T> {
this.#openAllowlistWarningCache.clear();
}
clearCachedRegistries(): void {
this.#registryCache.clear();
this.#openAllowlistWarningCache.clear();
}
get(cacheKey: string): T | undefined {
return this.#registryCache.get(cacheKey);
}

View File

@@ -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);
});
});

View File

@@ -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<unknown> {
@@ -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).

View File

@@ -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",

View File

@@ -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) {