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:
-
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.
-
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."
-
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.
- 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.
- 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.
- 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.
- 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.
- 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
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.
Problem / Goal
server/services/usageReconciler.jsnow 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.jsonand~/.claudetree. Any replacement must solve all three: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-codeandcodexappear 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 fromclaude-code-tui: out=500toclaude-code-tui: out=500plusclaude-code: out=200— the same sessions billed twice.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/sessionswith 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."Stale flat totals resurrect as a
legacyrow.replaceMeasuredDayUsagerewrote the per-provider split but left the day's flatsessions/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/$15fallback — 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.
data/runs/<id>/metadata.jsonalready recordsproviderId,model,workspacePath,startTime,endTime— everything the per-run path uses. Enumerate runs, then call the existingreadMeasuredUsage()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.buildUsageReportthat ameasuredday's flat fields are superseded — otherwise (3) recurs. Prefer the former; assert provider-row == sum-of-model-rows == day flat in a test.usageReconciledmarker in run metadata, or track which run ids a day bucket already counted) and make re-running the pass a verified no-op.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.Acceptance criteria
claude-code-tuimust not produce aclaude-coderow.legacyresidual row appears.cd server && npm testpasses.Out of scope