fix(restart-sentinel): report persistence cleanup failures (#106385)

* fix(restart-sentinel): log DB errors instead of silently swallowing them

Replace empty catch blocks in clearRestartSentinel, readRestartSentinel,
and hasRestartSentinel with sentinelLog.warn(...) calls so SQLite errors
are visible in diagnostics while preserving best-effort fallback semantics.

Tests use deterministic fault injection via vi.mock on openclaw-state-db.js
with hoisted throw flags, replacing the previous non-deterministic state-dir
removal approach. Each error path asserts both the warning message and the
correct fallback return value.

* fix(restart-sentinel): report persistence failures

Co-authored-by: wendy-chsy <wan.wenyan@xydigit.com>

* ci: retrigger pull request checks

* chore(ci): prune stale max-lines baseline

* chore(ci): retest against repaired protocol baseline

* chore: stabilize restart sentinel changelog entry

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: wendy-chsy <wan.wenyan@xydigit.com>
This commit is contained in:
zengLingbiao
2026-07-16 00:53:39 -07:00
committed by GitHub
co-authored by Peter Steinberger wendy-chsy
parent ee6ff936ff
commit 47ffb49f71
3 changed files with 115 additions and 5 deletions
+1
View File
@@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
- **macOS remote node readiness:** take the main-session key from the node hello snapshot instead of opening an operator connection during node admission, preventing remote tunnel recovery from leaving Computer Use and node exec stuck in lifecycle transition.
- **Claude CLI context budgets:** honor Anthropic model and per-agent `contextTokens` limits by passing the effective limit to Claude Code's native auto-compactor and persisting the same prepared budget in OpenClaw session state. Fixes #80933. (#93198) Thanks @mushuiyu886.
- **Transcript read failures:** propagate permission and I/O failures from streaming JSONL session reads instead of treating unreadable transcripts as empty. (#106412) Thanks @zenglingbiao.
- **Restart sentinel diagnostics:** report SQLite read/write and legacy-file cleanup failures while preserving best-effort restart recovery behavior. (#106385) Thanks @zenglingbiao and @wendy-chsy.
- **Native app connection and relay reliability:** keep Android disconnects stopped across Activity recreation, fail remote camera commands without opening permission prompts, refresh mobile node registration after capability changes, surface iOS onboarding connection failures, cancel stale Talk owners on session switches, reject invalid Watch acknowledgments, preserve Watch events received during startup, and prevent older agent overview requests from replacing newer gateway state.
- **Gateway source watch:** hand the configured port off from the installed service before starting the tmux watcher, preserve failed panes for attach/capture, and keep explicit alternate-port watches side by side with the managed Gateway.
- **Claude CLI max-turn diagnostics:** preserve terminal max-turn results with OpenClaw and Claude session context, warn when tool actions may already have run, and stop unsafe auth-profile or model replay for potentially side-effecting turns. (#94130) Thanks @zhangguiping-xydt.
+96 -1
View File
@@ -1,7 +1,35 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Covers restart sentinel persistence, summaries, and messages.
const { mockWarn, mockThrowOpen, mockThrowWrite } = vi.hoisted(() => ({
mockWarn: vi.fn(),
mockThrowOpen: vi.fn(),
mockThrowWrite: vi.fn(),
}));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({ warn: mockWarn }),
}));
vi.mock("../state/openclaw-state-db.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../state/openclaw-state-db.js")>();
return {
...actual,
openOpenClawStateDatabase: (...args: Parameters<typeof actual.openOpenClawStateDatabase>) => {
mockThrowOpen();
return actual.openOpenClawStateDatabase(...args);
},
runOpenClawStateWriteTransaction: (
...args: Parameters<typeof actual.runOpenClawStateWriteTransaction>
) => {
mockThrowWrite();
return actual.runOpenClawStateWriteTransaction(...args);
},
};
});
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabaseForTest,
@@ -34,6 +62,12 @@ import {
} from "./update-control-plane-sentinel.js";
import { buildUpdateRestartSentinelPayload } from "./update-restart-sentinel-payload.js";
beforeEach(() => {
mockWarn.mockClear();
mockThrowOpen.mockReset();
mockThrowWrite.mockReset();
});
async function withRestartSentinelStateDir(run: () => Promise<void>): Promise<void> {
await withTempDir({ prefix: "openclaw-sentinel-" }, async (tempDir) => {
try {
@@ -462,6 +496,67 @@ describe("restart sentinel", () => {
});
});
describe("restart sentinel error visibility", () => {
it("logs a warning when clearRestartSentinel DB write fails", async () => {
mockThrowWrite.mockImplementationOnce(() => {
throw new Error("SQLITE_IOERR: disk I/O error");
});
await withRestartSentinelStateDir(async () => {
await expect(clearRestartSentinel()).resolves.toBeUndefined();
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn).toHaveBeenCalledWith(
"Failed to clear restart sentinel: SQLITE_IOERR: disk I/O error",
);
});
});
it("logs a warning and returns null when readRestartSentinel DB read fails", async () => {
mockThrowOpen.mockImplementationOnce(() => {
throw new Error("SQLITE_CORRUPT: database disk image is malformed");
});
await withRestartSentinelStateDir(async () => {
await expect(readRestartSentinel()).resolves.toBeNull();
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn).toHaveBeenCalledWith(
"Failed to read restart sentinel: SQLITE_CORRUPT: database disk image is malformed",
);
});
});
it("logs a warning and returns false when hasRestartSentinel DB read fails", async () => {
mockThrowOpen.mockImplementationOnce(() => {
throw new Error("SQLITE_BUSY: database is locked");
});
await withRestartSentinelStateDir(async () => {
await expect(hasRestartSentinel()).resolves.toBe(false);
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn).toHaveBeenCalledWith(
"Failed to check restart sentinel: SQLITE_BUSY: database is locked",
);
});
});
it("logs a warning when the legacy sentinel path cannot be removed", async () => {
await withRestartSentinelStateDir(async () => {
const legacyPath = path.join(process.env.OPENCLAW_STATE_DIR ?? "", "restart-sentinel.json");
await fs.mkdir(legacyPath);
await expect(clearRestartSentinel()).resolves.toBeUndefined();
expect(mockWarn).toHaveBeenCalledTimes(1);
expect(mockWarn).toHaveBeenCalledWith(
expect.stringMatching(/^Failed to remove legacy restart sentinel: .+/),
);
});
});
});
describe("restart success continuation", () => {
it("does not infer an agent turn from session context alone", () => {
expect(buildRestartSuccessContinuation({ sessionKey: "agent:main:main" })).toBeNull();
+18 -4
View File
@@ -5,18 +5,22 @@ import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-c
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { formatCliCommand } from "../cli/command-format.js";
import { resolveStateDir } from "../config/paths.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
} from "../state/openclaw-state-db.js";
import { resolveRuntimeServiceVersion } from "../version.js";
import { formatErrorMessage } from "./errors.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
const sentinelLog = createSubsystemLogger("restart-sentinel");
type RestartSentinelLog = {
stdoutTail?: string | null;
stderrTail?: string | null;
@@ -225,7 +229,11 @@ export async function clearRestartSentinel(env: NodeJS.ProcessEnv = process.env)
},
{ env },
);
} catch {}
} catch (err) {
// Clearing the sentinel is best-effort during shutdown/cleanup, but a
// failure here may leave the gateway believing a restart is still pending.
sentinelLog.warn(`Failed to clear restart sentinel: ${formatErrorMessage(err)}`);
}
await removeLegacyRestartSentinel(env);
}
@@ -236,7 +244,11 @@ function resolveLegacyRestartSentinelPath(env: NodeJS.ProcessEnv): string {
async function removeLegacyRestartSentinel(env: NodeJS.ProcessEnv): Promise<void> {
try {
await rm(resolveLegacyRestartSentinelPath(env), { force: true });
} catch {}
} catch (err) {
// Legacy cleanup must not block the canonical SQLite operation, but a
// failed removal can replay stale restart state after the database clears.
sentinelLog.warn(`Failed to remove legacy restart sentinel: ${formatErrorMessage(err)}`);
}
}
async function importLegacyRestartSentinel(
@@ -298,7 +310,8 @@ export async function readRestartSentinel(
return null;
}
return { version: 1, payload };
} catch {
} catch (err) {
sentinelLog.warn(`Failed to read restart sentinel: ${formatErrorMessage(err)}`);
return null;
}
}
@@ -318,7 +331,8 @@ export async function hasRestartSentinel(env: NodeJS.ProcessEnv = process.env):
return true;
}
return Boolean(await importLegacyRestartSentinel(env));
} catch {
} catch (err) {
sentinelLog.warn(`Failed to check restart sentinel: ${formatErrorMessage(err)}`);
return false;
}
}