Skip to content

Commit 860594f

Browse files
fix(clawsweeper): address review for automerge-openclaw-openclaw-75976 (1)
1 parent bcaf99f commit 860594f

2 files changed

Lines changed: 220 additions & 3 deletions

File tree

src/gateway/config-reload.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
ConfigWriteNotification,
1212
OpenClawConfig,
1313
} from "../config/config.js";
14+
import type { PluginInstallRecord } from "../config/types.plugins.js";
1415
import {
1516
pinActivePluginChannelRegistry,
1617
resetPluginRuntimeStateForTest,
@@ -579,6 +580,8 @@ function createReloaderHarness(
579580
initialInternalWriteHash?: string | null;
580581
recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
581582
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
583+
initialPluginInstallRecords?: Record<string, PluginInstallRecord>;
584+
readPluginInstallRecords?: () => Promise<Record<string, PluginInstallRecord>>;
582585
onRecovered?: (params: {
583586
reason: string;
584587
snapshot: ConfigFileSnapshot;
@@ -611,6 +614,8 @@ function createReloaderHarness(
611614
readSnapshot,
612615
recoverSnapshot: options.recoverSnapshot,
613616
promoteSnapshot: options.promoteSnapshot,
617+
initialPluginInstallRecords: options.initialPluginInstallRecords ?? {},
618+
readPluginInstallRecords: options.readPluginInstallRecords ?? (async () => ({})),
614619
onRecovered: options.onRecovered,
615620
subscribeToWrites,
616621
onHotReload,
@@ -1227,6 +1232,167 @@ describe("startGatewayConfigReloader", () => {
12271232
await harness.reloader.stop();
12281233
});
12291234

1235+
it("queues restart when an external plugin source write only changes the managed index", async () => {
1236+
const activeConfig: OpenClawConfig = {
1237+
gateway: { reload: { debounceMs: 0 } },
1238+
plugins: {
1239+
allow: ["lossless-claw"],
1240+
entries: {
1241+
"lossless-claw": { enabled: true },
1242+
},
1243+
},
1244+
};
1245+
const readSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>().mockResolvedValueOnce(
1246+
makeSnapshot({
1247+
sourceConfig: activeConfig,
1248+
runtimeConfig: activeConfig,
1249+
config: activeConfig,
1250+
hash: "external-plugin-index-1",
1251+
}),
1252+
);
1253+
const readPluginInstallRecords = vi.fn().mockResolvedValueOnce({
1254+
"lossless-claw": {
1255+
source: "npm",
1256+
spec: "@martian-engineering/lossless-claw",
1257+
installPath: "/tmp/openclaw/plugins/lossless-claw",
1258+
installedAt: "2026-04-22T00:00:00.000Z",
1259+
},
1260+
} satisfies Record<string, PluginInstallRecord>);
1261+
const harness = createReloaderHarness(readSnapshot, {
1262+
initialCompareConfig: activeConfig,
1263+
initialPluginInstallRecords: {},
1264+
readPluginInstallRecords,
1265+
});
1266+
1267+
harness.watcher.emit("change");
1268+
await vi.runOnlyPendingTimersAsync();
1269+
1270+
expect(harness.onHotReload).not.toHaveBeenCalled();
1271+
expect(harness.onRestart).toHaveBeenCalledTimes(1);
1272+
expect(harness.onRestart).toHaveBeenCalledWith(
1273+
expect.objectContaining({
1274+
changedPaths: ["plugins.installs.lossless-claw"],
1275+
restartGateway: true,
1276+
restartReasons: ["plugins.installs.lossless-claw"],
1277+
}),
1278+
activeConfig,
1279+
);
1280+
1281+
await harness.reloader.stop();
1282+
});
1283+
1284+
it("keeps external plugin policy-only writes on the hot reload path", async () => {
1285+
const previousConfig: OpenClawConfig = {
1286+
gateway: { reload: { debounceMs: 0 } },
1287+
plugins: {
1288+
entries: {
1289+
telegram: { enabled: false },
1290+
},
1291+
},
1292+
};
1293+
const nextConfig: OpenClawConfig = {
1294+
gateway: { reload: { debounceMs: 0 } },
1295+
plugins: {
1296+
entries: {
1297+
telegram: { enabled: true },
1298+
},
1299+
},
1300+
};
1301+
const installRecords = {
1302+
telegram: {
1303+
source: "npm",
1304+
spec: "@openclaw/telegram",
1305+
installPath: "/tmp/openclaw/plugins/telegram",
1306+
},
1307+
} satisfies Record<string, PluginInstallRecord>;
1308+
const readSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>().mockResolvedValueOnce(
1309+
makeSnapshot({
1310+
sourceConfig: nextConfig,
1311+
runtimeConfig: nextConfig,
1312+
config: nextConfig,
1313+
hash: "external-plugin-policy-1",
1314+
}),
1315+
);
1316+
const readPluginInstallRecords = vi.fn().mockResolvedValueOnce(installRecords);
1317+
const harness = createReloaderHarness(readSnapshot, {
1318+
initialCompareConfig: previousConfig,
1319+
initialPluginInstallRecords: installRecords,
1320+
readPluginInstallRecords,
1321+
});
1322+
1323+
harness.watcher.emit("change");
1324+
await vi.runOnlyPendingTimersAsync();
1325+
1326+
expect(harness.onRestart).not.toHaveBeenCalled();
1327+
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
1328+
expect(harness.onHotReload).toHaveBeenCalledWith(
1329+
expect.objectContaining({
1330+
changedPaths: ["plugins.entries.telegram.enabled"],
1331+
restartGateway: false,
1332+
reloadPlugins: true,
1333+
hotReasons: ["plugins.entries.telegram.enabled"],
1334+
}),
1335+
nextConfig,
1336+
);
1337+
1338+
await harness.reloader.stop();
1339+
});
1340+
1341+
it("queues restart when an external plugin source write also changes plugin config", async () => {
1342+
const previousConfig: OpenClawConfig = {
1343+
gateway: { reload: { debounceMs: 0 } },
1344+
plugins: {
1345+
allow: ["lossless-claw"],
1346+
},
1347+
};
1348+
const nextConfig: OpenClawConfig = {
1349+
gateway: { reload: { debounceMs: 0 } },
1350+
plugins: {
1351+
allow: ["lossless-claw"],
1352+
entries: {
1353+
"lossless-claw": { enabled: true },
1354+
},
1355+
},
1356+
};
1357+
const readSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>().mockResolvedValueOnce(
1358+
makeSnapshot({
1359+
sourceConfig: nextConfig,
1360+
runtimeConfig: nextConfig,
1361+
config: nextConfig,
1362+
hash: "external-plugin-source-and-config-1",
1363+
}),
1364+
);
1365+
const readPluginInstallRecords = vi.fn().mockResolvedValueOnce({
1366+
"lossless-claw": {
1367+
source: "npm",
1368+
spec: "@martian-engineering/lossless-claw",
1369+
installPath: "/tmp/openclaw/plugins/lossless-claw",
1370+
installedAt: "2026-04-22T00:00:00.000Z",
1371+
},
1372+
} satisfies Record<string, PluginInstallRecord>);
1373+
const harness = createReloaderHarness(readSnapshot, {
1374+
initialCompareConfig: previousConfig,
1375+
initialPluginInstallRecords: {},
1376+
readPluginInstallRecords,
1377+
});
1378+
1379+
harness.watcher.emit("change");
1380+
await vi.runOnlyPendingTimersAsync();
1381+
1382+
expect(harness.onHotReload).not.toHaveBeenCalled();
1383+
expect(harness.onRestart).toHaveBeenCalledTimes(1);
1384+
expect(harness.onRestart).toHaveBeenCalledWith(
1385+
expect.objectContaining({
1386+
changedPaths: ["plugins.entries", "plugins.installs.lossless-claw"],
1387+
restartGateway: true,
1388+
restartReasons: ["plugins.installs.lossless-claw"],
1389+
}),
1390+
nextConfig,
1391+
);
1392+
1393+
await harness.reloader.stop();
1394+
});
1395+
12301396
it("skips in-process promotion when the persisted file hash no longer matches the write", async () => {
12311397
const readSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>().mockResolvedValueOnce(
12321398
makeSnapshot({

src/gateway/config-reload.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import {
1111
import { resolveConfigWriteFollowUp } from "../config/runtime-snapshot.js";
1212
import type { GatewayReloadMode } from "../config/types.gateway.js";
1313
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
14+
import type { PluginInstallRecord } from "../config/types.plugins.js";
1415
import { validateConfigObjectWithPlugins } from "../config/validation.js";
16+
import {
17+
loadInstalledPluginIndexInstallRecords,
18+
loadInstalledPluginIndexInstallRecordsSync,
19+
} from "../plugins/installed-plugin-index-records.js";
1520
import { isPlainObject } from "../utils.js";
1621
import {
1722
buildGatewayReloadPlan,
@@ -159,6 +164,16 @@ type GatewayConfigReloader = {
159164
stop: () => Promise<void>;
160165
};
161166

167+
type PluginInstallRecords = Record<string, PluginInstallRecord>;
168+
169+
function asPluginInstallConfig(records: PluginInstallRecords): OpenClawConfig {
170+
return {
171+
plugins: {
172+
installs: records,
173+
},
174+
};
175+
}
176+
162177
export function startGatewayConfigReloader(opts: {
163178
initialConfig: OpenClawConfig;
164179
initialCompareConfig?: OpenClawConfig;
@@ -168,6 +183,8 @@ export function startGatewayConfigReloader(opts: {
168183
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
169184
recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
170185
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
186+
initialPluginInstallRecords?: PluginInstallRecords;
187+
readPluginInstallRecords?: () => Promise<PluginInstallRecords>;
171188
onRecovered?: (params: {
172189
reason: string;
173190
snapshot: ConfigFileSnapshot;
@@ -197,6 +214,10 @@ export function startGatewayConfigReloader(opts: {
197214
afterWrite?: ConfigWriteNotification["afterWrite"];
198215
} | null = null;
199216
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
217+
let currentPluginInstallRecords =
218+
opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync();
219+
const readPluginInstallRecords =
220+
opts.readPluginInstallRecords ?? loadInstalledPluginIndexInstallRecords;
200221

201222
const scheduleAfter = (wait: number) => {
202223
if (stopped) {
@@ -295,17 +316,47 @@ export function startGatewayConfigReloader(opts: {
295316
nextCompareConfig: OpenClawConfig,
296317
afterWrite?: ConfigWriteNotification["afterWrite"],
297318
) => {
298-
const changedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig);
299-
const pluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths(
319+
const configChangedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig);
320+
const configPluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths(
300321
currentCompareConfig,
301322
nextCompareConfig,
302323
);
303-
const pluginInstallWholeRecordPaths = listPluginInstallWholeRecordPaths(
324+
const configPluginInstallWholeRecordPaths = listPluginInstallWholeRecordPaths(
304325
currentCompareConfig,
305326
nextCompareConfig,
306327
);
328+
let nextPluginInstallRecords = currentPluginInstallRecords;
329+
try {
330+
nextPluginInstallRecords = await readPluginInstallRecords();
331+
} catch (err) {
332+
opts.log.warn(`config reload plugin install record check failed: ${String(err)}`);
333+
}
334+
const previousPluginInstallConfig = asPluginInstallConfig(currentPluginInstallRecords);
335+
const nextPluginInstallConfig = asPluginInstallConfig(nextPluginInstallRecords);
336+
const pluginInstallRecordChangedPaths = diffConfigPaths(
337+
previousPluginInstallConfig,
338+
nextPluginInstallConfig,
339+
);
340+
const pluginInstallRecordTimestampNoopPaths = listPluginInstallTimestampMetadataPaths(
341+
previousPluginInstallConfig,
342+
nextPluginInstallConfig,
343+
);
344+
const pluginInstallRecordWholeRecordPaths = listPluginInstallWholeRecordPaths(
345+
previousPluginInstallConfig,
346+
nextPluginInstallConfig,
347+
);
348+
const changedPaths = [...configChangedPaths, ...pluginInstallRecordChangedPaths];
349+
const pluginInstallTimestampNoopPaths = [
350+
...configPluginInstallTimestampNoopPaths,
351+
...pluginInstallRecordTimestampNoopPaths,
352+
];
353+
const pluginInstallWholeRecordPaths = [
354+
...configPluginInstallWholeRecordPaths,
355+
...pluginInstallRecordWholeRecordPaths,
356+
];
307357
currentConfig = nextConfig;
308358
currentCompareConfig = nextCompareConfig;
359+
currentPluginInstallRecords = nextPluginInstallRecords;
309360
settings = resolveGatewayReloadSettings(nextConfig);
310361
if (changedPaths.length === 0) {
311362
return;

0 commit comments

Comments
 (0)