Skip to content

fix(cost): accumulate cost on resume and use final result-event cost - #573

Merged
jonwiggins merged 1 commit into
mainfrom
fix/cost-accounting-resume-and-parse
Aug 8, 2026
Merged

fix(cost): accumulate cost on resume and use final result-event cost#573
jonwiggins merged 1 commit into
mainfrom
fix/cost-accounting-resume-and-parse

Conversation

@jonwiggins

Copy link
Copy Markdown
Owner

Fixes #541

Problem

Two independent bugs both caused a task's recorded cost (and token usage) to be undercounted, which is what the reporter saw: after clicking Resume, the new run's cost showed on the run but "not updated elsewhere" — the analytics totals (/api/analytics/costs, which sums tasks.cost_usd) lost spend.

Bug 1 — resume/restart overwrote cost instead of accumulating

On a resumed or force-restarted run, task-worker streams the agent's logs into a fresh allLogs buffer, so adapter.parseResult(...) returns only that invocation's total_cost_usd / tokens — a claude --resume (or a brand-new session on the existing PR branch) is a separate process with no knowledge of what the prior run spent. The worker then did a plain db.update(tasks).set({ costUsd: String(result.costUsd) }), replacing the original run's cost with just the resumed invocation's spend. Since analytics sums tasks.cost_usd, every resume silently dropped the earlier spend.

Fix: accumulate prior + current on continuation runs. Because each relaunch is a distinct process reporting only its own cost, prior + current is always the true total and never double-counts. A genuine first run (no continuation signal) still writes its value directly, preserving replace-from-scratch semantics.

Continuation is detected from the job data: resumeSessionId (/resume, --resume), restartFromBranch (/force-restart and reconciler auto-resume on the existing PR), or resumePrompt (present on every relaunch path — including message-resume, where the stored session id may be absent). inputTokens/outputTokens had the same overwrite bug and are accumulated too (integers, exact).

Cost is stored as a string (to avoid float drift), so the addition goes through a new decimal-safe helper addCostStrings(a, b) (packages/shared/src/utils/cost.ts) that scales both operands to integers at the finer precision, adds, and re-emits a plain decimal literal (no scientific notation) so CAST(cost_usd AS NUMERIC) still parses it.

Bug 2 — cost parser took the first total_cost_usd

ClaudeCodeAdapter.parseResult used logs.match(/"total_cost_usd":\s*([\d.]+)/) — the first match. In --input-format stream-json mode, a multi-turn run (mid-task user messages) emits a result event per turn, and each result's total_cost_usd is the cumulative cost for the process so far. Taking the first match froze the cost at turn one and dropped all later spend.

Fix: take the cost from the last result event (captured in the existing event loop, which already tracks "last result event wins"), falling back to the last global regex match only when a result line isn't valid JSON. Token parsing already sums per-message assistant usage, which is correct, so it was left as-is.

Note on the reporter's "continually update" expectation

Live, mid-run cost updates are not feasible for Claude Code: cost only arrives in the terminal result event, never incrementally during a turn. This PR therefore fixes the correctness of the final recorded cost (and its accumulation across resumes), not live streaming of a running cost. A running task will still only show cost once its current turn's result event lands.

Resume vs restart handling (accounting semantics)

Both resume and restart launch a fresh Claude process that reports only its own spend, so accumulating prior + current is the accurate total in both cases and cannot double-count. Only a genuine first run (none of resumeSessionId / restartFromBranch / resumePrompt) overwrites. OpenTelemetry cost/token metrics continue to record the per-run delta (correct for additive counters).

Tests

  • packages/agent-adapters/src/claude-code.test.ts: multi-turn run asserts parseResult returns the last result-event cost, not the first; plus a non-JSON fallback case asserting the last regex match wins.
  • packages/shared/src/utils/cost.test.ts: unit tests for addCostStrings (accumulation, null/empty prior, decimal-safety 0.1 + 0.2 -> 0.3, mixed precision, no scientific notation, repeated resumes) and addTokenCounts.
  • apps/api/e2e/repo-task.e2e.test.ts: end-to-end resume through the real pipeline + fake runtime — first run records 0.05, resume adds 0.03, task total becomes 0.08 (tokens 100->200 / 25->50).

Verification

  • cd apps/api && npx tsc --noEmit — clean
  • pnpm turbo typecheck — 12/12
  • cd packages/agent-adapters && npx vitest run — 241 passed
  • cd packages/shared && npx vitest run — 452 passed
  • cd apps/api && npx vitest run — 2167 passed
  • pnpm --filter @optio/api test:e2e repo-task — 5 passed (run twice, no flake)
  • pnpm format:check — clean

@jonwiggins
jonwiggins merged commit 003a999 into main Aug 8, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cost doesn't continually update.

1 participant