fix: preserve runtime snapshot during refresh

This commit is contained in:
Gustavo Madeira Santana
2026-03-08 19:16:00 -04:00
parent bca30fec19
commit 69e1861abf
2 changed files with 53 additions and 7 deletions

View File

@@ -7,6 +7,7 @@ import {
clearRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
loadConfig,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,
writeConfigFile,
} from "./io.js";
@@ -41,6 +42,7 @@ function createRuntimeConfig(): OpenClawConfig {
}
function resetRuntimeConfigState(): void {
setRuntimeConfigSnapshotRefreshHandler(null);
clearRuntimeConfigSnapshot();
clearConfigCache();
}
@@ -169,4 +171,44 @@ describe("runtime config snapshot writes", () => {
}
});
});
it("keeps the last-known-good runtime snapshot active while a specialized refresh is pending", async () => {
await withTempHome("openclaw-config-runtime-refresh-pending-", async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const sourceConfig = createSourceConfig();
const runtimeConfig = createRuntimeConfig();
const nextRuntimeConfig: OpenClawConfig = {
...runtimeConfig,
gateway: { auth: { mode: "token" as const } },
};
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
let releaseRefresh!: () => void;
const refreshPending = new Promise<boolean>((resolve) => {
releaseRefresh = () => resolve(true);
});
try {
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
setRuntimeConfigSnapshotRefreshHandler({
refresh: async ({ sourceConfig: refreshedSource }) => {
expect(refreshedSource.gateway?.auth).toEqual({ mode: "token" });
expect(loadConfig().gateway?.auth).toBeUndefined();
return await refreshPending;
},
});
const writePromise = writeConfigFile(nextRuntimeConfig);
await Promise.resolve();
expect(loadConfig().gateway?.auth).toBeUndefined();
releaseRefresh();
await writePromise;
} finally {
resetRuntimeConfigState();
}
});
});
});

View File

@@ -1425,6 +1425,7 @@ export async function writeConfigFile(
): Promise<void> {
const io = createConfigIO();
let nextCfg = cfg;
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
if (hadBothSnapshots) {
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
@@ -1436,9 +1437,8 @@ export async function writeConfigFile(
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
unsetPaths: options.unsetPaths,
});
// Keep follow-up loadConfig() in sync with the just-persisted config (fixes race where a
// second connection's agents.update fails with "agent not found" after agents.create).
clearRuntimeConfigSnapshot();
// 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;
if (refreshHandler) {
try {
@@ -1460,11 +1460,15 @@ export async function writeConfigFile(
}
}
if (hadBothSnapshots) {
// Refresh both snapshots from disk so follow-up reads get normalized config and
// Refresh both snapshots from disk atomically so follow-up reads get normalized config and
// subsequent writes still get secret-preservation merge-patch (hadBothSnapshots stays true).
const fresh = loadConfig();
const fresh = io.loadConfig();
setRuntimeConfigSnapshot(fresh, nextCfg);
return;
}
// When we had no snapshot (or only one), do not set a new one: leave callers reading from
// disk/cache so external/manual edits to openclaw.json remain visible (no stale snapshot).
if (hadRuntimeSnapshot) {
clearRuntimeConfigSnapshot();
}
// When we had no runtime snapshot, keep callers reading from disk/cache so external/manual
// edits to openclaw.json remain visible (no stale snapshot).
}