Repository navigation
Description
Summary
The V1→V2 session migration (kv.migration.v1-v2) silently discards legacy messages that don't validate against the current SessionV1.Info schema. In my DB, 64 V1 sessions (created with OpenCode ≤ 1.0.65, Sep–Nov 2025) ended up with rows in session_v2 but zero session_message rows — their history is invisible in V2 even though the raw session/message/part rows are intact. Re-running the migration (deleting kv.migration.v1-v2) does not help: the same rows are dropped again.
Root cause
The migrator validates every legacy message against SessionV1.Info (packages/schema/src/v1/session.ts) and drops it on failure with reason=invalid-message (log: Skipped V1 migration row). The schema requires fields that older V1 messages never wrote:
- Assistant: requires
agent: stringandparentID: MessageID(both non-optional). - User: requires
agent: stringandmodel: { providerID, modelID }(both non-optional).
Messages from ≤ v1.0.65 (and 0.13.x) look like {"role":"user","time":{"created":...}} or {"role":"assistant","time":{...},"modelID":"...","providerID":"...","mode":"...","path":{...},"cost":0,"tokens":{...}} — no agent, no model, no parentID. They all fail validation and are discarded with no user-visible signal beyond a WARN log.
Evidence
level=WARN message="Skipped V1 migration row" reason=invalid-message sessionID=ses_57dd442f4ffejSUsp5Vg1gns06 messageID=msg_a822dc7a2002NqoUru5i32ebV7
On my DB (9.4 GB, opencode 2.0.24): 4867 × invalid-message, 154 × invalid-part. Sessions with messages but no session_message, grouped by V1 version: all ≤ 1.0.65; every version ≥ 1.0.132 migrated fine.
Workaround / fix (verified end-to-end)
Enrich the legacy rows so they validate, then re-run the migration:
- Add the missing fields:
agenton every message;model: {providerID, modelID}on user messages;parentID(→ preceding user message id) on assistant messages. DELETE FROM kv WHERE key='migration.v1-v2';- Restart OpenCode → the migration re-runs and materializes the history.
Result: 64 empty sessions → 0, session_message grew by ~1071, content verified with opencode session export (old sessions now show their real messages).
Expected behavior
- The migration should not silently drop rows: at minimum report the failing field, and ideally backfill missing metadata fields with sensible defaults (they are metadata, not conversation content).
step-start/step-finishparts are also logged asinvalid-parteven though they are intentionally filtered by the mapper — the warning is misleading.
Related
- V2: V1 session history is never imported into session_v2 — old sessions invisible in OpenCode 2 #41217 — V1 session history is never imported into session_v2
- 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 — V1→V2 migration loses/hides history (undecodable rows report no field detail)
- session: V1→V2 importer skips sessions created after first V2 launch #49641 — V1→V2 importer skips sessions created after first V2 launch
Environment
- opencode 2.0.24 (latest), Linux, 9.4 GB
~/.local/share/opencode/opencode.db - Legacy DB with ~6,676 V1 sessions + 264 V2 sessions merged
Activity
opencode-agent commentedon Oct 8, 2026
Thanks for the detailed report. I can confirm this on the latest
v2(7522502). The migration code is unchanged since 2.0.24.What I ran: I passed the migration a V1 session with legacy messages in the ≤1.0.65 format. The user message had only
roleandtime. The assistant message hadmodelID,providerID,mode,path,costandtokens, but noagentorparentID. Both messages were dropped and nothing was migrated. Each produced aninvalid-messagewarning that gives only the message ID, not the field that failed. When I addedagentandmodelto the user message andagentandparentIDto the assistant message, the same rows migrated normally, which matches your workaround.One point I couldn't reproduce:
step-startandstep-finishparts didn't produceinvalid-partwarnings in my test. Yourinvalid-partrows may come from other legacy part shapes. In #52226 they turned out to be{"type":"compaction"}markers.A caution about the workaround: deleting
migration.v1-v2to force a re-run can delete V2 history that was added after the first migration (see #49641 and #52226). Anyone trying it should back upopencode.dbfirst.Related: #52226 has the same root cause (the required
agentfield on v1.0.x assistant messages). It was auto-closed only because it didn't follow the issue template. #42671 also covers undecodable rows reporting no field detail.John-Rood commentedon Oct 8, 2026
The report leaves the enrichment implicit, so here is the full repair path. I tested it against a copy of a local V1 database (73 sessions, 447 messages) by stripping the three fields from every row and running the enrichment below: it restores each value to exactly what the V1 writer had produced, field by field, and a second run changes nothing.
Stop opencode first and wait for the process to actually exit (a stop can take a few minutes to drain; nothing should write to the database while it is being edited), then work from the backup:
sqlite3 ~/.local/share/opencode/opencode.db < repair.sql. Every statement only touches rows where the field is missing, so it is safe to run twice.Two derivation notes, both checked against the schema and a real database:
agentis the pre-rename name ofmode(#52226 auditedagent == modeon all 177,699 rows that carry both, and the same relation holds on all 374 assistant rows in my test copy), and the user-side values come from the reply that handled the turn, falling back to the session row when there is no reply. If you hand-roll themodeledit withjson_set, wrap the object injson(...)or SQLite stores it double-encoded as a string, which validation rejects.A delete also triggers a re-run, but from a cleared state the importer purges the entire
eventtable before it copies anything, and the reset form skips that purge (#49641 verified the reset as a zero-loss repair). Start opencode again (or run a throwawayopencode serveon another port against the same DB) and wait for the marker to return to{"phase":"completed"}. The run is cursor-based, so it is safe to interrupt.session_messageper session, so turns appended from V2 are dropped. Candidate rows:Most hits are projection rows the re-run regenerates; a session only needs protecting if it holds real turns you added under V2. #52226 has the move-aside-and-restore SQL for those.
Zero means no session with V1 messages is still missing its projection. Re-run the step 1 audit after; anything the enrichment could not resolve has no derivable source (no reply anywhere in the session, no earlier user message, no session model) and needs a manual value.
argszero commentedon Oct 8, 2026
I'd like to work on this.
The shape I have in mind keeps the V1 schema strict and puts the compatibility shim in the migrator, immediately before the decode at
packages/core/src/database/v1-migration.bun.ts:218:agenton every message, andmodel: { providerID, modelID }on user rows, defaulting to the session's ownagent/model—SessionV1.SessionInfoalready carries both as optional — and falling back to the neighbouring assistant row'smodeand top-levelmodelID/providerIDwhen the session row is empty too;parentIDon an assistant row from the preceding user row in the same session, the same orderingtransformSessionalready relies on when it sorts bytime_created;invalid-messagewarning, which today carries only the message id.That should turn the
invalid-messagedrops for the <=1.0.65 rows into migrated history without touchingpackages/schemaor the generated clients. I'd cover it with a case inpackages/core/test/v1-migration.test.tsbuilt from that era's row shape (user:role+timeonly; assistant:mode/modelID/providerID, noagent/parentID).One heads-up:
#53746also editsv1-migration.bun.ts, so I'll rebase if it lands first.skquievreux commentedon Oct 21, 2026
Second independent repair, same numbers class, on a much larger database — and a data point for the
event-purge caution above.Environment: opencode 2.1.2, Windows,
opencode.db~1.6 GB, 318 V1 sessions.Before: 32,573 V1 message rows, 31,395
session_messagerows, 9,330 assistant rows withagent IS NULL(all 9,330 carrymode: build 8121 / plan 665 / explore 514 / general 30), zero user rows withoutmodel, zero assistant rows withoutparentID. Marker{"phase":"completed"}.After setting
agentfrommode, deleting the marker and restarting:session_message31,395 → 40,847,agent IS NULL→ 0, sessions without any assistant row: 127 → 1. Sample sessionses_29790b6c9ffeu67nLM0EU5zTdTwent from 55 entries (prompts and synthetic notes only) to 747 assistant + 50 user + 5 compaction + 5 synthetic.On the
eventcaution: I used the delete form, andeventwas empty both before and in the pre-repair backup (0 rows), so nothing was purged and nothing was lost on this install. Worth knowing that the reset form is the safer default for others — it would have made this a non-question.Cost of the rebuild, measured: 257 V2-only rows in 185 V1-origin sessions, all synthetic "The available tools have changed…" notices, none of them real turns; they regenerate automatically. Also worth stating as a precondition —
max(time_updated)onsessionequalled the migration date here, i.e. no V1-origin session had been resumed under V2, so the per-sessionDELETEcould not drop anything real. That check (the query in step 4 above) is the one that decides whether the destructive rebuild is safe on a given install.Full before/after write-up with the acceptance numbers is in the PR thread: #53996 (comment) — I kept the pre-repair backup and can run a build against it.
John-Rood commentedon Oct 21, 2026
The purge is narrower than my step 3 implied, and the source says why.
run()enters the clearing branch only when themigration.v1-v2row is absent at start: that branch is the batchedDELETE FROM eventfollowed by writing the row back with phasesessions. Reset the row and the branch is never entered, so the two forms differ by exactly that block and nothing else. Youreventwas empty for the ordinary reason. The bus only persists events when a caller asks (persistdefaults false inbus.ts), and at first migration nothing has asked yet, so the table is normally empty there.Which means the delete form cannot cost V1 history. What it can drop is whatever the running process wrote into
eventafter the first migration, which is newer than the rows being repaired. That is the argument for the pre-repair backup, and for the reset form being the default.Your
max(time_updated)check and the step 4 orphan query answer different questions. The first is cheap and rules out the common case. The orphan count is exact and is what decides the rebuild, as you said. Keep both, run the exact one last.Last session, the one your step 5 query still returns: dump it before calling it a loss, substituting its id.
If every surviving row is a user prompt there is no assistant row to project and the result is correct as it stands. If assistant rows are there, the group with a flag set names the field the importer still rejects, and the split across flag combinations tells you whether it is a derivable gap or a row with no source to derive from.