Summary
Related to #6200 (same root cause class — subscribe-only presence cache on mobile), but this report adds the cold-open timing signature and a broader fix matrix including relay-side options that would also fix desktop's cold start.
Observed: opening the mobile app shows all agents offline, then they flip online one at a time over ~30–60 seconds. Which agent comes online first varies between runs.
Observed behavior (live, relay-side verified)
Environment: self-hosted relay (buzz-relay), 5 identities (1 human + 4 agents) each running an adapter that maintains an authenticated WebSocket and publishes kind:20001 heartbeats on a ~60s cycle.
At the moment the mobile app showed "all agents offline":
- Relay Redis presence keys: all identities
online, TTLs ~120–180s and continuously refreshing (verified directly in Redis: GET → online, TTL → counting down and resetting)
- HTTP bridge
POST /query with kinds:[20001] + authors:[...]: returns online for every requested pubkey (this is the path desktop uses as its backstop)
buzz users presence --pubkeys ... CLI against the same relay: online for all
So the authoritative state is fully available server-side; only the mobile client renders offline.
Root cause
mobile/lib/features/profile/presence_cache_provider.dart — PresenceCacheNotifier:
build() returns {} and opens a live subscription (kinds: [EventKind.presenceUpdate], limit: 0) — future events only, no snapshot.
track() (lines 40–46) is an explicit no-op for fetching:
/// Currently a no-op for the actual fetch — we rely on live kind:20001
/// events. The tracked set is still used to filter incoming events so the
/// cache doesn't grow unbounded.
void track(List<String> pubkeys) {
// TODO(presence): once the relay supports a `presence:true` filter
// extension, issue a one-shot fetch here for the latest known state per
// pubkey. Until then, presence is "online whenever they publish".
}
- Heartbeats from independent agents are staggered (each adapter process has its own ~60s cycle). As each agent's next heartbeat fires after the app subscribes, the relay fans the ephemeral event out and that one agent flips online. An agent whose heartbeat fired just before app open stays "offline" for up to a full cycle.
That produces the exact observed signature: empty cache on open → agents appear one-by-one inside a ~60s window, order varying run to run.
Note on the WS path: relay-side, presence synthesis from Redis exists only on the HTTP bridge (crates/buzz-relay/src/api/bridge.rs, synthesize_presence() around line 2188 — intercepts /query filters targeting kind:20001/40902 with authors, answers from pubsub.get_presence_bulk). The WS REQ handler (crates/buzz-relay/src/handlers/req.rs) has no equivalent, so a WS limit: 0 subscription legitimately returns nothing for current state — ephemeral events are never stored.
Fix options
Option 1 — mobile one-shot snapshot via the existing HTTP bridge (smallest, no relay change)
On track(pubkeys) (or once per successful (re)connect), issue a single POST /query with kinds:[20001], authors:[tracked] and seed the cache from the relay-synthesized events, then keep the live WS subscription for updates. The relay-side synthesis already exists and is what desktop's get_presence (desktop/src-tauri/src/commands/profile.rs:338) already consumes; note it returns relay-signed events whose subject is in a p tag, which desktop already parses (profile.rs ~line 360–374). Mirrors #6200's suggested short-term fix; also fixes this cold-open variant.
Option 2 — relay-side presence:true filter extension (the anticipated TODO)
Add a REQ filter extension so a client can ask for the relay-synthesized presence snapshot directly over WS in the same subscription that delivers live updates. This is what the mobile TODO in track() anticipates. Removes the separate REST round trip and the double source-of-truth, but is a protocol/relay change.
Option 3 — relay-side WS REQ synthesis for kind:20001 with authors
Generalize the existing synthesize_presence() interception to the WS REQ path: when a REQ targets only kind:20001 (or 40902) with a non-empty authors list and limit: 0, emit the current Redis-backed snapshot as events before EOSE, in addition to the live fan-out. One implementation fixes both clients at once — mobile's subscribe-only cache and desktop's cold start (desktop currently paints from a rate-limit-queued REST call; with WS synthesis its subscription would immediately deliver authoritative state).
Options 1 and 3 are complementary: 1 is an immediate client-side patch, 3 removes the architectural asymmetry that keeps producing this class of bug (#6200 reconnect case, this cold-open case, #6560 members-list indicators).
Repro
Self-hosted relay with ≥2 agents publishing staggered presence heartbeats. Open the mobile app cold (or kill/relaunch) and watch the roster/agents view: all agents start offline, then flip online individually over the next ~30–60s. Meanwhile POST /query kinds:[20001] authors:[...] on the same relay returns online for all of them instantly.
Summary
Related to #6200 (same root cause class — subscribe-only presence cache on mobile), but this report adds the cold-open timing signature and a broader fix matrix including relay-side options that would also fix desktop's cold start.
Observed: opening the mobile app shows all agents offline, then they flip online one at a time over ~30–60 seconds. Which agent comes online first varies between runs.
Observed behavior (live, relay-side verified)
Environment: self-hosted relay (
buzz-relay), 5 identities (1 human + 4 agents) each running an adapter that maintains an authenticated WebSocket and publishes kind:20001 heartbeats on a ~60s cycle.At the moment the mobile app showed "all agents offline":
online, TTLs ~120–180s and continuously refreshing (verified directly in Redis:GET→online,TTL→ counting down and resetting)POST /querywithkinds:[20001]+authors:[...]: returnsonlinefor every requested pubkey (this is the path desktop uses as its backstop)buzz users presence --pubkeys ...CLI against the same relay:onlinefor allSo the authoritative state is fully available server-side; only the mobile client renders offline.
Root cause
mobile/lib/features/profile/presence_cache_provider.dart—PresenceCacheNotifier:build()returns{}and opens a live subscription (kinds: [EventKind.presenceUpdate], limit: 0) — future events only, no snapshot.track()(lines 40–46) is an explicit no-op for fetching:That produces the exact observed signature: empty cache on open → agents appear one-by-one inside a ~60s window, order varying run to run.
Note on the WS path: relay-side, presence synthesis from Redis exists only on the HTTP bridge (
crates/buzz-relay/src/api/bridge.rs,synthesize_presence()around line 2188 — intercepts/queryfilters targeting kind:20001/40902 withauthors, answers frompubsub.get_presence_bulk). The WS REQ handler (crates/buzz-relay/src/handlers/req.rs) has no equivalent, so a WSlimit: 0subscription legitimately returns nothing for current state — ephemeral events are never stored.Fix options
Option 1 — mobile one-shot snapshot via the existing HTTP bridge (smallest, no relay change)
On
track(pubkeys)(or once per successful (re)connect), issue a singlePOST /querywithkinds:[20001], authors:[tracked]and seed the cache from the relay-synthesized events, then keep the live WS subscription for updates. The relay-side synthesis already exists and is what desktop'sget_presence(desktop/src-tauri/src/commands/profile.rs:338) already consumes; note it returns relay-signed events whose subject is in aptag, which desktop already parses (profile.rs~line 360–374). Mirrors #6200's suggested short-term fix; also fixes this cold-open variant.Option 2 — relay-side
presence:truefilter extension (the anticipated TODO)Add a REQ filter extension so a client can ask for the relay-synthesized presence snapshot directly over WS in the same subscription that delivers live updates. This is what the mobile TODO in
track()anticipates. Removes the separate REST round trip and the double source-of-truth, but is a protocol/relay change.Option 3 — relay-side WS REQ synthesis for kind:20001 with
authorsGeneralize the existing
synthesize_presence()interception to the WS REQ path: when a REQ targets only kind:20001 (or 40902) with a non-emptyauthorslist andlimit: 0, emit the current Redis-backed snapshot as events before EOSE, in addition to the live fan-out. One implementation fixes both clients at once — mobile's subscribe-only cache and desktop's cold start (desktop currently paints from a rate-limit-queued REST call; with WS synthesis its subscription would immediately deliver authoritative state).Options 1 and 3 are complementary: 1 is an immediate client-side patch, 3 removes the architectural asymmetry that keeps producing this class of bug (#6200 reconnect case, this cold-open case, #6560 members-list indicators).
Repro
Self-hosted relay with ≥2 agents publishing staggered presence heartbeats. Open the mobile app cold (or kill/relaunch) and watch the roster/agents view: all agents start offline, then flip online individually over the next ~30–60s. Meanwhile
POST /query kinds:[20001] authors:[...]on the same relay returnsonlinefor all of them instantly.