fix(opencode): first message dequeued from a busy-session queue loses its reply ("(empty response)") - #1687
Open
imwei25 wants to merge 1 commit into
Open
fix(opencode): first message dequeued from a busy-session queue loses its reply ("(empty response)")#1687imwei25 wants to merge 1 commit into
imwei25 wants to merge 1 commit into
Conversation
imwei25
force-pushed
the
fix/stale-queued-turn-result
branch
from
August 15, 2026 08:08
0e502ee to
d16ebb9
Compare
…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
force-pushed
the
fix/stale-queued-turn-result
branch
from
August 15, 2026 23:35
4eaf2de to
60749d3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Symptom
With the OpenCode agent (one
opencode run --format json --session <id>process per turn), when a user sends multiple messages in quick succession:message queued for busy session);Observed timeline from production logs (2026-08-15, times local +08:00):
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'sreadLoopfeeds the sames.eventschannel. A turn normally ends when the process printsstep_finish reason=stop→sendEventResult(). But the process does not exit at that moment — it lingers for teardown, typically several hundred milliseconds, and itsreadLoopstays alive until stdout EOF, where it calls the EOF fallbacksendEventResult()(meant to cover processes that die without astep_finish).The duplicate-suppression flag
resultSentis per-session and is reset by the nextSend(). That is exactly what happens on the busy-queue path: the engine consumes turn N'sEventResultand immediately dequeues the next message and callsSend()for turn N+1 — while turn N's process is still lingering. Sequence:step_finish reason=stop→EventResult(resultSent = true). Engine consumes it, dequeues, drains stale events, callsSend()for turn N+1 →resultSent = false, new process spawned.readLoophits EOF and fires the fallbacksendEventResult(). The guard was already reset, so a staleEventResultis emitted into turn N+1.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
EventErroremissions (scanner error / non-empty stderr of a stale process failing the current turn).Fix
Add a per-
Send()turn generation counter (turnGen atomic.Int64). EachSend()increments it and hands the value to the spawnedreadLoop. Terminal emissions — the EOF fallbackEventResult, thestep_finish-drivenEventResult, and the EOF-pathEventErrors (scanner error / stderr) — are dropped (with a log line) if a newer turn has started since. In-turn behavior is unchanged; the existingresultSentduplicate guard is kept.No new dependencies, no API changes outside the
agent/opencodepackage, ~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 realSend/readLooppath 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'sEventResult(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/opencodetests pass. (Theopencode_model_test.gofake-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