Skip to content

feat(imessage): inbound catchup (cursor + replay loop + monitor wiring)#79387

Merged
omarshahine merged 7 commits into
openclaw:mainfrom
omarshahine:feat/imsg-catchup
May 9, 2026
Merged

feat(imessage): inbound catchup (cursor + replay loop + monitor wiring)#79387
omarshahine merged 7 commits into
openclaw:mainfrom
omarshahine:feat/imsg-catchup

Conversation

@omarshahine

@omarshahine omarshahine commented May 8, 2026

Copy link
Copy Markdown
Contributor

Closes #78649. Adds opt-in inbound 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 (extensions/bluebubbles/src/catchup.ts in commit 07bf572f35^), adapted for the imsg JSON-RPC chats.list + messages.history fetch path.

In this PR

Foundation (commit 1)

  1. Schema (src/config/types.imessage.ts + src/config/zod-schema.providers-core.ts): new channels.imessage.catchup block.

    channels.imessage.catchup = {
      enabled: boolean (default false),
      maxAgeMinutes: 1..720 (default 120),
      perRunLimit: 1..500 (default 50),
      firstRunLookbackMinutes: 1..720 (default 30),
      maxFailureRetries: 1..1000 (default 10),
    }
    

    Disabled by default — opt-in.

  2. 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 matches inbound-dedupe.ts and persisted-echo-cache.ts so a replayed GUID is recognized by the existing dedupe after catchup re-feeds the message. Cursor shape: { lastSeenMs, lastSeenRowid, updatedAt, failureRetries? }.

  3. 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 crosses maxFailureRetries, then gives up with a warn log and advances. Defense-in-depth retry-map cap at 5000 entries.

Wiring (commit 2)

  1. Live fetch + dispatch adapters (extensions/imessage/src/monitor/catchup-bridge.ts): runIMessageCatchup builds the chats.list + per-chat messages.history fetch adapter and a dispatch adapter that routes each replayed row through the same per-message handler the live imsg watch notification loop uses (handleMessageNow post-debounce). Best-effort per chat — a failing messages.history for one chat does not poison the pass; rows that fail the notification-payload parser are dropped silently.

  2. Monitor wiring (extensions/imessage/src/monitor/monitor-provider.ts): catchup runs once between watch.subscribe and the live dispatch loop when enabled. Sequencing: imsg launch ready → watch.subscriberunIMessageCatchup → 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.

  3. 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 the maxAgeMinutes clamp ceiling.

  4. 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.

  5. Changelog entry under ## Unreleased > ### Changes.

Schema-metadata fix (commit 3)

  1. src/config/bundled-channel-config-metadata.generated.ts regenerated. The foundation commit added the catchup zod block but did not run node --import tsx scripts/generate-bundled-channel-config-metadata.ts, so the runtime AJV JSON schema (which is what config validate and gateway boot actually use) did not know about catchup and rejected any config that set it with channels.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-metadata now passes against HEAD.

Verification

  • Local: 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).
  • Build: pnpm build clean; pnpm exec tsc -p extensions/imessage/tsconfig.json --noEmit clean after build.
  • Schema-metadata: pnpm check:bundled-channel-config-metadata passes against HEAD; OPENCLAW_CONFIG_PATH=/tmp/with-catchup.json node ./dist/index.js config validate reports Config valid for a config that sets channels.imessage.catchup.enabled: true.

Bridge test coverage (catchup-bridge.test.ts)

  • chats.list + per-chat messages.history fetched, dispatched in rowid order across chats.
  • last_message_at < sinceMs chats skipped without per-chat round-trip.
  • chats.list throw → querySucceeded=false, no dispatch.
  • Single-chat messages.history throw → continues with other chats.
  • Cross-chat results sorted by rowid asc and capped at perRunLimit (oldest first).
  • Dispatch throw → failed += 1, cursor held below the failing row.
  • Junk rows that fail parseIMessageNotification are dropped silently.

Real behavior proof (live gateway, end-to-end)

  • Behavior addressed: closes issue iMessage: catchup missed inbound messages received while gateway was down #78649 — inbound iMessage catchup after gateway offline windows.
  • Real environment tested: macOS Sequoia 15.x, node v25.6.0, gateway built from this branch (feat/imsg-catchup head 9e63531b14) and run via launchd (ai.openclaw.gateway) with the catchup-pr dist/index.js swapped in via the LaunchAgent plist.
  • Steps run after the patch:
    1. pnpm build in the catchup-pr worktree.
    2. Backed up ~/.openclaw/openclaw.json; added channels.imessage.catchup: { enabled: true }.
    3. launchctl bootout the original gateway; swapped the LaunchAgent's ProgramArguments dist/index.js path to point at the catchup-pr build.
    4. With the gateway offline, sent one inbound iMessage from a separate device. Verified via imsg history --chat-id 3 --json that the message landed in chat.db (id=5239, is_from_me=false, created_at=2026-05-08T14:14:29.426Z).
    5. launchctl bootstrap the gateway with the catchup-pr build.
    6. Tailed ~/.openclaw/logs/openclaw.log.
  • Evidence after fix: terminal capture from the live gateway run + cursor file dump on disk, all from feat/imsg-catchup head 9e63531b14.
    • Catchup summary line redacted from ~/.openclaw/logs/openclaw.log (one occurrence, fired once after watch.subscribe succeeded):
      {"subsystem":"channels/imessage","level":"INFO",
       "time":"2026-05-08T14:21:15.089Z",
       "message":"imessage catchup: replayed=1 skippedFromMe=0
                  skippedGivenUp=0 failed=0 givenUp=0 fetchedCount=1"}
      
    • Reply row Lobster sent back during the same startup, queried via imsg history --chat-id 3 --json (sender redacted from chat metadata, content kept verbatim):
      id=5240, is_from_me=true, created_at=2026-05-08T14:21:11.583Z
      text="A🦞 Back online — received this offline test and replying now."
      
    • Cursor file at ~/.openclaw/imessage/catchup/default__37a8eec1ce19.json:
      {"lastSeenMs": 1778249669426,
       "lastSeenRowid": 5239,
       "updatedAt": 1778250075079}
  • Observed result after fix: the replayed row was dispatched through the live handleMessageNow path; the agent ran a Codex turn and produced the reply above. lastSeenMs / lastSeenRowid in the persisted cursor match the test row's created_at / id exactly; updatedAt matches the catchup-summary log timestamp. After the live test the LaunchAgent plist was reverted to the original dist/index.js and the catchup config was removed from the live openclaw.json; gateway is back on the published build, catchup stays disabled until users opt in.
  • Not tested: multi-chat fan-out (only one chat had activity in the window) and the give-up path (no failing dispatch) were not exercised in the live run. Both are covered by the catchup-bridge.test.ts and catchup.test.ts suites against real per-test OPENCLAW_STATE_DIR=$(mktemp -d) IO.

Notes

  • Helpers stay extension-local per AGENTS owner-boundary rule.
  • Catchup is one pass per startup, matching the retired BB catchup. If a single startup hits perRunLimit, a WARN ... capped to perRunLimit line is emitted and the next startup picks up the rest from the page boundary.
  • The retired BB catchup was the design reference (commit 07bf572f35^:extensions/bluebubbles/src/catchup.ts, 652 LOC). Two adjustments: lastSeenMs becomes { lastSeenMs, lastSeenRowid } (rowid is monotonic in chat.db and survives clock skew), and the fetch happens via chats.list + per-chat messages.history instead of an HTTP POST /api/v1/message/query.

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: imessage Channel integration: imessage size: L maintainer Maintainer-authored PR labels May 8, 2026
@clawsweeper

clawsweeper Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge.

Summary
Adds opt-in iMessage downtime catchup with config/schema metadata, cursor persistence, history replay through the live monitor path, longer echo-cache retention, docs/changelog, and focused tests.

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
Sufficient (logs): The PR body includes live macOS gateway logs, a cursor dump, and a reply row showing an offline inbound iMessage replayed after startup.

Next step before merge
Not a repair-lane candidate: the protected maintainer label, feature/config scope, and CI state require human maintainer handling rather than a narrow automated patch.

Security
Cleared: No concrete security or supply-chain regression found; the diff adds no dependencies or workflows and persists cursor state through the existing owner-only JSON store.

Review details

Best 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:

  • omarshahine: Prior merged iMessage JSON-RPC, group allowlist, and group systemPrompt work touched the same monitor/docs/config surface before this PR, so this is feature-history ownership beyond merely opening this PR. (role: introduced behavior and recent adjacent owner; confidence: high; commits: e259751ec9c9, df069f7b0222, 85ebb4c471a5; files: extensions/imessage/src/monitor/monitor-provider.ts, docs/channels/imessage.md, src/config/types.imessage.ts)
  • steipete: Recent commits on iMessage monitor actions, message lifecycle routing, docs, and config/runtime seams overlap the startup and dispatch integration touched here. (role: recent maintainer and adjacent refactor owner; confidence: high; commits: fe79d85ae0cb, 05eda57b3c72, d29201fe4fc5; files: extensions/imessage/src/monitor/monitor-provider.ts, docs/channels/imessage.md, src/config/types.imessage.ts)
  • vincentkoc: Recent iMessage setup and BlueBubbles deprecation documentation work overlaps the user-facing docs this PR updates. (role: docs/setup adjacent owner; confidence: medium; commits: 0fca66549794, 91ed1604b011, a834175e0186; files: docs/channels/imessage.md, docs/channels/imessage-from-bluebubbles.md)

Remaining risk / open question:

  • The protected maintainer label and new opt-in channel/config surface mean merge needs explicit maintainer judgment.
  • Latest head has two failed broad CI shards and one in-progress shard; available annotations look unrelated to the iMessage diff, but final CI is not green.
  • The live proof covers a one-chat happy path; multi-chat cap truncation and give-up behavior are covered by tests rather than live demonstration.

Codex review notes: model gpt-5.5, reasoning high; reviewed against c958716d1042.

@omarshahine omarshahine changed the title feat(imessage): catchup foundation (cursor + replay loop) [draft] feat(imessage): inbound catchup (cursor + replay loop + monitor wiring) May 8, 2026
@omarshahine
omarshahine marked this pull request as ready for review May 8, 2026 13:32
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 8, 2026
@omarshahine
omarshahine force-pushed the feat/imsg-catchup branch from 97d8682 to 21d5e27 Compare May 8, 2026 15:18
@omarshahine

Copy link
Copy Markdown
Contributor Author

Independent Codex review (gpt-5.4) flagged two cursor-correctness issues in the original push. Both could silently lose inbound messages. Both addressed in 21d5e27dcf:

1. Cursor leapfrog past held failureperformIMessageCatchup advanced lastSeenRowid on every successful row in the same pass, so a failure at rowid N followed by a success at rowid M > N would persist the cursor at M and skip the failed row forever via row.rowid <= sinceRowid on the next pass. Fix: track earliestHeldFailureRow separately from a highWatermark*; 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. The skippedFromMe / skippedPreCursor (age-bound) / skippedGivenUp paths now also write to the high watermark instead of lastSeen*, so they cannot leapfrog a held failure either.

2. Parser-rejected rows stall cursorcatchup-bridge.ts silently dropped rows that failed parseIMessageNotification. If a malformed row was the only fresh row after the cursor, the cursor never advanced and the same row got re-fetched-and-dropped forever. Fix: probe raw id / created_at per row before parsing and emit highWatermarkRowid / highWatermarkMs on the fetch result. The replay loop applies the watermark as a cursor floor only when no failure is held (a held failure has tighter clamp semantics that must win).

Regression coverage added (4 new tests):

  • catchup.test.ts: "does NOT leapfrog a held failure when a later row in the same batch succeeds" — the exact codex repro.
  • catchup.test.ts: "advances the cursor past parser-rejected rows via the fetch high-watermark" — covers issue 2.
  • catchup.test.ts: "does not let the high-watermark leapfrog a held failure" — interaction between the two fixes.
  • catchup-bridge.test.ts: "emits a high-watermark even when every row fails payload validation" — bridge-level coverage of issue 2.

Existing failure-path tests had cursorAfter.lastSeenRowid expectations based on the buggy "stays at 0" output; updated to expect failed.rowid - 1 (the now-correct clamp).

Tests: 322/322 pass in pnpm vitest run extensions/imessage/.

Branch was rebased onto current main during this push to resolve a bundled-channel-config-metadata.generated.ts conflict (regenerated fresh against the rebased state). PR is ready for maintainer review.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. and removed proof: sufficient ClawSweeper judged the real behavior proof convincing. labels May 8, 2026
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 8, 2026
… 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]>
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 8, 2026
…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]>
@omarshahine

Copy link
Copy Markdown
Contributor Author

@clawsweeper finding addressed in 6c795ff98f.

Root cause: the parse-rejected high-watermark fix in 8c6421b846 introduced the regression — rawWatermarkRowid covered every raw row the fetcher saw, so when collected.length > perRunLimit the dropped tail's rowids leaked into the watermark and the catchup loop applied them as a cursor floor.

Fix: in catchup-bridge.ts, after sorting + slicing to capped, clamp effectiveWatermarkRowid / effectiveWatermarkMs to capped[capped.length-1] when truncation hits. When no truncation, watermark still covers parse-rejected rows interspersed with the dispatched batch (the original forward-progress guarantee). When capped.length === 0 (pathological limit=0), suppress the watermark entirely.

Regression coverage: 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 of the chat-2 tail). Without the fix this assertion fails with expected 203 to be 200.

Acceptance criteria all green:

  • 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

@clawsweeper

clawsweeper Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper is taking a look at your question.

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 6c795ff98f. Root cause: the parse-rejected high-watermark fix in 8c6421b846 introduced the regression — rawWatermarkRowid covered every raw row the fetcher saw, so when collected.length > perRunLimit the dropped tail's rowids leaked into the watermark and the catchup loop applied them as a cursor floor. Fix: in catchup-bridge.ts, after sorting + slicing to capped, clamp effectiveWatermarkRowid / effectiveWatermarkMs to capped[capped.length-1] when t...

@omarshahine
omarshahine force-pushed the feat/imsg-catchup branch from 6c795ff to 92c243a Compare May 9, 2026 01:39
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
… 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]>
omarshahine added a commit to omarshahine/openclaw that referenced this pull request May 9, 2026
…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]>
omarshahine and others added 7 commits May 8, 2026 19:47
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]>
@omarshahine
omarshahine force-pushed the feat/imsg-catchup branch from 92c243a to 9ae75c7 Compare May 9, 2026 02:49
@omarshahine
omarshahine merged commit 81e0a1a into openclaw:main May 9, 2026
110 checks passed
@omarshahine
omarshahine deleted the feat/imsg-catchup branch May 9, 2026 03:23
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
…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]>
rogerdigital pushed a commit to rogerdigital/openclaw that referenced this pull request May 9, 2026
…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]>
lykeion-dev pushed a commit to lykeion-dev/openclaw--rev that referenced this pull request May 14, 2026
…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]>
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
…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]>
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
…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]>
sablehead pushed a commit to sablehead/openclaw that referenced this pull request Jun 10, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: imessage Channel integration: imessage docs Improvements or additions to documentation maintainer Maintainer-authored PR proof: sufficient ClawSweeper judged the real behavior proof convincing. size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iMessage: catchup missed inbound messages received while gateway was down

1 participant