fix(config): avoid false reload restarts

This commit is contained in:
Peter Steinberger
2026-04-23 00:43:58 +01:00
parent c65b232463
commit f437d96ae2
11 changed files with 613 additions and 24 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Providers/Amazon Bedrock: use known context-window metadata for discovered models while keeping the unknown-model fallback conservative, so compaction and overflow handling improve for newer Bedrock models without overstating unlisted model limits. Thanks @wirjo.
- Providers/Amazon Bedrock Mantle: refresh IAM-backed bearer tokens at runtime instead of baking discovery-time tokens into provider config, so long-lived Mantle sessions keep working after the initial token ages out. Thanks @wirjo.
- Config/includes: write through single-file top-level includes for isolated OpenClaw-owned mutations, so `plugins install` and `plugins update` update an included `plugins.json5` file instead of flattening modular `$include` configs. Fixes #41050 and #66048.
- Config/reload: plan gateway reloads from source-authored config instead of runtime-materialized snapshots, so plugin update writes no longer trigger false restarts from derived provider/plugin config paths. Fixes #68732.
- Codex harness: rotate the shared app-server websocket client when the configured bearer token changes, so auth-token refreshes reconnect with the new `Authorization` header instead of reusing a stale socket. (#70328) Thanks @Lucenx9.
- Telegram/sandbox: keep Telegram bot DMs on per-account sender session keys even when `session.dmScope=main`, so sandbox/tool policy can distinguish Telegram-originated direct chats from the agent main session.
- Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653.

View File

@@ -0,0 +1,176 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "$ROOT_DIR/scripts/lib/docker-e2e-logs.sh"
IMAGE_NAME="${OPENCLAW_CONFIG_RELOAD_E2E_IMAGE:-openclaw-config-reload-e2e}"
SKIP_BUILD="${OPENCLAW_CONFIG_RELOAD_E2E_SKIP_BUILD:-0}"
PORT="18789"
TOKEN="reload-e2e-token"
CONTAINER_NAME="openclaw-config-reload-e2e-$$"
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
}
trap cleanup EXIT
if [ "$SKIP_BUILD" = "1" ]; then
echo "Reusing Docker image: $IMAGE_NAME"
else
echo "Building Docker image..."
run_logged config-reload-build docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
fi
echo "Starting gateway container..."
docker run -d \
--name "$CONTAINER_NAME" \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e GATEWAY_AUTH_TOKEN_REF="$TOKEN" \
-e OPENCLAW_SKIP_CHANNELS=1 \
-e OPENCLAW_SKIP_PROVIDERS=1 \
-e OPENCLAW_SKIP_GMAIL_WATCHER=1 \
-e OPENCLAW_SKIP_CRON=1 \
-e OPENCLAW_SKIP_CANVAS_HOST=1 \
"$IMAGE_NAME" \
bash -lc "set -euo pipefail
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
mkdir -p \"\$HOME/.openclaw\"
cat > \"\$HOME/.openclaw/openclaw.json\" <<'JSON'
{
\"gateway\": {
\"port\": $PORT,
\"auth\": {
\"mode\": \"token\",
\"token\": {
\"source\": \"env\",
\"provider\": \"default\",
\"id\": \"GATEWAY_AUTH_TOKEN_REF\"
}
},
\"controlUi\": {
\"enabled\": false
},
\"reload\": {
\"mode\": \"hybrid\",
\"debounceMs\": 0
}
},
\"plugins\": {
\"installs\": {
\"lossless-claw\": {
\"source\": \"npm\",
\"spec\": \"@martian-engineering/lossless-claw\",
\"installPath\": \"/tmp/lossless-claw\",
\"installedAt\": \"2026-04-22T00:00:00.000Z\",
\"resolvedAt\": \"2026-04-22T00:00:00.000Z\"
}
}
}
}
JSON
node \"\$entry\" gateway --port $PORT --bind loopback --allow-unconfigured > /tmp/config-reload-e2e.log 2>&1" >/dev/null
echo "Waiting for gateway..."
ready=0
for _ in $(seq 1 180); do
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then
break
fi
if docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module -e '
import net from \"node:net\";
const socket = net.createConnection({ host: \"127.0.0.1\", port: $PORT });
const timeout = setTimeout(() => {
socket.destroy();
process.exit(1);
}, 400);
socket.on(\"connect\", () => {
clearTimeout(timeout);
socket.end();
process.exit(0);
});
socket.on(\"error\", () => {
clearTimeout(timeout);
process.exit(1);
});
' >/dev/null 2>&1"; then
ready=1
break
fi
sleep 0.5
done
if [ "$ready" -ne 1 ]; then
echo "Gateway failed to start"
docker logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true
docker exec "$CONTAINER_NAME" bash -lc "tail -n 120 /tmp/config-reload-e2e.log" || true
exit 1
fi
echo "Checking initial RPC status..."
docker exec "$CONTAINER_NAME" bash -lc "
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --require-rpc --timeout 5000 >/tmp/config-reload-status-before.log
"
echo "Mutating plugin install timestamp metadata..."
docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module - <<'NODE'
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.plugins.installs['lossless-claw'].installedAt = '2026-04-22T00:01:00.000Z';
config.plugins.installs['lossless-claw'].resolvedAt = '2026-04-22T00:01:00.000Z';
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
NODE"
sleep 2
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || echo false)" != "true" ]; then
echo "Gateway container exited after config metadata write"
docker logs "$CONTAINER_NAME" 2>&1 | tail -n 120 || true
exit 1
fi
echo "Checking post-write RPC status..."
docker exec "$CONTAINER_NAME" bash -lc "
entry=dist/index.mjs
[ -f \"\$entry\" ] || entry=dist/index.js
node \"\$entry\" gateway status --url ws://127.0.0.1:$PORT --token '$TOKEN' --require-rpc --timeout 5000 >/tmp/config-reload-status-after.log
"
echo "Checking reload log..."
docker exec "$CONTAINER_NAME" bash -lc "node --input-type=module - <<'NODE'
import fs from 'node:fs';
const log = fs.readFileSync('/tmp/config-reload-e2e.log', 'utf8');
const reloadLines = log
.split('\n')
.filter((line) => line.includes('config change detected; evaluating reload'));
const restartLines = log
.split('\n')
.filter((line) => line.includes('config change requires gateway restart'));
if (restartLines.length > 0) {
console.error(log.split('\n').slice(-160).join('\n'));
throw new Error('unexpected restart-required reload line found');
}
for (const line of reloadLines) {
for (const needle of ['gateway.auth.token', 'plugins.entries.firecrawl.config.webFetch']) {
if (line.includes(needle)) {
console.error(log.split('\n').slice(-160).join('\n'));
throw new Error('runtime-only path appeared in reload diff: ' + needle);
}
}
}
if (reloadLines.length === 0) {
console.error(log.split('\n').slice(-160).join('\n'));
throw new Error('expected config reload detection log after metadata write');
}
console.log('ok');
NODE"
echo "Config reload Docker E2E passed."

View File

@@ -1307,6 +1307,65 @@ describe("update-cli", () => {
expect(lastWrite?.nextConfig?.update?.channel).toBe("beta");
});
it("uses source config, not runtime-materialized config, for post-update plugin sync", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
const sourceConfig = {
plugins: {
installs: {
"lossless-claw": {
source: "npm",
spec: "@martian-engineering/lossless-claw",
installPath: "/tmp/lossless-claw",
},
},
},
} as OpenClawConfig;
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
sourceConfig,
config: {
...sourceConfig,
gateway: { auth: { mode: "token", token: "runtime" } },
plugins: {
...sourceConfig.plugins,
entries: {
firecrawl: {
config: {
webFetch: { provider: "firecrawl" },
},
},
},
},
} as OpenClawConfig,
});
syncPluginsForUpdateChannel.mockResolvedValue({
changed: false,
config: sourceConfig,
summary: {
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
});
updateNpmInstalledPlugins.mockResolvedValue({
changed: false,
config: sourceConfig,
outcomes: [],
});
await updateCommand({ channel: "beta", yes: true });
const syncConfig = vi.mocked(syncPluginsForUpdateChannel).mock.calls[0]?.[0]?.config as
| OpenClawConfig
| undefined;
expect(syncConfig?.plugins?.installs).toEqual(sourceConfig.plugins?.installs);
expect(syncConfig?.update?.channel).toBe("beta");
expect(syncConfig?.gateway?.auth).toBeUndefined();
expect(syncConfig?.plugins?.entries).toBeUndefined();
});
it("skips plugin sync in the old process after switching from package to git", async () => {
const tempDir = createCaseDir("openclaw-update");
const completionCacheSpy = vi

View File

@@ -576,7 +576,7 @@ async function updatePluginsAfterCoreUpdate(params: {
}
const syncResult = await syncPluginsForUpdateChannel({
config: params.configSnapshot.config,
config: params.configSnapshot.sourceConfig,
channel: params.channel,
workspaceDir: params.root,
logger: pluginLogger,
@@ -1324,9 +1324,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
}
} else {
const next = {
...configSnapshot.config,
...configSnapshot.sourceConfig,
update: {
...configSnapshot.config.update,
...configSnapshot.sourceConfig.update,
channel: requestedChannel,
},
};

View File

@@ -1603,7 +1603,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
async function writeConfigFile(
cfg: OpenClawConfig,
options: ConfigWriteOptions = {},
): Promise<{ persistedHash: string }> {
): Promise<{ persistedHash: string; persistedConfig: OpenClawConfig }> {
clearConfigCache();
let persistCandidate: unknown = cfg;
const snapshot = options.baseSnapshot ?? (await readConfigFileSnapshotInternal()).snapshot;
@@ -1857,7 +1857,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
undefined,
await deps.fs.promises.stat(configPath).catch(() => null),
);
return { persistedHash: nextHash };
return { persistedHash: nextHash, persistedConfig: stampedOutputConfig };
}
await deps.fs.promises.unlink(tmp).catch(() => {
// best-effort
@@ -1871,7 +1871,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
undefined,
await deps.fs.promises.stat(configPath).catch(() => null),
);
return { persistedHash: nextHash };
return { persistedHash: nextHash, persistedConfig: stampedOutputConfig };
} catch (err) {
await appendWriteAudit("failed", err);
throw err;
@@ -2050,6 +2050,7 @@ export async function writeConfigFile(
) {
return;
}
const committedSourceConfig = writeResult.persistedConfig ?? nextCfg;
const notifyCommittedWrite = () => {
const currentRuntimeConfig = getRuntimeConfigSnapshotState();
if (!currentRuntimeConfig) {
@@ -2057,7 +2058,7 @@ export async function writeConfigFile(
}
notifyRuntimeConfigWriteListeners({
configPath: io.configPath,
sourceConfig: nextCfg,
sourceConfig: committedSourceConfig,
runtimeConfig: currentRuntimeConfig,
persistedHash: writeResult.persistedHash,
writtenAtMs: Date.now(),
@@ -2066,7 +2067,7 @@ export async function writeConfigFile(
// Keep the last-known-good runtime snapshot active until the specialized refresh path
// succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh.
await finalizeRuntimeSnapshotWrite({
nextSourceConfig: nextCfg,
nextSourceConfig: committedSourceConfig,
hadRuntimeSnapshot,
hadBothSnapshots,
loadFreshConfig: () => io.loadConfig(),

View File

@@ -430,7 +430,7 @@ describe("config io write", () => {
},
},
}),
).resolves.toEqual({ persistedHash: expect.any(String) });
).resolves.toMatchObject({ persistedHash: expect.any(String) });
});
mockLoadPluginManifestRegistry.mockReturnValue({

View File

@@ -1,5 +1,6 @@
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { isPlainObject } from "../utils.js";
export type ChannelKind = ChannelId;
@@ -31,6 +32,11 @@ type ReloadAction =
| "restart-health-monitor"
| `restart-channel:${ChannelId}`;
export type GatewayReloadPlanOptions = {
noopPaths?: Iterable<string>;
forceChangedPaths?: Iterable<string>;
};
const BASE_RELOAD_RULES: ReloadRule[] = [
{ prefix: "gateway.remote", kind: "none" },
{ prefix: "gateway.reload", kind: "none" },
@@ -178,7 +184,73 @@ function matchRule(path: string): ReloadRule | null {
return null;
}
export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan {
function isPluginInstallTimestampPath(path: string): boolean {
return /^plugins\.installs\..+\.(installedAt|resolvedAt)$/.test(path);
}
function getPluginInstallRecords(config: unknown): Record<string, unknown> {
if (!isPlainObject(config)) {
return {};
}
const plugins = config.plugins;
if (!isPlainObject(plugins)) {
return {};
}
const installs = plugins.installs;
return isPlainObject(installs) ? installs : {};
}
export function listPluginInstallTimestampMetadataPaths(
prevConfig: unknown,
nextConfig: unknown,
): string[] {
const prevInstalls = getPluginInstallRecords(prevConfig);
const nextInstalls = getPluginInstallRecords(nextConfig);
const ids = new Set([...Object.keys(prevInstalls), ...Object.keys(nextInstalls)]);
const paths: string[] = [];
for (const id of ids) {
const prevRecord = prevInstalls[id];
const nextRecord = nextInstalls[id];
if (!isPlainObject(prevRecord) || !isPlainObject(nextRecord)) {
continue;
}
for (const key of ["installedAt", "resolvedAt"] as const) {
if (prevRecord[key] !== nextRecord[key]) {
paths.push(`plugins.installs.${id}.${key}`);
}
}
}
return paths;
}
export function listPluginInstallWholeRecordPaths(
prevConfig: unknown,
nextConfig: unknown,
): string[] {
const prevInstalls = getPluginInstallRecords(prevConfig);
const nextInstalls = getPluginInstallRecords(nextConfig);
const ids = new Set([...Object.keys(prevInstalls), ...Object.keys(nextInstalls)]);
const paths: string[] = [];
for (const id of ids) {
const prevRecord = prevInstalls[id];
const nextRecord = nextInstalls[id];
if (!isPlainObject(prevRecord) || !isPlainObject(nextRecord)) {
paths.push(`plugins.installs.${id}`);
}
}
return paths;
}
export function buildGatewayReloadPlan(
changedPaths: string[],
options: GatewayReloadPlanOptions = {},
): GatewayReloadPlan {
const noopPaths = new Set(options.noopPaths);
const forceChangedPaths = new Set(options.forceChangedPaths);
const plan: GatewayReloadPlan = {
changedPaths,
restartGateway: false,
@@ -221,6 +293,13 @@ export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPla
};
for (const path of changedPaths) {
const isTimestampNoop =
!forceChangedPaths.has(path) &&
(noopPaths.size > 0 ? noopPaths.has(path) : isPluginInstallTimestampPath(path));
if (isTimestampNoop) {
plan.noopPaths.push(path);
continue;
}
const rule = matchRule(path);
if (!rule) {
plan.restartGateway = true;

View File

@@ -6,12 +6,18 @@ import {
} from "../agents/skills/refresh-state.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { ConfigFileSnapshot, ConfigWriteNotification } from "../config/config.js";
import type {
ConfigFileSnapshot,
ConfigWriteNotification,
OpenClawConfig,
} from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import {
buildGatewayReloadPlan,
diffConfigPaths,
listPluginInstallTimestampMetadataPaths,
listPluginInstallWholeRecordPaths,
resolveGatewayReloadSettings,
shouldInvalidateSkillsSnapshotForPaths,
startGatewayConfigReloader,
@@ -88,6 +94,29 @@ describe("diffConfigPaths", () => {
expect(diffConfigPaths(prev, next)).toEqual(["agents.list"]);
});
it("can emit duplicate path strings for install timestamp and dotted install id add", () => {
const prev = {
plugins: {
installs: {
lossless: { source: "npm", resolvedAt: "2026-04-22T00:00:00.000Z" },
},
},
};
const next = {
plugins: {
installs: {
lossless: { source: "npm", resolvedAt: "2026-04-22T00:01:00.000Z" },
"lossless.resolvedAt": { source: "npm" },
},
},
};
expect(diffConfigPaths(prev, next)).toEqual([
"plugins.installs.lossless.resolvedAt",
"plugins.installs.lossless.resolvedAt",
]);
});
});
describe("buildGatewayReloadPlan", () => {
@@ -210,6 +239,60 @@ describe("buildGatewayReloadPlan", () => {
expect(plan.noopPaths).toEqual([]);
});
it("treats plugin install timestamp-only changes as no-op", () => {
const plan = buildGatewayReloadPlan([
"plugins.installs.lossless-claw.resolvedAt",
"plugins.installs.lossless-claw.installedAt",
]);
expect(plan.restartGateway).toBe(false);
expect(plan.noopPaths).toEqual([
"plugins.installs.lossless-claw.resolvedAt",
"plugins.installs.lossless-claw.installedAt",
]);
});
it("keeps colliding whole-record plugin install changes as restart reasons", () => {
const plan = buildGatewayReloadPlan(
["plugins.installs.lossless.resolvedAt", "plugins.installs.lossless.resolvedAt"],
{
noopPaths: ["plugins.installs.lossless.resolvedAt"],
forceChangedPaths: ["plugins.installs.lossless.resolvedAt"],
},
);
expect(plan.restartGateway).toBe(true);
expect(plan.restartReasons).toEqual([
"plugins.installs.lossless.resolvedAt",
"plugins.installs.lossless.resolvedAt",
]);
expect(plan.noopPaths).toEqual([]);
});
it("lists plugin install metadata and whole-record paths structurally", () => {
const prev = {
plugins: {
installs: {
lossless: { source: "npm", resolvedAt: "2026-04-22T00:00:00.000Z" },
},
},
};
const next = {
plugins: {
installs: {
lossless: { source: "npm", resolvedAt: "2026-04-22T00:01:00.000Z" },
"lossless.resolvedAt": { source: "npm" },
},
},
};
expect(listPluginInstallTimestampMetadataPaths(prev, next)).toEqual([
"plugins.installs.lossless.resolvedAt",
]);
expect(listPluginInstallWholeRecordPaths(prev, next)).toEqual([
"plugins.installs.lossless.resolvedAt",
]);
});
it("hot-reloads health monitor when channelHealthCheckMinutes changes", () => {
const plan = buildGatewayReloadPlan(["gateway.channelHealthCheckMinutes"]);
expect(plan.restartGateway).toBe(false);
@@ -349,16 +432,21 @@ function createWatcherMock() {
}
function makeSnapshot(partial: Partial<ConfigFileSnapshot> = {}): ConfigFileSnapshot {
const config = partial.config ?? {};
const sourceConfig = (partial.sourceConfig ??
partial.config ??
{}) as ConfigFileSnapshot["sourceConfig"];
const runtimeConfig = partial.runtimeConfig ?? partial.config ?? {};
return {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
sourceConfig: {},
resolved: {},
sourceConfig,
resolved: sourceConfig,
valid: true,
runtimeConfig: {},
config: {},
runtimeConfig,
config,
issues: [],
warnings: [],
legacyIssues: [],
@@ -370,6 +458,7 @@ function makeZeroDebounceHookSnapshot(hash: string): ConfigFileSnapshot {
return makeSnapshot({
sourceConfig: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: true },
},
runtimeConfig: {
gateway: { reload: { debounceMs: 0 } },
@@ -386,7 +475,7 @@ function makeZeroDebounceHookSnapshot(hash: string): ConfigFileSnapshot {
function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotification {
return {
configPath: "/tmp/openclaw.json",
sourceConfig: { gateway: { reload: { debounceMs: 0 } } },
sourceConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: true } },
runtimeConfig: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: true },
@@ -399,6 +488,7 @@ function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotificati
function createReloaderHarness(
readSnapshot: () => Promise<ConfigFileSnapshot>,
options: {
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null;
recoverSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
@@ -429,6 +519,7 @@ function createReloaderHarness(
};
const reloader = startGatewayConfigReloader({
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
initialCompareConfig: options.initialCompareConfig,
initialInternalWriteHash: options.initialInternalWriteHash,
readSnapshot,
recoverSnapshot: options.recoverSnapshot,
@@ -745,6 +836,141 @@ describe("startGatewayConfigReloader", () => {
await harness.reloader.stop();
});
it("plans in-process reloads from source config and ignores runtime materialized paths", async () => {
const baseInstall = {
source: "npm" as const,
spec: "@martian-engineering/lossless-claw",
installPath: "/tmp/lossless-claw",
installedAt: "2026-04-22T00:00:00.000Z",
resolvedAt: "2026-04-22T00:00:00.000Z",
};
const sourceConfig: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 }, auth: { mode: "token" } },
plugins: {
installs: {
"lossless-claw": baseInstall,
},
},
};
const readSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>().mockResolvedValueOnce(
makeSnapshot({
sourceConfig: {
...sourceConfig,
plugins: {
installs: {
"lossless-claw": {
...baseInstall,
installedAt: "2026-04-22T00:01:00.000Z",
resolvedAt: "2026-04-22T00:01:00.000Z",
},
},
},
},
hash: "plugin-timestamps-1",
}),
);
const harness = createReloaderHarness(readSnapshot, { initialCompareConfig: sourceConfig });
harness.emitWrite({
configPath: "/tmp/openclaw.json",
sourceConfig: {
...sourceConfig,
plugins: {
installs: {
"lossless-claw": {
...baseInstall,
installedAt: "2026-04-22T00:01:00.000Z",
resolvedAt: "2026-04-22T00:01:00.000Z",
},
},
},
},
runtimeConfig: {
...sourceConfig,
gateway: { reload: { debounceMs: 0 }, auth: { mode: "token", token: "runtime" } },
plugins: {
...sourceConfig.plugins,
entries: {
firecrawl: {
config: {
webFetch: { provider: "firecrawl" },
},
},
},
installs: {
"lossless-claw": {
...baseInstall,
installedAt: "2026-04-22T00:01:00.000Z",
resolvedAt: "2026-04-22T00:01:00.000Z",
},
},
},
},
persistedHash: "plugin-timestamps-1",
writtenAtMs: Date.now(),
});
await vi.runOnlyPendingTimersAsync();
expect(harness.onHotReload).not.toHaveBeenCalled();
expect(harness.onRestart).not.toHaveBeenCalled();
expect(harness.log.info).not.toHaveBeenCalledWith(
expect.stringContaining("gateway.auth.token"),
);
await harness.reloader.stop();
});
it("does not suppress functional install changes that collide with timestamp paths", async () => {
const sourceConfig: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
plugins: {
installs: {
lossless: {
source: "npm",
resolvedAt: "2026-04-22T00:00:00.000Z",
},
},
},
};
const nextSourceConfig: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
plugins: {
installs: {
lossless: {
source: "npm",
resolvedAt: "2026-04-22T00:01:00.000Z",
},
"lossless.resolvedAt": {
source: "npm",
},
},
},
};
const readSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>().mockResolvedValueOnce(
makeSnapshot({
sourceConfig: nextSourceConfig,
runtimeConfig: nextSourceConfig,
config: nextSourceConfig,
hash: "plugin-collision-1",
}),
);
const harness = createReloaderHarness(readSnapshot, { initialCompareConfig: sourceConfig });
harness.emitWrite({
configPath: "/tmp/openclaw.json",
sourceConfig: nextSourceConfig,
runtimeConfig: nextSourceConfig,
persistedHash: "plugin-collision-1",
writtenAtMs: Date.now(),
});
await vi.runOnlyPendingTimersAsync();
expect(harness.onHotReload).not.toHaveBeenCalled();
expect(harness.onRestart).toHaveBeenCalledTimes(1);
await harness.reloader.stop();
});
it("skips in-process promotion when the persisted file hash no longer matches the write", async () => {
const readSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>().mockResolvedValueOnce(
makeSnapshot({

View File

@@ -9,9 +9,18 @@ import type {
} from "../config/config.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { isPlainObject } from "../utils.js";
import { buildGatewayReloadPlan, type GatewayReloadPlan } from "./config-reload-plan.js";
import {
buildGatewayReloadPlan,
listPluginInstallTimestampMetadataPaths,
listPluginInstallWholeRecordPaths,
type GatewayReloadPlan,
} from "./config-reload-plan.js";
export { buildGatewayReloadPlan };
export {
buildGatewayReloadPlan,
listPluginInstallTimestampMetadataPaths,
listPluginInstallWholeRecordPaths,
};
export type { ChannelKind, GatewayReloadPlan } from "./config-reload-plan.js";
export type GatewayReloadSettings = {
@@ -49,6 +58,19 @@ export function shouldInvalidateSkillsSnapshotForPaths(changedPaths: string[]):
return firstSkillsChangedPath(changedPaths) !== undefined;
}
function isNoopReloadPlan(plan: GatewayReloadPlan): boolean {
return (
!plan.restartGateway &&
plan.hotReasons.length === 0 &&
!plan.reloadHooks &&
!plan.restartGmailWatcher &&
!plan.restartCron &&
!plan.restartHeartbeat &&
!plan.restartHealthMonitor &&
plan.restartChannels.size === 0
);
}
export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
if (prev === next) {
return [];
@@ -100,6 +122,7 @@ export type GatewayConfigReloader = {
export function startGatewayConfigReloader(opts: {
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null;
readSnapshot: () => Promise<ConfigFileSnapshot>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
@@ -120,6 +143,7 @@ export function startGatewayConfigReloader(opts: {
watchPath: string;
}): GatewayConfigReloader {
let currentConfig = opts.initialConfig;
let currentCompareConfig = opts.initialCompareConfig ?? opts.initialConfig;
let settings = resolveGatewayReloadSettings(currentConfig);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let pending = false;
@@ -127,7 +151,11 @@ export function startGatewayConfigReloader(opts: {
let stopped = false;
let restartQueued = false;
let missingConfigRetries = 0;
let pendingInProcessConfig: { config: OpenClawConfig; persistedHash: string } | null = null;
let pendingInProcessConfig: {
config: OpenClawConfig;
compareConfig: OpenClawConfig;
persistedHash: string;
} | null = null;
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
const scheduleAfter = (wait: number) => {
@@ -213,9 +241,18 @@ export function startGatewayConfigReloader(opts: {
return nextSnapshot;
};
const applySnapshot = async (nextConfig: OpenClawConfig) => {
const changedPaths = diffConfigPaths(currentConfig, nextConfig);
const applySnapshot = async (nextConfig: OpenClawConfig, nextCompareConfig: OpenClawConfig) => {
const changedPaths = diffConfigPaths(currentCompareConfig, nextCompareConfig);
const pluginInstallTimestampNoopPaths = listPluginInstallTimestampMetadataPaths(
currentCompareConfig,
nextCompareConfig,
);
const pluginInstallWholeRecordPaths = listPluginInstallWholeRecordPaths(
currentCompareConfig,
nextCompareConfig,
);
currentConfig = nextConfig;
currentCompareConfig = nextCompareConfig;
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) {
return;
@@ -232,7 +269,13 @@ export function startGatewayConfigReloader(opts: {
}
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
const plan = buildGatewayReloadPlan(changedPaths);
const plan = buildGatewayReloadPlan(changedPaths, {
noopPaths: pluginInstallTimestampNoopPaths,
forceChangedPaths: pluginInstallWholeRecordPaths,
});
if (isNoopReloadPlan(plan)) {
return;
}
if (settings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
return;
@@ -301,7 +344,7 @@ export function startGatewayConfigReloader(opts: {
const pendingWrite = pendingInProcessConfig;
pendingInProcessConfig = null;
missingConfigRetries = 0;
await applySnapshot(pendingWrite.config);
await applySnapshot(pendingWrite.config, pendingWrite.compareConfig);
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash);
return;
}
@@ -323,7 +366,7 @@ export function startGatewayConfigReloader(opts: {
}
snapshot = recoveredSnapshot;
}
await applySnapshot(snapshot.config);
await applySnapshot(snapshot.config, snapshot.sourceConfig);
await promoteAcceptedSnapshot(snapshot, "valid-config");
} catch (err) {
opts.log.error(`config reload failed: ${String(err)}`);
@@ -353,6 +396,7 @@ export function startGatewayConfigReloader(opts: {
}
pendingInProcessConfig = {
config: event.runtimeConfig,
compareConfig: event.sourceConfig,
persistedHash: event.persistedHash,
};
lastAppliedWriteHash = event.persistedHash;

View File

@@ -81,6 +81,7 @@ type ManagedGatewayConfigReloaderParams = Omit<
> & {
minimalTestGateway: boolean;
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash: string | null;
watchPath: string;
readSnapshot: typeof import("../config/config.js").readConfigFileSnapshot;
@@ -316,6 +317,7 @@ export function startManagedGatewayConfigReloader(params: ManagedGatewayConfigRe
return startGatewayConfigReloader({
initialConfig: params.initialConfig,
initialCompareConfig: params.initialCompareConfig,
initialInternalWriteHash: params.initialInternalWriteHash,
readSnapshot: params.readSnapshot,
recoverSnapshot: async (snapshot, reason) =>

View File

@@ -855,6 +855,7 @@ export async function startGatewayServer(
runtimeState.configReloader = startManagedGatewayConfigReloader({
minimalTestGateway,
initialConfig: cfgAtStart,
initialCompareConfig: startupLastGoodSnapshot.sourceConfig,
initialInternalWriteHash: startupInternalWriteHash,
watchPath: configSnapshot.path,
readSnapshot: readConfigFileSnapshot,