Skip to content

fix: settle usage on the timeout path, not only after DONE - #4553

Closed
atishpatel wants to merge 1 commit into
benchmark/harness-accounting-and-solofrom
benchmark/flush-usage-log-harness
Closed

fix: settle usage on the timeout path, not only after DONE#4553
atishpatel wants to merge 1 commit into
benchmark/harness-accounting-and-solofrom
benchmark/flush-usage-log-harness

Conversation

@atishpatel

Copy link
Copy Markdown
Contributor

The harness half of the usage-accounting fix. The agent half is #4545, which targets main.

What was wrong

_settle_usage waits for the agent to report its tokens before teardown kills it — but it was called only on the success path, after DONE:. The timeout path fell straight through to the kill, on the stated reasoning that "a turn that never completed has no usage to flush".

That reasoning held while buzz-agent reported once per turn. #4545 changes that: it now reports after every provider round, so an interrupted turn has reported. And it was the wrong path to skip regardless — under continue_until_timeout every phase but the last ends on the timeout path, so the skip covered essentially the entire run.

Why it matters

Measured on the OpenRouter LHTB-46 cell (Kimi K3 lead + 2x DeepSeek V4 Flash):

  • 97% of receipt rows came back all zeros, against 1–4% for the unphased Terminal-Bench cells
  • recorded cost $79.23 against $485 billed by the provider's own ledger for the same model — 5.9x low, concentrated on the lead seat, the one being relaunched

In one 450-phase trial exactly 7 phases recorded any usage at all — and each of those carries 177k–437k input tokens, i.e. a whole session's worth landing in the one phase that happened to end gracefully.

The change

Move the call into the finally so both paths settle, keeping the ordering that matters: settle, then kill.

It stays cheap. _settle_usage returns on the first poll once a usage line exists, which after the first round it does, so only a phase that never completed a single round can spend the full 60s budget — and that phase has nothing to report anyway.

Also corrects the _settle_usage docstring, which described the old once-per-turn emission and cited a line number that has moved.

Test

test_usage_is_settled_when_the_trial_times_out — drives run() with a _wait_for_done that never returns and a zero trial budget, then asserts usage was settled and that it happened before teardown killed the agent. Settling after the kill is the same as not settling, so the ordering is the assertion, not the call count.

272 pass.

Ordering

Worth little without #4545 — on its own it settles a turn that still only reports at the end. Land or merge the agent side alongside it.

This does not recover the already-collected runs. Every LHTB cost recorded so far stays a floor.

🤖 Generated with Claude Code

`_settle_usage` waited for the agent to report its tokens before teardown
killed it, but only on the success path. The timeout path fell straight
through to the kill, on the stated reasoning that "a turn that never
completed has no usage to flush".

That was true while buzz-agent reported once per turn. It is not true now
that it reports after every provider round, and it was always the wrong
path to skip: under `continue_until_timeout` every phase but the last ends
here, so the skip applied to essentially the whole run. It is the harness
half of the 97%-zero-rows / 5.9x-undercount measured on the OpenRouter
LHTB-46 cell; the agent half is the per-round reporting.

Moving the call into the `finally` covers both paths and keeps the ordering
that matters — settle, then kill. It stays cheap: it returns on the first
poll once a usage line exists, which after the first round it does, so only
a phase that never completed a single round can spend the full budget, and
that phase has nothing to report anyway.

Depends on the per-round reporting in buzz-agent to be worth much on its
own.

Signed-off-by: Atish Patel <atish@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
@atishpatel
atishpatel requested a review from a team as a code owner August 3, 2026 16:02
@atishpatel atishpatel closed this Aug 3, 2026
atishpatel added a commit that referenced this pull request Aug 3, 2026
## The bug

buzz-agent emitted its `usage_update` notification in exactly one place:
after `ctx.run()` returned. Until that moment a turn's token counters
lived only in the prompt task's stack frame. **A turn killed mid-flight
reported nothing at all** — the provider had already billed every round
it completed, and no consumer ever saw any of it.

That is not a corner case for anything that ends a turn on a clock. It
is the normal case for a long-horizon benchmark run that relaunches its
agent between phases.

## How big

Measured against a provider's own billing ledger over one run's window:

| | provider ledger | what we recorded |
|---|---|---|
| the relaunched lead seat | $485 / 348M tok | $98.99 / 90.3M tok |
| the two seats that were not relaunched | $29.90 / 856M | $25.81 / 765M
— reconciles |

97% of that run's usage rows came back all zeros, against 1–4% for
comparable runs that never relaunch. In one 450-phase trial exactly 7
phases recorded any usage — and each of those carries 177k–437k input
tokens, a whole session's worth landing in the one phase that happened
to end gracefully.

Worth being precise about what was *not* wrong, since both were
plausible and both were checked:

- **Not pricing.** The rates were verified against the provider's
endpoints API and match what we charge.
- **Not a truncation bug.** The usage files were intact and internally
consistent. The tokens were never captured in the first place.

## The fix

The run loop now emits a session-cumulative `usage_update` after every
usage-bearing provider response, so an interrupted turn has reported
everything but its single in-flight request.

- **Emitting more than once per turn is already part of the contract.**
buzz-acp's `UsageTracker` advances its committed baseline only at
publish time, and goose behaves the same way — which is why the tracker
was written to tolerate it.
- **The turn-start session baseline is snapshotted into `RunCtx`** so
the mid-turn figure stays *session*-cumulative. A turn-local number
would be discarded by a high-water-mark consumer and lose the turn
entirely; there is a test for exactly that.
- **Snapshot by value, not a session handle.** The loop reports once per
round, and taking the sessions lock on each would serialise concurrent
sessions behind one another's provider round-trips. Nothing else
advances those counters while the turn holds `busy`, so it cannot go
stale.
- **One shared `wire::usage_update_payload`** for both call sites, so
the mid-turn and end-of-turn shapes cannot drift. A drift there would
present as tokens silently vanishing, which is the failure this
reporting exists to prevent.

## Why not a SIGTERM handler

That was the obvious shape and it does not work. At signal time the
counters are not sitting anywhere a handler could reach — they are in
the turn's stack frame, and the value the handler would need has not
been folded into the session yet. Making usage durable *during* the turn
is what actually fixes it; once it is, a handler adds nothing beyond the
in-flight request, whose cost is unknown until its response lands.

## Tests

- `usage_is_reported_after_each_round_not_only_at_turn_end` — two
rounds; asserts the **first** notification carries round 1's counts
alone, proving it went out before round 2 returned.
- `mid_turn_usage_includes_earlier_turns` — a mid-turn report must be
session-cumulative, not turn-local.

buzz-agent 18/18 on the `fake_llm` suite, 382 unit. `cargo fmt` /
`clippy` / `cargo check --workspace --all-targets` clean.

## Scope

Agent-side only, against `main`. The matching harness change — settling
usage on the timeout path, which was skipped on the reasoning that an
incomplete turn has nothing to flush — is **#4553**, against the
benchmark branch, since that harness does not exist on `main`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Atish Patel <atish@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant