Skip to content

A session's first ResultMessage is not always terminal — early terminal_result_seen and wrong cost accounting #160

Description

@sebyx07

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.mdUnfinished 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/backendsize/4h~half a day — substantial, design work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions