mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:00:50 +00:00
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 head860594f722. - Required merge gates passed before the squash merge. Prepared head SHA:860594f722Review: 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:
committed by
GitHub
parent
c9fa7b61f1
commit
d678bcfcc7
@@ -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}` : ""),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
23
src/gateway/plugin-channel-reload-targets.test.ts
Normal file
23
src/gateway/plugin-channel-reload-targets.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
src/gateway/plugin-channel-reload-targets.ts
Normal file
40
src/gateway/plugin-channel-reload-targets.ts
Normal 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}.`)),
|
||||
);
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user