Repository navigation
Fleet event stream is lossy by construction — remote sessions lag and desync during live activity #1770
Description
Important
Problem — Sessions viewed across the fleet lag behind, or silently fall out of sync with, the machine that owns them — mostly during live activity. Live events cross up to four relays (owner engine, owner hub fan-in, SSH peer link, viewing window's fan-in, webview), and no frame on that path carries a sequence id, so nothing can be replayed and no hop can tell what it missed. Three mechanisms are confirmed on the current two-machine fleet. Listener eviction: each fan-in service serves exactly one listener, so a second view onto the same hub (another window, a Chat Deck pane, a split pane) ends the first view's stream, and the two re-open against each other once per second — the Studio engine logged ~340 reconnects in 15 minutes on Oct 12 as two interleaved ~1 s series, and ~234 per minute for 110 minutes on Sep 29. Silent gaps: the engine emits no SSE ids and ignores the resume cursor the client already sends; the fan-in's reconnect gap signal only fires for arms that delivered an id'd frame, which never happens in production; relays close listeners with a clean end of stream; and the client treats a clean end as normal, reconnecting without refetching the viewed session. Burst volume: per-turn summary events carry full patch text (up to 12.3 MB on the Studio, 10 MB on the laptop), every stored event is emitted twice, a running shell tool republishes its last 30 KB on every output chunk, and both stream readers re-scan their whole partial buffer per network chunk — quadratic cost on the webview's main thread. Amplifiers: the session-ownership map drops a peer's sessions on one failed refresh (closing its event arm and routing its sessions to the local engine), relay buffers cannot pause their upstream and heartbeats carry no timestamp (lag is invisible), and the peer SSH forward is hand-installed with stale keepalives and never health-checked.
Approach — Make the stream lossless by construction and every relay honest. The engine stamps each global-stream frame with a monotonic id (process epoch plus sequence) and keeps a bounded replay buffer that honors the resume cursor, answering a stale or foreign cursor with an explicit reset. Relays serve every listener independently, never end a listener silently, and resume arms from real ids. The client tracks the SSE id, checks contiguity, and resyncs on reset, unexpected end, or a hole. Burst volume is cut at the source (sync copies opt-in, patch bodies off the wire, shell output throttled) and frame splitting becomes linear. A census line on every fan-in makes evictions, re-opens, drops, gaps, and ownership refreshes visible.
Approaches considered — Keep patching per hop (#1601, #1617, #1638, #1646, #1649, #1766): rejected — each fixed one hop's symptom while the root (no ids, no replay) kept producing new ones; the fan-in's lossless-resume design (#1511 §D3) assumed ids the engine never sent, and its tests feed synthetic id'd frames, so they pass while production cannot. Sequence and replay in the hub service instead of the engine: rejected — leaves the fork untouched but needs a buffer at every relay, leaves the engine-to-hub hop unsequenced, and an engine restart still forces a full resync; the engine's bus already assigns ascending payload ids, so the patch is small. Client-side gap detection without replay: kept as the fallback (reset leads to a full resync), rejected as the primary — it turns every link blip into a full refetch over the same congested link.
Scope — in: the global event route (ids, replay, reset, heartbeat time, subscription release on abort); the fan-in driver and aggregator (per-listener connections, honest close, cursor resume, census); the session-ownership feed (last-good retention); the client stream loop (cursor, contiguity, reset and end-of-stream resync); burst-volume cuts; linear frame splitting; installer-managed peer forwards and their healthcheck. out: the per-directory event route used by the extension host's status client; the v2 per-session event API; compaction of the stored event log; the Python frontdoor deployment tracked in #1757 and #1751.
Assumptions — The reporter's workflow routinely has two or more views onto the same remote hub (confirmed). A replay window of about five minutes covers ordinary link blips and window reloads; longer outages fall back to a full resync. Engine changes reach the hubs only through scratch-engine validation followed by a human-scheduled hub restart per machine, because a hub restart ends that machine's in-flight sessions (#1552).
Acceptance Criteria
- Two or more views on the same remote session stay live together: no listener is evicted, and the owner engine's reconnect rate stays at its background level while they are open.
- Killing the peer link once mid-turn and restoring it loses nothing: the missed frames replay on reconnect, and the transcript afterwards equals a fresh load in every view.
- Every unrecoverable loss is announced: a stale or foreign cursor, an unexpected end of stream, or a sequence hole triggers a resync of the viewed session — never a silent gap.
- A turn with a multi-file edit and a chatty shell command keeps the largest live frame under 256 KB, and the viewer's 95th-percentile per-hop lag stays under 1 s on the LAN.
- Each fan-in service logs a periodic census (listeners, evictions, re-opens with reason, drops, gaps, per-peer ownership refresh outcome), so a wedge or storm is visible without a probe run.
Testing Decisions
- Reuse-first: extend the fan-in aggregator and driver pump suites, the session-multiplexer and owner-routing suites, the observe read/write/event routing suites, the app's stream-liveness and gap-frame suites, the engine server suite in the overlay, and the slow two-peer fleet E2E.
- Replace the fan-in suites' synthetic upstream frames with engine-shaped fixtures (id-less today; epoch-and-sequence ids after the engine slice), so the tests exercise the wire format production actually sees.
- New surface: a two-listener harness, a chunked large-frame harness, and a scratch-engine replay test against the compiled binary.
Key Decisions
- The engine is the sequencer: one counter at the global bus, ids of the form epoch-and-sequence, a replay buffer bounded by bytes and age, and an explicit reset for an unservable cursor.
- Relays are pipes with honest failure: they pass ids through, resume from the last delivered id, and end a listener with an error unless the service is shutting down.
- The client's cursor is the SSE id, not the JSON payload id; contiguity is checked per origin.
- Fleet-of-one byte identity (Fleet thin client — Slice 4: lossless SSE reconnect (cursor resume) #1264) is preserved: no synthetic frames enter a solo pass-through stream; loss there is signalled by severing the connection with an error.
- Burst cuts happen on the wire, not in storage: the stored record keeps full summaries.
Data Contracts
- Global-stream frame id:
<epoch>:<seq>— epoch is unique per engine process start; seq strictly increases across all subscribers of that process. A sync copy carries the id of the event it mirrors, so filtering sync copies leaves no holes. - Reset frame: the first frame on a connection whose cursor cannot be served; carries the current epoch and seq; tells the client to discard its cursor and fully resync.
- Heartbeat: carries server time in milliseconds and the current seq, so lag and idle holes are measurable.
- Fan-in composite cursor: format unchanged (
<namespace>=<id>pairs joined by;), now populated with real engine ids.
Constraints & Invariants
- No frame is reordered, and none is applied twice after a replay.
- An id-less control frame (gap, focus, comment) never advances a cursor.
- A gap or reset always forces a resync of the viewed session and the entity rail.
- The backpressure re-arm (Fan-in relay never re-arms backpressure (flowing stuck false) → session transcript + rail go stale until reload #1617) and the cursor bookkeeping (Fan-in relay resumes re-opened arm from connect-time cursor + no gap frame on reconnect → silent event loss #1638) keep working.
- No hub is restarted without a human-scheduled window.
Prior Art
- The fan-in aggregator and driver (Fleet Studio: per-session SSE fan-in aggregator + lossless resume (ADR 0033 impl) #1511, Fleet Studio: wire the SSE fan-in aggregator into the /event route (behind flag) #1519, Fleet Studio B2b · slice 3 — SSE fan-in on the observation /event (ADR 0033 D1-D4) #1543), the backpressure re-arm (Fan-in relay never re-arms backpressure (flowing stuck false) → session transcript + rail go stale until reload #1617), the cursor-advance and reconnect gap work (Fan-in relay resumes re-opened arm from connect-time cursor + no gap frame on reconnect → silent event loss #1638), the local-arm re-open (Fan-in /event driver never re-opens the LOCAL arm → local session events freeze until reload #1601), the transcript self-heal hooks (Chat message transcript has no reconnect/resync self-heal — stale rail heals only on reload (the #1617 fourth root) #1646), the status re-seed on gap (Client: honor status seq + re-seed status on reconnect/gap + turn-level working floor (non-text turns) #1637), the owner-map feed and merged projection, the global bus id assignment, the event-v2 bridge's sync re-emit, the shell tool's output preview, the generated SDK stream reader.
Source
- Investigation on the two-machine fleet (laptop and Mac Studio), Oct 12 2026: fan-in code read, engine logs on both machines (reconnect fingerprints), session-database size census, launchd and tunnel configuration.
- Supersedes the per-hop symptom issues once verified: [A1] Fix SSE fan-in race: always accept on observation wire #1565, [A2] Fix dead arm zombie: cleanup source on pump exit #1566, Fan-in /event driver never re-opens the LOCAL arm → local session events freeze until reload #1601, Fan-in relay never re-arms backpressure (flowing stuck false) → session transcript + rail go stale until reload #1617, Chat message transcript has no reconnect/resync self-heal — stale rail heals only on reload (the #1617 fourth root) #1646, Thought rail not live during a foreground subagent — running dot settles until reconnect (the #1617 fifth root) #1649, Remote session loses its working rail + tab active state after reopen (fleet-aware status re-seed) #1766 — they stay open until the verification slice passes.
Notes
- Side finding to settle in the engine slice: the laptop engine logged 18 global-stream connects and 0 disconnects in its current 13.5 h run (3.7 GB resident) while the Studio's engine balances (397 and 396, 1.5 GB) — possibly subscriptions not released when a client aborts.
- The stored event table is about 3 GB of the laptop's 5 GB session database; this campaign cuts wire bytes, not that table.
Activity
Merge #1771: fan-in census line + engine-shaped stream fixtures (S0 of …
Merge #1778: engine burst-volume cuts — opt-in sync copies, patches o…
Merge #1773: every lost stretch of events forces a resync — no silent…
Merge #1774: ownership map keeps a peer's sessions across a failed re…
Merge #1775: installer-managed per-peer SSH forwards with standard ke…
Merge #1777: lossless resume end to end — relays and client resume fr…
Merge #1779: linear-time SSE frame splitting in the relay and app rea…
Merge pull request #1781 from harmoniqs/amico/issue-1770-fleet-event-…
jeonghun-jj-lee commentedon Oct 13, 2026
Reopened deliberately. The code landed in #1781 (merge commit 4f5023b), and the PR's
Closes #1770closed this on merge. This issue's own definition of done, though, is the on-fleet verification in #1780: two views on a Studio session during live activity, with the link killed once. That hasn't run yet. The laptop is deployed and the Studio deploy is in progress. This issue closes when #1780 passes.