diff --git a/src/config/config.ts b/src/config/config.ts index 90e3d6f6353..08ac82bd955 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -2,6 +2,7 @@ export { clearConfigCache, ConfigRuntimeRefreshError, clearRuntimeConfigSnapshot, + registerConfigWriteListener, createConfigIO, getRuntimeConfigSnapshot, getRuntimeConfigSourceSnapshot, @@ -17,6 +18,7 @@ export { setRuntimeConfigSnapshot, writeConfigFile, } from "./io.js"; +export type { ConfigWriteNotification } from "./io.js"; export { migrateLegacyConfig } from "./legacy-migrate.js"; export * from "./paths.js"; export * from "./runtime-overrides.js"; diff --git a/src/config/io.runtime-snapshot-write.test.ts b/src/config/io.runtime-snapshot-write.test.ts index aea6f1ffe2d..2e166231051 100644 --- a/src/config/io.runtime-snapshot-write.test.ts +++ b/src/config/io.runtime-snapshot-write.test.ts @@ -6,6 +6,7 @@ import { getRuntimeConfigSourceSnapshot, loadConfig, projectConfigOntoRuntimeSourceSnapshot, + registerConfigWriteListener, resetConfigRuntimeState, setRuntimeConfigSnapshotRefreshHandler, setRuntimeConfigSnapshot, @@ -249,4 +250,35 @@ describe("runtime config snapshot writes", () => { } }); }); + + it("notifies in-process write listeners with the refreshed runtime snapshot", async () => { + await withTempHome("openclaw-config-runtime-write-listener-", async (home) => { + const configPath = path.join(home, ".openclaw", "openclaw.json"); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`); + + const seen: Array<{ configPath: string; runtimeConfig: OpenClawConfig }> = []; + const unsubscribe = registerConfigWriteListener((event) => { + seen.push({ + configPath: event.configPath, + runtimeConfig: event.runtimeConfig, + }); + }); + + try { + expect(loadConfig().gateway?.port).toBe(18789); + await writeConfigFile({ + ...loadConfig(), + gateway: { port: 19003 }, + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.configPath).toBe(configPath); + expect(seen[0]?.runtimeConfig.gateway?.port).toBe(19003); + } finally { + unsubscribe(); + resetRuntimeConfigState(); + } + }); + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 06591f9eb61..606cbef5482 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -243,6 +243,12 @@ export type RuntimeConfigSnapshotRefreshHandler = { clearOnRefreshFailure?: () => void; }; +export type ConfigWriteNotification = { + configPath: string; + sourceConfig: OpenClawConfig; + runtimeConfig: OpenClawConfig; +}; + export class ConfigRuntimeRefreshError extends Error { constructor(message: string, options?: { cause?: unknown }) { super(message, options); @@ -2076,11 +2082,31 @@ const AUTO_OWNER_DISPLAY_SECRET_PERSIST_WARNED = new Set(); let runtimeConfigSnapshot: OpenClawConfig | null = null; let runtimeConfigSourceSnapshot: OpenClawConfig | null = null; let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null; +const configWriteListeners = new Set<(event: ConfigWriteNotification) => void>(); + +function notifyConfigWriteListeners(event: ConfigWriteNotification): void { + for (const listener of configWriteListeners) { + try { + listener(event); + } catch { + // Best-effort observer path only; successful writes must still complete. + } + } +} export function clearConfigCache(): void { // Compat shim: runtime snapshot is the only in-process cache now. } +export function registerConfigWriteListener( + listener: (event: ConfigWriteNotification) => void, +): () => void { + configWriteListeners.add(listener); + return () => { + configWriteListeners.delete(listener); + }; +} + export function setRuntimeConfigSnapshot( config: OpenClawConfig, sourceConfig?: OpenClawConfig, @@ -2207,6 +2233,16 @@ export async function writeConfigFile( envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined, unsetPaths: options.unsetPaths, }); + const notifyCommittedWrite = () => { + if (!runtimeConfigSnapshot) { + return; + } + notifyConfigWriteListeners({ + configPath: io.configPath, + sourceConfig: nextCfg, + runtimeConfig: runtimeConfigSnapshot, + }); + }; // Keep the last-known-good runtime snapshot active until the specialized refresh path // succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh. const refreshHandler = runtimeConfigSnapshotRefreshHandler; @@ -2214,6 +2250,7 @@ export async function writeConfigFile( try { const refreshed = await refreshHandler.refresh({ sourceConfig: nextCfg }); if (refreshed) { + notifyCommittedWrite(); return; } } catch (error) { @@ -2234,12 +2271,15 @@ export async function writeConfigFile( // subsequent writes still get secret-preservation merge-patch (hadBothSnapshots stays true). const fresh = io.loadConfig(); setRuntimeConfigSnapshot(fresh, nextCfg); + notifyCommittedWrite(); return; } if (hadRuntimeSnapshot) { const fresh = io.loadConfig(); setRuntimeConfigSnapshot(fresh); + notifyCommittedWrite(); return; } setRuntimeConfigSnapshot(io.loadConfig()); + notifyCommittedWrite(); } diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index eb33cb110f3..4548a7a669e 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -2,7 +2,7 @@ import chokidar from "chokidar"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; -import type { ConfigFileSnapshot } from "../config/config.js"; +import type { ConfigFileSnapshot, ConfigWriteNotification } from "../config/config.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { @@ -307,6 +307,15 @@ function createReloaderHarness(readSnapshot: () => Promise) vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never); const onHotReload = vi.fn(async () => {}); const onRestart = vi.fn(); + let writeListener: ((event: ConfigWriteNotification) => void) | null = null; + const subscribeToWrites = vi.fn((listener: (event: ConfigWriteNotification) => void) => { + writeListener = listener; + return () => { + if (writeListener === listener) { + writeListener = null; + } + }; + }); const log = { info: vi.fn(), warn: vi.fn(), @@ -315,12 +324,22 @@ function createReloaderHarness(readSnapshot: () => Promise) const reloader = startGatewayConfigReloader({ initialConfig: { gateway: { reload: { debounceMs: 0 } } }, readSnapshot, + subscribeToWrites, onHotReload, onRestart, log, watchPath: "/tmp/openclaw.json", }); - return { watcher, onHotReload, onRestart, log, reloader }; + return { + watcher, + onHotReload, + onRestart, + log, + reloader, + emitWrite(event: ConfigWriteNotification) { + writeListener?.(event); + }, + }; } describe("startGatewayConfigReloader", () => { @@ -427,4 +446,45 @@ describe("startGatewayConfigReloader", () => { await reloader.stop(); } }); + + it("reuses in-process write notifications and suppresses watcher noise for the same write", async () => { + const readSnapshot = vi.fn<() => Promise>(); + const harness = createReloaderHarness(readSnapshot); + + harness.emitWrite({ + configPath: "/tmp/openclaw.json", + sourceConfig: { gateway: { reload: { debounceMs: 0 } } }, + runtimeConfig: { + gateway: { reload: { debounceMs: 0 } }, + hooks: { enabled: true }, + }, + }); + await vi.runOnlyPendingTimersAsync(); + + expect(readSnapshot).not.toHaveBeenCalled(); + expect(harness.onHotReload).toHaveBeenCalledTimes(1); + + harness.watcher.emit("change"); + harness.watcher.emit("change"); + await vi.runOnlyPendingTimersAsync(); + + expect(readSnapshot).not.toHaveBeenCalled(); + expect(harness.onHotReload).toHaveBeenCalledTimes(1); + + readSnapshot.mockResolvedValueOnce( + makeSnapshot({ + config: { + gateway: { reload: { debounceMs: 0 }, port: 19001 }, + }, + hash: "external-1", + }), + ); + harness.watcher.emit("change"); + await vi.runOnlyPendingTimersAsync(); + + expect(readSnapshot).toHaveBeenCalledTimes(1); + expect(harness.onHotReload).toHaveBeenCalledTimes(1); + + await harness.reloader.stop(); + }); }); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 3887548e51b..c966d40cff0 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -1,6 +1,11 @@ import { isDeepStrictEqual } from "node:util"; import chokidar from "chokidar"; -import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js"; +import type { + OpenClawConfig, + ConfigFileSnapshot, + ConfigWriteNotification, + GatewayReloadMode, +} 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"; @@ -74,6 +79,7 @@ export function startGatewayConfigReloader(opts: { readSnapshot: () => Promise; onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise; onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise; + subscribeToWrites?: (listener: (event: ConfigWriteNotification) => void) => () => void; log: { info: (msg: string) => void; warn: (msg: string) => void; @@ -89,6 +95,8 @@ export function startGatewayConfigReloader(opts: { let stopped = false; let restartQueued = false; let missingConfigRetries = 0; + let pendingInProcessConfig: OpenClawConfig | null = null; + let suppressedWatchEventsRemaining = 0; const scheduleAfter = (wait: number) => { if (stopped) { @@ -195,6 +203,13 @@ export function startGatewayConfigReloader(opts: { debounceTimer = null; } try { + if (pendingInProcessConfig) { + const nextConfig = pendingInProcessConfig; + pendingInProcessConfig = null; + missingConfigRetries = 0; + await applySnapshot(nextConfig); + return; + } const snapshot = await opts.readSnapshot(); if (handleMissingSnapshot(snapshot)) { return; @@ -220,9 +235,27 @@ export function startGatewayConfigReloader(opts: { usePolling: Boolean(process.env.VITEST), }); - watcher.on("add", schedule); - watcher.on("change", schedule); - watcher.on("unlink", schedule); + const scheduleFromWatcher = () => { + if (suppressedWatchEventsRemaining > 0) { + suppressedWatchEventsRemaining -= 1; + return; + } + schedule(); + }; + + const unsubscribeFromWrites = + opts.subscribeToWrites?.((event) => { + if (event.configPath !== opts.watchPath) { + return; + } + pendingInProcessConfig = event.runtimeConfig; + suppressedWatchEventsRemaining = 2; + scheduleAfter(0); + }) ?? (() => {}); + + watcher.on("add", scheduleFromWatcher); + watcher.on("change", scheduleFromWatcher); + watcher.on("unlink", scheduleFromWatcher); let watcherClosed = false; watcher.on("error", (err) => { if (watcherClosed) { @@ -241,6 +274,7 @@ export function startGatewayConfigReloader(opts: { } debounceTimer = null; watcherClosed = true; + unsubscribeFromWrites(); await watcher.close().catch(() => {}); }, }; diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 895936aea98..711054edb8b 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -16,6 +16,7 @@ import { isNixMode, loadConfig, migrateLegacyConfig, + registerConfigWriteListener, readConfigFileSnapshot, writeConfigFile, } from "../config/config.js"; @@ -1409,6 +1410,7 @@ export async function startGatewayServer( return startGatewayConfigReloader({ initialConfig: cfgAtStart, readSnapshot: readConfigFileSnapshot, + subscribeToWrites: registerConfigWriteListener, onHotReload: async (plan, nextConfig) => { const previousSnapshot = getActiveSecretsRuntimeSnapshot(); const prepared = await activateRuntimeSecrets(nextConfig, {