Skip to content

fix(opencode-usage): read OpenCode 2 session_v2 token totals - #22391

Merged
nwparker merged 2 commits into
mainfrom
nwparker/opencode2-usage-stats-15841
Sep 23, 2026
Merged

nwparker merged 2 commits into
mainfrom
nwparker/opencode2-usage-stats-15841

Conversation

@nwparker

@nwparker nwparker commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

Orca counts how many tokens and dollars your agents have used by reading OpenCode's own database. OpenCode 2 quietly moved its sessions into a new table and stopped writing to the old one. Orca kept reading the old table, so OpenCode 2 work showed up as zero usage. Now it reads both — and is careful not to count the same session twice.

What Changed

Before: any session created under OpenCode 2 contributed nothing to Stats & Usage or to orca-cli usage output. On a machine that has only ever run OpenCode 2, OpenCode usage reads zero across the board — no tokens, no cost, no sessions.

After: OpenCode 2 sessions are counted, with input/output/cache-read/cache-write tokens, cost, model, and worktree attribution, exactly as OpenCode 1 sessions are.

Mechanism: OpenCode 2 does not use a separate database file. On upgrade it copies every row from session into a new session_v2 table (same token/cost columns plus fork_session_id, time_suspended, idle_outcome, …), repoints session_message at it, and then writes only to session_v2. The legacy session table is left frozen at the pre-upgrade snapshot.

selectUsageRows in src/main/opencode-usage/opencode-usage-row-queries.ts only knew session — its top-level if (!tableExists(db, 'session')) return [] guard, its session-totals query, and both message-level fallbacks (JOIN session s ON s.id = sm.session_id). It now builds one deduplicated session relation covering both tables, and all three read paths select from it, so v2-only, migrated, and never-migrated sessions each count exactly once.

Shared ids resolve by greater token total, ties to the newer table — not by fixed table priority. session_v2 is not reliably a superset of the legacy row: upstream's importer recomputes v2 totals from decoded messages, so a session with undecodable messages lands below its frozen legacy copy, and a session_v2 without the token columns at all scores 0 and would erase the legacy row entirely. A faithful migration is a tie and still resolves to session_v2. (Both shapes were caught in review; see 41764e3.) Every column read from either table stays guarded by columnExists with a SQL fallback literal, so older and newer schema revisions both load.

OPENCODE_USAGE_SCHEMA_VERSION goes 3 → 4 (opencode-usage-provider.ts), which makes normalizePersistedState discard existing caches and force a rescan. Without it, a user whose opencode.db mtime and size haven't changed would keep serving the old zero-v2 cache to both the UI panel and the CLI.

New: opencode-usage-sqlite-fixture.ts (generates real SQLite databases in the verbatim v1, migrated, and v2-only shapes) and opencode2-session-usage.test.ts.

Why

#15841 reported OpenCode 2 token stats never appearing in orca-cli or the usage panel.

The obvious lead — a separate v2 database file — is a dead end, and worth recording so the next person doesn't chase it. opencode-local.db / opencode-next.db (matched by isOpenCodeV2DatabaseName) are beta-channel artifacts; the local opencode-local.db holds 6 sessions with zero tokens and zero messages, and that predicate is only used to route the AI Vault's v1 leg. Released OpenCode 2 shares opencode.db with v1. Database discovery was never the problem — the schema was.

Why deduplicate rather than read both tables: on the real local database, 234 of 234 legacy session ids also exist in session_v2. Reading both unfiltered would roughly double every user's historical OpenCode totals — a far more visible regression than the bug being fixed. Preferring session_v2 for any shared id is correct because the legacy row is a frozen pre-upgrade snapshot while session_v2 keeps receiving writes.

Why not switch to session_v2 only: a user who has never upgraded past OpenCode 1 has no session_v2 table at all, and would lose all their history.

Alternative considered: reuse the AI Vault's existing OpenCode 2 readers (session-scanner-opencode2-sqlite.ts). They were the right thing to learn the schema from, but they materialise message transcripts for search rather than aggregate token columns, and they run on the AI Vault's own worker protocol. Borrowing the schema knowledge and keeping the usage scanner's own query shape was the smaller change.

Linked Issue

Fixes #15841

Visual Proof

Orca dev build, Settings → Stats & Usage → Usage Analytics → OpenCode, identical scope in both shots: All local OpenCode usage • All time.

Both runs were pointed at the same v2-only database via OPENCODE_DB — a copy of a real OpenCode v2.0.12 opencode.db with the legacy session table emptied, i.e. a machine that has only ever run OpenCode 2 (session = 0 rows, session_v2 = 256 rows). Each run used its own throwaway user-data directory, so no cache could leak between them.

BEFORE — main (bf3f95245c5). No stat cards at all, just the empty state: "No local OpenCode usage found yet for this scope."

before-main.png

AFTER — this branch (6b4d8574c79). Full usage, plus the daily chart and the by-model / by-project breakdowns.

after-fix.png

Before After
Input tokens — 32.8M
Output tokens — 1.9M
Cached input — 335.4M
Reasoning output — 556.4k
Sessions / Events — 219 / 219
Recorded cost — $79.48

The before build was not failing to find the database — that matters, because "no database" and "database read as empty" look the same on screen. Its own cache file recorded processedDatabases[0].path = "/tmp/oc2-proof/opencode.db", lastScanError: null, sessions: [] at schemaVersion: 3. It opened the v2-only database, scanned it without error, and legitimately found nothing — which is the bug. The after run recorded the same path at schemaVersion: 4 with 219 sessions.

219 rather than 256 because the scanner only counts sessions with nonzero tokens; the 37 excluded rows are all-zero, so the totals are unaffected. Underlying values, 32,789,093 input / 1,942,497 output / $79.481722928, match a direct sqlite3 query against session_v2.

Screenshots were captured at 6b4d857, before the review fix in 41764e3. They remain valid: the fixture's legacy session table is empty, so there are no shared ids and the dedup rule is not exercised by this scenario at all. The dedup rule is covered by unit tests, and measured on the real database it changes nothing — zero shared ids have a legacy row that beats its session_v2 counterpart.

Incidental differences between the two images: the "Updated" timestamp and the status-bar memory readout. OpenCode analytics had to be toggled on in both runs, since a fresh profile persists enabled: false.

Measured directly against the real local opencode.db (OpenCode v2.0.12 — 234 legacy session rows, 252 session_v2 rows, 18 of them v2-only), old query vs. new:

sessions input output cache read total tokens cost
migrated db, before 206 32,296,211 1,922,452 329,089,806 367,419,745 $79.4704
migrated db, after 219 32,789,093 1,942,497 335,426,722 374,305,364 $79.4817
fresh v2 db, before 0 0 0 0 0 $0
fresh v2 db, after 219 32,789,093 1,942,497 335,426,722 374,305,364 $79.4817

"fresh v2 db" is the same snapshot with the legacy session table emptied — i.e. what a machine that only ever ran OpenCode 2 looks like, which is the reported zero-stats case. Cache-write tokens (3,590,638) are carried in both. No paths or account identifiers were written into the repo.

Testing

Platforms: macOS, against a real OpenCode v2.0.12 database.

opencode2-session-usage.test.ts — 8 tests over generated real SQLite databases:

  • v2-only rows are read
  • cache-read and cache-write tokens are included in totals
  • a migrated id is counted once, from session_v2
  • never-migrated legacy rows are still counted
  • OpenCode 1-only databases are unchanged
  • the session_message fallback joins session_v2
  • worktree attribution from session_v2.directory
  • project-worktree fallback

Suite results:

  • pnpm test src/main/opencode-usage — 6 files, 39 tests passed

  • pnpm test src/main/ai-vault — 169 files, 1509 tests passed

  • pnpm test src/main/usage — 44 passed

  • pnpm tc — exit 0

  • pnpm run check:code-quality:changed — passed

  • 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 (no session_v2) take the same path as before and produce identical numbers — covered by a test. Every new column read is columnExists-guarded with a SQL fallback, so a schema revision that lacks one degrades instead of throwing.
  • Double-counting: explicitly handled and tested; this was the main correctness risk.
  • Cache invalidation: the schema-version bump is required, not incidental — usage caches key on file mtime/size, which do not change when only the query changes.
  • SSH / remote and WSL: untouched. Discovery still goes through wslGatedReaddir / wslGatedStat in opencode-database-discovery.ts, and the scan still runs on its worker thread. The execution host remains the owner of the data.
  • Performance: the deduplicating relation adds one NOT IN (SELECT id FROM session_v2) subquery against a primary-key column; the scan remains one pass per database on the worker.
  • Cross-platform: pure SQL and existing path helpers; no new path assumptions.
  • Security, mobile: unaffected.

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

OpenCode 2 copies every v1 `session` row into a new `session_v2` table and
then writes only there, so every OpenCode 2 session was invisible to the
usage scanner, which only knew `session`. Reading both tables unfiltered
would double-count the migrated rows, so the scan now builds one
deduplicated session relation where `session_v2` owns any id it shares with
the legacy table, and joins the message-level fallbacks against it too.

Bumps the usage cache schema version so existing caches rescan.

Measured against the real local opencode.db (OpenCode v2.0.12, 234 legacy
`session` rows / 252 `session_v2` rows, 18 of them v2-only):

  migrated db   before: 206 sessions, 367,419,745 tokens, $79.4704
                after:  219 sessions, 374,305,364 tokens, $79.4817
  fresh v2 db   before:   0 sessions,           0 tokens, $0
  (legacy table after:  219 sessions, 374,305,364 tokens, $79.4817
   emptied)

Fixes #15841
@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: a77abcfa-5a05-4cfe-8e28-21445a2ee9b7

📥 Commits

Reviewing files that changed from the base of the PR and between 6b4d857 and 41764e3.

📒 Files selected for processing (3)
  • 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; 4 remain after this review.


📝 Walkthrough

Walkthrough

Usage queries now combine OpenCode 2 session_v2 rows with legacy session rows and deduplicate matching IDs by token total. The parser uses session-level totals when available and otherwise queries message usage. The schema version is now 4. New SQLite fixtures and tests cover v2-only, migrated, and v1 databases, token totals, message fallback, and worktree attribution.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 41764

OpenCode 2 sessions stored in session_v2 are now counted in usage totals alongside legacy sessions. Each migrated session is counted once, from its fuller row. This fixes the zero-usage display for OpenCode 2 users. The remaining limitation is that the fallback source is chosen per database rather than per session. That limitation predates this change and is tracked separately, so the change appears ready to merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 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 Issue [#15841] requires Orca to detect and display OpenCode 2 token and cost usage. The combined query reads session_v2 and legacy session rows, selects the fuller row per session ID, and uses the…
Out of Scope Changes check ✅ Passed The fixture writer and tests directly support [#15841] by validating OpenCode 2 schema handling, deduplication, legacy compatibility, and usage attribution. The cache version bump supports the reader …
Title check ✅ Passed The title clearly and concisely describes the primary change: reading OpenCode 2 session_v2 token totals.
Description check ✅ Passed The description is complete and directly matches the change. It includes the user impact, mechanism, rationale, linked issue, visual proof, testing results, compatibility considerations, and checklist…
  • 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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 43e89e7c-42a3-4e1b-8c45-1206ff904aee

📥 Commits

Reviewing files that changed from the base of the PR and between bf3f952 and 6b4d857.

📒 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; 5 remain after this review.

Comment on lines +174 to +198
const sessionTables = listSessionTables(db)
if (sessionTables.length === 0) {
return []
}
const sessionSource = buildSessionSource(db, sessionTables)

// Why: newer OpenCode DBs maintain session-level token/cost totals. Reading
// one aggregate row per session is faster than parsing every message blob.
if (getSessionUsageRowCount(db) > 0) {
return selectSessionUsageRows(db)
if (hasSessionUsageColumns(db, sessionTables) && getSessionUsageRowCount(db, sessionSource) > 0) {
return selectSessionUsageRows(db, sessionSource)
}

const projectJoin = getProjectJoin(db)
const sessionModelSelect = getSessionModelSelect(db)

if (getAssistantSessionMessageCount(db) > 0) {
const assistantPredicate = columnExists(db, 'session_message', 'type')
? "sm.type = 'assistant'"
: "json_extract(sm.data, '$.tokens.input') IS NOT NULL"
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT aliases match OpenCodeUsageRow across supported schemas.
return db
.prepare(
`SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data,
s.directory, s.title, p.worktree, ${sessionModelSelect}
s.directory, s.title, p.worktree, s.model AS session_model
FROM session_message sm
JOIN session s ON s.id = sm.session_id
JOIN ${sessionSource} s ON s.id = sm.session_id

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '1,240p' src/main/opencode-usage/opencode-usage-row-queries.ts
sed -n '1,241p' src/main/opencode-usage/opencode2-session-usage.test.ts
rg -n 'session_message|session_v2' src/main/opencode-usage docs 2>/dev/null | head -50

Repository: stablyai/orca

Length of output: 18931


🏁 Script executed:

sed -n '1,230p' src/main/opencode-usage/opencode-usage-sqlite-fixture.ts
rg -n -i 'opencode|session_v2|migration|session_message' README.md docs src/main/opencode-usage 2>/dev/null | head -100
sed -n '105,215p' src/main/opencode-usage/opencode2-session-usage.test.ts

Repository: stablyai/orca

Length of output: 21304


🌐 Web query:

OpenCode 2.0 session_v2 migration session token totals cost session_message migration source

💡 Result:

<source_evidence>

<title>V2: V1 session history is never imported into session_v2 — old sessions invisible in OpenCode 2</title> GitHub issue 41217 in anomalyco/opencode (link omitted to avoid creating a cross-reference) # V2: V1 session history is never imported into session_v2 — old sessions invisible in OpenCode 2 - State: closed - Author: RennNathan - Created: 2026-08-08T06:05:34Z - Updated: 2026-08-11T16:00:16Z - Repository: anomalyco/opencode - Number: `#41217` - Assignees: jlongster ## Labels - 2.0 --- ## Summary OpenCode 2 (beta) and OpenCode 1 share the same SQLite data directory (`~/.local/share/opencode/opencode.db`), but V2 reads/writes a separate `session_v2` / `session_message` schema family and never imports legacy V1 `session` / `message` / `part` rows. As a result, a user who upgrades to V2 loses visibility of their entire V1 session history — it remains in the DB but is unreachable from OpenCode 2, with no supported migration path. ## Environment - opencode version: 0.0.0-next-17028 (V2, next/beta channel); 1.18.15 (V1) - OS: macOS arm64 (Darwin 27.0.0, Kernel 27.0.0 arm64) - Terminal: Apple_Terminal, TERM=xterm-256color, COLORTERM=truecolor - Shell: /bin/zsh - Install/channel: V2 installed via npm `@opencode-ai/cli`@next, running as `opencode2` - Active plugins: fusion-audit.js, `@slkiser/opencode-quota`@latest, `@tarquinen/opencode-dcp`, opencode-caveman, opencode-memfs, opencode-rtk ## Reproduction 1. Have a V1 install with session history (legacy `session` / `message` / `part` rows). 2. Install the V2 beta (`npm install -g `@opencode-ai/cli`@next`) and run `opencode2` in the same project. 3. Open the session list in V2. ## Expected Behavior V2 either (a) surfaces existing V1 session history, or (b) offers a documented migration that imports legacy `session`/`message`/`part` data into `session_v2` — or the migration guide clearly states that V1 session history is not carried over and how to access it. ## Actual Behavior V1 sessions never appear in V2. They remain in the legacy tables and are not copied or converted. The DB `migration` table (41 applied rows) contains no import/backfill of legacy sessions — the only import migration is `20260805200742_import_legacy_credentials` (credentials, not sessions). `session_v2`/`session_message` rows only begin from the first V2 use. ## Additional Context - DB schema evidence (read-only, from ~/.local/share/opencode/opencode.db): - Legacy family: `session` (674 rows), `message` (12259), `part` (53858) - V2 family: `session_v2` (592 rows), `session_message` (10860), `session_pending` - `session.version` values are V1 CLI versions (1.18.x); `session_v2.version` includes `0.0.0-next-*` beta versions. - V2 marker migration: `20260622170816_reset_v2_session_state` - `session_v2` schema differs (adds fork_session_id, fork_boundary, time_suspended) so a naive copy would be unsafe. - This is likely a missing feature/compat gap rather than a crash. - Workaround: reopen old sessions in V1 (`opencode`), which still reads the legacy tables. No V2-side workaround exists. - Frequency: consistent/reproducible. - Recent change: adopting the V2 beta; no V1 config changes. ## Timeline - opencode-agent[bot] added label "2.0" **github-actions[bot]** commented on 2026-08-08T06:06:41Z: > This issue might be a duplicate of an existing issue. Please check: > - `#34445`: Data loss: update recreated `~/.local/share/opencode` and did not migrate legacy sessions — similar theme of V1 session history not being imported/migrated when upgrading to a newer storage format. > > If your issue is distinct (specifically about V1 SQLite `session`/`message`/`part` rows not being imported into the V2 `session_v2` schema, rather than JSON→SQLite migration), feel free to clarify the difference and this note can be disregarded. - jlongster was assigned - Referenced by issue `#41341`: V1→V2 session migration fails when previous channel DB lacks fork_boundary - Referenced by issue `#41346`: server: V1 data migration fails with SQLite syntax error on every start **kitlangton** commented on 2026-08-11T16:00:15Z: > > *This was generated by AI during triage.* > > V1 session import is now implemented b…[truncated] <title>366ce64 feat(opencode): parse OpenCode v2 session_message schema (`#920`)</title> https://github.com/junhoyeo/tokscale/commit/366ce64395594abf111e0409581d91016561b25a # 366ce64 feat(opencode): parse OpenCode v2 session_message schema (`#920`) - SHA: 366ce64395594abf111e0409581d91016561b25a - Repository: junhoyeo/tokscale - Author: saiqulhaq - Date: 2026-07-18T19:49:53Z - +524 -80 in 1 files - Verified: yes --- feat(opencode): parse OpenCode v2 session_message schema (`#920`) * feat(opencode): parse OpenCode v2 session_message schema OpenCode v2 (opencode-next.db) moves per-message data out of the `message` table and into `session_message`, keyed by a `type` column instead of a JSON `$.role`, with the model and provider nested under `$.model` (`model.id` / `model.providerID`). The existing parser only queried the v1 `message` table, so every v2 database reported zero OpenCode usage. Extend the SQLite parser to also read `session_message`: - Make `OpenCodeMessage.role` optional and add a nested `model {id, providerID}`, resolving model/provider from the top-level v1 fields or the nested v2 object. - Refactor the per-row body into a shared accumulator and run both the v2 `session_message` query and the v1 `message` query through it, so v1 and v2 databases both work and any overlap deduplicates across tables. - Pull the workspace directory from `session.directory` (unchanged from v1). Adds unit tests for nested-model resolution, the `type=&`#39`;assistant&`#39`;` filter, workspace-from-session, row-id dedup fallback, negative-token clamping, and forked-history dedup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(opencode): keep JSON file parsing strict on explicit assistant role Making OpenCodeMessage.role optional (for the v2 session_message SQLite path) also loosened parse_opencode_file: is_assistant() treats a missing role as assistant, so a role-less or malformed OpenCode JSON file -- previously skipped because the required `role` field failed to deserialize -- would now be counted as assistant usage. Require an explicit "assistant" role in the file path. Missing-role acceptance stays scoped to the v2 session_message query, whose SQL already filters type = &`#39`;assistant&`#39`;. Addresses cubic review finding. Adds a regression test asserting a role-less and a user-role JSON file are both skipped by parse_opencode_file. Confidence: high Scope-risk: narrow * fix(opencode): don&`#39`;t merge distinct messages that collide on fingerprint The SQLite dedup keyed only on a content fingerprint (timestamp, model, provider, clamped tokens, cost, agent) and tracked embedded message ids as a mere boolean, so two genuinely different assistant messages that happened to share every fingerprint field were collapsed into one -- undercounting usage. (Flagged by Codex review; pre-existing behavior the v2 refactor surfaced.) Track the actual embedded `$.id` per accumulated entry and map each fingerprint to a list of entries (one per distinct message identity). A row merges into an existing entry only when it is not a definitively-different message: equal embedded ids, or an id absent on either side, still collapse (fork copies share the id), while two differing ids are kept apart. Fork- history dedup and the row-id/embedded-id dedup_key promotion are preserved. Adds a regression test: two rows identical on every fingerprint field but with different `$.id` stay separate, while a same-id fork still collapses. Confidence: high Scope-risk: narrow --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Junho Yeo <i@junho.io> ## Changed Files | File | Status | + | - | | --- | --- | --- | --- | | crates/tokscale-core/src/sessions/opencode.rs | modified | 524 | 80 | <title>opencode2 mutates shared V1 database and breaks opencode 1.x coexistence</title> GitHub issue 42260 in anomalyco/opencode (link omitted to avoid creating a cross-reference) opencode2 migrated the database schema, and now in opencode the /move command is broken, I have a session trapped in a worktree. ... `opencode2` and stable `opencode` use the same default SQLite database, `~/.local/share/opencode/opencode.db`. Starting `opencode2` applies an incompatible V2 schema to that file. Stable `opencode` 1.x then fails in normal UI paths, including `/move`. ... The two executables have distinct names and are expected to be runnable side by side, but the V2 preview currently has no default data/database isolation and silently breaks the stable installation&`#39`;s database contract. ... Stable `opencode` fails because the shared database now has the V2 schema. ... ```text SQLiteError: no such table: session_context_epoch at SessionContextEpoch.reset at MoveSession.moveSession at ControlPlaneHttpApi.moveSession ``` ... and `session_context_epoch` no longer existed. Those are consistent with the V2 schema but incompatible with `opencode 1.18.18`. ... `opencode2` should not mutate the stable CLI&`#39`;s database by default. The separately named preview executable should use an isolated data/state namespace, for example: ... If sharing/migrating the stable database is intentional, `opencode2` should require explicit opt-in, preserve a compatible copy, or block with a clear warning that stable `opencode` will no longer be usable against that database. ... > The report confirms that `opencode2` currently opens and migrates the same database as stable 1.x, but the repository docs and V2 specs do not state whether side-by-side use is supported. > > Is the intended contract that preview and stable may coexist? If yes, migrations need to preserve 1.x readability (or use a compatible copy); if no, should `opencode2` isolate its data/state namespace by default and warn before touching an existing stable database? That product/compatibility decision determines the correct fix and regression tests. ... > ## Experimental one-time recovery script and coexistence workaround ... > > I used the following one-time script to reconstruct a separate OpenCode 1.x database from the surviving legacy tables. It was tested specifically with `opencode 1.18.18`. > > Safety notes: > > - Back up the original ... and its `-wal`/`-shm` files before recovery. ... > - exit all sessions of opencode/opencode2 ... > - The ... database is opened read-only and copied through SQLite backup. ... > - Account credentials and `session_share.secret` ... values are excluded by default; use ... -credentials` ... session history and ... account", ... state", ... _TABLES = ( > "event_ ... ", > "event", > "project", > "project_directory", > " ... > "message", > "part", > "todo", > "session_message", > ) ... Create a fresh ... .x database and copy ... " > "records from ... 2." ... credential, and session-share authorization tables; by default ... empty and OpenCode must be reauthenticated" ... def copy_table(connection: sqlite3 ... Connection, table: str) -> int: ... > source_columns = {row["name"] for row in columns(connection, "source", table)} > destination_columns = columns(connection, "main", table) ... > if not source_columns or not destination_columns: > raise RuntimeError(f"required table is missing: {table}") > > missing_required = [ > row["name"] > for row in destination_columns > if row["name"] not in source_columns > and row["notnull"] > and row["dflt_value"] is None > and not row["pk"] > ] > if missing_required: > raise RuntimeError( > f"source {table} lacks required destination columns: {missing_required}" > ) > > names = [row["name"] for row in destination_columns if row["name…[truncated] <title>Import session - OpenCode</title> https://opencode.ai/v2/docs/api/session/v2-session-import Import a projected session transcript at the requested location. ... `cost` Money.USD required ... `tokens` TokenUsage.Info required ... `messages` Session.Message.Info[] required ... `cost` Money.USD ... `tokens` TokenUsage.Info ... `data` Session.Info required ... `sessionID` object ... `cost` Money.USD required ... `tokens` TokenUsage.Info required ... ``` curl -X POST "/api/session/import" \ -H "Content-Type: application/json" \ -d &`#39`;{ "info": { "id": {}, "parentID": {}, "fork": { "sessionID": {}, "boundary": { "type": "before", "messageID": {} } }, "projectID": "string", "agent": "string", "model": { "id": "string", "providerID": "string", "variant": "string" }, "cost": 0, "tokens": { "input": 0, "output": 0, "reasoning": 0, "cache": { "read": 0, "write": 0 } }, "time": { "created": 0, "updated": 0, "archived": 0 }, "title": "string", "location": { "directory": "string", "workspaceID": {} }, "subpath": "string", "revert": { "messageID": {}, "partID": "string", "snapshot": "string", "files": [ { "file": "string", "patch": "string", "additions": {}, "deletions": {}, "status": "added" } ] } }, "messages": [ { "id": {}, "metadata": {}, "time": { "created": 0 }, "type": "agent-switched", "agent": "string" } ], "location": { "directory": "string", "workspaceID": {} } }&`#39`; ... ``` const response = await fetch("/api/session/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ "info": { "id": {}, "parentID": {}, "fork": { "sessionID": {}, "boundary": { "type": "before", "messageID": {} } }, "projectID": "string", "agent": "string", "model": { "id": "string", "providerID": "string", "variant": "string" }, "cost": 0, "tokens": { "input": 0, "output": 0, "reasoning": 0, "cache": { "read": 0, "write": 0 } }, "time": { "created": 0, "updated": 0, "archived": 0 }, "title": "string", "location": { "directory": "string", "workspaceID": {} }, "subpath": "string", "revert": { "messageID": {}, "partID": "string", "snapshot": "string", "files": [ { "file": "string", "patch": "string", "additions": {}, "deletions": {}, "status": "added" } ] } }, "messages": [ { "id": {}, "metadata": {}, "time": { "created": 0 }, "type": "agent-switched", "agent": "string" } ], "location": { "directory": "string", "workspaceID": {} } }) ... response = requests.post( "/api/session/import", headers={ "Content-Type": "application/json" }, json={ "info": { "id": {}, "parentID": {}, "fork": { "sessionID": {}, "boundary": { …[truncated] <title>V1→V2 migration loses/hides history: corrupt-part migrator 500s, undecodable rows report no field detail, >9k-message imports 500 with empty body, project picker is a hidden shortcut</title> GitHub issue 42671 in anomalyco/opencode (link omitted to avoid creating a cross-reference) Migrating OpenCode V1 chat history into the V2 desktop beta destroys or hides it: the built-in migrator 500s on a few corrupt legacy rows, sessions written by third-party tools into the V2 tables are silently undecodable, very large sessions can&`#39`;t be imported at all, and there is no discoverable way to open the old sessions ... 1. Start from a V1 data dir (`~/.local/share/opencode/opencode.db` containing legacy `session`/`message`/`part` tables alongside V2 `session_v2`/`session_message`), with 26 corrupt legacy `part` rows. ... 3. `GET /api/experimental/migration/v1` → `500 database disk image is malformed` (querying `message` by a session whose `part` rows are corrupt). No partial credit, no user-facing warning — history just looks empty. ... 4. Any tool or script that "helps" by writing V1-shaped rows directly into `session_v2`/`session_message` produces rows that `SELECT` cleanly but fail the runtime decoder: `GET /api/session/ /message` → `500 "failed to decode session message"` (log only). ... 5. `POST /api/session/import` with a session of ~13k messages → 500 with empty body; log shows `SQLite query expected 27270 values, received 92806` at `SessionTransfer.import (../server/src/handlers/session.ts:112)`. Last safe observed size: 9000 messages. ... - One corrupt part row aborts the whole migration; the app shows empty history with no error. ... - Hand-migrated rows are rejected with only `failed to decode session message` and no field detail (root causes we found: missing top-level `finish`, missing `model.variant`, `step-start`/`step-finish` content items that don&`#39`;t exist in V2, tool `state` using `output` instead of `content`, tool error states needing `state.error` as a `Session.StructuredError` object). ... - Sessions >~9k messages cannot be imported (prepared-statement parameter mismatch → silent empty-body 500). ... - Migrated history is effectively invisible: `GET /api/session` defaults to newest-50 and the app has no discoverable session/folder picker — the only candidate (`project.select` / `mod+shift+o`) is declared in the command catalog but non-functional in the desktop build. ... > ## Independent reproduction with a corrected decode harness (2026-08-20, fork 1.18.18 / v1.18.18 serve) > > Reproduced the same decode failure class from step 4 on a fork binary (`1.18.18`, `opencode serve --port 4096`) against a long-lived dual-layer DB. Evidence below is from the serve&`#39`;s own logs plus a bun:sqlite enumeration that mirrors the server-side decoder exactly. > > ### 1. The decode failure is DB-wide on `session_message`, not one corrupt session > > Harness calibrated to the server&`#39`;s decode path (`packages/core/src/session/history.ts` → `SessionHistory.load`), with `id` and `sessionID` injected from SQL columns exactly as the server does: > > - **52,575 of 58,002 `session_message` rows fail decode** under the current `SessionMessage.Message` schema ... > - The failures are the same V1-shaped row class described here: legacy `{"type":"aborted"}` error objects (pre-tighten error union), rows missing required fields added after they were written (`parentID`, `path`, `mode`, `agent`, `cost`, `tokens`, reasoning-part `time.start`), and rows carrying `role:"synthetic"` that the current schema no longer accepts as a standalone role ... > - Live endpoint confirmation: `GET /api/session/:id/message` → `500 "failed to decode session message"` for every affected session; serve log shows the rejection at `[N]["info"]["error"]` ... > > ### 2. Stronger finding: the projector stopped writing `session_message` entirely (Aug 13) > > Fresh sessions created after the V1/V2 split merge have **zero `session_message` rows** — `SessionProjector` (packages/core/src/session/projector.ts) no longer projects that table. So this isn&`#39`;t just "old rows are undecodable": the V2 mes…[truncated]

Citations:


🌐 Web query:

site:github.com/anomalyco/opencode session_v2 migration legacy session cost tokens_input session_message write session usage update code

💡 Result:

<source_evidence>

<title>V2: V1 session history is never imported into session_v2 — old sessions invisible in OpenCode 2</title> GitHub issue 41217 in anomalyco/opencode (link omitted to avoid creating a cross-reference) # V2: V1 session history is never imported into session_v2 — old sessions invisible in OpenCode 2 - State: closed - Author: RennNathan - Created: 2026-08-08T06:05:34Z - Updated: 2026-08-11T16:00:16Z - Repository: anomalyco/opencode - Number: `#41217` - Assignees: jlongster ## Labels - 2.0 --- ## Summary OpenCode 2 (beta) and OpenCode 1 share the same SQLite data directory (`~/.local/share/opencode/opencode.db`), but V2 reads/writes a separate `session_v2` / `session_message` schema family and never imports legacy V1 `session` / `message` / `part` rows. As a result, a user who upgrades to V2 loses visibility of their entire V1 session history — it remains in the DB but is unreachable from OpenCode 2, with no supported migration path. ## Environment - opencode version: 0.0.0-next-17028 (V2, next/beta channel); 1.18.15 (V1) - OS: macOS arm64 (Darwin 27.0.0, Kernel 27.0.0 arm64) - Terminal: Apple_Terminal, TERM=xterm-256color, COLORTERM=truecolor - Shell: /bin/zsh - Install/channel: V2 installed via npm `@opencode-ai/cli`@next, running as `opencode2` - Active plugins: fusion-audit.js, `@slkiser/opencode-quota`@latest, `@tarquinen/opencode-dcp`, opencode-caveman, opencode-memfs, opencode-rtk ## Reproduction 1. Have a V1 install with session history (legacy `session` / `message` / `part` rows). 2. Install the V2 beta (`npm install -g `@opencode-ai/cli`@next`) and run `opencode2` in the same project. 3. Open the session list in V2. ## Expected Behavior V2 either (a) surfaces existing V1 session history, or (b) offers a documented migration that imports legacy `session`/`message`/`part` data into `session_v2` — or the migration guide clearly states that V1 session history is not carried over and how to access it. ## Actual Behavior V1 sessions never appear in V2. They remain in the legacy tables and are not copied or converted. The DB `migration` table (41 applied rows) contains no import/backfill of legacy sessions — the only import migration is `20260805200742_import_legacy_credentials` (credentials, not sessions). `session_v2`/`session_message` rows only begin from the first V2 use. ## Additional Context - DB schema evidence (read-only, from ~/.local/share/opencode/opencode.db): - Legacy family: `session` (674 rows), `message` (12259), `part` (53858) - V2 family: `session_v2` (592 rows), `session_message` (10860), `session_pending` - `session.version` values are V1 CLI versions (1.18.x); `session_v2.version` includes `0.0.0-next-*` beta versions. - V2 marker migration: `20260622170816_reset_v2_session_state` - `session_v2` schema differs (adds fork_session_id, fork_boundary, time_suspended) so a naive copy would be unsafe. - This is likely a missing feature/compat gap rather than a crash. - Workaround: reopen old sessions in V1 (`opencode`), which still reads the legacy tables. No V2-side workaround exists. - Frequency: consistent/reproducible. - Recent change: adopting the V2 beta; no V1 config changes. ## Timeline - opencode-agent[bot] added label "2.0" **github-actions[bot]** commented on 2026-08-08T06:06:41Z: > This issue might be a duplicate of an existing issue. Please check: > - `#34445`: Data loss: update recreated `~/.local/share/opencode` and did not migrate legacy sessions — similar theme of V1 session history not being imported/migrated when upgrading to a newer storage format. > > If your issue is distinct (specifically about V1 SQLite `session`/`message`/`part` rows not being imported into the V2 `session_v2` schema, rather than JSON→SQLite migration), feel free to clarify the difference and this note can be disregarded. - jlongster was assigned - Referenced by issue `#41341`: V1→V2 session migration fails when previous channel DB lacks fork_boundary - Referenced by issue `#41346`: server: V1 data migration fails with SQLite syntax error on every start **kitlangton** commented on 2026-08-11T16:00:15Z: > > *This was generated by AI during triage.* > > V1 session import is now implemented b…[truncated] <title>V1→V2 migration loses/hides history: corrupt-part migrator 500s, undecodable rows report no field detail, >9k-message imports 500 with empty body, project picker is a hidden shortcut</title> GitHub issue 42671 in anomalyco/opencode (link omitted to avoid creating a cross-reference) hides it: ... picker exists only as a hidden keybind ... 1. Start from a V1 data dir (`~/.local/share/opencode/opencode.db` containing legacy `session`/`message`/`part` tables alongside V2 `session_v2`/`session_message`), with 26 corrupt legacy `part` rows. ... 3. `GET /api/experimental/migration/v1` → `500 database disk image is malformed` (querying `message` by a session whose `part` rows are corrupt). No partial credit, no user-facing warning — history just looks empty. ... 4. Any tool or script that "helps" by writing V1-shaped rows directly into `session_v2`/`session_message` produces rows that `SELECT` cleanly but fail the runtime decoder: `GET /api/session/ /message` → `500 "failed to decode session message"` (log only). ... 5. `POST /api/session/import` with a session of ~13k messages → 500 with empty body; log shows `SQLite query expected 27270 values, received 92806` at `SessionTransfer.import (../server/src/handlers/session.ts:112)`. Last safe observed size: 9000 messages. ... - One corrupt part row aborts the whole migration; the app shows empty history with no error. ... - Hand-migrated rows are rejected with only `failed to decode session message` and no field detail (root causes we found: missing top-level `finish`, missing `model.variant`, `step-start`/`step-finish` content items that don&`#39`;t exist in V2, tool `state` using `output` instead of `content`, tool error states needing `state.error` as a `Session.StructuredError` object). ... - Sessions >~9k messages cannot be imported (prepared-statement parameter mismatch → silent empty-body 500). ... - Migrated history is effectively invisible: `GET /api/session` defaults to newest-50 and the app has no discoverable session/folder picker — the only candidate (`project.select` / `mod+shift+o`) is declared in the command catalog but non-functional in the desktop build. ... - Workaround used: delete the offending V2 rows and re-import each session through `POST /api/session/import`, converting V1 part data to the strict V2 shapes; for the two ~13k/18k-message sessions, only the most recent 9000 messages could be imported. ... > ## Independent reproduction with a corrected decode harness (2026-08-20, fork 1.18.18 / v1.18.18 serve) > > Reproduced the same decode failure class from step 4 on a fork binary (`1.18.18`, `opencode serve --port 4096`) against a long-lived dual-layer DB. Evidence below is from the serve&`#39`;s own logs plus a bun:sqlite enumeration that mirrors the server-side decoder exactly. > > ### 1. The decode failure is DB-wide on `session_message`, not one corrupt session > > Harness calibrated to the server&`#39`;s decode path (`packages/core/src/session/history.ts` → `SessionHistory.load`), with `id` and `sessionID` injected from SQL columns exactly as the server does: > > - **52,575 of 58,002 `session_message` rows fail decode** under the current `SessionMessage.Message` schema ... > - The failures are the same V1-shaped row class described here: legacy `{"type":"aborted"}` error objects (pre-tighten error union), rows missing required fields added after they were written (`parentID`, `path`, `mode`, `agent`, `cost`, `tokens`, reasoning-part `time.start`), and rows carrying `role:"synthetic"` that the current schema no longer accepts as a standalone role ... > - Live endpoint confirmation: `GET /api/session/:id/message` → `500 "failed to decode session message"` for every affected session; serve log shows the rejection at `[N]["info"]["error"]` ... > > ### 2. Stronger finding: the projector stopped writing `session_message` entirely (Aug 13) > > Fresh sessions created after the V1/V2 split merge have **zero `session_message` rows** — `SessionProjector` (packages/core/src/session/projector.ts) no longer projects that table. So this isn&`#39`;t just "old rows are undecodable": the V2 message table is **writ…[truncated] <title>opencode2 service startup fails while V1 migration scans a large SQLite database</title> GitHub issue 41739 in anomalyco/opencode (link omitted to avoid creating a cross-reference) # opencode2 service startup fails while V1 migration scans a large SQLite database - State: open - Author: tsah - Created: 2026-08-11T12:48:38Z - Updated: 2026-08-11T14:50:21Z - Repository: anomalyco/opencode - Number: `#41739` - Assignees: jlongster ## Labels - 2.0 --- ## Summary `opencode2` cannot start its background service when migrating a large existing SQLite database. The CLI reports that the server exited after roughly 12 seconds, while the detached server process remains alive in disk wait and scans the database for about an hour. It eventually exits, the service remains stopped, and retrying reproduces the problem. I searched existing issues and found `#41346`, but this appears distinct: there is no SQLite syntax error here; startup stalls on a very large database and the client/server lifecycle leaves the service unavailable. ## Environment - `opencode2 v0.0.0-next-17155` - Node.js `v26.4.0` - Linux `6.1.175-219.359.amzn2023.aarch64` (aarch64) - XFS filesystem - Existing DB: `~/.local/share/opencode/opencode.db` - DB size: 29,889,863,680 bytes (~28 GiB), fully allocated - SQLite page size/count: 4096 / 7,297,330 - Journal mode: WAL ## Reproduction 1. Have an existing large `~/.local/share/opencode/opencode.db` (about 28 GiB in this case). 2. Run `opencode2`. 3. Observe: ```text Starting background server... ERROR (`#1`): Error: Server process exited with code 1 at map (native:1:11) at <anonymous> (../client/src/effect/service.ts:112:30) at service.ensure (src/services/server-connection.ts:66:53) ``` 4. `opencode2 service status` reports `stopped`. 5. Despite the CLI error, `opencode2.exe serve --service` remains running. It stays mostly in Linux `D` state (`folio_wait_bit_common`) and continues reading the DB. During one observation, `read_bytes` increased from 67 MB to 294 MB, confirming ongoing I/O. 6. After about an hour, that process exits. The service is still stopped, and another `opencode2` attempt fails the same way. A concurrent standalone attempt also failed because the first process held the DB lock: ```text V1 migration failed EffectDrizzleQueryError: Failed query: INSERT OR IGNORE INTO project ... SQLiteError: database is locked ``` That lock error is secondary; it occurred only because the original detached migration process was still running. ## Expected behavior The migration should complete and the service should become available. For large databases, startup should either: - wait without incorrectly reporting that the server exited, - expose migration progress/status, or - fail with an actionable diagnostic. It should not leave a detached migration process running for an extended period while `service status` reports `stopped`. ## Actual behavior The UI cannot start, the CLI says the server exited, a detached process continues scanning the database, and the service never reaches a usable state. Retrying can create lock contention with the still-running migration. ## Additional notes The existing data has been preserved for further diagnostics. I can run targeted, non-destructive SQLite queries if table-level information would help identify why the DB is this large or where migration time is spent. ## Timeline - jlongster was assigned - opencode-agent[bot] added label "2.0" **opencode-agent[bot]** commented on 2026-08-11T14:32:28Z: > Could you use `sqlite3` against the preserved database and tell us how many rows are in the `event` table? A read-only command is: > > ```sh > sqlite3 -readonly ~/.local/share/opencode/opencode.db "SELECT COUNT(*) FROM event;" > ``` > > Given the database size, this query may also take a while. **tsah** commented on 2026-08-11T14:50:21Z: > (agent) I ran the read-only query against the preserved database backup: > > ```sh > sqlite3 -readonly ~/.local/share/opencode/db-backup-20260811T125425Z/opencode.db "SELECT COUNT(*) FROM event;" > ``` > > Result: **880,544 rows**. > > Timing: *…[truncated] <title>opencode2 mutates shared V1 database and breaks opencode 1.x coexistence</title> GitHub issue 42260 in anomalyco/opencode (link omitted to avoid creating a cross-reference) `opencode2` and stable `opencode` use the same default SQLite database, `~/.local/share/opencode/opencode.db`. Starting `opencode2` applies an incompatible V2 schema to that file. Stable `opencode` 1.x then fails in normal UI paths, including `/move`. ... Stable `opencode` fails because the shared database now has the V2 schema. ... ```text SQLiteError: no such table: session_context_epoch at SessionContextEpoch.reset at MoveSession.moveSession at ControlPlaneHttpApi.moveSession ``` ... and `session_context_epoch` no longer existed. Those are consistent with the V2 schema but incompatible with `opencode 1.18.18`. ... `opencode2` should not mutate the stable CLI&`#39`;s database by default. The separately named preview executable should use an isolated data/state namespace, for example: ... If sharing/migrating the stable database is intentional, `opencode2` should require explicit opt-in, preserve a compatible copy, or block with a clear warning that stable `opencode` will no longer be usable against that database. ... > ## Experimental one-time recovery script and coexistence workaround > > I used the following one-time script to reconstruct a separate OpenCode 1.x database from the surviving legacy tables. It was tested specifically with `opencode 1.18.18`. > > Safety notes: > > - Back up the original database and its `-wal`/`-shm` files before recovery. > - exit all sessions of opencode/opencode2 ... > - The source ... is opened read-only and copied through SQLite backup. ... and `session_share.secret` ... are excluded by default; ... -credentials` ... session history and ... Do not upload it ... .x database from ... > ... from __future__ ... annotations > ... import argparse > import os ... from pathlib import Path > ... sqlite3 > ... subprocess > import tempfile ... > > > CREDENTIAL_TABLES = ( > "account", > "account_state", > "control_account", > "credential", > "session_share", > ) > > DATA_TABLES = ( > "event_sequence", > "event", > "project", > "project_directory", > "permission", > "session", > "message", > "part", > "todo", > "session_message", > ) > > > def arguments() -> argparse.Namespace: > parser = argparse.ArgumentParser( > description=( > "Create a fresh OpenCode 1.x database and copy compatible legacy " > "records from a database migrated by opencode2." > ) > ) ... copy account, credential, and session-share authorization tables; by default " > ... these tables remain empty and OpenCode must be reauthenticated" > ), ... _info({quote ... def copy_table(connection: sqlite ... Connection, table: str ... -> int: ... > source_columns = {row["name"] for row in columns(connection, "source", table)} > destination_columns = columns(connection, "main", table) ... > if not source_columns or not destination_columns: > raise RuntimeError(f"required table is missing: {table}") > > missing_required = [ > row["name"] > for row in destination_columns > if row["name"] not in source_columns > and row["notnull"] > and row["dflt_value"] is None > and not row["pk"] > ] > if missing_required: > raise RuntimeError( > f"source {table} lacks required destination columns: {missing_required}" > ) > > names = [row["name"] for row in destination_columns if row["name"] in source_columns] > selected = ", ".join(quote(name) for name in names) > where = "" > if table == "session_message": > # opencode2 reused this table for session_v2. Only legacy session rows > # satisfy the foreign key co…[truncated] <title>specs/v2/schema-changelog.md</title> https://github.com/anomalyco/opencode/blob/e23586af/specs/v2/schema-changelog.md - Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods. ... - Page public durable Session events after an optional exclusive aggregate sequence, with an explicit `hasMore` exhaustion signal. ... - Keep aggregate gaps legal, cap pages at 100 events, and preserve the existing durable replay-and-tail `sessions.events()` stream unchanged. ... - Add no migration or durable-event version; this is a finite read API over ... existing event manifest. ... - Keep `session.next.prompt.admitted.1` as the durable, client-visible record of pending Session input. ... - Replace `session.next.prompt.promoted.1` with the existing `session.next.prompted.1` event when input becomes model-visible. - Preserve the prompt endpoint, admission receipt, idempotency, steer/queue ordering, and atomic user-message projection. - Reset experimental V2 events, projections, inputs, Context Epochs, and synchronized workspace state while preserving canonical V1 `session`, `message`, and `part` rows. ... - `session_input`, `session_message`, `event`, `event_sequence`, and disposable workspace beta storage. - New synchronized `session.next.prompt.admitted.1` and `session.next.prompt.promoted.1` events. - Experimental `SessionV2.prompt(...)`, HTTP, and generated SDK admission receipt. ... - Replace inbox-local admission sequence with event-sourced prompt admission and promotion sequences. - Give projected Session messages stable `msg_*` resource IDs distinct from `evt_*` creator event IDs. - Give every event that creates a projected transcript resource an explicit `msg_*` resource ID. Assistant steps propagate one `assistantMessageID` through assistant-owned events. - Reset incompatible unreleased beta event history, derived Session projections, workspace rows, and Session workspace links. ... - The reset preserves canonical V1 `session`, `message`, and `part` rows. - Existing synchronized workspaces are disposable beta state and are removed by the reset. ... - Before starting the new build, discard adapter-managed external workspace resources created by unreleased builds. The SQL migration cannot remove external resources through runtime adapters, and rediscovering retained resources after startup can replay incompatible beta history. - Exact prompt retries reconcile one stable `msg_*` identity when Session, prompt, and delivery mode match. ... - Existing synchronized `session.next.*` event family in `packages/core/src/session/event.ts`. - Existing projected V2 Session-message union in `packages/core/src/session/message.ts`. ... - New explicit durable-event union and internal replay cursor returned by `sessions.events({ sessionID, after? })`. ... - New `session_input` table from `20260603141458_session_input_inbox.ts`. - Updated pending-input index from `20260603160727_jittery_ezekiel_stane.ts`. - New `SessionInput.Admitted` schema and `Prompted.delivery` field. - Prompt-admission conflict behavior in `SessionV2.prompt(...)`. ... - Persist admitted prompts before projection with an autoincrement inbox sequence, unique message ID, Session ID, encoded prompt, `steer` or `queue` delivery mode, optional promoted event sequence, and creation time. - Index pending inputs by Session, promotion state, delivery mode, and admission sequence. ... session_message.seq ... _session_message ... projection_order. ... - Reset pre-launch Session-message projections and add `session_message.seq` for newly projected synchronized events. - Add event aggregate-sequence and aggregate-type-sequence indexes. - Add Session-message sequence, type-sequence, and compatibility timestamp indexes. ... - Projected history, replay, compaction lookup, and pagination must follow durable aggregate order rather than timestamps or caller-generated IDs. - Runner and HTTP read paths need covering indexes for their concrete lookup shapes. ... - Pre-launch Session-message projections are disposable because historical versions could write them witho...

Citations:

  • 1: GitHub issue 41217 in anomalyco/opencode (link omitted to avoid creating a cross-reference)
  • 2: GitHub issue 42671 in anomalyco/opencode (link omitted to avoid creating a cross-reference)
  • 3: GitHub issue 41739 in anomalyco/opencode (link omitted to avoid creating a cross-reference)
  • 4: GitHub issue 42260 in anomalyco/opencode (link omitted to avoid creating a cross-reference)
  • 5: https://github.com/anomalyco/opencode/blob/e23586af/specs/v2/schema-changelog.md
  • 6: GitHub issue 27701 in anomalyco/opencode (link omitted to avoid creating a cross-reference)

Use message fallback per session.

When a migrated database contains a positive-total session and another session_v2 session with zero totals but assistant session_message usage, selectUsageRows takes the aggregate branch for the whole database. selectSessionUsageRows filters out zero-total sessions, so the second session’s usage is omitted. Choose the source per session and add a migrated-fixture regression case for this combination.

@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.

Important

The session-totals guard accepts any session table that has usage columns, but the source resolves each id to a single table — so a database that pairs a token-bearing legacy session with a session_v2 lacking those columns will drop the shared sessions' usage entirely. Reproduction inline.

Reviewed changes — initial review of 6b4d857; all four changed files read end to end.

  • Deduplicated session source — selectUsageRows builds one relation (session_v2 first, then session minus shared ids) and reads the session-totals, session_message, and message paths from it, so v2-only, migrated, and never-migrated sessions each count once.
  • Column guarding — every session column is read through a columnExists literal fallback via SESSION_SOURCE_COLUMNS, replacing the per-column helpers (getSessionModelSelect, getSessionCacheWriteSelect, getSessionTokenTotalExpression, canReadSessionUsageRows).
  • Schema-version bump — OPENCODE_USAGE_SCHEMA_VERSION 3 → 4 discards cached state and forces a rescan.
  • Fixture and tests — opencode-usage-sqlite-fixture.ts generates real v1 / migrated / v2-only databases; opencode2-session-usage.test.ts pins the eight behaviors.

ℹ️ The session_v2-wins precedence assumes v2 totals are never lower

The dedup rule assumes session_v2 always holds at least the legacy row's totals. Upstream's V1→V2 importer breaks that in two documented ways: it recomputes session_v2 cost/tokens from decoded messages after the initial copy, so sessions with undecodable messages land below the frozen legacy aggregate; and a V1 binary writing to the same opencode.db after the one-time migration grows the legacy row while leaving session_v2 behind. Preferring session_v2 then reports the smaller number. This is a design trade-off rather than a clear bug, but it sits behind the PR's "the legacy row is frozen" rationale, so it is worth confirming the intended behavior.

Technical details
# session_v2 precedence versus legacy totals

## Affected sites
- `src/main/opencode-usage/opencode-usage-row-queries.ts:38` — `SESSION_TABLES_BY_PRIORITY` puts `session_v2` ahead of `session` unconditionally.
- `src/main/opencode-usage/opencode-usage-row-queries.ts:74` — the legacy row is excluded whenever the id exists in `session_v2`.

## Evidence
- Upstream `packages/core/src/database/v1-migration.bun.ts`: after `INSERT OR IGNORE INTO session_v2 ... SELECT ... FROM session`, it runs `transformSession`, which sums decoded assistant messages and `UPDATE`s `session_v2` with that sum (`invalid-message` rows are dropped). The legacy `session` row is never dropped.
- Upstream `packages/core/src/database/migration/20260804233008_loose_psylocke.ts` only renames `session` → `session_v2` on the pre-split-V2 lineage; the main V1→V2 path leaves both tables.

## Open questions for the human
- Is there a case where the legacy `session` aggregate should win over the recomputed `session_v2` sum (e.g. partial-decode migrations)?
- Should shared ids combine per-column (greatest of the two) instead of picking one row?

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Review found two ways the fixed `session_v2`-wins precedence loses usage.

A `session_v2` without the token columns scores 0 through the source's
fallback literals, but still excluded the legacy row for every shared id, so
a token-bearing legacy `session` paired with a token-less `session_v2`
returned nothing at all — worse than before this branch. And upstream's
importer recomputes v2 totals from decoded messages, so a session whose
messages fail to decode lands *below* its frozen legacy copy, which the
"legacy is frozen, v2 is newer" rationale did not account for.

Both collapse into one rule: per id, the row with the greater token total
wins, and a tie goes to the higher-priority table. A faithful migration is a
tie, so it still resolves to `session_v2`; a v2 row that lost data no longer
erases the legacy record. This also makes `hasSessionUsageColumns`'s `some`
correct rather than merely lenient — a table without the columns can never
outrank a sibling that has them.

Measured on the real local opencode.db: zero shared ids have a legacy row
that beats its `session_v2` counterpart, so the rule is a no-op on healthy
data and only engages on the degraded shapes above.

Both new tests fail on the previous commit and pass here.
@nwparker

Copy link
Copy Markdown
Contributor Author

Thanks — both dedup findings were real, and they share one root, so I fixed them with one rule rather than two patches. Pushed in 41764e3.

@pullfrog's blocking finding (token-bearing legacy session + token-less session_v2 drops shared ids) and its second, informational finding (session_v2 recomputed lower than the frozen legacy row) are the same bug wearing two hats: fixed table priority assumes session_v2 is always at least as complete, and it isn't.

The id NOT IN (SELECT id FROM session_v2) exclusion is now a total comparison — per id, the row with the greater token total wins; ties go to the higher-priority table:

  • token-less session_v2 scores 0 through the fallback literals → the legacy row wins instead of being erased
  • recomputed-lower session_v2 → the fuller legacy row wins
  • a faithful migration is a tie → still resolves to session_v2, as before

That also makes hasSessionUsageColumns's some genuinely correct rather than just lenient, which was the inconsistency you flagged: a table without the columns can never outrank a sibling that has them. Added a comment saying so.

opencode-usage-sqlite-fixture.ts can now build a session_v2 without the token columns (v2WithoutTokenColumns) — you were right that no fixture could represent that shape. Three tests added: token-less v2, recomputed-lower v2, and the tie resolving to v2. The first two fail on 6b4d857 and pass on 41764e3; I verified by reverting just the query file.

On your open question about reachability: I measured the real local opencode.db (v2.0.12, 256 session_v2 rows). Zero shared ids have a legacy row that beats its session_v2 counterpart, so the new rule is a no-op on healthy data and only engages on the degraded shapes. Given upstream issue 42671 reports 52,575/58,002 session_message rows failing to decode, the recomputed-lower case looks considerably more than theoretical.


@coderabbitai — on choosing the aggregate vs. message-fallback source per database rather than per session: you're right that it's a real limitation, but it's pre-existing, not introduced here. main already branches once per database (canReadSessionUsageRows(db) && getSessionUsageRowCount(db) > 0); this PR only changed what session means in that expression. Fixing it means restructuring the read into a per-session union of aggregate and message sources, which is a larger behavioral change than "make OpenCode 2 visible" and would need its own before/after measurement on real data. Leaving it out of scope deliberately rather than silently — I'd rather ship the fix for the reported zero-usage bug and raise that separately than widen this PR.

@pullfrog

pullfrog Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Independently verified 41764e3; no new issues, and no commits have landed since the last review at this head, so no further review is warranted.

  • The tie-break is sound: for a shared id exactly one row survives (legacy only on a strictly greater token total, otherwise session_v2), so there is no double-count and no erasure.
  • Reproduced the two new tests against the 6b4d857 query file — both fail exactly as described (token-less session_v2 returns []; recomputed-lower returns 120 against a 900 legacy total) and pass at head. pnpm test src/main/opencode-usage is green (6 files / 42 tests).
  • Confirmed the per-database (not per-session) aggregate-vs-message branch is pre-existing: main already gated on canReadSessionUsageRows(db) && getSessionUsageRowCount(db) > 0, so the out-of-scope call holds.

The prior token-less-session_v2 thread is resolved and the review at this head was already clean.

Task list (5/5 completed)
  • Checkout PR and read incremental + full diffs
  • Fetch prior reviews and retire addressed Pullfrog threads
  • Verify the dedup rule fix in 41764e3 against the author's claims
  • Review new tests (token-less v2, recomputed-lower, tie)
  • Submit review or report no-review

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

@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 — incremental delta since the prior pullfrog review (6b4d857 → 41764e3); all four changed files read end to end, suite run against the new head.

  • Fuller row now wins a shared id — buildSessionTableSelect no longer uses fixed table priority to exclude a shared id. It emits a NOT EXISTS per sibling comparing token totals (>= for higher priority, > for lower), so the larger aggregate wins and equal totals resolve to session_v2.
  • Guard/source mismatch resolved — a session_v2 missing the token columns scores 0 in that comparison, so it can no longer erase a token-bearing legacy session row; hasSessionUsageColumns's some is consistent with that rule.
  • Legacy totals preserved when v2 is smaller — covers the upstream importer recomputing session_v2 from decoded messages, which can land a session below its frozen legacy aggregate.
  • Fixture and tests extended — opencode-usage-sqlite-fixture.ts gained a token-less session_v2 schema and the v2WithoutTokenColumns spec; keeps the legacy totals when session_v2 lacks the token columns, keeps the legacy totals when the migration recomputed session_v2 lower, and resolves an identical migrated copy to session_v2 pin all three branches of the tie-break.
  • Project join simplified — getProjectJoin no longer requires session.project_id, since the union source always exposes it.

Prior feedback from the initial review is resolved: the token-less-session_v2 undercount is fixed and pinned, and the session_v2 precedence question is answered by the fuller-row rule. pnpm test src/main/opencode-usage passes (6 files / 42 tests).

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

@coderabbitai

coderabbitai Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
ast-grep outline src/main/opencode-usage/opencode-usage-row-queries.ts --match 'selectUsageRows|canReadSessionUsageRows|getSessionUsageRowCount' --view expanded || true
rg -n -C 8 'canReadSessionUsageRows\(db\)|getSessionUsageRowCount\(db\)|function selectUsageRows|const selectUsageRows' src/main/opencode-usage/opencode-usage-row-queries.ts
git branch -a --list '*main*'
git show main:src/main/opencode-usage/opencode-usage-row-queries.ts | rg -n -C 8 'canReadSessionUsageRows\(db\)|getSessionUsageRowCount\(db\)|function selectUsageRows|const selectUsageRows' || true
git status --short

Length of output: 3140


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '202,265p' src/main/opencode-usage/opencode-usage-row-queries.ts
git show main:src/main/opencode-usage/opencode-usage-row-queries.ts | sed -n '140,170p'

Length of output: 3427


@nwparker, the PR-branch check is complete. Both main and this PR choose session aggregates or message fallback once per database. The PR changes the session source, but not the selection granularity.

You are right that per-session source selection is a pre-existing limitation. I withdraw my PR-specific finding; it should not block this fix. My earlier statement that the PR-branch check was incomplete is no longer applicable.

You are interacting with an AI system.

@nwparker

Copy link
Copy Markdown
Contributor Author

Post-merge correction to this PR's body, from an adversarial re-verification against OpenCode source at v2.0.15. The code is sound; three statements in the description are not, and I'd rather correct the record than leave them for the next reader.

1. Test counts are wrong. The body says the new test file has 8 tests and the suite 39. It is 11 and 42.

2. The Performance note describes SQL that was replaced before merge. It cites NOT IN (SELECT id FROM session_v2) — the shape from the first commit. The follow-up (41764e3) replaced it with a correlated NOT EXISTS carrying a five-column sum comparison. The note should read against that.

3. "Copies" overstates what the migration file does. migration/20260804233008_loose_psylocke.ts copies nothing: on the V1 lineage it creates an empty session_v2 and never touches session/message/part; on the pre-split V2 lineage it renames session → session_v2. Row population lives in a separate component, v1-migration.bun.ts.

What survived the attack, and is worth recording: the rationale for preferring the fuller row is correct, not merely harmless. v1-migration.bun.ts:461-466 re-sums over decoded assistant messages and :226-233 drops any message failing decodeMessage first, so a session_v2 row genuinely can land below its frozen legacy twin. The legacy table is never dropped — the real database carries kv migration.v1-v2 = {"phase":"completed"} alongside 234 surviving session rows, so coexistence is the settled steady state.

One inference in the verification itself was retracted mid-flight and is worth flagging so nobody repeats it: 234/234 byte-identical token columns were first read as proof of a copy rather than a recompute. That is wrong — a lossless recompute over integers is bit-identical. The actual evidence points the other way: five shared ids differ on cost by exactly 1, 1, 1, 2, 1 ULP, which only re-summation produces.

The same review found three real defects in the dedup rule — cost zeroed rather than shrunk, stale directory/model riding along with the token winner, and per-row rather than per-column winner-takes-all. Fixed in #22550.

nwparker added a commit that referenced this pull request Sep 24, 2026
…22550)

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.
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.

orca-cli doesn't show token usage stats for opencode2

1 participant