Skip to content

fix(opencode-usage): merge migrated session usage per column, not per row - #22550

Merged
nwparker merged 1 commit into
mainfrom
nwparker/opencode-usage-per-column-merge
Sep 24, 2026
Merged

nwparker merged 1 commit into
mainfrom
nwparker/opencode-usage-per-column-merge

Conversation

@nwparker

@nwparker nwparker commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 1 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​175 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​175
Prod 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​104 $\color{#cf222e}{\Huge{\mathbf{−}}}$​41 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​63

ELI5

Orca counts OpenCode token usage by reading OpenCode's database. After an upgrade, each session exists twice — an old frozen copy and a live one — and neither is guaranteed complete. My previous fix picked whichever copy had more tokens and took everything from it, including the session's folder, model and recorded cost. That could report a stale folder, or silently zero a real dollar amount. Now each field is taken from wherever it is actually correct.

What Changed

The single row-level comparison from #22391 is split into two rules, both inside the same UNION ALL relation:

  • Identity comes from the live generation. The session_v2 select now LEFT JOINs session and reads metadata as COALESCE(t.col, o1.col) for project_id, directory, title, model, time_created, time_updated. The frozen legacy twin only fills NULLs.
  • Usage merges per column: MAX(COALESCE(t.col, 0), COALESCE(o1.col, 0)) for cost and all five token columns.

Row ownership stays trivially exclusive and exhaustive — each select claims the ids the higher-priority generations don't hold via NOT EXISTS — so there is exactly one row per id. Verified on the real database: 254 rows / 254 distinct ids.

OPENCODE_USAGE_SCHEMA_VERSION 4 → 5 so existing caches rescan.

Why

Adversarial review of #22391 reproduced three wrong answers. The rationale there was right — the V1→V2 import genuinely recomputes, so a session_v2 row can legitimately hold less than its frozen twin — but one token comparison was made to decide far more than tokens.

  • (a) Cost was zeroed, not shrunk. legacy {tokensInput: 100, cost: 12.5} vs v2 {tokensInput: 200, cost: 0} → estimatedCostUsd: null, because opencode-usage-row-parsing.ts:104 maps cost 0 to null. $12.50 vanished. Cost is recomputed by the same lossy reduce as the tokens, but the tiebreak guarded only tokens.
  • (b) The token tiebreak decided unrelated fields. legacy {tokensInput: 900, directory: '/old/pre-migration-path'} vs v2 {tokensInput: 120, directory: '/new/current-path'} → attributed to the stale path.
  • (c) Winner-takes-all was per-row. legacy {input: 1000, cacheRead: 0} vs v2 {input: 0, cacheRead: 1200} → reported input 0.

Why per-column MAX cannot over-report. Let L be the frozen legacy total, V the v2 total, T the truth. V = decodable pre-migration assistant messages + post-migration ones; L = all pre-migration assistant messages. Every term is non-negative and the import only ever drops messages, so L ≤ T and V ≤ T per column, hence MAX(L, V) ≤ T. It is a strictly tighter lower bound than either row alone. Picking a whole row is the same kind of bound, just weaker.

Why cost tracks MAX rather than the token winner. Cost comes from the same assistants.reduce (v1-migration.bun.ts:461-466), is non-negative, and obeys the identical bound. Tying it to the token winner just recreates defect (a) mirrored — a cost comparison would then zero real tokens. The accepted tradeoff, noted in the code: cost and tokens may come from different rows, so implied $/token is not a row-consistent ratio. Neither row is correct anyway, and the alternative demonstrably zeroes $12.50.

No SUM — these are two views of one session, not two sessions.

Source evidence (v2.0.15):

  • The DDL migration migration/20260804233008_loose_psylocke.ts copies nothing — on the V1 lineage it creates an empty session_v2 and never touches session/message/part. Row population lives in a separate component, v1-migration.bun.ts. (On the pre-split V2 lineage it instead renames session → session_v2, which is the "v2-only" shape.)
  • v1-migration.bun.ts:593-604 — directory, title, project_id, time_created, time_updated are carried over verbatim.
  • The legacy table is never dropped — no DROP TABLE session in either file; completion is recorded only as kv migration.v1-v2 = {"phase":"completed"} (:215,670). Confirmed on the real database: that kv row coexists with 234 surviving session rows, so both tables coexisting is the settled steady state, not a mid-migration artifact.
  • :461-466 + :226-233 — all six usage columns are recomputed by one reduce over assistants, after messages failing decodeMessage are dropped. Cost is lossy exactly as the tokens are.
  • :449-455 — model: input.session.model ?? (derived from the last user message), so the v2 model is a superset: never NULL when legacy is non-NULL, sometimes non-NULL when legacy is NULL. That is the mechanism behind the 23-of-234 figure below.
  • :637-640 — the post-copy UPDATE touches only the transformed session and time_updated, so later metadata divergence is OpenCode 2 continuing to write session_v2 while session stays frozen.

Linked Issue

Refs #15841, and fixes three defects in my own merged #22391.

Visual Proof

N/A — and the measured reason is the interesting part.

On the real local database the change is a zero delta, so a before/after screenshot of the usage panel would show two identical images and imply, falsely, that nothing was fixed.

before after
rows / distinct ids / sessions 254 / 254 / 254 254 / 254 / 254
input tokens 33,324,966 33,324,966
output tokens 1,948,896 1,948,896
cache-read tokens 335,791,516 335,791,516
total tokens 375,241,139 375,241,139
cost $79.574321168 $79.574321168
sessions whose (model, cwd, timestamp) changed — 0

That is the correct result. This database's 234 shared ids all round-trip losslessly (a lossless recompute over integers is bit-identical, so equality here does not imply a copy — five ids differ on cost by exactly 1, 1, 1, 2, 1 ULP, which only re-summation produces): directory, title, project_id, time_created, time_updated are byte-identical on every one, all five token columns are equal on every one, and SUM(v2.cost − legacy.cost) across all 234 is 1.75e-14 — float noise. Because every token total ties, #22391's "ties go to session_v2" already picked the v2 row everywhere, including the 23 ids where only v2 carries a model.

So on this machine the three defects are latent, not active. The 23-of-234 is the blast radius — what gets lost the moment any legacy row wins on tokens — not a current mis-attribution. The fixtures are what reproduce the defects, which is why the proof below is tests rather than pixels.

The database copy used for measurement was taken first; the original was never opened for write.

Testing

Platforms: macOS; source verification against v2.0.15.

Reverted only opencode-usage-row-queries.ts, keeping the tests and fixture:

× keeps a recorded cost the recomputed session_v2 row lost          expected null to be 12.5
× attributes usage to the directory session_v2 records now          expected '/old/pre-migration-path' to be '/new/current-path'
× keeps the model session_v2 derived when the legacy row has none   expected null to be 'anthropic/claude-opus-4-1'
× takes each usage column from whichever generation recorded more   inputTokens 0 (expected 1000), totalTokens 1200 (expected 2200)
Tests  4 failed | 15 passed

Restored: 6 files / 50 tests pass.

Three of the seven new tests cannot fail before the change, deliberately. I had asked for every new test to fail first; that was wrong for the invariance cases — "resolves a faithful migrated copy exactly as the v2 row alone", "leaves an OpenCode 1-only database untouched", and "emits exactly one row per session id" exist precisely to assert that behaviour is identical before and after. A version of those that failed on the old code would be asserting the wrong thing. The four defect tests are the ones that must fail, and do.

pnpm tc → 0. pnpm run check:code-quality:changed → 0 new findings. oxfmt --check clean.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Backwards compatibility: OpenCode 1-only databases have no session_v2, take the unchanged path, and are covered by an invariance test. Every column stays columnExists-guarded with a SQL fallback.
  • project_id is the one metadata column where v2 is not unambiguously better. The import reassigns session_v2.project_id to Project.ID.global when the legacy value is dangling (v1-migration.bun.ts:586-591), so preferring v2 points the project join at the global project rather than at nothing. It only matters when directory is empty — and directory is NOT NULL in both schemas while extractCwd prefers it — so the practical impact is nil. Flagging it rather than hiding it; "use what OpenCode itself uses" seemed the right call.
  • Unrelated, worth a separate look: 198 of 254 sessions report no cost at all on the real database. These are v2-only sessions whose session_v2.cost is 0 with no legacy twin to recover from — upstream not populating the column. Nothing this change can fix.
  • Performance: adds one LEFT JOIN on a primary key plus per-column MAX/COALESCE; still one pass per database on the existing worker thread.
  • Cache invalidation: the schema bump is required, not incidental — usage caches key on file mtime/size, which do not change when only the query changes.
  • SSH/remote, WSL, mobile, cross-platform, security: unaffected. No wire change; discovery still routes through the wslGated* helpers.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test pass locally

A session that lived through OpenCode 2's V1 import has a row in both
`session` and `session_v2`, and neither is complete. #22391 resolved the
pair by ranking whole rows on one number — total token count, ties to
`session_v2` — which let that number decide everything else on the row.

Three consequences, each reproduced against that PR's own fixtures:

- A recorded cost could be zeroed. `session_v2` wins on tokens while
  carrying `cost = 0`, and row parsing maps a zero cost to `null`, so a
  legacy row's $12.50 disappeared. Cost is re-derived by the same lossy
  reduce as the tokens, but only the tokens were guarded.
- The token comparison decided metadata. A legacy row with more tokens
  supplied a stale pre-migration directory, and a legacy row with a NULL
  model erased the model `session_v2` had — 23 of 234 shared ids on a
  real migrated database have a model only on the v2 side.
- Winner-takes-all is per row, so a legacy row holding the input tokens
  and a v2 row holding the cache reads reported one of them as zero.

Metadata now comes from the generation OpenCode still writes, with older
generations filling only its NULLs; usage columns take a per-column MAX.
Both rows aggregate the same assistant messages, and the import can only
drop messages, never invent them, so each column's MAX is a tighter lower
bound on the truth than either row and can never exceed it.

The relation stays exactly one row per id — the highest-priority
generation holding it — every column stays `columnExists`-guarded with a
SQL fallback, and a database with a single session table builds the same
SQL it did before.

Cache schema version 4 -> 5 so existing caches rescan.
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 66634414-1329-4de5-a3b5-998b8511b488

📥 Commits

Reviewing files that changed from the base of the PR and between 845db9e and 81221d5.

📒 Files selected for processing (4)
  • src/main/opencode-usage/opencode-usage-provider.ts
  • src/main/opencode-usage/opencode-usage-row-queries.ts
  • src/main/opencode-usage/opencode-usage-sqlite-fixture.ts
  • src/main/opencode-usage/opencode2-session-usage.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Session queries now merge metadata and usage columns from rows across OpenCode generations. They select session ids by source priority and emit one row per session id. SQLite fixtures can represent a NULL model. The usage schema version increases from 4 to 5. Tests cover migrated sessions, v2-only sessions, and v1-only databases.

Merge Risk: ⚪ Minimal · up to 81221

The reported query failure does not occur with supported OpenCode databases. No actionable merge-blocking issue remains after normal checks.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 4 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: merging migrated OpenCode session usage per column instead of selecting one complete row.
Description check ✅ Passed The description is detailed and covers the required explanation, changes, rationale, issue reference, visual-proof exception, testing, compatibility, performance, and checklist items. The Review secti…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — the migration-merge rewrite in OpenCode usage scanning, verified against the full diff plus the affected test suite.

  • Metadata from the live generation — the session_v2 select now LEFT JOINs session and reads COALESCE(t.col, o1.col) for project_id, directory, title, model, and the timestamps, so a frozen pre-migration twin no longer supplies identity.
  • Usage merged per column — MAX(COALESCE(t.col,0), COALESCE(o1.col,0)) for cost and all five token columns, replacing the row-level token tiebreak that could zero a recorded cost or erase a token column.
  • Row ownership by existence — each select claims only ids no higher-priority table holds (NOT EXISTS), so the union stays one row per id.
  • Cache invalidation — OPENCODE_USAGE_SCHEMA_VERSION 4 → 5, required because usage caches key on file mtime/size which the query change does not touch.
  • Tests/fixture — 7 new tests (4 that fail pre-change, 3 invariance) plus a v2-only case; fixture gains model?: string | null.

I read the generated SQL through every schema shape (v1-only, v2-only, migrated, migrated-without-token-columns). The existence-based exclusions are exclusive and exhaustive; MAX(...) is only emitted with two or more arguments so SQLite cannot resolve it as the aggregate; and every outer-joined column is COALESCE-guarded, so an id the lower generation never held contributes 0. The per-column lower-bound argument holds — each MAX(c_L, c_V) ≤ truth_c, and the summed total stays ≤ the true sum even when columns come from different rows.

One latent consequence, already noted in the PR body: metadata prefers the live row even when the live directory is '' (the shape the codebase uses for "no directory") while the legacy twin holds a real path, because COALESCE only fills NULLs. Since the import carries directory verbatim and it does not change post-migration, this is not reachable in practice and is not actionable.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@nwparker
nwparker merged commit d438676 into main Sep 24, 2026
31 checks passed
@nwparker
nwparker deleted the nwparker/opencode-usage-per-column-merge branch September 24, 2026 00:39
flosrn pushed a commit to flosrn/orca that referenced this pull request Sep 25, 2026
…tablyai#22550)

A session that lived through OpenCode 2's V1 import has a row in both
`session` and `session_v2`, and neither is complete. stablyai#22391 resolved the
pair by ranking whole rows on one number — total token count, ties to
`session_v2` — which let that number decide everything else on the row.

Three consequences, each reproduced against that PR's own fixtures:

- A recorded cost could be zeroed. `session_v2` wins on tokens while
  carrying `cost = 0`, and row parsing maps a zero cost to `null`, so a
  legacy row's $12.50 disappeared. Cost is re-derived by the same lossy
  reduce as the tokens, but only the tokens were guarded.
- The token comparison decided metadata. A legacy row with more tokens
  supplied a stale pre-migration directory, and a legacy row with a NULL
  model erased the model `session_v2` had — 23 of 234 shared ids on a
  real migrated database have a model only on the v2 side.
- Winner-takes-all is per row, so a legacy row holding the input tokens
  and a v2 row holding the cache reads reported one of them as zero.

Metadata now comes from the generation OpenCode still writes, with older
generations filling only its NULLs; usage columns take a per-column MAX.
Both rows aggregate the same assistant messages, and the import can only
drop messages, never invent them, so each column's MAX is a tighter lower
bound on the truth than either row and can never exceed it.

The relation stays exactly one row per id — the highest-priority
generation holding it — every column stays `columnExists`-guarded with a
SQL fallback, and a database with a single session table builds the same
SQL it did before.

Cache schema version 4 -> 5 so existing caches rescan.

(cherry picked from commit d438676)
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.

1 participant