diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 31946fe2f998..ff4cdc5ca37e 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -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. diff --git a/extensions/imessage/src/monitor/recovery-cursor.ts b/extensions/imessage/src/monitor/recovery-cursor.ts index 3c0dc31546ac..2f870343e5cb 100644 --- a/extensions/imessage/src/monitor/recovery-cursor.ts +++ b/extensions/imessage/src/monitor/recovery-cursor.ts @@ -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,