You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Rejected sub-agent WebSocket connection retries forever, no error surfaced #2118
If a parent agent rejects a sub-agent WebSocket connection (e.g. onBeforeSubAgent returns a 404 for a deleted or unknown name), the client never finds out and retries forever.
The rejection happens before the upgrade, so the client sees a failed HTTP handshake instead of a WebSocket close frame. partysocket turns that into a synthesized close event with code 1000. isTerminalCloseEvent only treats 1008 and 4000-4999 as final (packages/agents/src/client.ts:32-34), so the client retries with maxRetries = Infinity and connectionError is never set. The app has no way to show an error.
The one code path that does send a final close, close(1008, "Sub-agent connection rejected") at packages/agents/src/index.ts:7412, only runs for nested sub-agents (gate: true). A direct parent to child rejection can never reach it, because the Response from onBeforeSubAgent is returned before the upgrade (index.ts:7024-7028).
git clone https://github.com/urjitc/agents-2118-repro && cd agents-2118-repro
npm install
node repro.mjs (starts a server that rejects upgrades with 404 and points partysocket at it)
Observed output:
tcp connections reaching server: 21
rejected upgrade attempts in 5s: 21
client error messages: [ 'Unexpected server response: 404' ]
close events seen by client: 21
distinct close codes: [ 1000 ]
any close classified terminal: false
client retryCount: 21
verdict: retries forever, nothing terminal ever surfaces
Expected behavior
After a rejected connection (or a few of them), the client should stop and set connectionError so the app can show an error state.
Screenshots
Not needed; the live demo shows the retry loop and counters directly.
Version:
agents@0.19.0 (same behavior on main at aed6d8f), partysocket@1.3.0.
Additional context
Related to #2003 / #2024. Note that #2024 closes a deleted sub-agent's sockets with code 1001, which is also not treated as final, so those clients would land in this same retry loop.
changed the title [-]Rejected sub-agent WebSocket connect retries forever with no terminal error (failed handshake surfaces as synthetic close 1000)[/-][+]Rejected sub-agent WebSocket connection retries forever, no error surfaced[/+]on Aug 16, 2026
We hit this in production last weekend. Adding impact data and one finding that isn't in the report yet.
Impact. We moved each of our product's runs into a native sub-agent facet, and the parent's onBeforeSubAgent returned 404 for a name that had no facet yet. That is the direct parent-to-child path you describe, so the rejection never became a close frame. Result: 62 of 68 active runs had a chat surface that retried the same 404 indefinitely and never reported anything. One Durable Object retried on a ~5 second cycle for 29 hours. Every affected person saw a loading state that never resolved and never errored, so it read as a hung app rather than a failure.
The client cannot work around this. We tried both documented options before concluding the fix has to be in the SDK:
shouldReconnectOnClose returning false stops the retry loop, but connectionError is still not set, because the handler requires !shouldReconnect && terminalClose and the synthesized close is non-terminal. The failure just becomes silent instead of noisy — strictly worse for the user.
maxRetries stops _connect() without clearing _shouldReconnect, so the same handler never fires. Also silent.
So today there is no combination of public options that lets an app distinguish "still connecting" from "this route will never connect." That distinction is the whole difference between a spinner and an error state.
Suggestion. Treating a close that arrives with no prior successful open as terminal after a small number of attempts would cover this, #2024's 1001, and any future non-terminal rejection, without changing behaviour for genuine mid-session drops.
The handshake 404 never becomes a close the client treats as final, so it just keeps retrying. Scott's right that the public knobs don't help either: shouldReconnectOnClose / maxRetries still never set connectionError, so the app can't tell "still connecting" from "this will never connect."
I'll stop retrying when a connection never actually opened, surface that as an error, and check it against Urjit's repro. Once there's a branch I'll drop it here so you can try it on the production path.
Activity
[-]Rejected sub-agent WebSocket connect retries forever with no terminal error (failed handshake surfaces as synthetic close 1000)[/-][+]Rejected sub-agent WebSocket connection retries forever, no error surfaced[/+]urjitc commentedon Aug 16, 2026
Added a runnable repro: live demo at https://agents-2118-repro.chakrabortyurjit.workers.dev (partysocket against an endpoint that rejects every upgrade with 404, live counters showing close code 1000 on every attempt and no terminal close), source and a local Node version at https://github.com/urjitc/agents-2118-repro.
scott-edwards commentedon Aug 17, 2026
We hit this in production last weekend. Adding impact data and one finding that isn't in the report yet.
Impact. We moved each of our product's runs into a native sub-agent facet, and the parent's
onBeforeSubAgentreturned 404 for a name that had no facet yet. That is the direct parent-to-child path you describe, so the rejection never became a close frame. Result: 62 of 68 active runs had a chat surface that retried the same 404 indefinitely and never reported anything. One Durable Object retried on a ~5 second cycle for 29 hours. Every affected person saw a loading state that never resolved and never errored, so it read as a hung app rather than a failure.The client cannot work around this. We tried both documented options before concluding the fix has to be in the SDK:
shouldReconnectOnClosereturningfalsestops the retry loop, butconnectionErroris still not set, because the handler requires!shouldReconnect && terminalCloseand the synthesized close is non-terminal. The failure just becomes silent instead of noisy — strictly worse for the user.maxRetriesstops_connect()without clearing_shouldReconnect, so the same handler never fires. Also silent.So today there is no combination of public options that lets an app distinguish "still connecting" from "this route will never connect." That distinction is the whole difference between a spinner and an error state.
Suggestion. Treating a close that arrives with no prior successful open as terminal after a small number of attempts would cover this, #2024's 1001, and any future non-terminal rejection, without changing behaviour for genuine mid-session drops.
Happy to test a fix against our workload.
HaoChiBao commentedon Aug 18, 2026
I'll pick this up.
The handshake 404 never becomes a close the client treats as final, so it just keeps retrying. Scott's right that the public knobs don't help either:
shouldReconnectOnClose/maxRetriesstill never setconnectionError, so the app can't tell "still connecting" from "this will never connect."I'll stop retrying when a connection never actually opened, surface that as an error, and check it against Urjit's repro. Once there's a branch I'll drop it here so you can try it on the production path.
urjitc commentedon Aug 18, 2026
@HaoChiBao are you just scraping issues and having agents respond/make prs 😑
fix(agents): close rejected sub-agent WebSockets instead of failing t…