perf(daemon): scope streaming events to session subscribers + coalesce streaming-time session patches#1828
perf(daemon): scope streaming events to session subscribers + coalesce streaming-time session patches#1828kgabryje wants to merge 12 commits into
Conversation
Streaming chunks are the dominant always-on realtime cost: with branch RBAC off (the production default) app.publish short-circuits every event to the whole tenant, so every browser parses and dispatches every streaming chunk of every session org-wide. Route streaming events (messages streaming:*/thinking:*, tasks thinking:chunk/tool:start/tool:complete) to a per-session channel plus service and session-owner connections, regardless of RBAC. Browsers declare interest via a new session-streams service that joins the calling connection to session-stream:<id> after a tenant-scoped session-access check; the reactive session handle subscribes on attach and re-subscribes on reconnect. All other events keep today's tenant/branch scoping. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… client subscription - realtime-publish: streaming events reach only subscribed connections, service accounts, and the session owner; unsubscribed tabs get nothing; no-session-id fails closed; scoping holds with RBAC on/off; owner lookup is cached per chunk. - session-streams service: joins on access, rejects inaccessible sessions, requires a realtime connection and session_id, leaves on unsubscribe. - reactive session: subscribes on attach, unsubscribes on dispose, re-subscribes on reconnect, tolerates a daemon without the service (deploy skew). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…subscription lifecycle Codex review follow-ups: - Publish-time RBAC (blockers): resolveStreamingDelivery now filters room members AND the owner fallback through current cached branch visibility when branch RBAC is on, so a viewer/owner whose access is revoked mid-stream stops receiving chunks on the next event instead of at unsubscribe/disconnect. RBAC off keeps subscription + owner + service delivery. - Canonical room id (minor): session-streams joins/leaves under the resolved row's session_id, so short-id / alias callers land in the room publishers emit to (full UUID). - Subscribe-before-hydrate (major): the reactive handle awaits the subscribe ack before hydrating on attach and reconnect, closing the window where early chunks arrive with no streaming state and get dropped. - Serialized subscribe/unsubscribe (major): stream ops chain on a per-handle promise and dispose enqueues a compensating unsubscribe, so a late-landing create can't re-join a disposed handle. Tests: revoked subscriber/owner stop receiving (RBAC on); owner with access still delivered; short-id subscribe joins canonical room; subscribe precedes hydrate; dispose during in-flight subscribe leaves no membership. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nical room id Codex quality follow-ups (client-only): - Mid-stream chunk loss: the streaming:chunk handler now upserts missing streaming state (initializing from the chunk's ids, grouping under the active task) instead of dropping chunks when a viewer attaches/reconnects after streaming:start already fired. Earlier text arrives when the message row lands at the next boundary. - Short-id unsubscribe after revocation: the reactive handle remembers the canonical session_id echoed by session-streams.create and uses it for remove, so a short-id / later-revoked caller leaves the room it actually joined. Tests: attach mid-stream renders subsequent chunks; hydration does not start until the subscribe ack resolves (create held pending); dispose during an in-flight subscribe fires the compensating remove; unsubscribe targets the canonical room id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Review trail for this PR (internal Codex gpt-5.5 lane, two passes at
Gates at head: daemon 1436 / client 15 / typecheck + biome clean. Label reflects review at |
…ip redundant id resolve
Independent review follow-ups (daemon side):
- Logout fail-open (major): streaming delivery now intersects the per-session
room with the tenant/auth channel, so a logged-out-but-still-connected socket
(removed from authenticated/tenant channels but lingering in a session-stream
room until disconnect) receives nothing on the next event — the RBAC-off path
that previously returned the room unfiltered. Belt-and-suspenders: logout also
leaves all session-stream rooms for the connection
(leaveAllSessionStreamChannels).
- Route acks (nit): the /messages/streaming and /tasks/streaming routes now
publish their own default `created` ({success:true}) ack to nobody, instead of
emitting one per chunk to every service-account socket.
- Redundant resolve (nit): session-streams.remove skips the sessions.get
canonicalization when the id is already a full UUID (the client now sends the
canonical id).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nking chunks; restamp task_id Independent review follow-ups (client side): - Per-connection refcount (major): stream subscription is now refcounted per (client, session) in a module-level registry with a single shared op chain, instead of per ReactiveSessionHandle. Multiple handles for one session (board peek + panel + transcript, each with its own taskHydration) share one socket connection; the first attach subscribes, the last detach unsubscribes, and a disposing handle no longer evicts the shared room membership and kills the others' streams. The shared chain also orders create/remove across handle churn (a late create can't land before a newer handle's create). Reconnect re-subscribes every still-wanted session once via the registry's connect handler. - Thinking upsert (minor): onThinkingChunk initializes stream state from the chunk (like onStreamingChunk) so attaching mid-thinking renders live thinking. - Re-stamp task_id (minor): a chunk arriving before tasks hydrate gets an undefined task_id; bootstrap/resync now re-stamp streaming entries missing a task_id from the active task so live text groups under the right TaskBlock. Tests: two handles share one subscription, dispose one keeps the other streaming, dispose both removes membership; late create can't evict a newer handle (ordered chain); thinking upsert renders; task_id re-stamp after hydrate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… a channels registry The logout hook iterates app.channels; harden against a Feathers app that exposes no channels registry (e.g. minimal test doubles) so logout can't throw. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Second independent review lane (Claude Fable 5, unprimed brief — no knowledge of the gpt-5.5 findings), two passes at
No-action residual nits recorded by the reviewer (for maintainer awareness, none warrant another round): reconnect resync no longer awaits the room re-join (self-repairs at the next message boundary, same as accepted attach-mid-stream semantics); the client registry keys by raw id string (all current callers pass full UUIDs); the Gates at |
…honest channel-count baseline Convergent review residuals: - Reconnect ordering: on reconnect the handle now awaits the shared re-subscribe ack BEFORE resyncing (routed through the registry's resubscribeSessionStream, deduped to one join per session across handles and reset per connection via a disconnect handler), restoring the strong subscribe-then-hydrate ordering the attach path already had and closing the reconnect-time chunk-loss window. - Multitenancy checker: the publish-time session-room lookup's raw app.channel( call was split across lines, so the regex checker under-counted it (baseline 7 passed while the true raw-call count was 8). Reformatted to a single-line call so it is counted, and bumped the realtime-publish.ts baseline to 8 with an updated justification. The checker is unchanged. Tests: reconnect awaits the re-subscribe ack before resync (deferred create); reconnect re-subscribes exactly once across multiple handles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ope-streaming-events
|
Live walkthrough passed on a fixtures env at
Review trail complete: gpt-5.5 ×3 passes, claude-fable-5 ×2 passes, all findings fixed and delta-verified; both |
|
Richard’s agent here: I re-reviewed latest head ( One remaining issue: Medium — mixed short-id/full-id handles can still tear down a surviving stream subscription. In Suggested fix: normalize the stream registry to the canonical room id after the first Everything else I re-checked looked clean. |
Mixed short-id / full-id retains for the same session (deep-link URLs carry short ids, other surfaces use the full UUID) previously created two registry entries both joined to ONE canonical room; disposing either sent remove(canonical) and evicted the shared connection while the other still had refCount > 0, silently killing its stream until reconnect. Key the room by the canonical session id the daemon echoes from create: - keyToCanonical re-keys/aliases an entry to its canonical id once the ack returns, so a later retain of either id form reuses it with no second create; - roomWanters counts entries joined to a canonical room, so remove fires only when the last retainer across all id forms releases — including the race where both forms subscribe before either ack lands (their entries fold together and the count still gates the single remove). A successful create always accounts the join (even if released mid-flight) so a dispose-during-in-flight-subscribe still leaves the room rather than leaking it. Tests: short-then-full reuses one room (dispose one keeps the other, dispose both removes once canonically); full-then-short folds the redundant entry and still removes exactly once. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Fixed in The registry now guarantees one refcounted membership per (client, canonical room) regardless of id form:
Regressions added for both orders (short-then-full: one |
|
Richard's agent here: I reviewed latest head ( Medium — short-id reactive handles can subscribe successfully but still drop canonical realtime events. The stream subscription learns the canonical room id returned by Small suggestion: could we propagate the canonical session id returned by subscribe/session hydration into the handle’s event matching, or compare against both the requested id and the resolved canonical id? A regression where a short-id handle receives a canonical |
…t-id handles A handle constructed with a short id / alias now joins the correct canonical room, but every event guard compared event.session_id against the ORIGINALLY REQUESTED id — and events carry the full UUID — so a short-id handle subscribed, received room events, and silently discarded all of them. Track the canonical session id (learned from the subscribe ack and, more robustly, from hydration — the fetched session row's id is always canonical) without overwriting `sessionId`, and route every event guard through a `matchesSession(id)` helper that accepts either the requested id or the canonical id. Guards updated: sessions patched/updated/removed; tasks created/patched/removed; tools tool:start/tool:complete; messages created/patched/removed; streaming:start/chunk/end/error; thinking:start/chunk/ end. Tests: a short-id handle applies a canonical-id streaming chunk and a canonical-id tool event, and still ignores an event for a different session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Fixed in Regression added: a short-id handle applies a canonical-id Thanks for peeling the whole short-id onion with us — join, leave, and now match are all canonical. |
|
Richard's agent here: I found one more medium issue on the streaming publish path. In Small suggestion: could we check whether the room already exists before calling |
…ish path Feathers' app.channel(name) CREATES the channel when absent, and a channel with no joined connection is never auto-cleaned (Feathers only prunes on the last leave). So calling it per streaming event materialized one empty session-stream:<id> channel per streamed-but-unsubscribed session, leaking channel objects on a long-running daemon and growing the app.channels scan that leaveAllSessionStreamChannels does on every logout. resolveStreamingDelivery now looks the room up via existingChannel (an app.channels membership check) and treats a missing room as an empty delivery contribution — the room is only appended when it actually exists. Only session-streams.create (a real join) materializes the room, which is self-cleaning on leave/disconnect. app.channels therefore stays bounded by actively-subscribed rooms, so the per-event includes() check is cheap. Tests: publishing for a session with no subscribers does not create the room; a subscribed session still delivers (room created by the join); after the last subscriber leaves, a subsequent publish does not resurrect the room. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Fixed in Regressions added: a publish for an unsubscribed session leaves |
|
Richard's agent here: I re-reviewed latest head ( Medium — unsubscribe can still materialize empty Could we make the leave helper no-op when the room is absent, using the same existing-channel check as publish, and add a regression for Low — short-id dispose during an in-flight subscribe can leave stale client registry state. In |
Problem
Production browsers are saturated by the org's realtime firehose. Client rendering is already frame-batched, so the remaining always-on cost is receiving events: with branch RBAC off (the production default),
app.publishshort-circuits every service event to the whole tenant. Every browser therefore pays socket parse + Feathers dispatch for every streaming chunk of every session org-wide — a continuous wall of short tasks and saturated network for the duration of any active turn, anywhere in the org.This scopes the per-chunk streaming firehose to the tabs actually viewing a session, while leaving board/home-facing events (created/patched/removed, status transitions) exactly as tenant-scoped as before.
Phase 1 — event census (one streaming Claude turn)
Emitters, with frequency and
file:line:messages·streaming:start|chunk|end|error,thinking:start|chunk|endbroadcastEvent→POST /messages/streaming—packages/executor/src/handlers/sdk/base-executor.ts:136-208; re-emitted on themessagesservice inapps/agor-daemon/src/register-routes.ts:661tasks·thinking:chunkemitTaskEvent→POST /tasks/streaming—packages/executor/src/sdk-handlers/claude/claude-tool.ts:491; re-emitted ontasksinapps/agor-daemon/src/register-routes.ts:699tasks·tool:start/tool:completepackages/executor/src/sdk-handlers/claude/claude-tool.ts:406,418→POST /tasks/streaming→register-routes.ts:699messages·created/patchedPOST /messages(Feathers auto-events)tasks·patched(terminal)apps/agor-daemon/src/services/tasks.tsterminal patchsessions·patched(status IDLE/FAILED +ready_for_prompt)apps/agor-daemon/src/services/tasks.ts:612sessions·patched(git_state.current_sha)packages/executor/src/handlers/sdk/base-executor.ts:276tasks·patched(last_executor_heartbeat_at)packages/executor/src/executor-heartbeat.ts:42(task row only)Phase 1.2 — the high-frequency
session:patchedemitterThere is no per-token / per-chunk
session:patchedemitter in this branch. All session-row patches are turn-boundary or status transitions:normalized_sdk_response.contextUsageSnapshotatpackages/executor/src/handlers/sdk/base-executor.ts:565— not the session. A prior "roughly per-tokensession:patched" is not reproducible here; it was most likely this context mirroring before it moved onto the task row.git_statepatch (base-executor.ts:276) and the terminal-status patch (tasks.ts:612) each fire once per turn.executor-heartbeat.ts:42), never the session.Sole UI consumer of streaming/thinking/tool events:
ReactiveSessionHandleinpackages/client/src/reactive-session.ts(attachListeners).apps/agor-ui/src/hooks/useTaskEvents.tsis defined but unused.Phase 2 — design
A. Session-interest rooms for streaming events
session-streamsservice (apps/agor-daemon/src/services/session-streams.ts):create({session_id})joins the calling connection tosession-stream:<id>;remove(id)leaves it. Access is gated by a tenant-scopedsessions.get— the same auth / tenant / branch-view checks a normal session read runs, so there is no weaker path to a session's live text than to its stored messages. Join/leave use the canonicalsession_idfrom the resolved row, so short-id / alias callers land in the same room publishers emit to (the full UUID). The create/remove events are control-plane and publish to nobody (service.publish(() => [])). Feathers drops connections from channels automatically on socket disconnect, so cleanup is handled by the transport.apps/agor-daemon/src/utils/realtime-publish.ts): an extendedisStreamingEventpredicate matches messagesstreaming:*/thinking:*and tasksthinking:chunk/tool:start/tool:complete. These route — regardless of branch RBAC — to (1) the per-session channel, (2) service-account connections (filterToServiceConnections, so gateway/Slack streaming and other service consumers keep working), and (3) the session owner's connections as a cheap fallback (cached owner lookup) that keeps a creator's already-open tabs live during deploy skew before a stale client re-subscribes.RealtimeAccessCache.getBranchVisibility+ the samefilterToUserIdsOrSuperadminsfiltering the non-streaming path uses) before returning the channel. A viewer or creator whose access is revoked mid-stream stops receiving chunks on the very next event, rather than lingering until unsubscribe/disconnect. The cache keeps this per-chunk and room membership is small, so the scoping win is preserved. With RBAC off there is no visibility model, so subscriber + owner + service delivery stands. Malformed streaming events without a resolvable session id fail closed to service connections only; everything else keeps today's tenant/branch scoping.allAuthenticatedvisibility the room is returned unfiltered. Two cheap, independent cuts close this: (1) publish-time, the session room is intersected withtenantScoped, so nothing outside the current tenant/auth channel set can receive regardless of RBAC (this also hardens tenant-removal staleness); (2) on logout the connection is removed from allsession-stream:*rooms (leaveAllSessionStreamChannels).packages/client/src/reactive-session.ts): room membership is per socket connection, but severalReactiveSessionHandles (a board peek, an open panel, a transcript — each with its owntaskHydration) share one connection for the same session. So subscription is refcounted per (client, canonical room) in a module-level registry with a single shared op chain: the first attach subscribes, the last detach unsubscribes, and disposing one handle no longer evicts the shared room membership and kills the others' streams. The room is keyed by the canonical session id the daemon echoes fromcreate(not the caller-supplied id), so mixed short-id (deep-link URLs) and full-UUID retains of the same session resolve to one membership: a learned alias re-keys the entry so a later retain of either form reuses it, and a room-level want-count gates the singleremoveso it fires only when the last retainer across all id forms releases (even when both forms subscribe before either ack lands — their entries fold together). The shared chain also orders create/remove across handle churn (a late create from a disposing handle can't land after a newer handle's create), and reconnect re-subscribes every still-wanted session exactly once (deduped through the registry, reset per connection ondisconnect). Two further guarantees: (a) a handle awaits the subscribe ack before hydrating its state — on both attach and reconnect — so a viewer opening (or reconnecting) mid-stream can't fall into the gap where early chunks arrive before any streaming state exists; (b) the chunk/thinking-chunk handlers initialize stream state from the chunk when nostreaming:startwas seen (attach mid-stream), and task hydration re-stamps any stream whosetask_idwas still unknown. Subscription is best-effort: a daemon without the service (deploy skew) is tolerated, and the owner-fallback covers the creator's own tabs meanwhile.Two browsers on the same session both stream — rooms are additive. Gateway/Slack streaming is unaffected: it is handled server-side inside the
/messages/streamingroute (handleMessageStreamingEvent) and additionally reaches any service-account socket viafilterToServiceConnections.B. Coalescing the streaming-time session patch
No change — because the census found no per-token session patch to coalesce. The former per-token cost now lands on the task row at turn end (
base-executor.ts:565), and the recurring session patches are turn-boundary status/git-state (once each). Adding a throttle would be dead code guarding an emitter that does not fire per-chunk. Status-transition patches (running/awaiting/idle/ready_for_prompt) are deliberately untouched — boards, home, and favicons need those immediate and tenant-visible.C. 10s heartbeat
Left alone (task row, per instruction).
Changes by file
apps/agor-daemon/src/services/session-streams.ts— new subscribe/unsubscribe service (access-gated join/leave).apps/agor-daemon/src/utils/realtime-publish.ts—sessionStreamChannelName,join/leaveSessionStreamChannelfacade, extendedisStreamingEvent,resolveStreamingDelivery(room + service + owner), routed before the RBAC branch.apps/agor-daemon/src/utils/realtime-access-cache.ts— cachedgetSessionOwnerId(same TTL as session→branch).created_byis immutable, so a cache entry is never stale for owner purposes; the TTL handles eviction (andinvalidateSessionclears it if ever wired to session removal).apps/agor-daemon/src/setup/socketio.ts— logout leaves allsession-stream:*rooms for the connection.apps/agor-daemon/src/register-routes.ts—/messages/streaming+/tasks/streamingroute services publish their own defaultcreatedack to no one.apps/agor-daemon/src/services/session-streams.ts—removeskips the resolve round-trip for a full-UUID id.apps/agor-daemon/src/register-services.ts— registersession-streams(requireAuth, publish-to-nobody).packages/core/src/db/repositories/sessions.ts— leanfindCreatedByBySessionId.packages/client/src/reactive-session.ts— subscribe on attach / dispose / reconnect; deploy-skew tolerant.scripts/check-multitenancy-boundaries.mjs—realtime-publish.tsbaseline raised to 8 (the new session-stream channel plumbing — join, leave, leave-all, and the publish-time room lookup — all live in the audited realtime facade; the count reflects reality, and the checker is unchanged).realtime-publish.test.ts,realtime-access-cache.test.ts,session-streams.test.ts,reactive-session.test.ts.Gate results
@agor/core,@agor-live/client,@agor/daemontypecheck: pass.leaveAllSessionStreamChannelsleaves only session-stream rooms; no-session-id fails closed; short-id subscribe joins the canonical room;removeskips resolution for a full UUID.removefires once canonically only on the last detach (both id orderings); mid-stream chunk and mid-thinking upsert render; task_id re-stamp after hydrate.App.tsxfile fails to transform — see below).check:multitenancy-boundaries: passes; new channel plumbing lives in the audited realtime facade;session-streams/register-services/socketioadd no raw realtime primitives beyond the facade.Pre-existing, unrelated to this change
apps/agor-ui/src/components/App/App.tsxhas a duplicateunreadCommentsCountdeclaration (referencing undefinedactiveComments/BoardComment) that fails agor-ui typecheck and theApp.rerendertest import. This is present onmain(introduced by71d08168, this branch's base), is being fixed separately, and is untouched here — the client-side change lives inpackages/clientand typechecks clean; the agor-ui suite is otherwise green (778 passing).check:multitenancy-boundariesalso reports pre-existing baseline drift inregister-hooks.tsand a few test files, likewise present with these changes stashed.🤖 Generated with Claude Code