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.
Summary
The Claude action's
Token usagestep hardcodes an all-zerostoken-usage.jsonwhenever the stream-json contains notype:"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, reportsturns=0, output_tokens=0, cost_usd=0. Everything downstream that consumestoken-usage.json(token-report.sh, thereview-reviewersevidence gist,review-runs' per-window cost line) silently under-counts by the cancellation rate.The per-message
usagedata 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):The step is
if: always(), so it does run on cancellation and does upload the artifact — the JSONL is right there next to the zeroedtoken-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.yamlparses the rollout JSONL directly rather than a terminal result event.Evidence — PRQL/prql, 24h window ending 2026-08-06T08:45Z
tend-reviewusescancel-in-progress: true, so cancellations are routine: 5 of 15tend-reviewruns in this window were cancelled. Two got far enough to upload an artifact, and both reported$0.00:turns=0, out=0, $0.00turns=0, out=0, $0.0031084182723 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 24reported$30.13across 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-runscompares 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.idand summing.message.usagereproduces theresultline's four token fields exactly — not approximately — on every session I checked:result)message.idsumin 85, out 21252, cc 47450, cr 3650202in 85, out 21252, cc 47450, cr 3650202in 99, out 26752, cc 74338, cr 5027737in 99, out 26752, cc 74338, cr 5027737in 40, out 10016, cc 59563, cr 1364718in 40, out 10016, cc 59563, cr 1364718The 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.
turnsalso reconstructs:(count of type:"user" lines) - 1matchednum_turnson 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:
Two things I'd leave to your judgement:
cost_usd. It isn't in the JSONL — onlyresult.total_cost_usdcarries it. Emitting0keeps 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.50per MTok for input / output / 1h-TTL cache-write / cache-read gives$2.831,$3.927,$1.529against reported$2.83,$3.93,$1.53), so deriving it is feasible — but hardcoding prices in the action is its own maintenance burden. Alternativelycost_usd: nullplus thepartialflag lets consumers decide.partial: truemarker (or similar) sotoken-report.shand 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 sametype:"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. Theunique_by(.id)is harmless either way.