Fix delayed sub-agent WebSocket operations - #2090
Conversation
🦋 Changeset detectedLatest commit: cc50e40 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
| const activeBridge = this._cf_activeSubAgentBridge(connectionId); | ||
| if (activeBridge) { | ||
| operation(activeBridge); | ||
| return; | ||
| } |
There was a problem hiding this comment.
🟡 Sub-agent messages can reach the browser out of order when a new client frame arrives while earlier messages are still being delivered
Connection operations taken while a client frame is in progress are executed immediately (operation(activeBridge) at packages/agents/src/index.ts:7253) even when earlier operations for the same connection are still waiting in the delivery queue, so a newer message can overtake and reach the browser before an older one.
Impact: A client can receive sub-agent messages, state updates, or a close in the wrong order, e.g. a reply from a new request arriving before a still-pending streaming update.
Mechanism: live-frame fast path bypasses the per-connection ordering queue
_cf_routeSubAgentConnectionOperation (packages/agents/src/index.ts:7246-7282) has two paths:
- Live frame: if the async-context bridge matches the connection id, the operation runs synchronously through the forwarded RPC bridge.
- No live frame: the operation is appended to
_cf_subAgentConnectionOperationTailsand only runs afterawait this._rootAlarmOwner()(an RPC round trip viagetServerByName,packages/agents/src/index.ts:4056-4076) plus the root-routed send.
The fast path never consults the tail. Concrete sequence for one connection:
- Delayed work (e.g. the streaming resume in the issue this PR fixes) calls
connection.send(A)after its frame ended → queued; the queued task is still awaiting root resolution. - The browser sends another frame; the facet handles it and calls
connection.send(B)inside that frame → matched active bridge → delivered immediately. Ais delivered afterwards, inverting call order — exactly the ordering guarantee the queue was added to provide (see also the new ordering test inpackages/agents/src/tests/sub-agent-rpc-bridge.test.ts:246-262, which only covers the all-queued case).
The same inversion applies to setState (last write wins incorrectly) and close (a queued send can be dropped after an immediate close).
Prompt for agents
In Agent._cf_routeSubAgentConnectionOperation (packages/agents/src/index.ts around lines 7246-7282), the live-frame fast path executes the operation immediately whenever the async-context bridge matches the connection id, ignoring any operations still pending in _cf_subAgentConnectionOperationTails for that same connection. Because root-routed operations must first await _rootAlarmOwner() (an RPC round trip), an operation issued from a later client frame can be delivered before an earlier queued one, breaking the per-connection ordering this queue is meant to guarantee. Consider only taking the fast path when there is no pending tail for that connection id, or chaining the fast-path operation onto the existing tail (falling back to the root bridge if the frame bridge is no longer valid by the time the chain runs).
Was this helpful? React with 👍 or 👎 to provide feedback.
Stacked on #2027. Fixes #2055.
Why
A sub-agent's virtual WebSocket
Connectionlives longer than the Workers RPC bridge used to forward one browser frame. Delayed work could therefore callconnection.send()through a bridge that had already been disposed.Think exposed this when
STREAM_PENDINGarrived but the laterSTREAM_RESUMINGsend was dropped, leaving the client waiting for its 60-second timeout. #2027 fixes which bridge an in-flight callable reply uses; this PR handles connection activity after that frame has finished.What changed
send,setState, andclosecalls through the durable root Agent, in call order, with failures reported.Validation
pnpm run checkSTREAM_PENDING → STREAM_RESUMING → done