Skip to content

session: migrated session missing messages written after V1→V2 migration; export truncated #57000

Open

Description

@uphome

Description

Summary

After upgrading from OpenCode 1.19.0 → 2.0.29 → 2.0.32, a migrated long-running session shows only part of its history in the TUI. About 812 messages written between the V2 migration and the exit of the pre-upgrade client process exist in the legacy message/part tables (and in the event log) but were never projected into session_message. opencode session export is truncated for the same reason, so the missing history cannot be exported.

Environment

  • opencode version: 2.0.32 (session created on 1.19.0; upgraded to 2.0.29, then 2.0.32)
  • OS: Linux 5.15.0-52-generic Panic when tool fails #58~20.04.1-Ubuntu SMP x86_64
  • Terminal: TERM_PROGRAM=vscode, TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/sh
  • Install/channel: latest (curl installer, ~/.opencode/bin/opencode)
  • Active plugins: none found (global config has no plugin entries)

Reproduction

  1. On OpenCode 1.19.0, start a long-running session and keep its client/server process open.
  2. Upgrade to 2.0.29 without stopping that process; keep appending messages through the old process for several days.
  3. Upgrade to 2.0.32 and open the same session in V2.
  4. The TUI shows history only up to the migration snapshot; messages written between the snapshot and the old process exit are absent.
  5. Verify with SQLite (~/.local/share/opencode/opencode.db):
    • SELECT COUNT(*) FROM message WHERE session_id=? → 1973
    • SELECT COUNT(*) FROM session_message WHERE session_id=? → 1185
    • Missing range (3 days, 662 assistant + 150 user messages).
  6. The event-sourced log still contains the missing events:
    • SELECT COUNT(*) FROM event WHERE aggregate_id=? AND seq BETWEEN 1162 AND 12163 → 10999 (message.updated, message.part.updated, session.updated).
    • However event_sequence.seq for the session is 12344, so V2 treats them as already processed and never projects them.
  7. opencode session export "<session-id>" returns 1184 messages (export reads session_message), so the missing history is truncated there too.

Expected Behavior

Migrated sessions should include all messages present in the legacy message/part tables, including those appended after the initial migration (e.g., by re-projecting late legacy messages at startup or when the session is opened). At minimum, session export should not silently omit them.

Actual Behavior

  • The TUI and session export omit roughly 812 messages written by the pre-upgrade process after migration.
  • Manually reconstructing session_message rows from the retained event log causes failed to decode session message (HTTP 500 from /api/experimental/session/<session-id>/export) for some reconstructed rows, so the projection schema contains constraints that are not publicly documented.

Additional Context

  • DB: ~/.local/share/opencode/opencode.db.
  • Version timeline from logs: 1.19.0 until 10-15 21:45 (local); 2.0.29 from 10-15 21:45; 2.0.32 from 10-19 14:43.
  • The pre-upgrade process kept running with a deleted binary (/proc/<pid>/exe -> ~/.opencode/bin/opencode (deleted)) and wrote only the legacy tables until it exited on 10-19.
  • Workarounds tried:
    • opencode session export + session import: import refuses with "Session already exists"; export is truncated anyway.
    • Rewinding event_sequence.seq: do not do this — it is the event append cursor, not a projection cursor. It causes new events to reuse sequence numbers and overwrite the log (observed on a DB copy).
    • Manual projection from event into session_message: partial success, but the decoder rejects some reconstructed rows (tool state.content, error-state shape, etc.), so it is not safe as an end-user workaround.
  • Suggestion: provide a repair/re-projection command (or automatically project late legacy messages) and let session export fall back to legacy tables.
  • Data safety: raw message/part/event rows are intact; only the session_message projection is missing.

Activity

  1. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can reproduce this on 2.0.32, and it still happens on the latest dev (ba06ff9e).

    What I did, in an isolated data directory:

    1. OpenCode 1.19.0 imports a session with 10 messages.
    2. V2 starts once. The one-time V1→V2 import completes and session_message has 10 rows.
    3. 1.19.0 adds 10 more messages to that same session, standing in for the old client that was still running in your case.
    4. V2 starts again as a fresh process.

    Result: the legacy message table has 20 rows for the session, but session_message still has 10, and opencode session export returns only 10 messages. Once the import has run, it never looks at an already-imported session again.

    One caveat: opencode import in 1.19.0 doesn't write event rows, so I couldn't check your point about event_sequence already being past the logged events. The missing messages themselves reproduce. The script below runs the whole thing.

    This is close to #50130 and #49641, where whole V1 sessions created after the import are never imported. Your case is new messages inside a session that was already imported. And thanks for flagging that rewinding event_sequence.seq corrupts the log; until there is a proper repair path, please keep using the legacy tables or your DB backup.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces #57000: messages V1 appends to an already-migrated session never reach V2's session_message / export.
    # Needs: bun, python3. V2 command: @opencode/[email protected] by default, or OC_DEV=/path/to/checkout to run from source.
    set -e
    R=$(cd "$(dirname "$0")" && pwd); W=/tmp/opencode/r57000; rm -rf $W; mkdir -p $W/v1 $W/proj $W/home
    (cd $W/v1 && bun add [email protected] >/dev/null 2>&1)
    V1=$W/v1/node_modules/.bin/opencode
    if [ -n "$OC_DEV" ]; then
      # spawned server child needs the same bun flags; a source build uses opencode-local.db unless OPENCODE_DB points at the shared db
      export BUN_OPTIONS="--conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js"
      export OPENCODE_DB=$W/home/data/opencode/opencode.db
      V2=(bun $OC_DEV/packages/cli/src/index.ts)
    else
      mkdir -p $W/v2; (cd $W/v2 && bun add --trust @opencode/[email protected] >/dev/null 2>&1); V2=($W/v2/node_modules/.bin/opencode)
    fi
    export XDG_DATA_HOME=$W/home/data XDG_CONFIG_HOME=$W/home/config XDG_STATE_HOME=$W/home/state XDG_CACHE_HOME=$W/home/cache
    cd $W/proj
    python3 $R/gen.py 5 $W/a.json; python3 $R/gen.py 10 $W/b.json   # same session id; b = a + 5 more turns
    DB=$XDG_DATA_HOME/opencode/opencode.db; S=ses_0aaaaaaaaaaaaaaaaaaaaaaaaa
    echo "== v1 1.19.0 creates session with 10 messages"; $V1 import $W/a.json
    echo "== v2 runs once (one-shot V1->V2 import)"; timeout 120 "${V2[@]}" session list >/dev/null 2>&1 || true; "${V2[@]}" service stop >/dev/null 2>&1 || true
    bun $R/q.ts $DB "select value from kv where key='migration.v1-v2'" "select count(*) c from session_message where session_id='$S'"
    echo "== v1 (still in use) appends 10 more messages to the same session"; $V1 import $W/b.json
    echo "== v2 runs again"; timeout 120 "${V2[@]}" session list >/dev/null 2>&1 || true; "${V2[@]}" service stop >/dev/null 2>&1 || true
    bun $R/q.ts $DB "select count(*) legacy from message where session_id='$S'" "select count(*) projected from session_message where session_id='$S'"
    timeout 120 "${V2[@]}" session export $S > $W/exp.json 2>/dev/null || true; "${V2[@]}" service stop >/dev/null 2>&1 || true
    python3 -c "import json;print('export messages:',len(json.load(open('$W/exp.json'))['messages']))"

    Run it: bash repro.sh

  2. hexin commented on Oct 21, 2026

    @hexin

    I can observe the same problem.

  3. desaiamogh commented on Oct 25, 2026

    @desaiamogh

    Cross-posting from #58840 (closing as a duplicate of this + #50130), since the constraints that caused failed to decode session message in your manual projection are exactly the ones we had to reverse-engineer.

    We hit the same truncation pattern (one migrated session that gained ~380 legacy-only messages after the import, on top of 5 fully-stranded sessions / 916 messages) and recovered by inserting session_message rows directly. To get the shapes right, I rebuilt the official importer's transformation and validated it against all 650 messages it had already produced on our DB — exact match (parsed-JSON equality) on 650/650, then applied it to the stranded data. The rules:

    • Row fields: same id; type = legacy meta role; seq = legacy order (ORDER BY time_created, rowid); time_created/time_updated copied from the legacy message row. Message-level time = {created: row.time_created, completed: row.time_updated} — note the importer uses the row's time_updated, not meta.time.completed.
    • user: {"text": <first text part>, "time": {"created": ...}}. V1 "attachment echo" text parts after the first (Called the Read tool with the following input: ...) are dropped. file parts → files: [{data: <raw base64 payload after "base64,">, mime, name: filename, source: {"type": "inline"}}] (data stays base64, not decoded).
    • assistant: {agent, model: {providerID, id: modelID, variant: meta.variant ?? "default"}, content, snapshot?, finish?, cost, tokens: {input, output, reasoning, cache} (drop total), error?, time}.
    • parts → content: drop step-start/step-finish/patch; snapshot.start/snapshot.end come from their snapshot field only when present (older V1 rows have no snapshot field at all → no snapshot key in the message); patch.files merges into snapshot.files.
    • reasoning part: keep time, renamed start→created, end→completed. text part: drop time (only {type, text}).
    • tool part: callID→id, tool→name, part-level metadata (thoughtSignature) → providerState. state: keep status/input/metadata, drop title and time; state.output (a plain string) → state.content = [{"type": "text", "text": ...}]; state.error plain string → {"type": "tool.execution", "message": ...} (this and the content shape are the most likely causes of the decode failures you saw). Errored tools normally have no content — the only exception is aborted tools, where the partial output hides in state.metadata.output (can be ""), not state.output. Tool time moved between V1 builds: older rows carry it on the part, newer rows inside state.time — handle both, emit as part-level {created, completed}.
    • Message-level error: {name: "MessageAbortedError"|"APIError", data: {message}} → {type: "aborted"|"provider.error", message}.

    Caveats: this is end-user surgery, not a supported path — take an online sqlite3 .backup first, leave the kv['migration.v1-v2'] marker alone (per the warning in #49641), and keep the legacy tables intact so it stays reversible. We verified the recovered sessions afterwards through GET /api/session/{id}/message (paginates via limit + cursor.next). Happy to share the Python transform if useful.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions