Found while verifying the SDK 0.2.150 upgrade (#PR pending) against a live fanned-out session. Not caused by that upgrade — filing separately because terminal-result semantics are the most load-bearing thing in the work loop (CLAUDE.md → Unfinished sessions), and changing them needs its own reproduction and tests.
Evidence
Probe: one query() dispatching two concurrent subagents, logging every message with a monotonic timestamp. Full ordering (abridged):
7.6s AssistantMessage parent=- USE:Agent:4RwnGq <- dispatch worker A
7.7s UserMessage parent=- RES:4RwnGq <- "result" 0.1s later (an ACK)
8.3s AssistantMessage parent=- USE:Agent:HpuPwo <- dispatch worker B
8.3s UserMessage parent=- RES:HpuPwo
9.0s AssistantMessage parent=4RwnGq <- worker A actually starts
...
37.0s AssistantMessage parent=- TEXT
37.0s ResultMessage parent=- <- ResultMessage #1
37.0s ResultMessage parent=- <- #2
37.0s ResultMessage parent=- <- #3
43.6s AssistantMessage parent=HpuPwo <- worker B STILL WORKING
46.0s AssistantMessage parent=HpuPwo TEXT
47.7s AssistantMessage parent=- TEXT
47.7s ResultMessage parent=- <- the real terminal result
Three ResultMessages arrive at 37.0s, ~10s before the session actually ends, and real work continues in between. All carry parent_tool_use_id = None, so nothing distinguishes them from the terminal one.
Why it matters
_execute_query (core/agent_query_execute.py) sets terminal_result_seen = True on the first ResultMessage. From that point:
- the short
POST_COMPLETION_IDLE_TIMEOUT_SEC (120s) is armed, so a worker that goes quiet for longer than that mid-session would be cut off as "SDK lost the ResultMessage";
- any later stream error is swallowed as post-terminal teardown noise — which is exactly the guard that exists to stop a real error from killing a run, now firing while the run is still live;
MessageProcessor overwrites last_result_is_error, last_result_subtype, last_total_cost_usd and the token counters on every ResultMessage, so the session's derived success and its cost report come from whichever arrived last.
Observed symptom in a real claudetm fan-out on the same day: a session that ran three workers reported
Session cost: $0.3927 | in 24 out 2,613 tok
24 input tokens for a session whose workers consumed ~100k+ each. Per CLAUDE.md, that figure exists precisely because "a fanned-out session's figure, which aggregates the lead and every worker, was the one number that would have shown a fan-out costing N agents' tokens" — it is not currently doing that.
Not yet established
- Whether the extra
ResultMessages are per-background-task results. The probe used general-purpose agents (backgroundable); claudetm's hive-worker pins background=False, but the real-run cost line above suggests the same shape reaches real sessions.
- Whether the SDK offers a field that identifies the genuinely-terminal result (
ResultMessage.uuid / session_id / origin are candidates, none checked).
Suggested direction
Identify the terminal result positively rather than by "first seen", and make cost/token capture additive-or-last-terminal rather than last-wins. Both need a live reproduction to confirm before changing, given how much of the loop keys off is_error.
Repro harness used for the trace is in the PR discussion; it is ~30 lines around claude_agent_sdk.query with forward_subagent_text=True.
Found while verifying the SDK 0.2.150 upgrade (#PR pending) against a live fanned-out session. Not caused by that upgrade — filing separately because terminal-result semantics are the most load-bearing thing in the work loop (
CLAUDE.md→ Unfinished sessions), and changing them needs its own reproduction and tests.Evidence
Probe: one
query()dispatching two concurrent subagents, logging every message with a monotonic timestamp. Full ordering (abridged):Three
ResultMessages arrive at 37.0s, ~10s before the session actually ends, and real work continues in between. All carryparent_tool_use_id = None, so nothing distinguishes them from the terminal one.Why it matters
_execute_query(core/agent_query_execute.py) setsterminal_result_seen = Trueon the firstResultMessage. From that point:POST_COMPLETION_IDLE_TIMEOUT_SEC(120s) is armed, so a worker that goes quiet for longer than that mid-session would be cut off as "SDK lost the ResultMessage";MessageProcessoroverwriteslast_result_is_error,last_result_subtype,last_total_cost_usdand the token counters on everyResultMessage, so the session's derived success and its cost report come from whichever arrived last.Observed symptom in a real claudetm fan-out on the same day: a session that ran three workers reported
24 input tokens for a session whose workers consumed ~100k+ each. Per
CLAUDE.md, that figure exists precisely because "a fanned-out session's figure, which aggregates the lead and every worker, was the one number that would have shown a fan-out costing N agents' tokens" — it is not currently doing that.Not yet established
ResultMessages are per-background-task results. The probe usedgeneral-purposeagents (backgroundable); claudetm'shive-workerpinsbackground=False, but the real-run cost line above suggests the same shape reaches real sessions.ResultMessage.uuid/session_id/originare candidates, none checked).Suggested direction
Identify the terminal result positively rather than by "first seen", and make cost/token capture additive-or-last-terminal rather than last-wins. Both need a live reproduction to confirm before changing, given how much of the loop keys off
is_error.Repro harness used for the trace is in the PR discussion; it is ~30 lines around
claude_agent_sdk.querywithforward_subagent_text=True.