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.
This commit is contained in:
teamclaw
2026-04-28 13:16:58 +08:00
committed by GitHub
parent a644e30245
commit 057b8276cc
3 changed files with 114 additions and 2 deletions

View File

@@ -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.

View File

@@ -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(),

View File

@@ -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;
}
}
});
});
});