Skip to content

fix(reports): count errors as misses and stop losing cost on error paths - #63

Open
bai-uipath wants to merge 15 commits into
mainfrom
bai/pass-rate-and-cost-accounting
Open

fix(reports): count errors as misses and stop losing cost on error paths#63
bai-uipath wants to merge 15 commits into
mainfrom
bai/pass-rate-and-cost-accounting

Conversation

@bai-uipath

@bai-uipath bai-uipath commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Cost went missing on the paths where a task did not finish, and the pass rate paid a bonus for erroring. Ordered below by how much each part changes what a run reports.

Scope: the recovery fix below is the only part that touches the run path (the agent's cancel branch and the orchestrator's timeout handler). Everything else changes the rate card and what gets reported, not what gets executed. git diff main -- evalboard/ is empty, so the dashboard is untouched.

A hard-killed task recorded nothing at all

A task that runs to the wall spends the most getting there, and it was recording none of it. Not just the cost: no turns, no tokens, no commands, nothing.

A task-level timeout is a different code path from a turn-level one, and that is where it went:

  • A turn-level timeout raises TurnTimeoutError, which reaches _on_attempt_failure, which drains agent.pending_turn into result.iterations. The partial keeps its tokens and gets priced. (What happened to that price is the section below.)
  • A task-level timeout comes from the ThreadedWatchdog, which SIGKILLs the agent by PID and cancels the asyncio task awaiting the turn. It never reaches _on_attempt_failure, and nothing had parked a record to drain anyway.

Reproduced live on a 30s wall (task_timeout has a ge=30 floor, so 30s is as tight as it goes), against a task written to outlast it:

status = TIMEOUT   duration = 30.6s   total_turns = 0
iterations = []    total_tokens = None   total_cost_usd = None
cost_complete = False    run tasks_unpriced = 1

❌ That is byte-for-byte the shape of the production rows this was chased from: TIMEOUT, zero turns, no tokens, no cost. Across seven recent runs there are 26 such rows totalling 377 minutes of wall clock that recorded nothing.

That spend is now recovered rather than declared missing. The kill does not destroy the telemetry. The agent's finalize still reduces everything the event stream delivered into a complete record and prices it from the buckets. What lost it was the last line of that method, which parks the record on pending_turn only for a turn flagged as crashed: the Claude agent flagged an external cancel as COMPLETED, so the record was assembled and then dropped. It now finalizes as a crash, which is what Codex and Antigravity already did, and their copy of that fragment moves to a shared kernel on the base class.

Reading the slot was the other half, and nothing did on this path: the cancel arrives as a BaseException, so it never reaches the retry executor's per-attempt hook. _drain_killed_turn reads it in the TaskTimeoutError handler, ahead of teardown (whose agent.stop() clears the slot) and ahead of finalization, so a recovered turn feeds token aggregation, command statistics and model resolution exactly like a turn that ended on its own.

Measured on the VM against the same task, same box, with only these two seams toggled:

Harness Model Seams off Seams on
Claude Code claude-sonnet-4-6 ❌ 0 turns, 0 commands, no tokens, no cost ✅ 9 commands, 235,312 tokens, $0.1830
Antigravity gemini-3.1-pro-preview ❌ 0 turns, 0 commands, no tokens, no cost ✅ 12 commands, 93,478 tokens, $0.1209
Codex gpt-5.6-terra ✅ 20 commands, 94,398 tokens, $0.0739 ✅ 10 commands, 91,556 tokens, $0.0640

Two things that table settles. Codex never lost this money: its turn runs on a worker thread that finishes the record and returns it before the cancel lands, so the row arrives priced through the ordinary path (crashed=false on both runs). It still gains the drain for the timings where the cancel wins the race. And Antigravity's ghost proves the orchestrator half carries its own weight: that agent already parked its partial before this PR, so the row was blank purely because nothing read the slot.

⚠️ One field stays blank on a recovered Claude row: total_turns is the SDK's num_turns, which only arrives on the terminal result message a hard kill never delivers. visible_turns and actual_commands (9 and 9 above) do reflect the work, and the tokens and cost are whole.

⚠️ The row still reports cost_complete: false. Recovery captures what the stream delivered, which is most of it, but the generation the agent was waiting on when it was killed was never delivered by anyone. The total stays a floor and the row says so. _cost_complete used to call these rows fully priced, because it asked "does every turn that burned tokens carry a cost?" and a row with no turns answers yes vacuously. It now returns False for any TIMEOUT, keyed on the status rather than on elapsed time, so a setup failure stays free (it genuinely spent nothing) while a hard kill never does.

Keying on the status rather than on emptiness matters, and live testing is what showed it. A first attempt flagged only TIMEOUT rows with no preserved turn. But a task killed mid-dialog keeps the turns that already finished, and those carry costs, so it still reported as complete while the in-flight turn was just as lost. Re-projecting that captured run through the corrected rule:

final_status    = TIMEOUT
preserved turns = 3   (only 2 carry token_usage; the third is the killed turn, usage None)
total_cost_usd  = 0.0995112
cost_complete   = False     # was True under the emptiness rule

A killed turn's tokens were captured but never priced

The turns that were recorded had a second, unrelated problem, and it is the one visible on the evalboard: 26 turns, 30m03s, 1,201,607 tokens captured, cost None.

status         = ERROR       crash_reason = Agent turn timed out after 1800s
total_tokens   = 1,201,607   total_cost_usd = None      model = claude-opus-5

It is a two-step failure, and the second step is the one that surprises:

  1. On a clean turn the Claude Code SDK reports its own dollar cost. The rate card is never consulted, which is why the other 972 rows in that run priced fine despite claude-opus-5 being absent from the rate card entirely.
  2. A timeout kill means no terminal result message, so no SDK cost. The rate card is the only fallback left, and with no entry for the model the fallback is a no-op (test_backfill_noop_for_unknown_model pins that). Full token counts, no money.

The fix is the missing rate, verified by replaying that row's real buckets through the corrected card:

that row (32 uncached, 18,519 out, 99,959 cache write, 1,083,097 cache read)
         None  ->  $1.6294
all 9 killed rows in that run
         $0    ->  $40.37

This is also what prices the turn the section above recovers: a hard kill delivers no result message either, so the recovered turn is only worth dollars if the card can price it.

The rate card was wrong in five other places

Audited every entry against the published tables. What was wrong, and by how much:

Entry Was Is Effect
claude-opus-5 (absent) 5 / 25 / 6.25 / 0.50 the case above
claude-opus-4-8, -4-7, -4-6, -4-6-20250514, -4-5-20251101 15 / 75 / 18.75 / 1.50 5 / 25 / 6.25 / 0.50 3x overcharge on every fallback-priced turn
claude-haiku-4-5-20251001 0.80 / 4 / 1 / 0.08 1 / 5 / 1.25 / 0.10 25% undercharge (those were Haiku 3.5's rates)
gpt-5.6-sol, -terra, -luna cache write at 1.25x input cache write == input OpenAI bills no cache-write fee; matches every other OpenAI entry

Opus 4.5 and later dropped to $5/$25 while Opus 4.1 and Opus 4 stayed at $15/$75, so the version boundary is the price boundary and a newer Opus is not the more expensive one. That is what the old entries got backwards.

Also added the aliases that silently went unpriced because only the dated id was keyed: claude-opus-4-5, claude-opus-4-1, claude-opus-4, claude-sonnet-4-5, claude-haiku-4-5, claude-haiku-3-5, plus claude-fable-5 and claude-mythos-5.

Verified correct and left alone: every Sonnet 4.x entry, the Claude 3.x entries, all nine remaining OpenAI entries, and the Gemini entries (including the documented >200K-tier caveat). virtuoso-* correctly stays out: the coder_eval_uipath plugin registers it through register_pricing.

One deliberate non-correction: claude-sonnet-5 stays at the standard $3/$15 rather than the $2/$10 introductory rate in effect through 2026-08-31. A static table cannot express a promo window, and of the two available errors this one overstates cost for a few weeks instead of understating it indefinitely afterwards. The comment says so.

So the next missing rate cannot hide

The rate card is a static table baked into the installed version, so this recurs the next time a model ships. Three guards, none of which block a run:

  • check_pricing_coverage warns at dispatch for any pinned agent.model the card cannot price. A warning, not a refusal, so a new model stays evaluable the day it ships: cost degrades, the evaluation does not. Its limit, worth knowing: it only sees pinned agent.model, so a model reached through BEDROCK_MODEL gets no dispatch warning.
  • tasks_unpriced / cost_complete count what a run actually lost, per row and per run. The report prints (floor — N task(s) have spend missing from this total), or unavailable when nothing could be priced. The wording is cause-agnostic because both failures above land here and the report cannot always tell which applied. One predicate, row_cost_incomplete, is shared by the report and RunSummary so the two cannot disagree about which rows lost money. One edge it does not cover: a run where every row is a ghost renders no token section at all, so the caveat lives only in run.json. A run with any priced row shows it.
  • A CI test walks every committed experiment and fails if one names an unpriced model.

Judge and simulator spend is reported at all

Per-row judge_cost_usd and simulator_cost_usd, run-level eval_overhead_cost_usd. This was captured per criterion and rolled up nowhere, so it was absent from every total.

llm_judge prices its own call from criterion.model. The simulator prices from the route model rather than the subject's, because it resolves model=None through to BEDROCK_MODEL; pricing it off the subject would mis-bill every task that pins agent.model.

Kept beside the agent bill rather than folded into it: judge cost is a property of the suite's criteria and identical across harnesses, so folding it in would make two harnesses look closer than they are. **Total Cost** on the report always means the two added together.

Errors count as misses

RunSummary.pass_rate is tasks_succeeded / tasks_run. The old rate divided by tasks_run - tasks_error, so the more a harness fell over, the smaller its denominator got.

How much this actually moves: on a healthy run, very little. Measured across the last six runs:

run tasks errors old new Δ
07-28 sonnet-4-6 1005 4 (0.4%) 93.4% 93.0% 0.4
07-27 sonnet-4-6 1002 6 (0.6%) 93.1% 92.5% 0.6
07-26 sonnet-4-6 986 9 (0.9%) 88.9% 88.1% 0.8
07-25 opus-5 984 11 (1.1%) 89.1% 88.1% 1.0
07-25 virtuoso-1-5 984 78 (7.9%) 84.5% 77.8% 6.7
07-24 gpt-5.6-luna 984 0 84.0% 84.0% 0.0

So this is not a headline-number change for the Claude nightly: it costs well under a point. It matters exactly when a harness errors a lot, which is also exactly when the old formula flattered it most. The virtuoso-1-5 row is a live 6.7-point example, and the degenerate case is real: a run that errored on 854 of 861 rows reported 100.0% because 7 of its 7 evaluable rows passed.

pass_rate is a pydantic computed_field, so it serializes into run.json and cannot drift from the counts it derives from. VariantAggregate gets the same property so an A/B whose variants error at different rates compares like with like. error_share ships beside it, diagnostic only, never adjusting the rate: a drop at a high error share is an infrastructure night, the same drop at a normal share is the model.

Minor points

One denominator across every surface. Four surfaces derived their own rate; each now reads the published field (reports.py, reports_experiment.py, reports_html.py). The dashboard was never part of this: evalboard/lib/trends.ts already counted every row in its denominator, so its numbers were correct before this PR and are unchanged by it.

Error diagnostics on the row. error_message (truncated to 400 chars) and error_category, projected from the existing error_details. Nothing new is captured, only surfaced. Without them an errored run is untriageable without fetching per-task artifacts one at a time.

A crash that burned nothing is free, and reported free. An error before the agent ran genuinely cost zero. On a live errored run the zero-token row came back cost_complete: true with tasks_unpriced: 0, which is what keeps the flag a signal rather than something that fires on every error.

Budget kills (TOKEN_BUDGET_EXCEEDED / COST_BUDGET_EXCEEDED) are category failed, not error: they carry their spend on the row like any other task and sit in the denominator as misses.

⚠️ simulator_cost_usd is a floor. UserSimulator records only uncached_input_tokens and drops both cache buckets, so a cached prefix is largely absent from the count. Widening that changes what the run captures rather than what this reports, so it is not done here. Same for a kill that lands inside the simulator between agent turns: the agent's turns are safe, that one simulator call is not.

How it was verified

Live hard kill on three harnesses, on the VM, at this commit: Claude Code, Codex and Antigravity, each against a task built to outlast the 30s floor, each run twice with only the two recovery seams toggled. That is the table above. Claude and Antigravity go from a blank row to a priced one; Codex was already whole. A mid-dialog kill from an earlier session, re-projected through the rule, is what caught the emptiness version of _cost_complete under-flagging.

Live Bedrock run for the judge and simulator paths, which had no live evidence: a dialog task with a simulated user and an llm_judge, subject and judge both sonnet-4-6.

run.agent_cost_usd          = 0.0995112
run.eval_overhead_cost_usd  = 0.008076     # = 0.007518 + 0.000558, exactly
row.judge_cost_usd          = 0.007518
row.simulator_cost_usd      = 0.000558

The route-versus-subject branch was then exercised against that run's real telemetry: repinning the subject to haiku left the simulator cost unchanged, proving it prices at the route; removing the route dropped it to the haiku rate, proving the fallback. A run with no judge reports eval_overhead_cost_usd: null rather than $0.00.

Real-run replay for the cost and rate work: the timed-out row and the nine killed rows above, pulled from blob and repriced through the corrected card. Historical replay for the denominator, plus the six-run table above.

Recovery is pinned by a test that fails without it. An agent-level test streams a turn carrying real usage, cancels it from outside exactly as the task watchdog does, and asserts the record lands on pending_turn with its tokens and a cost backfilled from the buckets. With the one-line finalize change reverted, that assertion reports partial = None: the production symptom. An orchestrator-level test drives a real task timeout and asserts the parked turn reaches both result.iterations and total_token_usage, and a companion asserts an empty slot still lands a clean TIMEOUT row, since the drain runs on the way to a saved row and must not raise.

Every remaining spend path traced rather than assumed. Subject-agent turns and their internal sub-agents fold into the turn total. llm_judge makes exactly one call per criterion with no retry loop and no text-channel fallback, and its criterion.model defaults to a priced model, so it cannot silently go unpriced. agent_judge runs a single communicate(), so its TurnRecord carries the whole sub-agent loop with the SDK's cost. Variant reports feed the same token section, so an A/B gets the same cost lines. Two things specifically looked for and not found: a retry around the judge invoke that would keep only the last attempt's usage, and any double-count between the agent bill and the overhead.

Unit, snapshot and CI. Tests pin the pass-rate reductio, the empty-run None, the error-message truncation, the pricing pre-flight's silent / warning / unpinned paths, the floor and unavailable renderings, and a legacy row reading as cost-complete. Two pre-existing tests that hardcoded the old Opus and Haiku rates were updated to the corrected ones, which is the check that the card change took effect. Report snapshots regenerated: the only diffs are the Success RatePass Rate label, with no rate value moving.

Local: 3840 passed / 8 skipped, make check / typecheck / lint clean. Three failures in test_reports_stats_nonfinite.py are unrelated and pre-existing (cohens_d raises on non-finite input; reproduces with this branch's changes stashed).

🤖 Generated with Claude Code

bai-uipath and others added 12 commits July 28, 2026 15:40
The reported success rate divided by `tasks_run - tasks_error`, so a run was
rewarded for erroring: whichever harness errored most got the biggest bonus.
Measured on real nightlies, the inflation was +10.0 points for a codex run
(116 errors of 947) and +7.1 for a sonnet-5 run, and one LiteLLM run rendered
as 100.0% while passing 7 of its 861 rows.

`RunSummary.pass_rate` is now `tasks_succeeded / tasks_run` with errors in the
denominator, published as a computed field so consumers read it instead of
deriving their own. Four surfaces across two repos were each deriving a
denominator, which is why the dashboard and the markdown report disagreed by up
to 10 points on the same run.json. An error is still not a failure, so the
reason survives as diagnostics on the row (`error_message`, `error_category`)
and as `error_share` on the run: a bad infrastructure night now shows as a bad
night instead of being absorbed into a flattering denominator.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…priced

Cost went missing three ways, each silently.

A turn killed mid-flight carries real billed tokens and no SDK-reported cost,
and those partials were summed as free. The orchestrator now prices any turn
that burned tokens without a cost from the rate card, leaving an SDK-reported
cost untouched as the authoritative figure.

The rate card is a static table baked into the installed version, so a model
released after it prices every turn as null. That understated one nightly by
$209.81 across 62 rows, 18.9% of its true bill, with one log line to show for
it. Runs now pre-flight their models against the card and warn (or refuse under
--strict-pricing), report `tasks_unpriced` / `cost_complete`, and label a total
built from partly-priced rows as the floor it is. A committed experiment whose
model has no rate now fails CI rather than a nightly's cost column.

Judge and simulator spend was captured per criterion and rolled up nowhere.
Both are now priced and reported as `eval_overhead_cost_usd`, deliberately
beside the agent bill rather than inside it: judge cost is a property of the
suite's criteria and identical across harnesses, so folding it in would make
two harnesses look closer than they are.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…cost

The dashboard derived its own pass rate and summed per-task cost treating a
missing cost as zero, so an unpriced row was indistinguishable from a free one
and a run's bill read low with nothing to say so. It now prefers run.json's
`pass_rate`, falling back to the identical formula so historical runs render
unchanged, and counts unpriced rows into the existing cost-partial caveat.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An A/B on a real timed-out Bedrock run booked its spend with the backfill
disabled, so the earlier claim that killed partials "were summed as free" does
not hold: every in-tree agent already prices its own partials
(ClaudeCodeAgent._backfill_cost; codex and antigravity compute from buckets and
never depend on an SDK cost). The measured $209.81 loss was the rate-card miss
alone.

The backfill stays, described accurately: it makes "tokens on the record imply a
cost on the record" an invariant at one agent-agnostic seam, which the plugin
SPI needs — an out-of-tree agent that registers pricing but never applies it
would otherwise lose all of its spend silently.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…icing

Both were justified by anticipation rather than by measurement, and this PR's
other claims were all measured. Removing them leaves the orchestrator, the batch
config, and the CLI byte-identical to main, so the PR no longer touches the run
path at all.

The per-turn backfill (Orchestrator._backfill_turn_costs) protected against an
out-of-tree plugin agent that registers pricing but never applies it. No such
agent exists, and the A/B established that every in-tree agent already prices
its own killed partials — so it guarded a hypothetical while adding a mutation
to the aggregate path and forcing cost_data_available to be computed earlier to
stay honest about budget enforceability. Reverting restores main's ordering.

--strict-pricing had no caller: nothing in CI or the nightly passed it, and the
pre-flight warning already surfaces the rate-card miss that cost $209.81. The
warning and the is_priced/unpriced_models seam stay, as does the CI guard that
fails on a committed experiment referencing an unpriced model.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two definitions of "which rows lost money" had appeared: RunSummary.tasks_unpriced
computed one, and the report's token section re-derived the same predicate inline.
That is the duplication this PR exists to remove, reintroduced one layer down. Both
now call row_cost_incomplete / eval_overhead_costs, defined on the row schema where
the reports (which have task dicts, not a RunSummary) can reach them.

The predicate also gets simpler: read the row's cost_complete flag, and treat its
absence as complete. It previously fell back to inferring unpriced-ness from
"burned tokens but carries no cost", which only ever mattered for runs written
before the field existed. Every new run sets the flag, so the fallback bought a
caveat on historical runs at the cost of a second definition living in TypeScript
and drifting from the Python one. Old runs now render exactly as they did before.

Unpriced stays reachable, so the field is not going away: any model released after
the installed framework version prices as null, and the dispatch pre-flight only
sees pinned agent.model values, so a run pointed at a new model through the route
gets no warning at all. tasks_unpriced is the only thing that catches that.

Also drops a stale reference to the reverted turn-cost backfill, and one to the
deleted framework/harness error split.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The run page's Total cost tile summed the priced tasks and rendered the result with
a p50/p90 subtitle under it. An unpriced task contributes $0 to that sum, so the
2026-07-21 nightly displayed $902.81 as though measured, with $209.81 missing and
percentiles describing only the tasks that happened to be priced.

It now reads ≥$902.81 with "floor · 62 tasks unpriced" in place of the percentiles,
rather than beside them: a number presented as exact is worse than one presented as
a bound. The count is filter-aware and skips mature-skipped tasks, matching how the
tile already scopes cost itself.

This was the surface that mattered. The overview tile already flagged the run, so
the flow a person actually takes — see something off, click the run to find out
what — landed on a page that denied it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The dashboard now reads exactly as it did before this PR: `git diff main -- evalboard/`
is empty.

Marking a total as `≥$902.81 · floor · 62 tasks unpriced` is hedging in a place that
should just show a number. A dashboard that qualifies its own figures trains people
to distrust the unqualified ones, and it is worse to read than a number that is
quietly a little low. The run.json fields stay, so anyone who wants the caveat can
compute it; the markdown report still labels a partly-priced total as a floor, which
is a written artifact where a caveat belongs.

This also retires eight declared-but-unread fields, one of which duplicated an
existing `errorMessage` already rendered on the task-detail page from task.json.

The pass rate needed nothing here: lib/trends.ts already counted every row in its
denominator, so the dashboard was correct before this PR and is correct after. Only
the markdown report and the downstream runner were inflating.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…variant error share

The simulator cost is a floor, not an upper bound. UserSimulator keeps
uncached_input_tokens and drops both cache buckets, so a cached prompt prefix is
absent from the count: a live run's full persona-and-goal prompt recorded 6 input
tokens. The docstring claimed the opposite, which would tell a reader the figure
is conservatively high and stop them looking.

Also drops is_priced's reference to a refusal path that was cut, and removes
VariantAggregate.error_share: no surface rendered it, the variant tables already
print Errors beside Pass Rate (n/m), and the experiment JSON has no downstream
reader, so it would have shipped published and unread. pass_rate stays.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… comments framework-general

The pre-flight warning claimed cost "will be recorded as null" for an unpriced
model. That is wrong for any agent whose backend reports its own cost: those turns
price fine. The rate card is the FALLBACK, and the only source for a turn the
backend never priced, so what an unpriced model actually costs you is the killed
and timed-out partials, which arrive with full token counts and no cost. Traced
against a real timed-out row carrying 1.2M tokens and no dollars.

Also strips run ids, dates, dollar amounts and suite names from comments and test
docstrings. The behavioural claim each one made is kept; the incident it came from
is not something a general framework should narrate.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… zero

The rate card had no entry for claude-opus-5. On a clean turn that costs nothing,
since the Claude Code SDK reports its own cost, but the card is the only fallback
for a turn the backend never priced. So every timed-out partial booked its full
token counts against no money.

Verified against a real timed-out row: 32 uncached / 18,519 out / 99,959 cache
write / 1,083,097 cache read priced as None before, $1.6294 now. Across that run's
nine killed rows, $40.37 that previously read as zero.

Rates from the published table: $5/$25 per MTok, with the standard 1.25x cache
write and 0.1x cache read multipliers.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
… gaps

Audited the whole card against the published tables.

Opus 4.5 and later dropped to $5/$25 while Opus 4.1 and Opus 4 stayed at $15/$75,
so the version boundary is the price boundary. The card had opus-4-5 through 4-8 at
the old $15/$75, a 3x overcharge on every turn the rate card actually prices.

haiku-4-5 carried Haiku 3.5's rates ($0.80/$4 instead of $1/$5). The gpt-5.6 family
had an Anthropic-style 1.25x cache-write rate, where OpenAI bills no cache-write fee
and every other OpenAI entry sets cache_write == input.

Also keys the bare aliases that previously went unpriced because only the dated id
was present (opus-4-5, opus-4-1, opus-4, sonnet-4-5, haiku-4-5, haiku-3-5), and adds
fable-5 / mythos-5.

sonnet-5 deliberately stays at the standard $3/$15 rather than the $2/$10
introductory rate: a static table cannot express a promo window, and this error
overstates cost for a few weeks instead of understating it indefinitely after.

Two pre-existing tests hardcoded the old Opus and Haiku rates; their expected values
are recomputed from the corrected ones.

Verified correct and unchanged: the Sonnet 4.x and Claude 3.x entries, the nine other
OpenAI entries, and the Gemini entries.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@bai-uipath
bai-uipath marked this pull request as ready for review July 29, 2026 03:07
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @bai-uipath's task in 1m 52s —— View job


📋 Code Review in Progress

Todo List:

  • Read .github/code_review.md for review standards and process
  • Read CLAUDE.md for project conventions
  • Get full diff with git diff origin/main...HEAD
  • Review each changed file with full context:
    • Core pricing and cost accounting changes
    • Model and results updates
    • Report generation changes
    • New test coverage
  • Perform cross-file consistency checks
  • Analyze "what's missing" from the changeset
  • Design-level scrutiny
  • Post comprehensive review feedback

bai-uipath and others added 3 commits July 28, 2026 20:13
… not free

_cost_complete returned True for a row whose iterations list was empty, on the
reasoning that a row which burned nothing is not missing cost. That is right for a
setup error and wrong for a task-level timeout.

TaskTimeoutError comes from the ThreadedWatchdog, which SIGKILLs the agent by PID
from a non-asyncio thread. Unlike a turn-level timeout it never reaches
_on_attempt_failure, so no partial turn is drained and the row lands with zero
turns, zero tokens and no cost. The evaluation loop was still running, so that
spend is real; reporting the row as fully priced is a false claim.

Keyed on TIMEOUT rather than elapsed time, so a slow setup failure stays free while
a task timeout never does.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
…empty ones

Live testing found the previous rule under-flagged. Keying on "TIMEOUT with no
preserved turn" caught a task killed during its first turn, but a task killed
mid-dialog after two turns had completed still reported cost_complete: true,
because those two turns carry costs. The turn that was in flight when the wall hit
is lost either way.

The watchdog fires while the evaluation loop is running, so a TIMEOUT row always has
an in-flight turn whose spend was never recorded. Keyed on the status alone.

The report wording drops its rate-card explanation, since the two causes (a turn the
rate card could not price, and a hard kill that recorded nothing) reach the same
conclusion and the report cannot always tell which applied.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
A task-level timeout kills the agent and cancels the task awaiting
communicate(), so the turn in flight never returns a record. The Claude
agent finalized that cancel as COMPLETED, which keeps no record, so the
tokens it had already spent were dropped: the row landed with no turns, no
tokens and no cost for work that was billed.

The telemetry is intact at cancel time, so finalize as a crash instead,
which parks it on pending_turn under the existing contract. Codex and
Antigravity already did this; their fragment moves to a shared kernel on
the base class.

Nothing read that slot on this path either: the cancel is a BaseException,
so it never reaches the retry executor's per-attempt hook that drains it on
a turn-level timeout. The task-timeout handler now drains it, before
teardown clears the slot and before finalization, so the recovered turn
feeds token aggregation and command stats like any other.

Rows stay flagged cost_complete=false. Recovery captures everything the
event stream delivered, but the generation the agent was waiting on when it
died was never delivered by anyone, so the total is still a floor.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
await streaming.wait()
turn.cancel()
with pytest.raises(asyncio.CancelledError):
await turn
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.

2 participants