You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
V1→V2 migration silently drops legacy messages missing required fields (agent/model/parentID) — old sessions show empty history #53971
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 zerosession_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: string and parentID: MessageID (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.
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: agent on 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-finish parts are also logged as invalid-part even though they are intentionally filtered by the mapper — the warning is misleading.
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 role and time. The assistant message had modelID, providerID, mode, path, cost and tokens, but no agent or parentID. Both messages were dropped and nothing was migrated. Each produced an invalid-message warning that gives only the message ID, not the field that failed. When I added agent and model to the user message and agent and parentID to the assistant message, the same rows migrated normally, which matches your workaround.
One point I couldn't reproduce: step-start and step-finish parts didn't produce invalid-part warnings in my test. Your invalid-part rows 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-v2 to force a re-run can delete V2 history that was added after the first migration (see #49641 and #52226). Anyone trying it should back up opencode.db first.
Related: #52226 has the same root cause (the required agent field 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.
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:
SELECT'assistant missing agent'AS gap, count(*) FROM message WHERE json_extract(data,'$.role')='assistant'AND json_extract(data,'$.agent') IS NULLUNION ALLSELECT'assistant missing parentID', count(*) FROM message WHERE json_extract(data,'$.role')='assistant'AND json_extract(data,'$.parentID') IS NULLUNION ALLSELECT'user missing agent', count(*) FROM message WHERE json_extract(data,'$.role')='user'AND json_extract(data,'$.agent') IS NULLUNION ALLSELECT'user missing model', count(*) FROM message WHERE json_extract(data,'$.role')='user'AND json_extract(data,'$.model') IS NULL;
Enrich. Save the block below as repair.sql and run 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.
-- assistant messages: agent is the pre-rename name of modeUPDATE message
SET data = json_set(data, '$.agent',
COALESCE(json_extract(data, '$.mode'),
(SELECTs.agentFROM session s WHEREs.id=message.session_id),
'build'))
WHERE json_extract(data, '$.role') ='assistant'AND json_extract(data, '$.agent') IS NULL;
-- assistant messages: parentID resolves to the nearest earlier user message in the same sessionUPDATE message
SET data = json_set(data, '$.parentID',
(SELECTu.idFROM message u
WHEREu.session_id=message.session_idAND json_extract(u.data, '$.role') ='user'AND (u.time_created<message.time_createdOR (u.time_created=message.time_createdANDu.id<message.id))
ORDER BYu.time_createdDESC, u.idDESCLIMIT1))
WHERE json_extract(data, '$.role') ='assistant'AND json_extract(data, '$.parentID') IS NULLAND EXISTS (
SELECT1FROM message u
WHEREu.session_id=message.session_idAND json_extract(u.data, '$.role') ='user'AND (u.time_created<message.time_createdOR (u.time_created=message.time_createdANDu.id<message.id)));
-- user messages: agent comes from the assistant reply that handled the turnUPDATE message
SET data = json_set(data, '$.agent',
COALESCE(
(SELECT json_extract(a.data, '$.agent') FROM message a
WHEREa.session_id=message.session_idAND json_extract(a.data, '$.role') ='assistant'AND json_extract(a.data, '$.agent') IS NOT NULLAND (a.time_created>message.time_createdOR (a.time_created=message.time_createdANDa.id>message.id))
ORDER BYa.time_createdASC, a.idASCLIMIT1),
(SELECT json_extract(a.data, '$.agent') FROM message a
WHEREa.session_id=message.session_idAND json_extract(a.data, '$.role') ='assistant'AND json_extract(a.data, '$.agent') IS NOT NULLAND (a.time_created<message.time_createdOR (a.time_created=message.time_createdANDa.id<message.id))
ORDER BYa.time_createdDESC, a.idDESCLIMIT1),
(SELECTs.agentFROM session s WHEREs.id=message.session_id),
'build'))
WHERE json_extract(data, '$.role') ='user'AND json_extract(data, '$.agent') IS NULL;
-- user messages: model comes from the same replyUPDATE message
SET data = json_set(data, '$.model',
COALESCE(
json((SELECT json_object('providerID', json_extract(a.data, '$.providerID'),
'modelID', json_extract(a.data, '$.modelID'))
FROM message a
WHEREa.session_id=message.session_idAND json_extract(a.data, '$.role') ='assistant'AND json_extract(a.data, '$.providerID') IS NOT NULLAND json_extract(a.data, '$.modelID') IS NOT NULLAND (a.time_created>message.time_createdOR (a.time_created=message.time_createdANDa.id>message.id))
ORDER BYa.time_createdASC, a.idASCLIMIT1)),
json((SELECT json_object('providerID', json_extract(a.data, '$.providerID'),
'modelID', json_extract(a.data, '$.modelID'))
FROM message a
WHEREa.session_id=message.session_idAND json_extract(a.data, '$.role') ='assistant'AND json_extract(a.data, '$.providerID') IS NOT NULLAND json_extract(a.data, '$.modelID') IS NOT NULLAND (a.time_created<message.time_createdOR (a.time_created=message.time_createdANDa.id<message.id))
ORDER BYa.time_createdDESC, a.idDESCLIMIT1)),
json((SELECT json_object('providerID', json_extract(s.model, '$.providerID'),
'modelID', json_extract(s.model, '$.id'))
FROM session s
WHEREs.id=message.session_idANDs.modelIS NOT NULLAND json_extract(s.model, '$.providerID') IS NOT NULLAND json_extract(s.model, '$.id') IS NOT NULL))))
WHERE json_extract(data, '$.role') ='user'AND json_extract(data, '$.model') IS NULLAND ((SELECTs.modelFROM session s WHEREs.id=message.session_id) IS NOT NULLOR EXISTS (SELECT1FROM message a
WHEREa.session_id=message.session_idAND json_extract(a.data, '$.role') ='assistant'AND json_extract(a.data, '$.providerID') IS NOT NULLAND json_extract(a.data, '$.modelID') IS NOT NULL));
Two derivation notes, both checked against the schema and a real database: agent is the pre-rename name of mode (#52226 audited agent == mode on 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 the model edit with json_set, wrap the object in json(...) or SQLite stores it double-encoded as a string, which validation rejects.
Force the re-run by resetting the marker, not deleting it.
INSERT INTO kv (key, value, time_created, time_updated)
VALUES ('migration.v1-v2', '{"phase":"sessions"}',
CAST(strftime('%s','now') ASINTEGER)*1000,
CAST(strftime('%s','now') ASINTEGER)*1000)
ON CONFLICT(key) DO UPDATESET value =excluded.value, time_updated =excluded.time_updated;
A delete also triggers a re-run, but from a cleared state the importer purges the entire event table 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 throwaway opencode serve on 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.
If any session was continued under V2 after the first migration, protect it before the re-run. The rebuild does an unconditional delete and re-insert of session_message per session, so turns appended from V2 are dropped. Candidate rows:
SELECTsm.session_id, count(*) AS rows_not_from_v1
FROM session_message sm LEFT JOIN message m ONm.id=sm.idWHEREm.id IS NULLGROUP BYsm.session_id;
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.
Verify.
SELECTcount(*) FROM session s
WHERE EXISTS (SELECT1FROM message m WHEREm.session_id=s.id)
AND NOT EXISTS (SELECT1FROM session_message sm WHEREsm.session_id=s.id);
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.
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:
backfill the metadata those rows never wrote: agent on every message, and model: { providerID, modelID } on user rows, defaulting to the session's own agent/model — SessionV1.SessionInfo already carries both as optional — and falling back to the neighbouring assistant row's mode and top-level modelID/providerID when the session row is empty too;
set parentID on an assistant row from the preceding user row in the same session, the same ordering transformSession already relies on when it sorts by time_created;
include the offending field on the invalid-message warning, which today carries only the message id.
That should turn the invalid-message drops for the <=1.0.65 rows into migrated history without touching packages/schema or the generated clients. I'd cover it with a case in packages/core/test/v1-migration.test.ts built from that era's row shape (user: role + time only; assistant: mode/modelID/providerID, no agent/parentID).
One heads-up: #53746 also edits v1-migration.bun.ts, so I'll rebase if it lands first.
Before: 32,573 V1 message rows, 31,395 session_message rows, 9,330 assistant rows with agent IS NULL (all 9,330 carry mode: build 8121 / plan 665 / explore 514 / general 30), zero user rows without model, zero assistant rows without parentID. Marker {"phase":"completed"}.
After setting agent from mode, deleting the marker and restarting: session_message 31,395 → 40,847, agent IS NULL → 0, sessions without any assistant row: 127 → 1. Sample session ses_29790b6c9ffeu67nLM0EU5zTdT went from 55 entries (prompts and synthetic notes only) to 747 assistant + 50 user + 5 compaction + 5 synthetic.
On the event caution: I used the delete form, and event was 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) on session equalled the migration date here, i.e. no V1-origin session had been resumed under V2, so the per-session DELETE could 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.
The purge is narrower than my step 3 implied, and the source says why. run() enters the clearing branch only when the migration.v1-v2 row is absent at start: that branch is the batched DELETE FROM event followed by writing the row back with phase sessions. Reset the row and the branch is never entered, so the two forms differ by exactly that block and nothing else. Your event was empty for the ordinary reason. The bus only persists events when a caller asks (persist defaults false in bus.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 event after 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.
SELECT json_extract(data,'$.role') AS role,
json_extract(data,'$.agent') IS NULLAS no_agent,
json_extract(data,'$.model') IS NULLAS no_model,
json_extract(data,'$.parentID') IS NULLAS no_parent,
count(*) AS rows
FROM message WHERE session_id ='<the id step 5 returns>'GROUP BY1,2,3,4;
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.
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.