Skip to content

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 #42671

Closed as not planned

Description

@conorcmack

Summary

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't be imported at all, and there is no discoverable way to open the old sessions (project picker exists only as a hidden keybind).

Environment

  • opencode version: 0.0.0-next-17403 (embedded opencode-cli serve --service via OpenCode Beta desktop app), also opencode2 0.0.0-next-17428
  • OS: Linux 6.8.0-136-generic (Ubuntu), x86_64
  • Terminal: xterm-256color (desktop app is the client used)
  • Shell: /bin/bash
  • Install/channel: next / beta desktop (Electron, /opt/OpenCode Beta/ai.opencode.desktop.beta)
  • Active plugins: none found in config

Reproduction

  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.
  2. Open the V2 desktop beta; let it run its built-in migration.
  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/<id>/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.
  6. Even after messages are present and decoding, the beta desktop home/history shows nothing for the project — the project picker (project.select) exists only as a hidden keybind mod+shift+o (not in Settings), and in the current desktop build that shortcut does nothing when pressed. lastProject stays pinned to whatever was open on restart, so there is no working way to reach another folder's history from the UI.

Expected Behavior

  • Migration should survive a few bad legacy rows (warn + skip that session) and report what failed.
  • Records written in a compatible format should either decode or fail with the specific field (the import validator already does this well).
  • History, once migrated, should be reachable from the UI without a hidden shortcut.

Actual Behavior

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

Additional Context

  • Expected import payload shape that works: { "info": Session.Info, "messages": Session.Message.Info[] } — verified end-to-end (1000+ sessions imported, all decode, served via the message endpoint).
  • Frequency: consistently reproducible across restarts.
  • 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.
  • Idempotency note: an import whose POST returns empty-body 500 looks like success to clients; ?.data and error tags are not consistently enforced.
  • No opencode git checkout was available on the machine, so no exact source refs were inspected; stack path in step 5 came from the server log.

Activity

  1. github-actions commented on Aug 15, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of (or closely related to) existing issues. Please check:

    For keybind-related issues, please also check our pinned keybinds documentation: #4997

  2. Qiiks commented on Aug 20, 2026

    @Qiiks

    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'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'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't just "old rows are undecodable": the V2 message table is write-dead for new sessions and read-dead for old ones. The table is currently a zombie: 58,002 rows, ~90% undecodable, no new writes.

    3. Why it's invisible today — and the trigger that makes it user-visible

    The app's detectServerProtocol (packages/app/src/utils/server-protocol.ts:24-34) returns "v1" for any healthy server (/global/health → healthy:true short-circuits before the v2 probe). All current clients (web UI, mobile SDK, paseo SDK 1.14.46) therefore ride the legacy /session/:id/message surface and never touch the V2 read path — which is why nothing crashes until a V2-protocol client connects. The moment protocol detection or a client selects v2, every old session with pre-merge rows 500s, exactly as step 4 predicts.

    4. The legacy surface has the same row class — fixed at the data layer, already verified

    The same V1-shaped rows also break the legacy unlimited fetch (validates every row against SessionV1.WithParts), which is the surface mobile/paseo/web actually use. That side was repaired in-place on 2026-08-20 (no fork changes, no migration):

    • 13 legacy rows {"type":"aborted"} → current writer's {"name":"MessageAbortedError","data":{"message":"Aborted"}} (shape verified against 3 post-merge reference rows the 1.18.18 binary itself emits)
    • 1,493 assistant rows: backfilled parentID (most-recent preceding user message) and path {cwd,root} from per-session healthy values
    • 13 role:"synthetic" rows → reparented as synthetic:true text parts onto the preceding user message (matches current writer via reminders.ts; 0 post-merge standalone rows exist)
    • 1,333 reasoning parts time:{created,completed} → time:{start,end}

    Result: 13,053 messages + 41,648 parts decode clean (0 failures), both affected sessions return HTTP 200 on unlimited fetch, and the serve reads the DB live per request (no restart required). The repair surface was precisely bounded — 1,506 failing rows across exactly the 2 user-visible sessions, matching the serve log's rejection indexes.

    Suggested fix direction for the V2 side

    Data-level repair of session_message is impractical at this scale (52.5K rows) and pointless while the projector is write-dead. The two fork-side options, per this issue's framing:

    1. Decoder tolerance — map the known legacy shapes at decode time ({"type":"aborted"} → MessageAbortedError, tolerate missing optional fields with schema defaults). This kills the whole breakage class without a data migration, but is a carve-out from the deliberate schema tighten.
    2. Restore the projector — investigate why the merged tree stopped writing session_message (upstream may have disabled/renamed the projection); without writes, V2 message history is empty for all new sessions regardless of decode.

    Both are latent today (no client selects v2), but become user-visible the moment the V1 cutover (#35054) lands. Happy to provide the harness script, per-session row dumps, or the exact failing-row enumeration if useful.

  3. github-actions commented on Oct 20, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions