Skip to content

Commit c49ed32

Browse files
committed
fix(config): log observe recovery write failures
1 parent 33b043b commit c49ed32

5 files changed

Lines changed: 166 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
1919
- Plugins/CLI: keep git plugin install paths credential-free, preserve existing git checkouts until replacement succeeds, honor duplicate npm install mode, and remove managed git repos on uninstall. Thanks @vincentkoc.
2020
- Channels/status reactions: remove stale non-terminal lifecycle reactions when a run reaches done or error, so Discord does not leave a permanent thinking emoji after completion. Fixes #75458. Thanks @davelutztx.
2121
- Discord/doctor: migrate unsupported per-channel `agentId` entries under guild channel config into top-level `bindings[]` routes, so `openclaw doctor --fix` preserves the intended agent route instead of stripping it as an unknown key. Fixes #62455. Thanks @lobster-biscuit.
22+
- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.
2223

2324
## 2026.4.30
2425

src/config/io.observe-recovery.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,47 @@ describe("config observe recovery", () => {
159159
};
160160
}
161161

162+
function withAsyncHealthWriteFailure(
163+
deps: ObserveRecoveryDeps,
164+
healthPath: string,
165+
): ObserveRecoveryDeps {
166+
const writeFile = deps.fs.promises.writeFile.bind(deps.fs.promises);
167+
return {
168+
...deps,
169+
fs: {
170+
...deps.fs,
171+
promises: {
172+
...deps.fs.promises,
173+
writeFile: async (target, data, options) => {
174+
if (target === healthPath) {
175+
throw new Error("health write failed");
176+
}
177+
return await writeFile(target, data, options);
178+
},
179+
},
180+
},
181+
};
182+
}
183+
184+
function withSyncHealthWriteFailure(
185+
deps: ObserveRecoveryDeps,
186+
healthPath: string,
187+
): ObserveRecoveryDeps {
188+
const writeFileSync = deps.fs.writeFileSync.bind(deps.fs);
189+
return {
190+
...deps,
191+
fs: {
192+
...deps.fs,
193+
writeFileSync: (target, data, options) => {
194+
if (target === healthPath) {
195+
throw new Error("health write failed");
196+
}
197+
return writeFileSync(target, data, options);
198+
},
199+
},
200+
};
201+
}
202+
162203
it("auto-restores suspicious update-channel-only roots from backup", async () => {
163204
await withSuiteHome(async (home) => {
164205
const { deps, configPath, auditPath, warn } = makeDeps(home);
@@ -383,6 +424,48 @@ describe("config observe recovery", () => {
383424
});
384425
});
385426

427+
it("logs async health-state write failures", async () => {
428+
await withSuiteHome(async (home) => {
429+
const { deps, configPath, warn } = makeDeps(home);
430+
const snapshot = await makeSnapshot(configPath, recoverableTelegramConfig);
431+
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
432+
433+
await expect(
434+
promoteConfigSnapshotToLastKnownGood({
435+
deps: withAsyncHealthWriteFailure(deps, healthPath),
436+
snapshot,
437+
logger: deps.logger,
438+
}),
439+
).resolves.toBe(true);
440+
441+
expect(warn).toHaveBeenCalledWith(
442+
expect.stringContaining(
443+
`Config health-state write failed: ${healthPath}: health write failed`,
444+
),
445+
);
446+
});
447+
});
448+
449+
it("logs sync health-state write failures", async () => {
450+
await withSuiteHome(async (home) => {
451+
const { deps, configPath, warn } = makeDeps(home);
452+
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
453+
await seedConfigBackup(configPath, recoverableTelegramConfig);
454+
await writeClobberedUpdateChannel(configPath);
455+
456+
recoverClobberedUpdateChannelSync({
457+
deps: withSyncHealthWriteFailure(deps, healthPath),
458+
configPath,
459+
});
460+
461+
expect(warn).toHaveBeenCalledWith(
462+
expect.stringContaining(
463+
`Config health-state write failed: ${healthPath}: health write failed`,
464+
),
465+
);
466+
});
467+
});
468+
386469
it("promotes a valid startup config and restores it after an invalid direct edit", async () => {
387470
await withSuiteHome(async (home) => {
388471
const { deps, configPath, auditPath, warn } = makeDeps(home);

src/config/io.observe-recovery.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@ function resolveConfigHealthStatePath(env: NodeJS.ProcessEnv, homedir: () => str
313313
return path.join(resolveStateDir(env, homedir), "logs", "config-health.json");
314314
}
315315

316+
function formatObserveRecoveryError(error: unknown): string {
317+
return error instanceof Error ? error.message : String(error);
318+
}
319+
316320
async function readConfigHealthState(deps: ObserveRecoveryDeps): Promise<ConfigHealthState> {
317321
try {
318322
const raw = await deps.fs.promises.readFile(
@@ -340,25 +344,33 @@ async function writeConfigHealthState(
340344
deps: ObserveRecoveryDeps,
341345
state: ConfigHealthState,
342346
): Promise<void> {
347+
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
343348
try {
344-
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
345349
await deps.fs.promises.mkdir(path.dirname(healthPath), { recursive: true, mode: 0o700 });
346350
await deps.fs.promises.writeFile(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
347351
encoding: "utf-8",
348352
mode: 0o600,
349353
});
350-
} catch {}
354+
} catch (err) {
355+
deps.logger.warn(
356+
`Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`,
357+
);
358+
}
351359
}
352360

353361
function writeConfigHealthStateSync(deps: ObserveRecoveryDeps, state: ConfigHealthState): void {
362+
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
354363
try {
355-
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
356364
deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 });
357365
deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
358366
encoding: "utf-8",
359367
mode: 0o600,
360368
});
361-
} catch {}
369+
} catch (err) {
370+
deps.logger.warn(
371+
`Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`,
372+
);
373+
}
362374
}
363375

364376
function getConfigHealthEntry(state: ConfigHealthState, configPath: string): ConfigHealthEntry {

src/config/io.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -441,28 +441,28 @@ async function writeConfigHealthState(
441441
deps: Required<ConfigIoDeps>,
442442
state: ConfigHealthState,
443443
): Promise<void> {
444+
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
444445
try {
445-
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
446446
await deps.fs.promises.mkdir(path.dirname(healthPath), { recursive: true, mode: 0o700 });
447447
await deps.fs.promises.writeFile(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
448448
encoding: "utf-8",
449449
mode: 0o600,
450450
});
451-
} catch {
452-
// best-effort
451+
} catch (err) {
452+
deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`);
453453
}
454454
}
455455

456456
function writeConfigHealthStateSync(deps: Required<ConfigIoDeps>, state: ConfigHealthState): void {
457+
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
457458
try {
458-
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
459459
deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 });
460460
deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
461461
encoding: "utf-8",
462462
mode: 0o600,
463463
});
464-
} catch {
465-
// best-effort
464+
} catch (err) {
465+
deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`);
466466
}
467467
}
468468

src/config/io.write-config.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fsNode from "node:fs";
12
import fs from "node:fs/promises";
23
import path from "node:path";
34
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -103,6 +104,65 @@ describe("config io write", () => {
103104
logger: silentLogger,
104105
});
105106

107+
function withHealthStateWriteFailure(healthPath: string): typeof fsNode {
108+
const writeFile = fsNode.promises.writeFile.bind(fsNode.promises);
109+
const writeFileSync = fsNode.writeFileSync.bind(fsNode);
110+
return {
111+
...fsNode,
112+
promises: {
113+
...fsNode.promises,
114+
writeFile: async (target, data, options) => {
115+
if (target === healthPath) {
116+
throw new Error("health write failed");
117+
}
118+
return await writeFile(target, data, options);
119+
},
120+
},
121+
writeFileSync: (target, data, options) => {
122+
if (target === healthPath) {
123+
throw new Error("health write failed");
124+
}
125+
return writeFileSync(target, data, options);
126+
},
127+
};
128+
}
129+
130+
it("logs health-state write failures through public config reads", async () => {
131+
await withSuiteHome(async (home) => {
132+
const configPath = path.join(home, ".openclaw", "openclaw.json");
133+
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
134+
await fs.mkdir(path.dirname(configPath), { recursive: true });
135+
await fs.writeFile(
136+
configPath,
137+
`${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`,
138+
"utf-8",
139+
);
140+
const warn = vi.fn();
141+
const io = createConfigIO({
142+
configPath,
143+
env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv,
144+
fs: withHealthStateWriteFailure(healthPath),
145+
homedir: () => home,
146+
logger: { warn, error: vi.fn() },
147+
});
148+
149+
await expect(io.readConfigFileSnapshot()).resolves.toMatchObject({ exists: true });
150+
expect(() => io.loadConfig()).not.toThrow();
151+
152+
expect(warn).toHaveBeenCalledWith(
153+
expect.stringContaining(
154+
`Config health-state write failed: ${healthPath}: health write failed`,
155+
),
156+
);
157+
expect(
158+
warn.mock.calls.filter(
159+
([message]) =>
160+
typeof message === "string" && message.includes("Config health-state write failed:"),
161+
),
162+
).toHaveLength(2);
163+
});
164+
});
165+
106166
it("migrates shipped plugin install config records into the plugin index", async () => {
107167
await withSuiteHome(async (home) => {
108168
const configPath = path.join(home, ".openclaw", "openclaw.json");

0 commit comments

Comments
 (0)