diff --git a/src/state/openclaw-state-ownership.test.ts b/src/state/openclaw-state-ownership.test.ts index d8d30d8dee2a..eb5000bb7b65 100644 --- a/src/state/openclaw-state-ownership.test.ts +++ b/src/state/openclaw-state-ownership.test.ts @@ -88,6 +88,38 @@ describe("external shared-state ownership", () => { expect(inspectOpenClawStateOwnershipAtPath(database.path)).toBeNull(); }); + it("reads ownership from a WAL when the SHM index is absent", () => { + const env = createEnv(true); + const databasePath = openOpenClawStateDatabase({ env }).path; + closeOpenClawStateDatabaseForTest(); + const { DatabaseSync } = requireNodeSqlite(); + const writer = new DatabaseSync(databasePath); + try { + writer.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;"); + const ownership = { + version: 1, + mode: "external", + managerId: "wal-only-manager", + claimedAt: 1, + } as const; + writer + .prepare( + "INSERT INTO config_machine_state (state_key, value_json, updated_at_ms) VALUES (?, ?, ?)", + ) + .run(STATE_SUPERVISION_KEY, JSON.stringify(ownership), ownership.claimedAt); + + const copyDir = tempDirs.make("openclaw-state-ownership-wal-only-"); + const copyPath = path.join(copyDir, "openclaw.sqlite"); + fs.copyFileSync(databasePath, copyPath); + fs.copyFileSync(`${databasePath}-wal`, `${copyPath}-wal`); + expect(fs.existsSync(`${copyPath}-shm`)).toBe(false); + + expect(inspectOpenClawStateOwnershipAtPath(copyPath)).toEqual(ownership); + } finally { + writer.close(); + } + }); + it("requires the external marker and makes claims idempotent only for one manager", () => { const env = createEnv(); expect(() => claimOpenClawStateOwnership("gateway-supervisor", { env })).toThrow( diff --git a/src/state/openclaw-state-ownership.ts b/src/state/openclaw-state-ownership.ts index b54e9a114643..2085c4bdf547 100644 --- a/src/state/openclaw-state-ownership.ts +++ b/src/state/openclaw-state-ownership.ts @@ -4,6 +4,7 @@ import type { DatabaseSync } from "node:sqlite"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { isGatewayExternallySupervised } from "../infra/gateway-supervision.js"; import { openNodeSqliteDatabase, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js"; +import { isSqliteCorruptionError } from "../infra/sqlite-transaction.js"; import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db-contract.js"; import { tableExists } from "./openclaw-state-db-schema-helpers.js"; @@ -114,7 +115,26 @@ export function inspectOpenClawStateOwnershipFromDatabase( return parseExternalOwnership(row.value_json, databasePath); } -/** Inspect one resolved state database path without mutating a quiescent SQLite family. */ +function hasLiveWal(databasePath: string): boolean { + return existsSync(`${databasePath}-wal`); +} + +function inspectOpenClawStateOwnershipAtLocation( + databasePath: string, + location = databasePath, +): OpenClawExternalStateOwnership | null { + const database = openNodeSqliteDatabase(location, { readOnly: true }); + try { + database.exec( + `PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS}; PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;`, + ); + return inspectOpenClawStateOwnershipFromDatabase(database, databasePath); + } finally { + database.close(); + } +} + +/** Inspect one resolved state database path without joining the writable lifecycle. */ export function inspectOpenClawStateOwnershipAtPath( databasePath: string, ): OpenClawExternalStateOwnership | null { @@ -122,18 +142,21 @@ export function inspectOpenClawStateOwnershipAtPath( if (!existsSync(resolvedPath)) { return null; } - // WAL readers need the live sidecars. Rollback-journal recovery stays with the - // writable lifecycle after this ownership fence, so it remains immutable here. - const hasLiveWal = ["-shm", "-wal"].some((suffix) => existsSync(`${resolvedPath}${suffix}`)); - const location = hasLiveWal ? resolvedPath : resolveImmutableSqliteFileUri(resolvedPath); - const database = openNodeSqliteDatabase(location, { readOnly: true }); + if (hasLiveWal(resolvedPath)) { + return inspectOpenClawStateOwnershipAtLocation(resolvedPath); + } try { - database.exec( - `${hasLiveWal ? `PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS}; ` : ""}PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;`, + return inspectOpenClawStateOwnershipAtLocation( + resolvedPath, + resolveImmutableSqliteFileUri(resolvedPath), ); - return inspectOpenClawStateOwnershipFromDatabase(database, resolvedPath); - } finally { - database.close(); + } catch (error) { + // External claims checkpoint before returning, so the main file is authoritative. + // If a WAL appeared during this open, retry with SQLite's normal WAL-aware reader. + if (!isSqliteCorruptionError(error) || !hasLiveWal(resolvedPath)) { + throw error; + } + return inspectOpenClawStateOwnershipAtLocation(resolvedPath); } }