Skip to content

Agent stream timeout is recorded as a successful turn — session goes idle with no terminal event and the UI hangs #6462

Description

@ayenisholah

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions