docs(imessage): align recovery with durable ingress (#111002)

This commit is contained in:
Peter Steinberger
2026-07-19 00:09:44 +01:00
committed by GitHub
parent b1c49677ec
commit 14a714901b
2 changed files with 11 additions and 13 deletions
+3 -3
View File
@@ -748,10 +748,10 @@ The "Flag on" column shows behavior on an `imsg` build that emits `balloon_bundl
## Inbound recovery after a bridge or gateway restart
iMessage recovers messages missed while the gateway was down, and at the same time suppresses the stale "backlog bomb" Apple can flush after a Push recovery. The default behavior is always on, built on the inbound dedupe.
iMessage recovers messages missed while the gateway was down, and at the same time suppresses the stale "backlog bomb" Apple can flush after a Push recovery. The default behavior is always on, built on durable ingress plus an age fence.
- **Replay dedupe.** Every dispatched inbound message is recorded by its Apple GUID in persistent plugin state (`imessage.inbound-dedupe`), claimed at ingestion and committed after handling (released on a transient failure so it can retry). Anything already handled is dropped instead of dispatched twice. This is what lets recovery replay aggressively without per-message bookkeeping.
- **Downtime recovery.** On startup the monitor remembers the last dispatched `chat.db` rowid (a persisted per-account cursor) and passes it to `imsg watch.subscribe` as `since_rowid`, so imsg replays the rows that landed while the gateway was down, then tails live. Replay is bounded to the most recent 500 rows and to messages up to ~2 hours old, and the dedupe drops anything already handled.
- **Durable replay protection.** Before advancing the recovery cursor, OpenClaw journals each raw row in the shared SQLite ingress queue with its Apple GUID as the event ID. A completed row leaves a tombstone for about 4 hours, capped at 10,000 entries, so a replay with the same GUID is dropped even after a restart. A pending row stays recoverable until dispatch adopts it.
- **Downtime recovery.** On startup the monitor remembers the last durably admitted `chat.db` rowid (a persisted per-account cursor) and passes it to `imsg watch.subscribe` as `since_rowid`, so imsg replays rows that were not yet journaled and then tails live. Rows journaled before a crash resume from SQLite. Replay is bounded to the most recent 500 rows and to messages up to ~2 hours old, and GUID tombstones drop anything already handled.
- **Stale-backlog age fence.** Rows above the startup boundary are genuinely live; one whose send date is more than ~15 minutes older than its arrival is the Push-flush backlog and is suppressed. Replayed rows (at or below the boundary) use the wider recovery window instead, so a recently-missed message is delivered while ancient history is not.
Recovery works over both local and remote `cliPath` setups, because `since_rowid` replay runs over the same `imsg` RPC connection. The difference is the window: when the gateway can read `chat.db` (local), it anchors the startup rowid boundary, caps the replay span, and delivers missed messages up to a couple of hours old. Over a remote SSH `cliPath` it cannot read the database, so the replay is uncapped and every row uses the live age fence — it still recovers recently-missed messages and still suppresses old backlog, just with the narrower live window. Run the gateway on the Messages Mac for the wider recovery window.
@@ -1,10 +1,8 @@
// Per-(account, database) high-water of the last dispatched chat.db rowid. On
// startup it is passed to imsg `watch.subscribe` as `since_rowid` so imsg
// replays the rows that landed while the gateway was down (downtime recovery),
// then tails live. The GUID dedupe makes over-replay safe — anything already
// handled is dropped — so this needs none of the cursor/retry bookkeeping the
// old catchup subsystem carried. The database identity is part of the store key
// (not just a number per account): a high-water from one chat.db must never seed
// Per-(account, database) high-water of the last durably admitted chat.db rowid.
// It advances only after the SQLite ingress enqueue, then seeds `since_rowid`
// on startup. GUID-keyed ingress tombstones make over-replay safe, while rows
// journaled before a crash resume from the queue. The store key also includes
// the database identity: a high-water from one chat.db must never seed
// since_rowid for a different one, or repointing `dbPath`/`remoteHost` to a
// lower-rowid database silently suppresses every row in it forever (#99638).
import { createHash } from "node:crypto";
@@ -149,9 +147,9 @@ function migrateLegacyCatchupCursor(accountId: string, dbIdentity: string): numb
}
/**
* Last dispatched rowid for this account on `dbIdentity`, or null when none is
* recorded yet (including when the only stored cursor belongs to a different
* database).
* Last durably admitted rowid for this account on `dbIdentity`, or null when
* none is recorded yet (including when the only stored cursor belongs to a
* different database).
*/
export function loadIMessageRecoveryCursor(
accountId: string,