Skip to content

fix(opencode): first message dequeued from a busy-session queue loses its reply ("(empty response)") - #1687

Open
imwei25 wants to merge 1 commit into
chenhg5:mainfrom
imwei25:fix/stale-queued-turn-result
Open

fix(opencode): first message dequeued from a busy-session queue loses its reply ("(empty response)")#1687
imwei25 wants to merge 1 commit into
chenhg5:mainfrom
imwei25:fix/stale-queued-turn-result

Conversation

@imwei25

@imwei25 imwei25 commented Aug 15, 2026

Copy link
Copy Markdown

Symptom

With the OpenCode agent (one opencode run --format json --session <id> process per turn), when a user sends multiple messages in quick succession:

  • the first message is processed normally;
  • follow-up messages are queued (message queued for busy session);
  • the first message dequeued from the queue always completes with the "(空响应)" / "(empty response)" placeholder — its real reply is lost. Messages after it behave normally.

Observed timeline from production logs (2026-08-15, times local +08:00):

13:10:45.418  msg="processing queued message" remaining_queue=0
13:10:45.486  (agent-side log) new opencode process wrote its first stdout byte
13:10:45.946  msg="turn complete" ... tools=0 response_len=11   ← len("(空响应)") in UTF-8

The gap between dequeue and the bogus "turn complete" was 704 ms / 1090 ms / 528 ms across three occurrences — no correlation with any timeout, and making the agent emit its first byte 68 ms after startup did not help. The freshly spawned agent process kept running (its work completed) and was later killed without receiving a normal completion.

Root cause

opencodeSession.Send() spawns a new process per turn, and every process's readLoop feeds the same s.events channel. A turn normally ends when the process prints step_finish reason=stopsendEventResult(). But the process does not exit at that moment — it lingers for teardown, typically several hundred milliseconds, and its readLoop stays alive until stdout EOF, where it calls the EOF fallback sendEventResult() (meant to cover processes that die without a step_finish).

The duplicate-suppression flag resultSent is per-session and is reset by the next Send(). That is exactly what happens on the busy-queue path: the engine consumes turn N's EventResult and immediately dequeues the next message and calls Send() for turn N+1 — while turn N's process is still lingering. Sequence:

  1. Turn N's process emits step_finish reason=stopEventResult (resultSent = true). Engine consumes it, dequeues, drains stale events, calls Send() for turn N+1 → resultSent = false, new process spawned.
  2. Turn N's process finally exits (0.5–1 s later). Its readLoop hits EOF and fires the fallback sendEventResult(). The guard was already reset, so a stale EventResult is emitted into turn N+1.
  3. The engine treats it as turn N+1's completion. No text has arrived yet (the model is still working), so the response is empty → "(empty response)" placeholder. Turn N+1's real output is later discarded / its process torn down.

This explains every observed detail: only the first dequeued message is affected (it is the only turn started inside the previous process's linger window), the irregular 0.5–1 s "give-up" delay (= the previous process's exit lag), and why early first-byte output from the new process doesn't help (its stdout is read — the turn is just terminated from outside before the reply text arrives).

The same hole applies to the EOF-path EventError emissions (scanner error / non-empty stderr of a stale process failing the current turn).

Fix

Add a per-Send() turn generation counter (turnGen atomic.Int64). Each Send() increments it and hands the value to the spawned readLoop. Terminal emissions — the EOF fallback EventResult, the step_finish-driven EventResult, and the EOF-path EventErrors (scanner error / stderr) — are dropped (with a log line) if a newer turn has started since. In-turn behavior is unchanged; the existing resultSent duplicate guard is kept.

No new dependencies, no API changes outside the agent/opencode package, ~30 lines.

Tests

  • TestStaleFallbackEventResultSuppressed — unit test of the exact race ordering (result → next Send resets state → stale fallback fires → must emit nothing; the new turn's own result must still pass).
  • TestQueuedTurnNotTerminatedByLingeringPreviousProcess — integration repro through the real Send/readLoop path using the test binary as a fake agent CLI: turn 1 prints its final event then lingers 400 ms before exiting; turn 2 is sent immediately after turn 1's EventResult (as the engine's queue-drain path does) and produces output after 700 ms. Red/green verified: with the generation guard disabled, the test fails with exactly the production symptom (turn 2 completes with no text at ~400 ms); with the guard it passes (3/3 runs).

Existing agent/opencode tests pass. (The opencode_model_test.go fake-CLI discovery tests fail on Windows with or without this change — pre-existing, unrelated: the fixtures are extension-less Unix scripts.)

🤖 Generated with Claude Code

@imwei25
imwei25 requested a review from chenhg5 as a code owner August 15, 2026 06:01
@imwei25
imwei25 force-pushed the fix/stale-queued-turn-result branch from 0e502ee to d16ebb9 Compare August 15, 2026 08:08
…ing process

Each Send() spawns a new opencode process, but the previous turn's
readLoop lingers until that process exits — often hundreds of
milliseconds after it already emitted step_finish reason=stop. When the
engine dequeues a queued message and calls Send() in that window, Send
resets resultSent, so the old process's EOF fallback emits an EventResult
that terminates the NEW turn before any of its output arrives. The user
receives the "(empty response)" placeholder and the real reply is lost;
this hits exactly the first message dequeued from a busy-session queue.

Guard all terminal emissions (fallback EventResult, scanner-error and
stderr EventError) with a per-Send turn generation counter so a stale
readLoop can no longer close or fail a turn it does not own.

Also move cmd.Wait() from a defer to before the stderrBuf read in
readLoop: os/exec's Wait is what joins the internal goroutine copying
the child's stderr into stderrBuf, so reading the buffer before Wait
races with that copy (latent; first exercised by the new integration
test under -race). All stdout reads are complete at that point, which
is the documented precondition for calling Wait.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@imwei25
imwei25 force-pushed the fix/stale-queued-turn-result branch from 4eaf2de to 60749d3 Compare August 15, 2026 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant