fix(config): log observe recovery write failures

This commit is contained in:
sallyom
2026-05-01 01:31:56 -04:00
parent 33b043b920
commit c49ed32f45
5 changed files with 166 additions and 10 deletions

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom.
## 2026.4.30

View File

@@ -159,6 +159,47 @@ describe("config observe recovery", () => {
};
}
function withAsyncHealthWriteFailure(
deps: ObserveRecoveryDeps,
healthPath: string,
): ObserveRecoveryDeps {
const writeFile = deps.fs.promises.writeFile.bind(deps.fs.promises);
return {
...deps,
fs: {
...deps.fs,
promises: {
...deps.fs.promises,
writeFile: async (target, data, options) => {
if (target === healthPath) {
throw new Error("health write failed");
}
return await writeFile(target, data, options);
},
},
},
};
}
function withSyncHealthWriteFailure(
deps: ObserveRecoveryDeps,
healthPath: string,
): ObserveRecoveryDeps {
const writeFileSync = deps.fs.writeFileSync.bind(deps.fs);
return {
...deps,
fs: {
...deps.fs,
writeFileSync: (target, data, options) => {
if (target === healthPath) {
throw new Error("health write failed");
}
return writeFileSync(target, data, options);
},
},
};
}
it("auto-restores suspicious update-channel-only roots from backup", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, auditPath, warn } = makeDeps(home);
@@ -383,6 +424,48 @@ describe("config observe recovery", () => {
});
});
it("logs async health-state write failures", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, warn } = makeDeps(home);
const snapshot = await makeSnapshot(configPath, recoverableTelegramConfig);
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
await expect(
promoteConfigSnapshotToLastKnownGood({
deps: withAsyncHealthWriteFailure(deps, healthPath),
snapshot,
logger: deps.logger,
}),
).resolves.toBe(true);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
`Config health-state write failed: ${healthPath}: health write failed`,
),
);
});
});
it("logs sync health-state write failures", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, warn } = makeDeps(home);
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
await seedConfigBackup(configPath, recoverableTelegramConfig);
await writeClobberedUpdateChannel(configPath);
recoverClobberedUpdateChannelSync({
deps: withSyncHealthWriteFailure(deps, healthPath),
configPath,
});
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
`Config health-state write failed: ${healthPath}: health write failed`,
),
);
});
});
it("promotes a valid startup config and restores it after an invalid direct edit", async () => {
await withSuiteHome(async (home) => {
const { deps, configPath, auditPath, warn } = makeDeps(home);

View File

@@ -313,6 +313,10 @@ function resolveConfigHealthStatePath(env: NodeJS.ProcessEnv, homedir: () => str
return path.join(resolveStateDir(env, homedir), "logs", "config-health.json");
}
function formatObserveRecoveryError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function readConfigHealthState(deps: ObserveRecoveryDeps): Promise<ConfigHealthState> {
try {
const raw = await deps.fs.promises.readFile(
@@ -340,25 +344,33 @@ async function writeConfigHealthState(
deps: ObserveRecoveryDeps,
state: ConfigHealthState,
): Promise<void> {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
await deps.fs.promises.mkdir(path.dirname(healthPath), { recursive: true, mode: 0o700 });
await deps.fs.promises.writeFile(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {}
} catch (err) {
deps.logger.warn(
`Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`,
);
}
}
function writeConfigHealthStateSync(deps: ObserveRecoveryDeps, state: ConfigHealthState): void {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 });
deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {}
} catch (err) {
deps.logger.warn(
`Config health-state write failed: ${healthPath}: ${formatObserveRecoveryError(err)}`,
);
}
}
function getConfigHealthEntry(state: ConfigHealthState, configPath: string): ConfigHealthEntry {

View File

@@ -441,28 +441,28 @@ async function writeConfigHealthState(
deps: Required<ConfigIoDeps>,
state: ConfigHealthState,
): Promise<void> {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
await deps.fs.promises.mkdir(path.dirname(healthPath), { recursive: true, mode: 0o700 });
await deps.fs.promises.writeFile(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {
// best-effort
} catch (err) {
deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`);
}
}
function writeConfigHealthStateSync(deps: Required<ConfigIoDeps>, state: ConfigHealthState): void {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
try {
const healthPath = resolveConfigHealthStatePath(deps.env, deps.homedir);
deps.fs.mkdirSync(path.dirname(healthPath), { recursive: true, mode: 0o700 });
deps.fs.writeFileSync(healthPath, `${JSON.stringify(state, null, 2)}\n`, {
encoding: "utf-8",
mode: 0o600,
});
} catch {
// best-effort
} catch (err) {
deps.logger.warn(`Config health-state write failed: ${healthPath}: ${formatErrorMessage(err)}`);
}
}

View File

@@ -1,3 +1,4 @@
import fsNode from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
@@ -103,6 +104,65 @@ describe("config io write", () => {
logger: silentLogger,
});
function withHealthStateWriteFailure(healthPath: string): typeof fsNode {
const writeFile = fsNode.promises.writeFile.bind(fsNode.promises);
const writeFileSync = fsNode.writeFileSync.bind(fsNode);
return {
...fsNode,
promises: {
...fsNode.promises,
writeFile: async (target, data, options) => {
if (target === healthPath) {
throw new Error("health write failed");
}
return await writeFile(target, data, options);
},
},
writeFileSync: (target, data, options) => {
if (target === healthPath) {
throw new Error("health write failed");
}
return writeFileSync(target, data, options);
},
};
}
it("logs health-state write failures through public config reads", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");
const healthPath = path.join(home, ".openclaw", "logs", "config-health.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify({ gateway: { mode: "local" } }, null, 2)}\n`,
"utf-8",
);
const warn = vi.fn();
const io = createConfigIO({
configPath,
env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv,
fs: withHealthStateWriteFailure(healthPath),
homedir: () => home,
logger: { warn, error: vi.fn() },
});
await expect(io.readConfigFileSnapshot()).resolves.toMatchObject({ exists: true });
expect(() => io.loadConfig()).not.toThrow();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
`Config health-state write failed: ${healthPath}: health write failed`,
),
);
expect(
warn.mock.calls.filter(
([message]) =>
typeof message === "string" && message.includes("Config health-state write failed:"),
),
).toHaveLength(2);
});
});
it("migrates shipped plugin install config records into the plugin index", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");