Implementing duo chat streamer that auto reconnects and allow users to manually reconnect
What does this MR do and why?
Note
Third of three MRs splitting the Duo Agentic Chat WebSocket service layer into independently reviewable pieces. Depends on BufferedEventHub (MR 1) and WorkflowStream (MR 2). Nothing consumes this yet.
In the WorkflowStream class, a close event is terminal: a server restart or a dropped connection ends the stream even though the workflow behind is still running, and the user is left with a conversation that has silently stopped updating.
RetryableWorkflowStream sits in front of an ordinary workflow stream so that routine connection trouble stays invisible. When a connection drops for a reason likely to be transient, a server restart, a brief network blip, it reconnects on its own and the conversation carries on updating. It resumes the workflow already running rather than starting it over, so work in progress is neither lost nor duplicated. Closes that genuinely mean something are passed through untouched: the workflow finished, another tab already owns it, credits have run out, the request was rejected.
Repeated attempts wait progressively longer and are staggered. Only a short run of consecutive failures is tolerated, any message getting through counts as progress and resets the count, so a connection that keeps dropping but keeps recovering never runs out. Once the budget is spent it stops and reports that it has given up, letting the interface offer a manual reconnect.
That manual reconnect happens immediately, and is ignored when there is nothing to recover.
Close-event observability
New module ee/app/assets/javascripts/ai/duo_agentic_chat/observability/stream_close_reporting.js. Closes are now observable on two separate channels: console logging and Sentry.
Console logging — every close, whatever the code.
logStreamClose()logs each close atconsole.info, prefixed[duo-chat][stream] closed, with{ code, category, retryable, reason }. It logs even a correct, clean close, because the symptom users report is always "it just stopped", which looks identical whichever code caused it. Which close code produced it is the first thing worth knowing.console.inforather thanwarn/error, because these are breadcrumbs, not faults. Also, jest's ConsoleWatcher turnswarn/errorinto thrown test failures, sowarn/errorhere would break every suite that closes a stream.logStreamReconnect()logs one line per reconnect attempt, prefixed[duo-chat][stream] reconnecting, with{ trigger, consecutiveFailures, maxRetries, delay }.triggeris eitherretryable_close(automatic) orrequested(user pressed retry). Without this the console shows a close followed by silence, which reads like the stream gave up, then an open that comes from nowhere.consecutiveFailuresis the count before this attempt, so a user-requested reconnect still reports how many automatic ones preceded it.
Sentry — only the closes a human should look at.
The close-code table in workflow_stream.js now carries an expected flag alongside category and retryable. expected means the close is an outcome, not a fault.
| Code | Category | Retryable | Expected | Why |
|---|---|---|---|---|
| 1000 | normal |
No | Yes | clean shutdown |
| 1001 | going_away |
Yes | Yes | server restarting; reconnecting is the whole answer |
| 1008 | policy_violation |
No | Yes | user out of credits or billing blocked — a state, not a defect |
| 1013 | try_again_later |
No | Yes | another tab already holds this workflow, so retrying would fight it |
| 4400 | invalid_request |
No | No | the backend rejected what we sent |
| other (e.g. 1006) | error |
Yes | No | an unrecognised code is worth hearing about once retrying has failed |
Chain
| MR | Delivers | |
|---|---|---|
| 1 | !249095 (merged) | BufferedEventHub |
| 2 | !247787 (merged) | WorkflowStream |
| 3 | this MR | RetryableWorkflowStream + factory |
| 4 | !245705 (merged) | Wiring + retry UX |
How to validate
yarn jest ee/spec/frontend/ai/duo_agentic_chat/websocket/ ee/spec/frontend/ai/duo_agentic_chat/observability/New specs: stream_close_reporting_spec.js, retryable_workflow_stream_spec.js, workflow_stream_factory_spec.js. Updated: workflow_stream_spec.js.
The reconnect specs use jest.useFakeTimers() to drive retryDelay. retryable_workflow_stream_spec.js stubs the inner stream, so it exercises the decorator in isolation.
No user-facing change, so no changelog entry.