Skip to content

V1→V2 migration silently drops legacy messages missing required fields (agent/model/parentID) — old sessions show empty history #53971

Open

Description

@fede-ciliberti

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: string and parentID: MessageID (both non-optional).
  • User: requires agent: string and model: { 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:

  1. Add the missing fields: agent on every message; model: {providerID, modelID} on user messages; parentID (→ preceding user message id) on assistant messages.
  2. DELETE FROM kv WHERE key='migration.v1-v2';
  3. 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.

Related

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

  1. opencode-agent commented on Oct 8, 2026

    @opencode-agent
    Contributor

    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.

  2. John-Rood commented on Oct 8, 2026

    @John-Rood

    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:

    cp ~/.local/share/opencode/opencode.db ~/.local/share/opencode/opencode.db.pre-repair
    1. Audit what the importer drops today.
    SELECT 'assistant missing agent' AS gap, count(*) FROM message WHERE json_extract(data,'$.role')='assistant' AND json_extract(data,'$.agent') IS NULL
    UNION ALL SELECT 'assistant missing parentID', count(*) FROM message WHERE json_extract(data,'$.role')='assistant' AND json_extract(data,'$.parentID') IS NULL
    UNION ALL SELECT 'user missing agent', count(*) FROM message WHERE json_extract(data,'$.role')='user' AND json_extract(data,'$.agent') IS NULL
    UNION ALL SELECT 'user missing model', count(*) FROM message WHERE json_extract(data,'$.role')='user' AND json_extract(data,'$.model') IS NULL;
    1. 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 mode
    UPDATE message
    SET data = json_set(data, '$.agent',
      COALESCE(json_extract(data, '$.mode'),
               (SELECT s.agent FROM session s WHERE s.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 session
    UPDATE message
    SET data = json_set(data, '$.parentID',
      (SELECT u.id FROM message u
        WHERE u.session_id = message.session_id
          AND json_extract(u.data, '$.role') = 'user'
          AND (u.time_created < message.time_created
               OR (u.time_created = message.time_created AND u.id < message.id))
        ORDER BY u.time_created DESC, u.id DESC
        LIMIT 1))
    WHERE json_extract(data, '$.role') = 'assistant'
      AND json_extract(data, '$.parentID') IS NULL
      AND EXISTS (
        SELECT 1 FROM message u
        WHERE u.session_id = message.session_id
          AND json_extract(u.data, '$.role') = 'user'
          AND (u.time_created < message.time_created
               OR (u.time_created = message.time_created AND u.id < message.id)));
    
    -- user messages: agent comes from the assistant reply that handled the turn
    UPDATE message
    SET data = json_set(data, '$.agent',
      COALESCE(
        (SELECT json_extract(a.data, '$.agent') FROM message a
          WHERE a.session_id = message.session_id
            AND json_extract(a.data, '$.role') = 'assistant'
            AND json_extract(a.data, '$.agent') IS NOT NULL
            AND (a.time_created > message.time_created
                 OR (a.time_created = message.time_created AND a.id > message.id))
          ORDER BY a.time_created ASC, a.id ASC LIMIT 1),
        (SELECT json_extract(a.data, '$.agent') FROM message a
          WHERE a.session_id = message.session_id
            AND json_extract(a.data, '$.role') = 'assistant'
            AND json_extract(a.data, '$.agent') IS NOT NULL
            AND (a.time_created < message.time_created
                 OR (a.time_created = message.time_created AND a.id < message.id))
          ORDER BY a.time_created DESC, a.id DESC LIMIT 1),
        (SELECT s.agent FROM session s WHERE s.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 reply
    UPDATE 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
          WHERE a.session_id = message.session_id
            AND json_extract(a.data, '$.role') = 'assistant'
            AND json_extract(a.data, '$.providerID') IS NOT NULL
            AND json_extract(a.data, '$.modelID') IS NOT NULL
            AND (a.time_created > message.time_created
                 OR (a.time_created = message.time_created AND a.id > message.id))
          ORDER BY a.time_created ASC, a.id ASC LIMIT 1)),
        json((SELECT json_object('providerID', json_extract(a.data, '$.providerID'),
                                 'modelID',    json_extract(a.data, '$.modelID'))
           FROM message a
          WHERE a.session_id = message.session_id
            AND json_extract(a.data, '$.role') = 'assistant'
            AND json_extract(a.data, '$.providerID') IS NOT NULL
            AND json_extract(a.data, '$.modelID') IS NOT NULL
            AND (a.time_created < message.time_created
                 OR (a.time_created = message.time_created AND a.id < message.id))
          ORDER BY a.time_created DESC, a.id DESC LIMIT 1)),
        json((SELECT json_object('providerID', json_extract(s.model, '$.providerID'),
                                 'modelID',    json_extract(s.model, '$.id'))
           FROM session s
          WHERE s.id = message.session_id
            AND s.model IS NOT NULL
            AND json_extract(s.model, '$.providerID') IS NOT NULL
            AND json_extract(s.model, '$.id') IS NOT NULL))))
    WHERE json_extract(data, '$.role') = 'user'
      AND json_extract(data, '$.model') IS NULL
      AND ((SELECT s.model FROM session s WHERE s.id = message.session_id) IS NOT NULL
           OR EXISTS (SELECT 1 FROM message a
                      WHERE a.session_id = message.session_id
                        AND json_extract(a.data, '$.role') = 'assistant'
                        AND json_extract(a.data, '$.providerID') IS NOT NULL
                        AND 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.

    1. 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') AS INTEGER)*1000,
            CAST(strftime('%s','now') AS INTEGER)*1000)
    ON CONFLICT(key) DO UPDATE SET 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.

    1. 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:
    SELECT sm.session_id, count(*) AS rows_not_from_v1
    FROM session_message sm LEFT JOIN message m ON m.id = sm.id
    WHERE m.id IS NULL GROUP BY sm.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.

    1. Verify.
    SELECT count(*) FROM session s
    WHERE EXISTS (SELECT 1 FROM message m WHERE m.session_id = s.id)
      AND NOT EXISTS (SELECT 1 FROM session_message sm WHERE sm.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.

  3. argszero commented on Oct 8, 2026

    @argszero
    Contributor

    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:

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

  4. skquievreux commented on Oct 21, 2026

    @skquievreux

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

  5. John-Rood commented on Oct 21, 2026

    @John-Rood

    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 NULL AS no_agent,
           json_extract(data,'$.model') IS NULL AS no_model,
           json_extract(data,'$.parentID') IS NULL AS no_parent,
           count(*) AS rows
    FROM message WHERE session_id = '<the id step 5 returns>'
    GROUP BY 1,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.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions