Bug
A provider turn that runs longer than agentStreamTimeout is recorded as a successful turn. The session is marked idle, no terminal event is ever published, and the chat sits on "thinking" until the user reloads the page. The turn's partial output is accepted as if it were complete.
Why it happens
isContextCancel matches context.DeadlineExceeded as well as context.Canceled:
func isContextCancel(err error) bool {
if err == nil {
return false
}
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
}
streamProviderTurn bounds every turn with exactly that deadline (agent_stream_worker.go:443, agentStreamTimeout = 15 * time.Minute), and then discards the error it produces:
if streamErr == nil && err != nil && !isContextCancel(err) {
streamErr = err
}
if streamErr != nil || !customTools.resultsRequired {
return streamErr // nil — indistinguishable from a completed turn
}
So the deadline never becomes a streamErr. handleLocked skips its failure branch and marks the session idle at agent_stream_worker.go:413. That write succeeds — TouchAgentSessionHeartbeat deliberately uses UpdateColumn, so updated_at still matches the optimistic-concurrency guard.
Nothing publishes a terminal event. turn_completed is emitted only from handleProviderEvent on agents.ProviderEventTurnCompleted (agent_stream_worker.go:516). The deadline kills the stream before the provider ever emits it.
The frontend (web_src/src/hooks/useAgentSessionWebsocket.ts:20-31) changes session status only on stream_started / turn_completed / session_failed. It got stream_started and never gets a terminal event.
The stuck-session sweeper cannot recover it. FailStuckStreamingSessions (pkg/models/agent_session.go:306) filters Where("status = ?", AgentSessionStatusStreaming). The row is now idle, so it is invisible to cleanup. The session is stuck permanently.
Impact
Long-running agent turns are precisely this worker's purpose, so this fires on the product's core use case. The same path is taken on every rolling deploy or SIGTERM that lands mid-stream, because a cancelled parent context is swallowed by the same guard.
Reproduction
A provider stream that returns context.DeadlineExceeded is enough:
--- FAIL: TestAgentStreamWorker_FailsSessionWhenTurnDeadlineExpires
Error: Not equal:
expected: "failed"
actual : "idle"
Messages: a turn cut short by its deadline must be reported as failed,
not recorded as a completed turn
Expected
- A turn cut short by its own deadline is a failure: mark the session
failed and publish session_failed so the UI unblocks.
- A turn interrupted by worker shutdown has no outcome at all. It should leave the row
streaming so FailStuckStreamingSessions (or another replica) can reclaim it — not mark it idle or failed.
These two need to land together. Making the deadline a failure without also teaching handleLocked's loop to check its parent context would turn every deploy into a burst of sessions marked failed.
Note that the existing isContextCancel guard does not protect the user-Stop path, which is what it looks like it is for: InterruptSession is a DB write and never cancels the worker's context. Stop is already handled separately by errSessionAlreadyReset.
I have a patch with tests and will open a PR against this issue.
Unrelated things noticed nearby (happy to send separately)
go eventDistributer.Start() at pkg/server/server.go:411 discards the returned error. If config.RabbitMQURL() fails, every websocket fan-out in the product is silently dead — no log line — while the process reports healthy.
EventRouter.StartRabbitMQConsumer (pkg/workers/event_router.go:83) takes a ctx and never reads it, so the goroutine outlives shutdown. Sibling workers (node_queue_worker.go, usage_sync_worker.go, run_finalizer.go) all check theirs.
- ~15 reconnect loops use a bare
time.Sleep(5 * time.Second) with no jitter or cap, so consumers reconnect in lockstep after a broker restart.
Bug
A provider turn that runs longer than
agentStreamTimeoutis recorded as a successful turn. The session is markedidle, no terminal event is ever published, and the chat sits on "thinking" until the user reloads the page. The turn's partial output is accepted as if it were complete.Why it happens
isContextCancelmatchescontext.DeadlineExceededas well ascontext.Canceled:streamProviderTurnbounds every turn with exactly that deadline (agent_stream_worker.go:443,agentStreamTimeout = 15 * time.Minute), and then discards the error it produces:So the deadline never becomes a
streamErr.handleLockedskips its failure branch and marks the session idle atagent_stream_worker.go:413. That write succeeds —TouchAgentSessionHeartbeatdeliberately usesUpdateColumn, soupdated_atstill matches the optimistic-concurrency guard.Nothing publishes a terminal event.
turn_completedis emitted only fromhandleProviderEventonagents.ProviderEventTurnCompleted(agent_stream_worker.go:516). The deadline kills the stream before the provider ever emits it.The frontend (
web_src/src/hooks/useAgentSessionWebsocket.ts:20-31) changes session status only onstream_started/turn_completed/session_failed. It gotstream_startedand never gets a terminal event.The stuck-session sweeper cannot recover it.
FailStuckStreamingSessions(pkg/models/agent_session.go:306) filtersWhere("status = ?", AgentSessionStatusStreaming). The row is nowidle, so it is invisible to cleanup. The session is stuck permanently.Impact
Long-running agent turns are precisely this worker's purpose, so this fires on the product's core use case. The same path is taken on every rolling deploy or
SIGTERMthat lands mid-stream, because a cancelled parent context is swallowed by the same guard.Reproduction
A provider stream that returns
context.DeadlineExceededis enough:Expected
failedand publishsession_failedso the UI unblocks.streamingsoFailStuckStreamingSessions(or another replica) can reclaim it — not mark it idle or failed.These two need to land together. Making the deadline a failure without also teaching
handleLocked's loop to check its parent context would turn every deploy into a burst of sessions markedfailed.Note that the existing
isContextCancelguard does not protect the user-Stop path, which is what it looks like it is for:InterruptSessionis a DB write and never cancels the worker's context. Stop is already handled separately byerrSessionAlreadyReset.I have a patch with tests and will open a PR against this issue.
Unrelated things noticed nearby (happy to send separately)
go eventDistributer.Start()atpkg/server/server.go:411discards the returnederror. Ifconfig.RabbitMQURL()fails, every websocket fan-out in the product is silently dead — no log line — while the process reports healthy.EventRouter.StartRabbitMQConsumer(pkg/workers/event_router.go:83) takes actxand never reads it, so the goroutine outlives shutdown. Sibling workers (node_queue_worker.go,usage_sync_worker.go,run_finalizer.go) all check theirs.time.Sleep(5 * time.Second)with no jitter or cap, so consumers reconnect in lockstep after a broker restart.