Measured on a real run (2026-07-30T12:46:22Z, issue #1 of an out-of-fleet target). The session ran the full 1500s wall clock and was killed. The ledger entry:
{"outcome":"failure","detail":"session failed for #1 with no commit: session exceeded 1500s wall-clock timeout","cost_usd":0.0,"turns":0}
The persisted transcript for that same run contains 61 assistant lines and 35 tool calls over 25 minutes. The real spend was material; the recorded spend is zero.
Cause: _parse_result (session.py) only learns cost and turn count from the single type=result line that claude emits when it finishes. ClaudeSessionRunner.run catches subprocess.TimeoutExpired and returns SessionResult(ok=False, transcript=..., error=...) with the cost_usd/turns defaults of 0.0/0 — a timed-out stream never carries that final line.
Why this matters beyond bookkeeping: cost_usd is what the retry loop accumulates into max_total_budget_usd, and what my-fleet's $20/day ceiling and per-session caps meter. A timeout is the most expensive way a session can end, and it is the one shape that meters as free. A repeatedly-timing-out candidate can therefore run well past a ceiling that believes nothing has been spent — and _RETRYABLE includes failure, so --max-attempts > 1 will retry it, each attempt also metering zero.
Acceptance criteria
- A timed-out session reports a non-zero, best-effort cost and turn count, recovered from the partial stream rather than defaulted (the incremental lines carry
usage, and the partial stdout is already captured in exc.stdout).
- Where cost genuinely cannot be recovered, it is recorded as unknown rather than as
0.0, and the retry/budget logic treats unknown as "assume the cap was consumed", not as free.
- A test pinning that a timeout does not meter as zero.
Measured on a real run (
2026-07-30T12:46:22Z, issue #1 of an out-of-fleet target). The session ran the full 1500s wall clock and was killed. The ledger entry:{"outcome":"failure","detail":"session failed for #1 with no commit: session exceeded 1500s wall-clock timeout","cost_usd":0.0,"turns":0}The persisted transcript for that same run contains 61
assistantlines and 35 tool calls over 25 minutes. The real spend was material; the recorded spend is zero.Cause:
_parse_result(session.py) only learns cost and turn count from the singletype=resultline thatclaudeemits when it finishes.ClaudeSessionRunner.runcatchessubprocess.TimeoutExpiredand returnsSessionResult(ok=False, transcript=..., error=...)with thecost_usd/turnsdefaults of0.0/0— a timed-out stream never carries that final line.Why this matters beyond bookkeeping:
cost_usdis what the retry loop accumulates intomax_total_budget_usd, and whatmy-fleet's$20/dayceiling and per-session caps meter. A timeout is the most expensive way a session can end, and it is the one shape that meters as free. A repeatedly-timing-out candidate can therefore run well past a ceiling that believes nothing has been spent — and_RETRYABLEincludesfailure, so--max-attempts > 1will retry it, each attempt also metering zero.Acceptance criteria
usage, and the partial stdout is already captured inexc.stdout).0.0, and the retry/budget logic treats unknown as "assume the cap was consumed", not as free.