Agent Session Continuity#
Agent Session Continuity covers the client-side problem of keeping a browser view synchronized with an agent session it may not be driving. This is distinct from Session Pool Management (runner keep-alive pool, history fingerprinting, server-side eviction) and Sandbox Infrastructure (sandbox lifecycle states).
The central challenge: there is no push channel to browsers today. The runner publishes every event to a Redis stream, but the only consumer is the ingest worker that writes to Postgres β entries are deleted on ingest, so a second browser cannot subscribe to a run it is not driving. The only live stream is the driving browser's own /run response.
Four concrete failure modes were identified and partially addressed as of mid-2026:
- Second browser permanently stuck on a stale partial transcript β #5530 , fixed in PR #5589
- Refresh while paused on an approval gate loses the gate β #5542 , open
- One browser tab kills another's live stream β #5530, fixed in PR #5589
- Second open tab misses short runs entirely β #5624 , stopgap in PR #5629
Failure Modes#
Stale Partial Transcript After Refresh (Multi-Browser)#
The revalidate-on-open path in AgentChatSlice refetched durable records on mount but only adopted them if the server had more messages than the local copy. transcriptToMessages folds a paused turn and its resume into a single assistant message β only a done record closes a message. A turn that grows in place (tool results landing, an approval round-trip completing) therefore has the same message count from start to finish. The adoption guard concluded the server was not ahead and kept the stale partial copy β permanently, on every reload.
One Tab Killing Another's Live Stream#
Session history, open-tab list, active tab, and cached transcripts were all atomWithStorage atoms subscribed to the window storage event. Every write in one tab replaced those records live in every other tab of the same origin. The open-tab list drives the Ant Design Tabs items, so an incoming replacement could remove a tab and unmount its useChat stream mid-turn β the in-flight turn was lost. Switching browser tabs made it worse because both session queries revalidate on focus, and the reconcile that follows writes to all three keys.
Refresh While Paused on an Approval Gate#
Triggering a Terminal/Write approval gate and reloading the page caused the approval card to disappear entirely. The turn rendered as "No response β the agent ended its turn without answering", and there was no way to re-answer the gate. Only sending a brand-new message unblocked the session, which triggered a new tool call rather than resuming the parked one.
Second Tab Misses Short Runs Entirely#
For an already-mounted session, only two paths ever refetched the transcript: (1) mount (once per session tab), and (2) the remote-run poll, only while runningElsewhere is true. The poll had two sharp edges: the first fetch was scheduled with a setTimeout using REMOTE_RUN_POLL_MS (15 s), and the effect cleanup cancelled the pending timer the moment runningElsewhere flipped false. A run that ended before the next tick discarded the pending fetch with no final catch-up. For short turns (the common case), a second open tab converged never rather than late.
Fixes (PR #5589 and PR #5629)#
Record-Count Watermark Adoption Guard#
PR #5589 replaced message-count comparison with a record-count watermark. The durable record log is append-only and ordered, so "the server has more records than my transcript was built from" is an exact test.
loadSessionMessages now returns the record count it built from, and that watermark is persisted next to the cached messages. The adoption function shouldAdoptServerTranscript compares server record count against the stored watermark; both the cache-miss hydration path and the revalidate-on-open path now share the same rule. A message-count check is kept only as a floor (ingest lag can serve a snapshot shorter than what the browser renders, and that must never be traded down).
The watermark is cleared when a turn goes live locally β since the runner's log for that turn is unknown at that point, the next open re-syncs from the log. Messages and watermarks can only move together: one writer sets both, and a single dropSessionMessages helper is the only deletion path.
Per-Tab Storage#
The four session stores were switched from cross-tab synchronized atomWithStorage (with subscribe removed) to per-tab localStorage. Cross-tab awareness is correctly handled by the server reconcile on focus and renames pushed to the durable stream header β the storage-event push was a redundant second channel that replaced whole records instead of merging them.
Running-Elsewhere Signal and Polling#
liveness.ts maintains a project-scoped is_alive signal (Redis TTL, polled every 15 s, refetched on window focus). When runningElsewhere is true, useSessionHydration.ts polls the record log using chained timeouts (not setInterval β the log is large and backend-slow), with backoff from 15 s to 60 s while the log is quiet and a reset on real growth. A visual strip is shown above the composer.
PR #5629 added two stopgaps to useSessionHydration.ts:
- Immediate first tick β
void poll()instead ofsetTimeout(poll, delay), so short runs that complete inside 15 s are not missed. - Falling-edge fetch β a
useEffecton therunningElsewhereβfalsetransition fires one final guarded adoption to capture records written between the last tick and the run's end. AprevRemoteRunReftracks bothsessionIdandrunningto avoid spurious fetches on session switches.
Remaining Gaps and Planned Work#
The stopgaps in PR #5629 close the short-run reproduction but do not eliminate the full-log poll cost. Three longer-term options were documented in issue #5624:
| Option | Cost | What it solves |
|---|---|---|
A. Lightweight count endpoint (GET /sessions/{id}/records/count) | ~1 day | Tabs poll a single indexed COUNT instead of the full record log. Dissolves the request-budget objection and replaces the stopgap polling. Scheduled as next ticket. |
| B. Per-session pub/sub + SSE | ~1 week | PUBLISH each record batch to a per-session Redis channel alongside the existing XADD; expose GET /sessions/{id}/events as SSE. Enables near-real-time following from a second browser. Requires careful subscribe-before-backfill ordering to avoid the ~250 ms ingest-lag gap. Reverses an explicit budget decision. |
| C. BroadcastChannel (frontend only) | Hours | Tab 1 posts "session X grew" to sibling tabs, which run guarded adoption. Same-browser only; safe because it triggers guarded adoption, not raw state replacement. |
The recommended sequence is A now, B as a deliberate architecture decision, C optional.
The approval-gate-lost-on-refresh issue (#5542) is separately tracked and requires correct reconstruction of pause sentinels from interaction_request records on reload.
Key Files and References#
| File | Purpose |
|---|---|
web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts | Transcript hydration, adoption guard, remote-run poll, falling-edge fetch |
web/oss/src/components/AgentChatSlice/state/liveness.ts | Project-scoped is_alive signal; per-session running / alive derivation |
web/oss/src/components/AgentChatSlice/assets/loadSession.ts | Returns record count (watermark) alongside the loaded messages |
web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.ts | Folds paused turn + resume into single assistant message β explains why message-count was wrong |
The adoption rule (shouldAdoptServerTranscript) lives in @agenta/entities so it runs in CI unit tests; transcriptToMessages.test.ts pins the property that made message counts wrong.
Related issues and PRs:
- PR #5589 β Record-count watermark, per-tab storage, running-elsewhere poll
- PR #5629 β Immediate first poll tick, falling-edge fetch stopgap
- Issue #5530 β Second browser stuck on stale transcript
- Issue #5542 β Refresh while paused on approval gate
- Issue #5624 β Second tab misses short runs