mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:50:43 +00:00
fix(config): avoid false reload restarts
This commit is contained in:
@@ -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.
|
||||
|
||||
176
scripts/e2e/config-reload-source-docker.sh
Executable file
176
scripts/e2e/config-reload-source-docker.sh
Executable 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."
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -430,7 +430,7 @@ describe("config io write", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ persistedHash: expect.any(String) });
|
||||
).resolves.toMatchObject({ persistedHash: expect.any(String) });
|
||||
});
|
||||
|
||||
mockLoadPluginManifestRegistry.mockReturnValue({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -855,6 +855,7 @@ export async function startGatewayServer(
|
||||
runtimeState.configReloader = startManagedGatewayConfigReloader({
|
||||
minimalTestGateway,
|
||||
initialConfig: cfgAtStart,
|
||||
initialCompareConfig: startupLastGoodSnapshot.sourceConfig,
|
||||
initialInternalWriteHash: startupInternalWriteHash,
|
||||
watchPath: configSnapshot.path,
|
||||
readSnapshot: readConfigFileSnapshot,
|
||||
|
||||
Reference in New Issue
Block a user