notification dismiss (auto-clear stale blocked pushes)
The mobile-side contract for auto-clearing a stale “blocked” push. When an agent
blocks, the bridge fans a blocked push out to every device (the “agent
needs you” notification) and mirrors it as gothalo.push_sent. When that block
is later resolved from anywhere — this phone, another paired device, the
desktop Herdr app, or the agent simply moving on — the tray notification on
every phone clears itself.
The backend half is a single process-wide bus consumer (the
notification-clearer, internal/notify). It watches the same unified event bus
that feeds WS /events, so “from anywhere” works for free: the
Herdr transition reaches the bus regardless of who caused it. There is no new
endpoint — the app only needs to handle the dismiss FCM message below (and may
optionally react to the gothalo.notification_cleared delta on WS /events).
Base event-stream framing (the envelope,
seqgap detection, reconnect semantics) lives in../CONTRACT.md. This document covers only the dismiss contract.
1. The dismiss FCM message (what the app receives)
Section titled “1. The dismiss FCM message (what the app receives)”A data-only FCM message (no notification block, so the app’s background
handler runs and cancels silently), sent at high priority so Doze does not sit on
it. Three data keys:
{ "type": "dismiss", "agent": "<pane_id>", "server_id": "<server_id>" }| Key | Value |
|---|---|
type |
always "dismiss" — the discriminator. An alert carries "alert". |
agent |
the pane_id — the same key an alert carries. |
server_id |
the sending bridge. The app cancels the notification tagged <server_id>/<pane_id>, so a dismiss from one machine never clears another machine’s alert about a pane of the same name. |
There is no title / body / status / state_change_seq on a dismiss — it
is purely data-only. The full payload contract for both message kinds is in
CONTRACT-notifications.md.
Cancelling the tray notification is the whole of the app-side effect. There is no alert log to reconcile: the tray is the only record an alert leaves, so a dismiss that clears it has by definition brought the app back in sync.
2. Target devices
Section titled “2. Target devices”The same set as the blocked push: all registered devices (every non-empty
FCM token in the device store, store.FCMTokens()), via the same FCM client.
There is no second FCM path and no per-device targeting — a block handled on one
device dismisses the notification on all of them.
3. The gothalo.notification_cleared bus event
Section titled “3. The gothalo.notification_cleared bus event”Alongside each dismiss, the bridge publishes a gothalo.notification_cleared
delta on WS /events:
{ "source":"gothalo", "type":"notification_cleared", "seq":<uint>, "ts":<ms>, "payload": { "pane": "<pane_id>", "server_id": "<server_id>" } }It is a consistency/bonus signal: a foreground app can clear its own UI
from this event without relying on the FCM dismiss. The app’s primary path
is the FCM dismiss (§1); this event is the in-band mirror. It is published
whenever an armed pane resolves — even if FCM is disabled or no devices are
registered (in which case only this event fires).
4. Trigger conditions (when a dismiss is sent)
Section titled “4. Trigger conditions (when a dismiss is sent)”A pane is armed the moment a push goes out for it (gothalo.push_sent with
status of blocked or done), and the clearer remembers which status
the notification announced. An armed pane is dismissed exactly once on the
first of these bus events:
| Bus event | Condition |
|---|---|
herdr.pane_agent_status_changed |
agent_status differs from the status the notification announced — the agent left the state it was notified about |
pane_closed |
the pane was closed (either herdr.pane_closed or gothalo.pane_closed) |
pane_exited |
the pane’s process exited (herdr.pane_exited) |
Guarantees:
- No double-dismiss. The pane is removed from the tracker the instant it’s dismissed, so a burst of resolving events (e.g. a status change and a close) dismisses only once. A resolution for a pane that was never armed is ignored.
- Re-arm on a new block. A pane that flips
blocked → working → blockedis armed again by the newblockedpush, so the next resolution dismisses again. - A
donenotice clears too, but only once the agent moves on. Because the armed status is remembered, thepane_agent_status_changed → doneevent that raised the completion notice does not also clear it; the agent starting work again does. Previouslydonenotifications stayed in the tray forever. - In-memory, resets on restart. The tracker is a small in-memory set; it’s cleared on bridge restart. A gap there is acceptable — the app re-snapshots on reconnect. If FCM is disabled (no creds) the consumer no-ops cleanly (logs only), mirroring how the blocked-push path already degrades.
Backend: internal/notify (the clearer) + gothalo.notification_cleared on the
unified bus. App side: handle data["type"] == "dismiss" in the background FCM
handler.
