feat(imessage): inbound catchup (cursor + replay loop + monitor wiring)#79387
Conversation
|
Codex review: needs maintainer review before merge. Summary Reproducibility: yes. Current main docs and monitor-provider source show the downtime-catchup gap, and the PR body provides live after-fix proof; I did not run local tests because this review is read-only. Real behavior proof Next step before merge Security Review detailsBest possible solution: Proceed with maintainer review, resolve or waive unrelated CI failures, and land the opt-in iMessage catchup only after final checks are green. Do we have a high-confidence way to reproduce the issue? Yes. Current main docs and monitor-provider source show the downtime-catchup gap, and the PR body provides live after-fix proof; I did not run local tests because this review is read-only. Is this the best way to solve the issue? Yes, with maintainer review. Keeping the recovery logic extension-local, disabled by default, and routed through the existing live dispatch path is the narrow maintainable direction, and the current head preserves the cap-aware cursor clamp for the prior loss bug. What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against c958716d1042. |
97d8682 to
21d5e27
Compare
|
Independent Codex review (gpt-5.4) flagged two cursor-correctness issues in the original push. Both could silently lose inbound messages. Both addressed in 1. Cursor leapfrog past held failure — 2. Parser-rejected rows stall cursor — Regression coverage added (4 new tests):
Existing failure-path tests had Tests: 322/322 pass in Branch was rebased onto current main during this push to resolve a |
… PR ref Maintainer-flow CHANGELOG rules require: - Entries appended at the END of `## Unreleased > ### Changes` (append-only ordering prevents merge conflicts when multiple PRs race the same unreleased block). - Entry must reference the PR number with `(#<PR>) Thanks @<author>` so release-notes tooling can attribute the change. The original entry was inserted near the top of `### Changes` and ended with `Closes openclaw#78649.` (issue link only, no PR/thanks). Moved to the end of the sub-section and updated the suffix to `Fixes openclaw#78649. (openclaw#79387) Thanks @omarshahine.`. No content change beyond the location move + suffix. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…uncating clawsweeper review on PR openclaw#79387 caught a regression I introduced when adding the parse-rejected high-watermark fix in 8c6421b: when the cross-chat fetch returns more valid rows than `perRunLimit`, the fetcher caps `capped = collected.slice(0, limit)` but `rawWatermarkRowid` still reflects the highest rowid across ALL raw rows — including the cap-truncated tail. The catchup loop applies that watermark as a cursor floor, so the persisted cursor leapfrogs past valid-but- undispatched rows. Same silent message-loss class as the original codex finding, just on a different code path. Concrete repro in `catchup-bridge.test.ts > caps cross-chat results at perRunLimit, oldest first`: 8 valid rows (chat 1: rowids 100-103, chat 2: rowids 200-203), perRunLimit=5. Dispatched: 100, 101, 102, 103, 200. Before this fix: cursor persisted at 203 (raw watermark of chat 2's last row), so rowids 201, 202, 203 — explicitly promised by the WARN log to be picked up on the next startup — were silently dropped. After this fix: cursor stops at 200 (last dispatched rowid). Fix: in `catchup-bridge.ts`, derive `effectiveWatermarkRowid` / `effectiveWatermarkMs` from `rawWatermarkRowid` / `rawWatermarkMs` clamped to `capped[capped.length-1]` when truncation hits. When no truncation, the watermark still covers parse-rejected rows interspersed with the dispatched batch (the original forward-progress guarantee from 8c6421b). When `capped.length === 0` (pathological limit=0 or all rows parse-rejected past the cap boundary), suppress the watermark entirely so the cursor stays put. Regression test: extended the existing "caps cross-chat results at perRunLimit, oldest first" test to assert `summary.cursorAfter.lastSeenRowid === 200` (the last dispatched rowid), not 203 (the raw watermark). Acceptance criteria from clawsweeper: - pnpm vitest run extensions/imessage/src/monitor/catchup.test.ts extensions/imessage/src/monitor/catchup-bridge.test.ts → 29/29 pass - pnpm exec oxfmt --check --threads=1 ... → All matched files use the correct format - pnpm lint:extensions:bundled → 0 warnings, 0 errors Also drops `[...collected].sort()` for `collected.toSorted()` in the same block (oxlint no-array-sort, latent in the 8c6421b commit because oxlint missed it on one path). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
@clawsweeper finding addressed in Root cause: the parse-rejected high-watermark fix in Fix: in Regression coverage: extended the existing Acceptance criteria all green:
|
|
🦞👀 I asked ClawSweeper to answer this maintainer mention in the next review comment. Tiny claws, bounded scope: this is a read-only assist pass unless it produces one of the existing structured safe-action markers. Request: finding addressed in |
6c795ff to
92c243a
Compare
… PR ref Maintainer-flow CHANGELOG rules require: - Entries appended at the END of `## Unreleased > ### Changes` (append-only ordering prevents merge conflicts when multiple PRs race the same unreleased block). - Entry must reference the PR number with `(#<PR>) Thanks @<author>` so release-notes tooling can attribute the change. The original entry was inserted near the top of `### Changes` and ended with `Closes openclaw#78649.` (issue link only, no PR/thanks). Moved to the end of the sub-section and updated the suffix to `Fixes openclaw#78649. (openclaw#79387) Thanks @omarshahine.`. No content change beyond the location move + suffix. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…uncating clawsweeper review on PR openclaw#79387 caught a regression I introduced when adding the parse-rejected high-watermark fix in 8c6421b: when the cross-chat fetch returns more valid rows than `perRunLimit`, the fetcher caps `capped = collected.slice(0, limit)` but `rawWatermarkRowid` still reflects the highest rowid across ALL raw rows — including the cap-truncated tail. The catchup loop applies that watermark as a cursor floor, so the persisted cursor leapfrogs past valid-but- undispatched rows. Same silent message-loss class as the original codex finding, just on a different code path. Concrete repro in `catchup-bridge.test.ts > caps cross-chat results at perRunLimit, oldest first`: 8 valid rows (chat 1: rowids 100-103, chat 2: rowids 200-203), perRunLimit=5. Dispatched: 100, 101, 102, 103, 200. Before this fix: cursor persisted at 203 (raw watermark of chat 2's last row), so rowids 201, 202, 203 — explicitly promised by the WARN log to be picked up on the next startup — were silently dropped. After this fix: cursor stops at 200 (last dispatched rowid). Fix: in `catchup-bridge.ts`, derive `effectiveWatermarkRowid` / `effectiveWatermarkMs` from `rawWatermarkRowid` / `rawWatermarkMs` clamped to `capped[capped.length-1]` when truncation hits. When no truncation, the watermark still covers parse-rejected rows interspersed with the dispatched batch (the original forward-progress guarantee from 8c6421b). When `capped.length === 0` (pathological limit=0 or all rows parse-rejected past the cap boundary), suppress the watermark entirely so the cursor stays put. Regression test: extended the existing "caps cross-chat results at perRunLimit, oldest first" test to assert `summary.cursorAfter.lastSeenRowid === 200` (the last dispatched rowid), not 203 (the raw watermark). Acceptance criteria from clawsweeper: - pnpm vitest run extensions/imessage/src/monitor/catchup.test.ts extensions/imessage/src/monitor/catchup-bridge.test.ts → 29/29 pass - pnpm exec oxfmt --check --threads=1 ... → All matched files use the correct format - pnpm lint:extensions:bundled → 0 warnings, 0 errors Also drops `[...collected].sort()` for `collected.toSorted()` in the same block (oxlint no-array-sort, latent in the 8c6421b commit because oxlint missed it on one path). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Foundation for inbound catchup that recovers messages landing in chat.db
while the gateway was offline (crash, restart, mac sleep). Mirrors the
design of the retired BlueBubbles catchup module.
Done in this commit:
- Schema: channels.imessage.catchup config block with enabled,
maxAgeMinutes (clamp 1..720), perRunLimit (clamp 1..500),
firstRunLookbackMinutes, maxFailureRetries (clamp 1..1000).
- catchup.ts: cursor management (loadCatchupCursor / saveCatchupCursor)
with per-account hashed-prefix layout matching inbound-dedupe; cursor
shape { lastSeenMs, lastSeenRowid, updatedAt, failureRetries? }.
- catchup.ts: performIMessageCatchup() replay loop with injected fetch
+ dispatch callbacks. Walks rows oldest-first; advances the cursor
past skipped (is_from_me, age-bound, given-up) rows; holds the cursor
on a failing row until count crosses maxFailureRetries, then gives
up with a warn log and advances. Defense-in-depth retry-map cap at
5000 entries.
- 18 unit tests covering cursor round-trip, malformed-input recovery,
per-account isolation, replay happy path, is_from_me skip, age-bound
skip, retry-hold, give-up transition, already-given-up skip, fetch
failure preserves cursor.
Still TODO (tracked on the issue):
- Wire performIMessageCatchup into monitor-provider.ts after watch.subscribe.
- Implement the live fetch adapter against client.request('messages.history').
- Implement the dispatch adapter that re-feeds rows through the
evaluateIMessageInbound + dispatchInboundMessage pipeline.
- Extend persisted-echo-cache retention to cover the catchup window so
the agent's own outbound rows are not re-fed.
- Doc section in docs/channels/imessage.md and the BB migration guide.
Refs openclaw#78649.
Builds on the catchup foundation (cursor + replay loop) with the live wiring it needs to actually recover messages after a gateway gap: - New `monitor/catchup-bridge.ts` with the live `chats.list` + per-chat `messages.history` fetch adapter and a dispatch adapter that routes each replayed row through the same `handleMessageNow` path the live `imsg watch` notification loop uses. Best-effort per chat — a single failing chat does not poison the pass; rows that fail the notification-payload parser are dropped silently. - `monitor-provider.ts` runs catchup once between `watch.subscribe` and the live dispatch loop when `channels.imessage.catchup.enabled` is `true`. Disabled by default. Catchup bypasses the inbound debouncer so each row can be awaited serially and the cursor advances per successful dispatch. - `persisted-echo-cache.ts` retention bumped from 2 min to 12 h so the agent's own outbound rows from before a gap are not re-fed as inbound when catchup replays the surrounding messages. 12 h matches the `maxAgeMinutes` clamp ceiling. - Docs: new "Catching up after gateway downtime" section in `docs/channels/imessage.md` covering enable/tune, cursor semantics, retry/give-up behavior, and operator-visible signals; updated the BlueBubbles migration doc to mark catchup parity now reached. - Changelog entry under `## Unreleased > ### Changes`. Tests: 7 new bridge tests (chats fan-out, window filtering, fetch-error fall-through, perRunLimit cap with cross-chat sort, dispatch-throw holds cursor, payload-parser filtering) + 1 new echo-cache retention test; existing 18 catchup foundation tests + the rest of the imessage extension suite all pass (318/318). Closes openclaw#78649. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
The foundation commit (1ad84a4) added the `channels.imessage.catchup` block to the zod schema but did not regenerate `src/config/bundled-channel-config-metadata.generated.ts`, which holds the JSON Schema the runtime AJV validator uses on `config validate` and gateway boot. As a result, a config with `channels.imessage.catchup` present was rejected on the new build with: channels.imessage: invalid config: must NOT have additional properties Found running this PR end-to-end against a live gateway. Repro: cp ~/.openclaw/openclaw.json /tmp/with-catchup.json jq '.channels.imessage.catchup = {enabled: true}' \ /tmp/with-catchup.json > /tmp/cfg.json OPENCLAW_CONFIG_PATH=/tmp/cfg.json node ./dist/index.js config validate # → Config invalid ... must NOT have additional properties Fix: regenerate the metadata via `node --import tsx scripts/generate-bundled-channel-config-metadata.ts` and ship the diff. Schema check now passes: Config valid: /tmp/cfg.json Also fix the inherited cursor-path note in `catchup.ts` docs, the `docs/channels/imessage.md` catchup section, and the changelog entry — the cursor lands at `<openclawStateDir>/imessage/catchup/...`, which on a default install resolves to `~/.openclaw/imessage/catchup/...` (no `state/` subpath; that came from a misreading of `resolveStateDir`). Verified live on the catchup-pr build: 1 test message landed in chat.db at 14:14:29Z while gateway was offline; on restart the catchup pass replayed it through `handleMessageNow`, the agent dispatched a reply at 14:21:11Z, and the cursor at `~/.openclaw/imessage/catchup/default__37a8eec1ce19.json` ended at `{lastSeenMs: 1778249669426, lastSeenRowid: 5239, updatedAt: 1778250075079}` — exactly the test row's timestamp + rowid. Summary line in the gateway log: imessage catchup: replayed=1 skippedFromMe=0 skippedGivenUp=0 failed=0 givenUp=0 fetchedCount=1 Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…parseable rows
Two cursor-correctness bugs surfaced by an independent Codex review of
this PR. Both could silently lose inbound messages.
1. Cursor leapfrog past a held failure (silent message loss)
The replay loop in `performIMessageCatchup` advanced `lastSeenRowid` on
every successful row in the same pass. When a row at rowid N failed
dispatch below `maxFailureRetries` and a later row at rowid M > N
succeeded, the persisted cursor jumped to M. The next pass then filtered
the failed row out via `row.rowid <= sinceRowid` and never retried it —
exactly the "permanently lost message" case the retry counter was
supposed to prevent.
Repro that was missing from coverage:
rows = [{rowid:10, guid:"A", fails}, {rowid:11, guid:"B", succeeds}]
summary.cursorAfter.lastSeenRowid // before fix: 11 (BUG)
// after fix: 9 (correct)
Fix: track `earliestHeldFailureRow` separately from a `highWatermark*`
that only commits when no failure is held. On hold, persist
`Math.max(cursorBefore, failed.rowid - 1)` so the next pass refetches
the failed row. Already-successful rows above the held failure get
re-replayed and absorbed by the existing inbound-dedupe cache.
`skippedFromMe`, `skippedPreCursor` (age-bound), and `skippedGivenUp`
paths now also write to the high watermark instead of `lastSeen*`, so
they cannot leapfrog a held failure either.
Regression test in `catchup.test.ts`: "does NOT leapfrog a held failure
when a later row in the same batch succeeds".
2. Parser-rejected rows stall the cursor forever
`catchup-bridge.ts` silently dropped rows that failed
`parseIMessageNotification`. If a malformed row was the only fresh row
after the cursor, the cursor never advanced — the next pass re-fetched
and re-dropped the same row indefinitely.
Fix: probe raw `id` / `created_at` per row before parsing and emit
`highWatermarkRowid` / `highWatermarkMs` on the fetch result. The replay
loop uses these as a cursor-advance floor only when no failure is held
(a held failure has tighter clamp semantics that must win).
`CatchupFetchFn`'s return type adds two optional watermark fields; test
fetchers emitting only fully-valid rows can omit them.
Regression tests:
- catchup.test.ts: "advances the cursor past parser-rejected rows via
the fetch high-watermark" and "does not let the high-watermark
leapfrog a held failure".
- catchup-bridge.test.ts: "emits a high-watermark even when every row
fails payload validation".
Existing failure-path tests adjusted: their `cursorAfter.lastSeenRowid`
expectations were based on the buggy "stays at 0 / prior cursor" output
when there was no successful row before the failure. The clamp at
`failed.rowid - 1` is the correct value (0 → 9 / 0 → 499 in the
existing fixtures), and prevents the next pass from re-walking older
history that's already been processed.
Tests: 322/322 pass in `pnpm vitest run extensions/imessage/` (was 318
before — 4 new regression tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
CI lint failures from `pnpm lint:extensions:bundled`:
- prefer-number-properties: replace 3× `NaN` with `Number.NaN`
(catchup-bridge.ts:123, :161, :179)
- no-array-sort: replace `[...rows].sort()` with `rows.toSorted()`
(catchup.ts:342) and `Object.keys(map).sort()` with
`Object.keys(map).toSorted()` (catchup.test.ts:136)
- no-useless-fallback-in-spread: drop the `?? {}` fallback when
spreading an optional record (catchup.ts:343 — spreading undefined
is a no-op, the fallback adds noise)
No behavior change. Tests still 29/29 in catchup + bridge suites.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
… PR ref Maintainer-flow CHANGELOG rules require: - Entries appended at the END of `## Unreleased > ### Changes` (append-only ordering prevents merge conflicts when multiple PRs race the same unreleased block). - Entry must reference the PR number with `(#<PR>) Thanks @<author>` so release-notes tooling can attribute the change. The original entry was inserted near the top of `### Changes` and ended with `Closes openclaw#78649.` (issue link only, no PR/thanks). Moved to the end of the sub-section and updated the suffix to `Fixes openclaw#78649. (openclaw#79387) Thanks @omarshahine.`. No content change beyond the location move + suffix. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…uncating clawsweeper review on PR openclaw#79387 caught a regression I introduced when adding the parse-rejected high-watermark fix in 8c6421b: when the cross-chat fetch returns more valid rows than `perRunLimit`, the fetcher caps `capped = collected.slice(0, limit)` but `rawWatermarkRowid` still reflects the highest rowid across ALL raw rows — including the cap-truncated tail. The catchup loop applies that watermark as a cursor floor, so the persisted cursor leapfrogs past valid-but- undispatched rows. Same silent message-loss class as the original codex finding, just on a different code path. Concrete repro in `catchup-bridge.test.ts > caps cross-chat results at perRunLimit, oldest first`: 8 valid rows (chat 1: rowids 100-103, chat 2: rowids 200-203), perRunLimit=5. Dispatched: 100, 101, 102, 103, 200. Before this fix: cursor persisted at 203 (raw watermark of chat 2's last row), so rowids 201, 202, 203 — explicitly promised by the WARN log to be picked up on the next startup — were silently dropped. After this fix: cursor stops at 200 (last dispatched rowid). Fix: in `catchup-bridge.ts`, derive `effectiveWatermarkRowid` / `effectiveWatermarkMs` from `rawWatermarkRowid` / `rawWatermarkMs` clamped to `capped[capped.length-1]` when truncation hits. When no truncation, the watermark still covers parse-rejected rows interspersed with the dispatched batch (the original forward-progress guarantee from 8c6421b). When `capped.length === 0` (pathological limit=0 or all rows parse-rejected past the cap boundary), suppress the watermark entirely so the cursor stays put. Regression test: extended the existing "caps cross-chat results at perRunLimit, oldest first" test to assert `summary.cursorAfter.lastSeenRowid === 200` (the last dispatched rowid), not 203 (the raw watermark). Acceptance criteria from clawsweeper: - pnpm vitest run extensions/imessage/src/monitor/catchup.test.ts extensions/imessage/src/monitor/catchup-bridge.test.ts → 29/29 pass - pnpm exec oxfmt --check --threads=1 ... → All matched files use the correct format - pnpm lint:extensions:bundled → 0 warnings, 0 errors Also drops `[...collected].sort()` for `collected.toSorted()` in the same block (oxlint no-array-sort, latent in the 8c6421b commit because oxlint missed it on one path). Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
92c243a to
9ae75c7
Compare
…g) (openclaw#79387) Closes openclaw#78649. Adds opt-in inbound iMessage catchup that recovers messages landing in chat.db while the gateway is offline (crash, restart, mac sleep). Mirrors the design of the retired BlueBubbles catchup, adapted for the imsg JSON-RPC chats.list + messages.history fetch path. - Schema: new channels.imessage.catchup block with enabled / maxAgeMinutes (1..720) / perRunLimit (1..500) / firstRunLookbackMinutes (1..720) / maxFailureRetries (1..1000). Disabled by default — opt-in. - Cursor + replay loop (extensions/imessage/src/monitor/catchup.ts): per-account state under <openclawStateDir>/imessage/catchup/. Walks rows oldest-first, advances on success/give-up, holds at failed.rowid - 1 when a failure is below maxFailureRetries (cannot leapfrog held failures even when later rows in the same batch succeed). Watermark floor for parse-rejected rows. - Bridge (extensions/imessage/src/monitor/catchup-bridge.ts): live chats.list + per-chat messages.history fetch adapter; dispatch adapter routes through the live handleMessageNow path so allowlists / group policy / dedupe / echo cache behave identically on replayed and live messages. Watermark clamped to last dispatched rowid when the cap truncates. - Monitor wiring (extensions/imessage/src/monitor/monitor-provider.ts): catchup runs once between watch.subscribe and the live dispatch loop when enabled. Bypasses the inbound debouncer for serial per-row dispatch. - Echo-cache TTL bumped 2 min → 12 h so own outbound rows from before a gap are not re-fed as inbound on replay. - Generated bundled-channel-config-metadata.generated.ts so the runtime AJV schema accepts the new catchup block. - Docs: new "Catching up after gateway downtime" section + BlueBubbles migration parity update. Tests: 322/322 in extensions/imessage/, including 5 regression tests covering the cursor-leapfrog, parse-rejected stall, watermark vs held failure, and cap-truncation-cursor-floor edge cases that codex (gpt-5.4) and clawsweeper (gpt-5.5) found during review. Live-tested end-to-end against the running gateway: replayed=1 fetchedCount=1, agent reply observed, cursor persisted at the test row's exact rowid. Co-authored-by: Omar Shahine <[email protected]> Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…g) (openclaw#79387) Closes openclaw#78649. Adds opt-in inbound iMessage catchup that recovers messages landing in chat.db while the gateway is offline (crash, restart, mac sleep). Mirrors the design of the retired BlueBubbles catchup, adapted for the imsg JSON-RPC chats.list + messages.history fetch path. - Schema: new channels.imessage.catchup block with enabled / maxAgeMinutes (1..720) / perRunLimit (1..500) / firstRunLookbackMinutes (1..720) / maxFailureRetries (1..1000). Disabled by default — opt-in. - Cursor + replay loop (extensions/imessage/src/monitor/catchup.ts): per-account state under <openclawStateDir>/imessage/catchup/. Walks rows oldest-first, advances on success/give-up, holds at failed.rowid - 1 when a failure is below maxFailureRetries (cannot leapfrog held failures even when later rows in the same batch succeed). Watermark floor for parse-rejected rows. - Bridge (extensions/imessage/src/monitor/catchup-bridge.ts): live chats.list + per-chat messages.history fetch adapter; dispatch adapter routes through the live handleMessageNow path so allowlists / group policy / dedupe / echo cache behave identically on replayed and live messages. Watermark clamped to last dispatched rowid when the cap truncates. - Monitor wiring (extensions/imessage/src/monitor/monitor-provider.ts): catchup runs once between watch.subscribe and the live dispatch loop when enabled. Bypasses the inbound debouncer for serial per-row dispatch. - Echo-cache TTL bumped 2 min → 12 h so own outbound rows from before a gap are not re-fed as inbound on replay. - Generated bundled-channel-config-metadata.generated.ts so the runtime AJV schema accepts the new catchup block. - Docs: new "Catching up after gateway downtime" section + BlueBubbles migration parity update. Tests: 322/322 in extensions/imessage/, including 5 regression tests covering the cursor-leapfrog, parse-rejected stall, watermark vs held failure, and cap-truncation-cursor-floor edge cases that codex (gpt-5.4) and clawsweeper (gpt-5.5) found during review. Live-tested end-to-end against the running gateway: replayed=1 fetchedCount=1, agent reply observed, cursor persisted at the test row's exact rowid. Co-authored-by: Omar Shahine <[email protected]> Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…g) (openclaw#79387) Closes openclaw#78649. Adds opt-in inbound iMessage catchup that recovers messages landing in chat.db while the gateway is offline (crash, restart, mac sleep). Mirrors the design of the retired BlueBubbles catchup, adapted for the imsg JSON-RPC chats.list + messages.history fetch path. - Schema: new channels.imessage.catchup block with enabled / maxAgeMinutes (1..720) / perRunLimit (1..500) / firstRunLookbackMinutes (1..720) / maxFailureRetries (1..1000). Disabled by default — opt-in. - Cursor + replay loop (extensions/imessage/src/monitor/catchup.ts): per-account state under <openclawStateDir>/imessage/catchup/. Walks rows oldest-first, advances on success/give-up, holds at failed.rowid - 1 when a failure is below maxFailureRetries (cannot leapfrog held failures even when later rows in the same batch succeed). Watermark floor for parse-rejected rows. - Bridge (extensions/imessage/src/monitor/catchup-bridge.ts): live chats.list + per-chat messages.history fetch adapter; dispatch adapter routes through the live handleMessageNow path so allowlists / group policy / dedupe / echo cache behave identically on replayed and live messages. Watermark clamped to last dispatched rowid when the cap truncates. - Monitor wiring (extensions/imessage/src/monitor/monitor-provider.ts): catchup runs once between watch.subscribe and the live dispatch loop when enabled. Bypasses the inbound debouncer for serial per-row dispatch. - Echo-cache TTL bumped 2 min → 12 h so own outbound rows from before a gap are not re-fed as inbound on replay. - Generated bundled-channel-config-metadata.generated.ts so the runtime AJV schema accepts the new catchup block. - Docs: new "Catching up after gateway downtime" section + BlueBubbles migration parity update. Tests: 322/322 in extensions/imessage/, including 5 regression tests covering the cursor-leapfrog, parse-rejected stall, watermark vs held failure, and cap-truncation-cursor-floor edge cases that codex (gpt-5.4) and clawsweeper (gpt-5.5) found during review. Live-tested end-to-end against the running gateway: replayed=1 fetchedCount=1, agent reply observed, cursor persisted at the test row's exact rowid. Co-authored-by: Omar Shahine <[email protected]> Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…g) (openclaw#79387) Closes openclaw#78649. Adds opt-in inbound iMessage catchup that recovers messages landing in chat.db while the gateway is offline (crash, restart, mac sleep). Mirrors the design of the retired BlueBubbles catchup, adapted for the imsg JSON-RPC chats.list + messages.history fetch path. - Schema: new channels.imessage.catchup block with enabled / maxAgeMinutes (1..720) / perRunLimit (1..500) / firstRunLookbackMinutes (1..720) / maxFailureRetries (1..1000). Disabled by default — opt-in. - Cursor + replay loop (extensions/imessage/src/monitor/catchup.ts): per-account state under <openclawStateDir>/imessage/catchup/. Walks rows oldest-first, advances on success/give-up, holds at failed.rowid - 1 when a failure is below maxFailureRetries (cannot leapfrog held failures even when later rows in the same batch succeed). Watermark floor for parse-rejected rows. - Bridge (extensions/imessage/src/monitor/catchup-bridge.ts): live chats.list + per-chat messages.history fetch adapter; dispatch adapter routes through the live handleMessageNow path so allowlists / group policy / dedupe / echo cache behave identically on replayed and live messages. Watermark clamped to last dispatched rowid when the cap truncates. - Monitor wiring (extensions/imessage/src/monitor/monitor-provider.ts): catchup runs once between watch.subscribe and the live dispatch loop when enabled. Bypasses the inbound debouncer for serial per-row dispatch. - Echo-cache TTL bumped 2 min → 12 h so own outbound rows from before a gap are not re-fed as inbound on replay. - Generated bundled-channel-config-metadata.generated.ts so the runtime AJV schema accepts the new catchup block. - Docs: new "Catching up after gateway downtime" section + BlueBubbles migration parity update. Tests: 322/322 in extensions/imessage/, including 5 regression tests covering the cursor-leapfrog, parse-rejected stall, watermark vs held failure, and cap-truncation-cursor-floor edge cases that codex (gpt-5.4) and clawsweeper (gpt-5.5) found during review. Live-tested end-to-end against the running gateway: replayed=1 fetchedCount=1, agent reply observed, cursor persisted at the test row's exact rowid. Co-authored-by: Omar Shahine <[email protected]> Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…g) (openclaw#79387) Closes openclaw#78649. Adds opt-in inbound iMessage catchup that recovers messages landing in chat.db while the gateway is offline (crash, restart, mac sleep). Mirrors the design of the retired BlueBubbles catchup, adapted for the imsg JSON-RPC chats.list + messages.history fetch path. - Schema: new channels.imessage.catchup block with enabled / maxAgeMinutes (1..720) / perRunLimit (1..500) / firstRunLookbackMinutes (1..720) / maxFailureRetries (1..1000). Disabled by default — opt-in. - Cursor + replay loop (extensions/imessage/src/monitor/catchup.ts): per-account state under <openclawStateDir>/imessage/catchup/. Walks rows oldest-first, advances on success/give-up, holds at failed.rowid - 1 when a failure is below maxFailureRetries (cannot leapfrog held failures even when later rows in the same batch succeed). Watermark floor for parse-rejected rows. - Bridge (extensions/imessage/src/monitor/catchup-bridge.ts): live chats.list + per-chat messages.history fetch adapter; dispatch adapter routes through the live handleMessageNow path so allowlists / group policy / dedupe / echo cache behave identically on replayed and live messages. Watermark clamped to last dispatched rowid when the cap truncates. - Monitor wiring (extensions/imessage/src/monitor/monitor-provider.ts): catchup runs once between watch.subscribe and the live dispatch loop when enabled. Bypasses the inbound debouncer for serial per-row dispatch. - Echo-cache TTL bumped 2 min → 12 h so own outbound rows from before a gap are not re-fed as inbound on replay. - Generated bundled-channel-config-metadata.generated.ts so the runtime AJV schema accepts the new catchup block. - Docs: new "Catching up after gateway downtime" section + BlueBubbles migration parity update. Tests: 322/322 in extensions/imessage/, including 5 regression tests covering the cursor-leapfrog, parse-rejected stall, watermark vs held failure, and cap-truncation-cursor-floor edge cases that codex (gpt-5.4) and clawsweeper (gpt-5.5) found during review. Live-tested end-to-end against the running gateway: replayed=1 fetchedCount=1, agent reply observed, cursor persisted at the test row's exact rowid. Co-authored-by: Omar Shahine <[email protected]> Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…g) (openclaw#79387) Closes openclaw#78649. Adds opt-in inbound iMessage catchup that recovers messages landing in chat.db while the gateway is offline (crash, restart, mac sleep). Mirrors the design of the retired BlueBubbles catchup, adapted for the imsg JSON-RPC chats.list + messages.history fetch path. - Schema: new channels.imessage.catchup block with enabled / maxAgeMinutes (1..720) / perRunLimit (1..500) / firstRunLookbackMinutes (1..720) / maxFailureRetries (1..1000). Disabled by default — opt-in. - Cursor + replay loop (extensions/imessage/src/monitor/catchup.ts): per-account state under <openclawStateDir>/imessage/catchup/. Walks rows oldest-first, advances on success/give-up, holds at failed.rowid - 1 when a failure is below maxFailureRetries (cannot leapfrog held failures even when later rows in the same batch succeed). Watermark floor for parse-rejected rows. - Bridge (extensions/imessage/src/monitor/catchup-bridge.ts): live chats.list + per-chat messages.history fetch adapter; dispatch adapter routes through the live handleMessageNow path so allowlists / group policy / dedupe / echo cache behave identically on replayed and live messages. Watermark clamped to last dispatched rowid when the cap truncates. - Monitor wiring (extensions/imessage/src/monitor/monitor-provider.ts): catchup runs once between watch.subscribe and the live dispatch loop when enabled. Bypasses the inbound debouncer for serial per-row dispatch. - Echo-cache TTL bumped 2 min → 12 h so own outbound rows from before a gap are not re-fed as inbound on replay. - Generated bundled-channel-config-metadata.generated.ts so the runtime AJV schema accepts the new catchup block. - Docs: new "Catching up after gateway downtime" section + BlueBubbles migration parity update. Tests: 322/322 in extensions/imessage/, including 5 regression tests covering the cursor-leapfrog, parse-rejected stall, watermark vs held failure, and cap-truncation-cursor-floor edge cases that codex (gpt-5.4) and clawsweeper (gpt-5.5) found during review. Live-tested end-to-end against the running gateway: replayed=1 fetchedCount=1, agent reply observed, cursor persisted at the test row's exact rowid. Co-authored-by: Omar Shahine <[email protected]> Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Closes #78649. Adds opt-in inbound catchup that recovers messages landing in
chat.dbwhile the gateway is offline (crash, restart, mac sleep). Mirrors the design of the retired BlueBubbles catchup (extensions/bluebubbles/src/catchup.tsin commit07bf572f35^), adapted for the imsg JSON-RPCchats.list+messages.historyfetch path.In this PR
Foundation (commit 1)
Schema (
src/config/types.imessage.ts+src/config/zod-schema.providers-core.ts): newchannels.imessage.catchupblock.Disabled by default — opt-in.
Cursor management (
extensions/imessage/src/monitor/catchup.ts): per-account state at<openclawStateDir>/imessage/catchup/<safePrefix>__<sha256[:12]>.json(default install resolves to~/.openclaw/imessage/catchup/...). Layout matchesinbound-dedupe.tsandpersisted-echo-cache.tsso a replayed GUID is recognized by the existing dedupe after catchup re-feeds the message. Cursor shape:{ lastSeenMs, lastSeenRowid, updatedAt, failureRetries? }.Replay loop (
performIMessageCatchup): walks fetched rows oldest-first; advances the cursor past skipped (isFromMe, age-bound, given-up) rows; holds the cursor on a failing row until the per-GUID retry counter crossesmaxFailureRetries, then gives up with awarnlog and advances. Defense-in-depth retry-map cap at 5000 entries.Wiring (commit 2)
Live fetch + dispatch adapters (
extensions/imessage/src/monitor/catchup-bridge.ts):runIMessageCatchupbuilds thechats.list+ per-chatmessages.historyfetch adapter and a dispatch adapter that routes each replayed row through the same per-message handler the liveimsg watchnotification loop uses (handleMessageNowpost-debounce). Best-effort per chat — a failingmessages.historyfor one chat does not poison the pass; rows that fail the notification-payload parser are dropped silently.Monitor wiring (
extensions/imessage/src/monitor/monitor-provider.ts): catchup runs once betweenwatch.subscribeand the live dispatch loop when enabled. Sequencing:imsg launchready →watch.subscribe→runIMessageCatchup→ live dispatch loop. Catchup bypasses the inbound debouncer so each row is awaited serially and the cursor advances per successful dispatch (split-sends from before the gap arrive as separate turns rather than coalesced — matches the retired BB catchup behavior). Anything that arrives during the catchup pass itself flows through live dispatch normally; the existing inbound-dedupe cache absorbs any overlap.Echo-cache retention (
extensions/imessage/src/monitor/persisted-echo-cache.ts): TTL bumped from 2 min to 12 h so the agent's own outbound rows from before a gateway gap are not re-fed as inbound when catchup replays the surrounding messages. 12 h matches themaxAgeMinutesclamp ceiling.Docs (
docs/channels/imessage.md+docs/channels/imessage-from-bluebubbles.md): new "Catching up after gateway downtime" section covering enable/tune, cursor semantics, retry/give-up behavior, and operator-visible signals; the BlueBubbles migration doc is updated to mark catchup parity reached.Changelog entry under
## Unreleased > ### Changes.Schema-metadata fix (commit 3)
src/config/bundled-channel-config-metadata.generated.tsregenerated. The foundation commit added the catchup zod block but did not runnode --import tsx scripts/generate-bundled-channel-config-metadata.ts, so the runtime AJV JSON schema (which is whatconfig validateand gateway boot actually use) did not know aboutcatchupand rejected any config that set it withchannels.imessage: invalid config: must NOT have additional properties. Found running this PR end-to-end against a live gateway. Schema-metadata regeneration ships in this commit;pnpm check:bundled-channel-config-metadatanow passes against HEAD.Verification
pnpm vitest run extensions/imessage/— 318/318 pass (18 catchup foundation tests + 7 new bridge tests + 1 new echo-cache retention regression guard + the pre-existing imessage suite).pnpm buildclean;pnpm exec tsc -p extensions/imessage/tsconfig.json --noEmitclean after build.pnpm check:bundled-channel-config-metadatapasses against HEAD;OPENCLAW_CONFIG_PATH=/tmp/with-catchup.json node ./dist/index.js config validatereportsConfig validfor a config that setschannels.imessage.catchup.enabled: true.Bridge test coverage (catchup-bridge.test.ts)
chats.list+ per-chatmessages.historyfetched, dispatched in rowid order across chats.last_message_at < sinceMschats skipped without per-chat round-trip.chats.listthrow →querySucceeded=false, no dispatch.messages.historythrow → continues with other chats.perRunLimit(oldest first).failed += 1, cursor held below the failing row.parseIMessageNotificationare dropped silently.Real behavior proof (live gateway, end-to-end)
feat/imsg-catchuphead9e63531b14) and run via launchd (ai.openclaw.gateway) with the catchup-prdist/index.jsswapped in via the LaunchAgent plist.pnpm buildin the catchup-pr worktree.~/.openclaw/openclaw.json; addedchannels.imessage.catchup: { enabled: true }.launchctl bootoutthe original gateway; swapped the LaunchAgent'sProgramArgumentsdist/index.jspath to point at the catchup-pr build.imsg history --chat-id 3 --jsonthat the message landed inchat.db(id=5239, is_from_me=false, created_at=2026-05-08T14:14:29.426Z).launchctl bootstrapthe gateway with the catchup-pr build.~/.openclaw/logs/openclaw.log.feat/imsg-catchuphead9e63531b14.~/.openclaw/logs/openclaw.log(one occurrence, fired once afterwatch.subscribesucceeded):imsg history --chat-id 3 --json(sender redacted from chat metadata, content kept verbatim):~/.openclaw/imessage/catchup/default__37a8eec1ce19.json:{"lastSeenMs": 1778249669426, "lastSeenRowid": 5239, "updatedAt": 1778250075079}handleMessageNowpath; the agent ran a Codex turn and produced the reply above.lastSeenMs/lastSeenRowidin the persisted cursor match the test row'screated_at/idexactly;updatedAtmatches the catchup-summary log timestamp. After the live test the LaunchAgent plist was reverted to the originaldist/index.jsand the catchup config was removed from the liveopenclaw.json; gateway is back on the published build, catchup stays disabled until users opt in.catchup-bridge.test.tsandcatchup.test.tssuites against real per-testOPENCLAW_STATE_DIR=$(mktemp -d)IO.Notes
perRunLimit, aWARN ... capped to perRunLimitline is emitted and the next startup picks up the rest from the page boundary.07bf572f35^:extensions/bluebubbles/src/catchup.ts, 652 LOC). Two adjustments:lastSeenMsbecomes{ lastSeenMs, lastSeenRowid }(rowid is monotonic inchat.dband survives clock skew), and the fetch happens viachats.list+ per-chatmessages.historyinstead of an HTTPPOST /api/v1/message/query.