Skip to content

Backfill historical usage buckets from provider transcripts (correlated per-run, no double-count) #3156

Description

@atomantic

Problem / Goal

server/services/usageReconciler.js now measures real token counts (input, output, prompt-cache read/write) from the provider CLIs' local JSONL transcripts for runs going forward (#3124 Phase 2, PR referenced below). Activity recorded before that landed keeps its original estimate, which understates real cost by roughly 10x. A one-shot pass that re-reads the on-disk transcripts and corrects the retained day buckets was drafted alongside Phase 2 and deliberately removed before merge — it double-counted. This issue is to build it correctly.

Why the first attempt was wrong

Three defects, all found by review against this install's real data/usage.json and ~/.claude tree. Any replacement must solve all three:

  1. Provider-id mismatch → duplicate rows. The backfill attributed to a canonical per-CLI id (claude-code / codex) because a transcript does not record which PortOS provider config invoked it. But real day buckets are keyed by the config id — on this install: claude-code-tui, claude-code-tui-bedrock, claude-ollama-tui, codex-tui, antigravity-tui, ollama. claude-code and codex appear in zero buckets, so the "replace" pass reset a bucket nobody wrote and added a second row for the same work. Measured: one day went from claude-code-tui: out=500 to claude-code-tui: out=500 plus claude-code: out=200 — the same sessions billed twice.

  2. No run correlation → bills sessions PortOS never ran. The per-run path correlates on cwd and the run's [startTime, endTime] window. The backfill walked all of ~/.claude/projects + ~/.codex/sessions with neither. Review measured that only ~37% of the tokens it would attribute fall inside any PortOS run window; the rest are the user's own hand-typed CLI sessions and ~32 unrelated repos — thousands of dollars of invented cost on a report whose question is "what did PortOS cost me."

  3. Stale flat totals resurrect as a legacy row. replaceMeasuredDayUsage rewrote the per-provider split but left the day's flat sessions/messages/tokens. buildUsageReport's residual reconciliation then treats the difference between the old estimate and the new measurement as unattributed legacy usage and bills it at the $3/$15 fallback — re-charging the very number the backfill just corrected.

Proposed approach

The correlation problem is the crux: to attribute a historical transcript to a PortOS run you need the run's identity, and PortOS never captured the CLI's own session id.

  1. Correlate against run metadata, not the whole home directory. data/runs/<id>/metadata.json already records providerId, model, workspacePath, startTime, endTime — everything the per-run path uses. Enumerate runs, then call the existing readMeasuredUsage() per run. This fixes (1) and (2) at once: the provider id comes from the run record (so it matches the live bucket key exactly), and only transcript spans inside a real run window are counted.
  2. Rebuild the day's flat totals from the measured split, or teach buildUsageReport that a measured day's flat fields are superseded — otherwise (3) recurs. Prefer the former; assert provider-row == sum-of-model-rows == day flat in a test.
  3. Guard the double-count with the per-run path. A run already reconciled at completion must not be re-added. Stamp reconciled runs (e.g. a usageReconciled marker in run metadata, or track which run ids a day bucket already counted) and make re-running the pass a verified no-op.
  4. Concurrency: WINDOW_SLACK_MS (±60s) plus up to 5 concurrent runs means two runs in the same cwd can both claim one transcript span. Review counted 38 truly-overlapping run pairs at 0 slack and 143 at ±60s on this install, one over-counting cache-read by 67%. Partition overlapping spans (or attribute by nearest window) instead of letting both sum the same messages. This affects the per-run path too and may deserve its own issue.
  5. Explicitly user-triggered only, per the AI Provider Usage Policy's background-pre-generation pattern — no boot, no schedule. Reading local JSONL spends no tokens, but a from-zero pass rewrites recorded history and is slow: review measured 24.3s of blocked event loop (676ms max lag) walking 439MB. It needs an off-thread or chunked implementation plus progress reporting, not a synchronous request handler.

Acceptance criteria

  • Running the pass twice over the same transcripts is a verified no-op (asserted in a test, not just observed).
  • Running the pass after the per-run path already recorded a run does not double-count that run.
  • Every corrected bucket keys on the same provider id the live path writes — a test with a fixture whose day buckets use claude-code-tui must not produce a claude-code row.
  • A transcript span outside every PortOS run window is not counted.
  • After the pass, for every touched day: provider row == sum of its model rows, and no synthetic legacy residual row appears.
  • The pass does not block the event loop for more than ~100ms at a time, and reports progress.
  • Reachable only from an explicit user action.
  • cd server && npm test passes.

Out of scope

  • The per-run measurement path itself (shipped, working).
  • Reading usage from provider billing APIs — local transcripts only.
  • Providers that write no transcript (ollama, LM Studio, agy, grok): those stay estimated, priced at $0 where free.

Metadata

Metadata

Assignees

Labels

area:devtoolsDevtools/workspace/code-review surfacesbugSomething isn't workingplanTracked by /do:replan

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions