From 057b8276cc5faa81730ea66d1f1faec869fa0f5d Mon Sep 17 00:00:00 2001 From: teamclaw <88223778+szsip239@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:16:58 +0800 Subject: [PATCH] fix(config): align in-process write sourceConfig with file-watcher (#73267) Fix config writes so in-process reload notifications use the canonical post-write source snapshot, matching the file watcher path. Adds regression coverage for the runtime source snapshot and changelog credit. --- CHANGELOG.md | 1 + src/config/io.ts | 26 ++++++++- src/config/io.write-config.test.ts | 89 ++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f8da5daf35..11cd2bde281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai - Channels/Telegram: keep Bot API network fallbacks sticky after failed attempts and retry timed-out startup control calls once on the fallback route, so `deleteWebhook` IPv6 stalls no longer trigger slow multi-account retry storms. Fixes #73255. Thanks @ttomiczek and @sktbrd. - Gateway/models: merge explicit `models.providers.*.models` rows into the Gateway model catalog with normalized provider/model dedupe, and use normalized image-capability lookup so custom vision models keep native image attachments even when Pi discovery omits them or model ID casing differs. Fixes #64213 and #65165. Thanks @billonese and @202233a. +- Gateway/reload: publish canonical post-write source config to in-process reloaders so simple config saves no longer create phantom plugin diffs or trigger unnecessary Gateway restarts. (#73267) Thanks @szsip239. - Export/session: keep inline export HTML scripts and vendor libraries injected after template formatting so generated session exports open with the app code, markdown renderer, and syntax highlighter present. Fixes #41862 and #49957; carries forward #41861 and #68947. Thanks @briannewman, @martenzi, and @armanddp. - Agents/ACPX: stage the patched Claude ACP adapter as an ACPX runtime dependency and route known Codex/Claude ACP commands through local wrappers, so Gateway runtime no longer depends on live `npx` adapter resolution. Fixes #73202. Thanks @joerod26. - Memory/compaction: let pre-compaction memory flush use an exact `agents.defaults.compaction.memoryFlush.model` override such as `ollama/qwen3:8b` without inheriting the active session fallback chain, so local housekeeping can avoid paid conversation models. Fixes #53772. Thanks @limen96. diff --git a/src/config/io.ts b/src/config/io.ts index af6bd11a1c6..5682125a39b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -2442,6 +2442,28 @@ export async function writeConfigFile( ) { return; } + // Re-read the freshly persisted file so the sourceConfig we publish matches + // exactly what readConfigFileSnapshot() will produce when the file-watcher + // path next picks up an external edit. Without this, the in-process write + // path emits `nextCfg` (the pre-write source merge) while the file-watcher + // path emits a sourceConfig that has additionally been shaped by include/ + // env resolution, legacy migration, and the shipped-plugin-install strip. + // The two diverge on schema-derived defaults that the read pipeline adds + // but `nextCfg` never sees, so the gateway reload pump's + // currentCompareConfig drifts permanently from on-disk state and diffs out + // phantom paths under plugins.entries.* on every save — incorrectly + // triggering a `plugins`-scoped restart of the gateway for changes that + // never touched any plugin entry. + let canonicalSourceConfig: OpenClawConfig = nextCfg; + try { + const freshSnapshot = await io.readConfigFileSnapshot(); + if (freshSnapshot.exists && freshSnapshot.valid) { + canonicalSourceConfig = freshSnapshot.sourceConfig; + } + } catch { + // Best-effort; fall back to nextCfg so a transient read failure does not + // block the write notification. + } const notifyCommittedWrite = () => { const currentRuntimeConfig = getRuntimeConfigSnapshotState(); if (!currentRuntimeConfig) { @@ -2450,7 +2472,7 @@ export async function writeConfigFile( notifyRuntimeConfigWriteListeners( createRuntimeConfigWriteNotification({ configPath: io.configPath, - sourceConfig: nextCfg, + sourceConfig: canonicalSourceConfig, runtimeConfig: currentRuntimeConfig, persistedHash: writeResult.persistedHash, afterWrite: options.afterWrite, @@ -2460,7 +2482,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: canonicalSourceConfig, hadRuntimeSnapshot, hadBothSnapshots, loadFreshConfig: () => io.loadConfig(), diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index cd04d5b6e50..fc910a77f16 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -6,6 +6,7 @@ import type { PluginManifestRegistry } from "../plugins/manifest-registry.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; import { createConfigIO, + getRuntimeConfigSourceSnapshot, registerConfigWriteListener, resetConfigRuntimeState, setRuntimeConfigSnapshot, @@ -887,4 +888,92 @@ describe("config io write", () => { } }); }); + + it("notifies in-process reloaders with canonical post-write source config", async () => { + mockLoadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [ + { + id: "demo", + origin: "bundled", + channels: [], + providers: [], + cliBackends: [], + skills: [], + hooks: [], + rootDir: "/tmp/openclaw-test-demo", + source: "/tmp/openclaw-test-demo/index.ts", + manifestPath: "/tmp/openclaw-test-demo/openclaw.plugin.json", + configSchema: { + type: "object", + properties: { + mode: { type: "string", default: "auto" }, + }, + additionalProperties: true, + }, + }, + ], + } satisfies PluginManifestRegistry); + + await withSuiteHome(async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + const sourceConfig = { + gateway: { mode: "local" }, + agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, + plugins: { entries: { demo: { enabled: true, config: {} } } }, + } satisfies ConfigFileSnapshot["sourceConfig"]; + await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf-8"); + const runtimeConfig = { + ...structuredClone(sourceConfig), + plugins: { + entries: { + demo: { enabled: true, config: { mode: "auto" } }, + }, + }, + } satisfies ConfigFileSnapshot["config"]; + const observedSources: unknown[] = []; + const unsubscribe = registerConfigWriteListener((event) => { + observedSources.push(event.sourceConfig); + }); + + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + + await writeConfigFile({ + ...runtimeConfig, + agents: { + defaults: { + model: { primary: "openrouter/anthropic/claude-sonnet-4.6" }, + }, + }, + }); + + const postWriteSnapshot = await createConfigIO({ + env: { OPENCLAW_CONFIG_PATH: configPath, VITEST: "true" } as NodeJS.ProcessEnv, + homedir: () => home, + logger: silentLogger, + }).readConfigFileSnapshot(); + + expect(postWriteSnapshot.valid).toBe(true); + expect(observedSources).toEqual([postWriteSnapshot.sourceConfig]); + expect(getRuntimeConfigSourceSnapshot()).toEqual(postWriteSnapshot.sourceConfig); + expect(postWriteSnapshot.sourceConfig.meta?.lastTouchedAt).toEqual(expect.any(String)); + expect(postWriteSnapshot.sourceConfig.plugins?.entries?.demo?.config).toEqual({}); + } finally { + unsubscribe(); + mockLoadPluginManifestRegistry.mockReturnValue({ + diagnostics: [], + plugins: [], + } satisfies PluginManifestRegistry); + if (previousConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + } + } + }); + }); });