fix(engine,cli): report weighted tokens and label both units (#129) - #188
Conversation
Token accounting used two units and named neither. Every budget judges the cost-weighted total (cache reads discounted by limits.cache_read_weight, default 0.1, matching ~0.1x vendor billing), but the run-finished summary and `bmad-loop status` printed the raw total with cache reads at full price. On agentic work cache reads are 80-95% of the raw count, so the operator-facing number overstated spend ~6.5x against the figure the engine enforced, and neither said which unit it was. The split was historical: aa73202 weighted the budgets and deliberately left displays raw; be7836b then fixed the TUI only, as "a pure observer". This finishes the job for the CLI surfaces. - RunSummary carries weighted_tokens beside total_tokens (raw) and renders "<weighted> weighted tokens (<raw> raw incl. cache reads)". render() is the single chokepoint for stdout, the ATTENTION file and the desktop notification, so all three change together. No usage tracked at all still renders one plain "0 tokens" rather than asserting free work twice. - session-end journal entries carry tokens_weighted beside tokens, so per-session spend is reconstructible after the fact -- the raw scalar alone cannot be un-weighted. null (never 0) when the usage read failed, and absent on an aborted end, preserving "untracked != zero"; distinct from a tripped session's budget_weighted (the guard's sample at trip time). - `bmad-loop status` gained a run-level tokens: line and shows weighted with raw alongside per story. Visible output change: cells go from "<raw>t" to "<weighted>t (<raw> raw)". - The diagnose bundle reports weighted beside total, per task and per run. Displays resolve the weight from the run's persisted policy snapshot, not live policy, so every observer reproduces the same number from state.json alone -- resume reloads policy.toml without re-stamping the snapshot, so sourcing this from live policy would make the CLI and the TUI disagree for one run. Weighted totals sum per task, matching the TUI: weighted_total rounds internally, so sum-of-rounds and round-of-sum differ under banker's rounding. Both behaviours are pinned by tests that fail without the fix. Also documents the weighted/raw split where the docs still claimed "displayed totals stay raw", corrects the TUI guide's task table (stale since 0.7.12 -- it described `tokens` as raw and omitted the `raw` column), and gives cache_read_weight the settings description it never had.
WalkthroughThe change adds cost-weighted token accounting to run summaries, status output, diagnostics, and ChangesWeighted token accounting
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Engine
participant RunState
participant Usage
participant Journal
Engine->>Usage: Read session token usage
Engine->>RunState: Read persisted cache_read_weight
RunState-->>Engine: Return weighting factor
Engine->>Usage: Compute weighted total
Engine->>Journal: Write raw and weighted session totals
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Closes #129.
The problem
Token accounting used two units and labelled neither.
Every budget judges the cost-weighted total — cache reads discounted by
limits.cache_read_weight(default 0.1, matching ~0.1x vendor billing). But the run-finished summary andbmad-loop statusprinted the raw total, cache reads at full price. On agentic work cache reads are 80–95% of the raw count, so the number an operator reads overstated spend ~6.5x against the figure the engine actually enforced — and neither said which unit it was.Separately,
session-endjournal entries carriedtokensas a bare scalar. The weight cannot be backed out of it, so a session's weighted spend was unreconstructible after the fact; the weighted figure existed only for sessions that tripped the budget guard.The split was historical, not intentional-forever:
aa73202weighted the budgets and deliberately left displays raw;be7836bthen fixed the TUI only, explicitly as "a pure observer". This finishes the job for the CLI surfaces, as discussed on the issue.What changed
RunSummarycarriesweighted_tokensbesidetotal_tokens(raw) and renders<weighted> weighted tokens (<raw> raw incl. cache reads).render()is a single chokepoint for stdout, theATTENTIONfile and the desktop notification, so all three move together. A run with no usage tracked at all (usage_parser = "none", Copilot's shutdown-only flush) still renders one plain0 tokensrather than asserting free work twice over.session-endentries carrytokens_weightedbesidetokens.null— never0— when the usage read failed, and both absent on anabortedend, preserving thetokens.pydoctrine that untracked ≠ zero. Distinct from a tripped session'sbudget_weighted, which is the guard's mid-session sample at trip time rather than the end-of-session total.bmad-loop statusgained a run-leveltokens:line (it printed no run total at all before) and shows weighted with raw alongside per story.weightedbesidetotal, per task and per run — including the Tasks table, which rendered a single unlabelled raw number.cache_read_weightnever had.Two decisions worth reviewing
Displays read the run's persisted policy snapshot, not live policy.
_resume_paused_runreloadspolicy.tomlwithout re-stampingstate.policy_snapshot, so the two can disagree on a resumed run. Sourcing the summary from live policy would make the CLI and the TUI print different totals for the same run from the samestate.json— reintroducing the exact bug class this issue is about. The invariant is: every display surface is reproducible fromstate.jsonalone. Filed as a follow-up, since the engine-vs-display gap itself is out of scope here.Weighted totals sum per task, not over an aggregated
TokenUsage.weighted_totalrounds internally, so sum-of-rounds ≠ round-of-sum under banker's rounding (verified: 30 vs 32 on a three-task fixture). Per-task summation is whattui/widgets.pydoes, which is what makes the CLI and TUI agree to the token.Both are commented at the call sites, because both look like pointless complexity to a future reader.
Visible output change
Per-story
statuscells go from<raw>tto<weighted>t (<raw> raw)— differently scaled and differently shaped, so scripts scraping that column need updating.statushas no--json, which is why the text layout carries this weight; filed as a follow-up. Called out in the CHANGELOG.Testing
2507 passed, 1 skipped;trunk checkclean.Coverage here was the actual hazard: nothing asserted
RunSummary.render()'s text or thestatustoken cell, so the whole format change initially landed with the suite still green. New tests close that, and the load-bearing ones were each confirmed to fail without the fix:cache_read_weight = 0guard — a cache-read-only story must render0, not-(which means no tokens at all).statusgated correctly by accident before, since raw was the displayed value; the natural edit after this change regresses it, which the test now catches.Tests use a non-default weight (0.5) so assertions can't be satisfied by a silent fall-back to the 0.1 default.
Driven end-to-end on a real run as well:
summary.render(),ATTENTION,journal.jsonl,bmad-loop status,diagnose, and the TUI observer path all report the same two numbers (164,000 weighted / 1,244,000 raw — a 7.59x gap). That E2E is what caught the diagnose Tasks table still emitting a lone raw figure.Follow-ups filed
bmad-loop statushas no --json, so its text format is a de-facto API #190 —bmad-loop statushas no--json, so its text format is a de-facto API.Summary by CodeRabbit
New Features
bmad-loop statusdocumentation covering run summaries and stories mode.Documentation