Files
iris_x_hermes/docs/08-push.md
T
ARIA d801a18db5
CI / Gateway plugin tests (push) Failing after 6m27s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m2s
fix FCM push notifications
2026-08-23 14:32:40 +02:00

8.3 KiB

08 — Push Notifications, Outbox & Sync

The gateway can't reach a sleeping phone directly. Push goes through a cloud relay. Decision: FCM primary, ntfy fallback (IRIS_PUSH_BACKEND).

8.1 When push fires

  • A frame targets a chat_id whose device is disconnected (no live SSE/long-poll subscriber) → drop to outbox + fire push — with one refinement, turn-aware push (below).
  • Also fire push for high-priority foreground events the user should see even if the app is backgrounded (approvals, clarifies, cron completions) — the app decides whether to also show an in-app banner.
  • If the device is connected, no push (the live frame is enough).

8.1.1 Turn-aware push (one push per turn, final answer as body)

An agent turn can span minutes and emit several completed status messages ("researching X…", "found Y…", "writing findings…", final answer). Pushing each parked message would spam an offline user with the steps in between. So while the agent's turn is in flight (hermes holds the typing indicator on from turn start until the handler's finally at turn end), normal-priority message / message.stop / media.offer frames that park with no live device are held back per chat instead of pushing; the latest one is pushed when the turn ends (stop_typing), so the offline user gets one push with the final answer. Details:

  • Turn state is tracked per chat_id from the typing indicator (send_typing → in flight, stop_typing → ended; hermes fires stop_typing in the handler's finally, after the final send, so the flush always sees the final frame).
  • The held-back frame is still parked in the outbox — sync catch-up is unaffected; only the push is deferred.
  • High-priority notifications (approval/clarify/cron) push immediately, even mid-turn — they need user action.
  • If the device reconnects mid-turn (SSE/long-poll open), the held-back push is dropped: the app syncs the parked frames and must not get a duplicate push at turn end.
  • If the turn ends while the device is live, nothing is pushed (the frames were delivered live / synced).
  • Turns without a typing indicator (e.g. typing disabled in config) and non-turn deliveries (cron, standalone sends) push immediately as before.
  • Best-effort: a gateway crash mid-turn loses the held-back push (the frames remain in the outbox and sync on reconnect).

8.2 PushBackend interface (push.py)

class PushBackend(Protocol):
    name: str
    async def send(self, *, device_id: str, chat_id: str,
                   title: str, body: str,
                   data: dict) -> bool: ...
    def configured(self) -> bool: ...

Selected at adapter init by IRIS_PUSH_BACKEND (fcm default, ntfy).

8.2.1 FcmBackend (primary)

  • FCM HTTP v1 API via httpx (core dep). Auth = Firebase service account (IRIS_FCM_SERVICE_ACCOUNT JSON path) → mint a short-lived OAuth2 access token (cached, refreshed before expiry).
  • Fallback: legacy server key (IRIS_FCM_SERVER_KEY) if no service account (simpler, but legacy).
  • Target = the device's FCM token (registered via hello / fcm.register, stored in devices.db).
  • Payload:
    • notification {title, body} → system notification (tap → open app).
    • data {chat_id, thread_id, kind, message_id, cursor} → app uses to sync.
  • Data-only option: for background catch-up, send a data message (silent) so the app's FirebaseMessagingService wakes a foreground service and syncs without a visible notification (used for non-urgent updates).
  • Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few devices).

8.2.2 NtfyBackend (fallback, self-host friendly)

  • Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
  • Publish to NTFY_TOPIC on NTFY_SERVER_URL (default https://ntfy.sh) via httpx POST, with an X-Title / X-Message / X-Tag / X-Priority and a JSON data attachment ({chat_id, thread_id, kind, cursor}).
  • The app subscribes to the topic (via an ntfy client lib or a lightweight listener) to receive pushes without Firebase.
  • Best for users who self-host ntfy and want zero Firebase.

8.3 Outbox (outbox.py)

  • SQLite outbox.db: rows {cursor (monotonic), chat_id, thread_id, frame_json, ts, delivered}.
  • Write on every outbound frame that has no live subscriber (or always, then mark delivered on live send — simpler + crash-safe).
  • Retention: outbox_retention_hours (default 72h); prune on write.
  • Cursor: global monotonic int; hello.ack returns the current cursor; sync {cursor} replays rows with cursor > given.
  • Bounded: if the outbox grows past a cap (e.g. 5k rows), oldest are pruned and a notification {kind:"generic", body:"Older messages pruned"} is sent.

8.4 Sync (reconnect catch-up)

  1. App reconnects → hello → hello.ack {sync_cursor}.
  2. App sends sync {cursor: <last seen>}.
  3. Server replays outbox frames (in cursor order) → app applies them (messages, tools, channels, media offers).
  4. Server sends sync.done {cursor: <new>}.
  5. App updates its local cursor (Room) — idempotent (dedupe by message_id).

This makes the app eventually consistent across disconnects, restarts, and gateway restarts.

8.5 App-side push handling (Android)

  • FirebaseMessagingService.onMessageReceived (foreground): show in-app banner + optionally sync.
  • onNewToken → fcm.register the new token.
  • Background data message → start a foreground service (low-priority, notification channel "Sync") → open WS → sync → stop.
  • Notification channels (Android 8+): one per chat so cron channels can have their own style/priority (e.g. "Cron Reports" = high, "Default" = default). Tapping a notification deep-links to the chat/thread.
  • ntfy mode: a foreground service maintains the ntfy subscription; incoming events trigger the same sync path.

8.6 In-app banners (foreground)

notification frames (e.g. channel_renamed, approval, clarify, cron) render as a transient banner above the composer (like the reference screenshot's "ARIA hat Thema … umbenannt"). Dismissible; high-priority ones (approval/clarify) persist until acted on.

8.7 Security

  • FCM tokens are per-device, revocable; stored only in devices.db.
  • Push payloads carry no secrets and no full message bodies larger than a short preview (privacy on lock screen). Full content is fetched via sync over the authenticated WS.
  • ntfy: use a private topic + auth token for any real trust boundary (hermes ntfy adapter guidance).

8.8 Notification dedupe (push vs. sync)

A message sent while the device is offline is notified twice by naive design: once by the push (FCM displays the notification payload), and again when the app reconnects, syncs the outbox, and mirrors the replayed frames to system notifications (the background-mirror path, §8.5). The fix is a per- device push watermark:

  • Gateway records the highest outbox cursor delivered to each device via the push backend (devices.last_pushed_cursor, advanced only on a successful send) and returns it in hello.ack as last_pushed_cursor.
  • Gateway coalesces back-to-back pushes per chat (5 s window): a cron delivery parks a notification frame AND a message frame, and only the first pushes — the second reaches the app via sync (tap the first notification).
  • Gateway tags every sync-replayed frame with its outbox cursor in the frame envelope (cursor; live frames carry none).
  • App skips system notifications for replayed frames with cursor <= lastPushedCursor (they already woke the device). Live frames are never suppressed — that is exactly the case where no push fired and the app must notify itself.
  • App FCM handler (onMessageReceived) posts nothing when the WS is connected (the background-mirror path handles it) and nothing when the message carried a notification payload (FCM already displayed it); data-only messages are the exception (the app must display them itself).

Residual edge: FCM is at-least-once, so a lost device ack can still produce a duplicate system-displayed notification (two FCM-Notification:* ids). The designed evolution is the data-only push option (§8.2.1), which moves display into the app and lets it use a stable per-message notification id.