fix(claude-code-agent): end the turn on the terminal ResultMessage, not stream EOF#15
fix(claude-code-agent): end the turn on the terminal ResultMessage, not stream EOF#15tmatup wants to merge 3 commits into
Conversation
…ot stream EOF A CLI process that lingers after emitting its result (stray child holding stdio, slow shutdown flush) kept the SDK message stream open until the turn watchdog hard-killed it — converting a FINISHED, successful agent run into a 0-score TurnTimeoutError. Observed three times on live sweeps (dispute-analyst 2x, lowcode-agent 1x): final assistant message + result (subtype=success, stop_reason=end_turn) captured ~530s before the 1200s watchdog fired. The turn is semantically over when the one-shot query()'s ResultMessage arrives — nothing follows it except process exit. Break the message loop there, and reap the subprocess with a bounded grace (it may still be flushing its session file) then SIGKILL, since generator finalization is GC-scheduled and the SDK's anyio scopes swallow cooperative cancellation. test_multiple_assistant_turns is reshaped to the real one-shot protocol (a single terminal ResultMessage); its old stream interleaved two mid-stream ResultMessages, a shape the CLI never produces in one-shot mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrwDtggu1dG9WKAsgvxfnq
|
Claude finished @tmatup's task in 1m 22s —— View job Code Review in Progress
|
communicate() crossed ruff's PLR0915 statement cap (84 > 80) with the result-message break added; the per-message logic (deadline guard → dispatch → terminal-result break) reads better as its own method anyway. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrwDtggu1dG9WKAsgvxfnq
|
Deployment note for the eval images: the images baked at |
…t by duck-typing TaskProgressMessage (sub-agent progress tick) carries BOTH session_id and usage, so the duck-typed result check misread it as the terminal ResultMessage. That misread pre-existed silently (corrupted session-id advance + token backfill whenever a sub-agent ran); once the turn began ENDING on the terminal result, it truncated every sub-agent-spawning turn ~20s in — the agent was reaped mid-run with zero authored output. _is_sdk_result_message now matches isinstance(ResultMessage) positively and rejects the whole SystemMessage family (all task-lifecycle messages subclass it); the duck-typed fallback remains only for non-SDK test mocks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrwDtggu1dG9WKAsgvxfnq
|
Second commit (0fbed2c): the break exposed a pre-existing misclassification — fixed at its root. First live run with the turn-end fix truncated every turn that spawned a sub-agent: ~20 s in, zero authored output, empty sandbox. Cause: The classifier now matches Backport branch |

What
When the SDK stream delivers the terminal
ResultMessageof a one-shotquery(),communicate()now ends the turn immediately — instead of waiting for stream EOF (i.e. CLI process exit). The subprocess is then reaped with a bounded grace (10 s, so it can flush its session file for resume) followed by SIGKILL.Why
A successful agent run could be scored 0. If the Claude CLI process lingers after emitting its result — a stray child process holding stdio, a slow shutdown flush — the stream never hits EOF, so the turn watchdog eventually hard-kills it and raises
TurnTimeoutError. The task is then recordedfinal_status=ERROR, score=0even though the agent finished cleanly.Observed 3× on live eval sweeps (twice yesterday, once today, all at exactly
turn_timeout+4swall):~530 s of dead waiting on a process whose result was already in hand.
How
communicate()message loop: afterstate.dispatch(message), break when the terminal ResultMessage has been captured (state.sdk_result_summaryset). The ResultMessage is by protocol the last message of a one-shot query — nothing follows it but process exit._reap_transport_after_result(): bounded grace (default 10 s) for the CLI to exit on its own — killing instantly could lose the session-file tail the SDK's ownclose()protects (see its #625 comment) — then SIGKILL. No-op in the common already-exited case. Breaking alone is not enough: async-generator finalization is GC-scheduled, and the SDK's anyio scopes swallow cooperative cancellation (same reason the watchdog exists).test_multiple_assistant_turnsreshaped to the real one-shot protocol (single terminal ResultMessage; previously it interleaved two mid-stream ResultMessages, a shape the CLI never produces in one-shot mode).Tests
test_communicate_ends_turn_on_result_message_when_cli_lingers(the regression: stream yields result then hangs — must return a clean TurnRecord, not TurnTimeoutError),test_reap_after_result_kills_lingering_process(grace-then-kill + no-op paths).🤖 Generated with Claude Code
https://claude.ai/code/session_01LrwDtggu1dG9WKAsgvxfnq