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 at console.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.info rather than warn/error, because these are breadcrumbs, not faults. Also, jest's ConsoleWatcher turns warn/error into thrown test failures, so warn/error here 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 }. trigger is either retryable_close (automatic) or requested (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. consecutiveFailures is 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.

Edited by Enrique Alcántara

Merge request reports

Loading
Loading