Introduce clientTransport interface in duoworkflow runner
What does this MR do and why?
This is a refactor of workhorse/internal/ai_assist/duoworkflow/. There is no
behavior change.
runner orchestrates one Duo Agent Platform workflow execution: it holds the
distributed lock, drives the stop handshake with the Duo Workflow Service (DWS),
handles graceful shutdown, executes MCP tool calls, and records metrics. Until
now it also assumed its client was a WebSocket: it held a concrete *wsManager
in a field named ws, and its method names and log messages were written in
WebSocket terms.
This MR adds a clientTransport interface in a new transport.go and makes
runner depend on it. wsManager becomes the WebSocket implementation.
Changes:
- New
clientTransportinterface:Start,KeepaliveInterval,Keepalive,ReadClientEvent,ReadError,WriteAction,SendGoingAway,SendInvalidRequest,Close. runner.ws *wsManagerbecomesrunner.client clientTransport.newRunnertakes aclientTransport;handler.gopassesnewWsManager(conn).- Two WebSocket-specific steps moved from
runnerinto the newwsManager.Start(): registering the gorilla/websocket pong handler and arming the initial read deadline.runner.ExecutecallsStart()once before spawning goroutines. wsManager.Ping()is renamedKeepalive(). The interval is exposed throughKeepaliveInterval()instead of the runner reading thewsPingIntervalconstant directly.- Renames inside
runner, so shared orchestration no longer says "WebSocket":handleWebSocketMessages->handleClientEvents,handleWebSocketMessage->handleClientEvent,pingWebSocket->keepaliveClient. Comments and log messages follow. - The
StopWorkflowRequestreason sent to DWS on keepalive failure is unchanged. It is still the stringWORKHORSE_WEBSOCKET_PING_FAILED, now behind a named constantreasonKeepaliveFailed, so DWS-side telemetry that matches on the value keeps working. - Tests updated mechanically. The runner test that exercised the pong handler
moved to
TestWsManager_Startinwebsocket_test.go, because that behavior now lives on the transport.
One ordering nuance: the initial read deadline is now set synchronously in
Start(), before the read goroutine starts. Previously the ping goroutine set
it. This closes a small window in which the reader could run with no deadline.
Why
This is step 1 of adding a second transport. A follow-up MR will add a
plain-HTTP transport, so a server-side caller (for example Rails delivering a
Slack chat turn) can start a flow with a single request and read the resulting
actions as a newline-delimited JSON stream, instead of needing a CI job just to
hold a connection open. Sharing runner means that transport inherits the
locking, stop handshake, shutdown handling, MCP tool execution, and metrics
that the WebSocket path already has, rather than forking the logic.
How to set up and validate locally
There is nothing to click through, since behavior is unchanged. Run the package tests:
cd workhorse
go test ./internal/ai_assist/duoworkflow/ -count=1
go test -race ./internal/ai_assist/duoworkflow/ -count=1
make golangciA couple of the lock tests need a workhorse/config.toml with Redis
configured. GDK provides one.
MR acceptance checklist
- I have evaluated the MR acceptance checklist for this MR.