Skip to content

token-usage.json reports all zeros for cancelled sessions (no type:"result" entry), under-counting cost by the cancellation rate #871

Description

@prql-bot

Summary

The Claude action's Token usage step hardcodes an all-zeros token-usage.json whenever the stream-json contains no type:"result" entry. A cancelled session never emits one — so a run that did dozens of turns of real work, and may have already posted a review, reports turns=0, output_tokens=0, cost_usd=0. Everything downstream that consumes token-usage.json (token-report.sh, the review-reviewers evidence gist, review-runs' per-window cost line) silently under-counts by the cancellation rate.

The per-message usage data needed to reconstruct the true numbers is already in the session JSONL that the same step uploads — it is just never read on this path.

Mechanism

claude/action.yaml#L558-L580 (0.1.14, current release):

(map(select(.type == "result"))) as $rs |
if ($rs | length) == 0 then
  {input_tokens:0, output_tokens:0, cache_creation_input_tokens:0,
   cache_read_input_tokens:0, turns:0, model:$model, cost_usd:0}
else
  ...

The step is if: always(), so it does run on cancellation and does upload the artifact — the JSONL is right there next to the zeroed token-usage.json. Only the accounting is lost.

This is distinct from #302 / #437, which were about taking the last result entry instead of summing across several. That path is fixed. This is the zero result entries case, which still falls through to the hardcoded zeros.

Codex is unaffected — codex/action.yaml parses the rollout JSONL directly rather than a terminal result event.

Evidence — PRQL/prql, 24h window ending 2026-08-06T08:45Z

tend-review uses cancel-in-progress: true, so cancellations are routine: 5 of 15 tend-review runs in this window were cancelled. Two got far enough to upload an artifact, and both reported $0.00:

Run Reported Reconstructed from its own JSONL
31084182723 turns=0, out=0, $0.00 36 assistant messages, 19,315 out, 70,507 cache-create, 2,560,826 cache-read → ~$2.47
31085384920 turns=0, out=0, $0.00 5 assistant messages, 1,232 out, 21,848 cache-create, 244,648 cache-read → ~$0.37

31084182723 is the sharp case: it was not a stillborn run. It completed a full review of PRQL#6150 and posted it (review 4872609096, 08:27:20Z) before a later push cancelled it at 08:33Z. The work shipped; only the accounting vanished.

Window effect: token-report.sh 24 reported $30.13 across 25 runs. Adding just these two brings it to ~$32.97 (+9.4%), before the three cancelled runs that died too early to upload anything.

The bias is invisible and it is not constant — it scales with push cadence, which is exactly the axis review-runs compares windows along. Its tracking issues carry lines like "$18.46 — down from the $42.62 peak"; those comparisons are currently distorted by however many reviews got cancelled that day.

Reconstruction verified

Deduplicating type:"assistant" lines by .message.id and summing .message.usage reproduces the result line's four token fields exactly — not approximately — on every session I checked:

Run Reported (from result) Dedup-by-message.id sum
31085526795 in 85, out 21252, cc 47450, cr 3650202 in 85, out 21252, cc 47450, cr 3650202
31082529861 in 99, out 26752, cc 74338, cr 5027737 in 99, out 26752, cc 74338, cr 5027737
31081834016 in 40, out 10016, cc 59563, cr 1364718 in 40, out 10016, cc 59563, cr 1364718

The dedup matters: the session JSONL records each assistant message roughly twice (81 lines / 45 distinct ids on 31085526795), so a naive sum lands ~2× high.

turns also reconstructs: (count of type:"user" lines) - 1 matched num_turns on all 5 sessions I checked (53→52, 55→54, 24→23, 26→25, 17→16). I'd treat that as an empirical match rather than a documented invariant — worth confirming against the schema before relying on it.

Suggested fix

Replace the zeros branch with a fallback over the same file:

(map(select(.type == "result"))) as $rs |
if ($rs | length) == 0 then
  ([.[] | select(.type == "assistant") | {id: .message.id, u: .message.usage}]
   | unique_by(.id)) as $ms |
  {input_tokens:               ([$ms[].u.input_tokens // 0]               | add // 0),
   output_tokens:              ([$ms[].u.output_tokens // 0]              | add // 0),
   cache_creation_input_tokens:([$ms[].u.cache_creation_input_tokens // 0]| add // 0),
   cache_read_input_tokens:    ([$ms[].u.cache_read_input_tokens // 0]    | add // 0),
   turns: (([.[] | select(.type == "user")] | length) - 1 | if . < 0 then 0 else . end),
   model: $model, cost_usd: 0, partial: true}
else
  ...

Two things I'd leave to your judgement:

  • cost_usd. It isn't in the JSONL — only result.total_cost_usd carries it. Emitting 0 keeps the current shape but makes a partial run look free, which is the same failure in miniature. On this window's data a per-model rate table reproduces the reported cost to the cent (Opus at $5 / $25 / $10 / $0.50 per MTok for input / output / 1h-TTL cache-write / cache-read gives $2.831, $3.927, $1.529 against reported $2.83, $3.93, $1.53), so deriving it is feasible — but hardcoding prices in the action is its own maintenance burden. Alternatively cost_usd: null plus the partial flag lets consumers decide.
  • A partial: true marker (or similar) so token-report.sh and the evidence gist can distinguish "cancelled, partial accounting" from "ran and cost nothing", rather than the two being indistinguishable as they are today.

Verification caveat

I verified the reconstruction against the session JSONL in the uploaded artifact (~/.claude/projects/…), because the raw stream-json is only preserved on tend's own repo. The step parses $STREAM_JSON, which carries the same type:"assistant" message events, so the same filter should apply — but I could not confirm that directly from a consumer repo, and the two files may differ in whether messages are duplicated. The unique_by(.id) is harmless either way.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions