Skip to content

migrate-v1: non-git sessions in "global" project hidden after V1→V2 upgrade #49827

Description

@unneao

Summary

After upgrading from V1.18.31 to V2.0.8, sessions that V1 stored in the global project (sessions created in non-git directories) become invisible in the TUI session picker, because V2 creates a per-directory project and the picker filters to the current project. The history is still in the database, so users perceive all recent conversations as lost.

Environment

  • opencode version: 2.0.8 (upgraded from 1.18.31)
  • OS: Linux 6.18.33.2-microsoft-standard-WSL2 (WSL2), x86_64
  • Terminal: xterm-256color
  • Shell: /bin/bash
  • Install/channel: latest (curl installer replaced the V1 binary)
  • Active plugins: @wallbreakerno4/opencode-commandcode

Reproduction

  1. On V1.18.31, run opencode in a non-git directory (e.g. /home/wjf) and create several sessions. They are stored with project_id = 'global'.
  2. Upgrade to V2.0.8 so the installer replaces the V1 binary.
  3. Start V2 in the same directory (/home/wjf).
  4. V2 creates a new project keyed to /home/wjf. The session picker defaults to filtering by the current project and directory, so only sessions created after the upgrade are listed; all prior sessions are hidden.

Expected Behavior

Sessions created for the same working directory should remain visible after upgrading. V1's global sessions in a non-git directory should be migrated to that directory's V2 project (or otherwise kept visible), per the migration guide's statement that supported non-API V1 behavior stays compatible.

Actual Behavior

The sessions remain in ~/.local/share/opencode/opencode.db but are not shown in the default session list. GET /api/session returns them (they carry projectID: "global"), while the TUI picker filters client-side to projectID === current project.id && location.directory === current directory. The Ctrl+O dialog reports No matches · search all sessions with <leader>l.

Additional Context

  • Database: ~/.local/share/opencode/opencode.db, table session_v2. Before repair: 104 sessions total, 93 with project_id = 'global' (81 in /home/wjf, 8 in /tmp/opencode, 3 in /home/wjf/novel, 1 in /home/wjf/videos).
  • Sessions in a git repository were unaffected: /home/wjf/fadebreak kept a stable project id across the upgrade and stayed visible.
  • Relevant log timing: migrations ran at 2026-09-18T17:52:20Z; the new /home/wjf project row was created at 2026-09-18T17:52:21Z.
  • Workaround: Ctrl+X then L ("List all sessions") shows everything, including the global project. Inside Ctrl+O, pressing <leader>l switches to searching all sessions.
  • Repair performed locally: POST /api/session/{sessionID}/move with {"directory": "<the session's directory>"} re-bound each of the 93 global sessions to its directory project, after which they appear normally. This worked, but required knowing the sessions were hidden.
  • Likely cause: V1 used a single global project for non-git directories, while V2 derives a per-directory project id (SHA-1 of the path). No data migration re-keys V1 global sessions.

Activity

  1. veekayinhk commented on Sep 20, 2026

    @veekayinhk

    Same issue on my end and almost exactly the same setup.

  2. emsearcy commented on Sep 22, 2026

    @emsearcy

    Confirming the same symptom on the CLI side, not just the TUI picker/Ctrl+O dialog.

    Environment: opencode 2.0.12, macOS (Darwin 27.0.0, arm64), zsh, ghostty terminal, no plugins configured.

    Reproduction: Same setup — a workspace parent directory that is not itself a git repo but contains several git-repo subfolders (e.g. ~/code/myorg-workspace with ~/code/myorg-workspace/service-a, ~/code/myorg-workspace/service-b, etc. as separate repos). Historical V1 sessions were created with cwd set to the non-git parent directory itself.

    After upgrading to V2 and starting a new session in that same parent directory, opencode session list shows only the single new session — none of the ~228 historical sessions for that directory. Direct inspection of session_v2 confirms the same root cause already described here:

    sqlite3 opencode.db "select project_id, count(*) from session_v2 where directory='<parent-dir>' group by project_id;"
    
    global                                | 228
    <new-per-directory-project-id>        | 1
    

    GET /api/session?directory=<parent-dir> does return the historical sessions (confirming the data itself is intact), but the plain session list CLI subcommand — like the TUI picker — filters strictly to the project resolved for the current directory, so all the global-bucketed history is hidden by default.

    This suggests the fix (or workaround) described here should also cover the CLI session list subcommand, not only the TUI/Ctrl+O surfaces. The POST /api/session/{sessionID}/move repair mentioned above worked as a description of the underlying mechanism, but a built-in "show global-bucketed sessions for this directory" option (CLI flag and/or automatic re-key on first V2 use of a directory) would avoid needing to know about the API to recover history.

  3. wescran commented on Oct 1, 2026

    @wescran

    Still reproduces on v2.0.21 (latest), macOS arm64 (Darwin 25.6.0), upgraded from V1.18.32.

    • 53 of 121 migrated root sessions remain under project_id = 'global' (non-git dirs: 43 in ~/git, 9 in ~, 1 in another non-git subdirectory). Messages migrated fine; GET /api/session returns all of them.
    • V2 created a separate per-directory project for ~/git on first launch, so /sessions there shows only post-upgrade sessions. Git-repo projects are unaffected (same project IDs in V1 and V2).
    • Workaround: ctrl+a in /sessions and browse the / (global) project.
  4. Hpkings777 commented on Oct 11, 2026

    @Hpkings777

    Confirming this still reproduces on v2.0.26 (latest) — adding fresh, fully-verified evidence.

    Environment

    • opencode version: 2.0.26 (latest), upgraded from V1
    • OS: Linux 6.17.0-PRoot-Distro (aarch64) — Termux / PRoot-Distro on Android
    • Terminal: TERM=xterm-256color, COLORTERM=truecolor
    • Shell: /bin/bash
    • Install/channel: latest
    • Active plugins: @tarquinen/opencode-dcp, @cortexkit/opencode-magic-context, opencode-models-discovery (not required to reproduce)

    Symptom

    From /root (non-git dir), the directory-scoped TUI session picker (Ctrl+X then L, or /sessions) shows 1 session. Switching to "all projects" (ctrl+a) shows all of them. 44 legacy V1 sessions are silently hidden.

    Database evidence (~/.local/share/opencode/opencode.db)

    • session_v2: 197 rows, of which 175 keep project_id='global'.
    • /root: 45 rows — 44 with project_id='global' + 1 new V2 session with the freshly-created project cd31f2124f23b5c0556ebda23ab6bff6bdc91712.
    • Legacy session table has the same distribution → importer copied project_id verbatim.
    • Scope across the whole DB: global sessions stranded in /root (44), phorixgpa (42+11), samtron (33), exp (8), … — i.e. every non-git directory.

    Hash formula verified locally

    sha1("directory:" + dir) reproduces the V2 project id byte-for-byte:

    sha1("directory:/root") = cd31f2124f23b5c0556ebda23ab6bff6bdc91712   # == the V2 project created for /root
    

    This matches the Project.resolve analysis in #50551.

    API evidence (opencode api session.list)

    query result
    (no filter — the Ctrl+A view) 197
    directory=/root 45 (44 global + 1 new)
    project=cd31f212… 1 ← what the TUI sends for directory scope
    project=cd31f212…&subpath=root 0

    Why the TUI misses them

    The client resolves /root to its own V2 project (cd31f212…) instead of the legacy global project, so it scopes the query to { project: "cd31f212…", subpath: "" } → 1 hit, while the 44 legacy rows live under { project: "global", subpath: "root" }. The V1→V2 importer created the per-directory project rows but never re-homed the global sessions.

    Workaround

    POST /api/session/{sessionID}/move with the session's own directory re-resolves project_id and restores visibility (consistent with #51176 / #50800).

    Cross-refs: #50551 (code-level cause), #51176, #50800, #54521. Please keep this open until the importer re-keys global sessions by directory (or the list falls back to directory matching).

  5. psno commented on Oct 12, 2026

    @psno

    Confirming this on 2.0.27 with independent diagnostics, plus one edge case and a caveat about the session move workaround that may be useful for PR #51935.

    Environment

    • opencode 2.0.27 (channel latest), upgraded from V1 with the installer that replaces the V1 binary
    • Linux 5.15.167.4-microsoft-standard-WSL2 (x86_64)
    • Database ~/.local/share/opencode/opencode.db, 620 MB, WAL
    • session_v2: 308 rows total, 149 with project_id = 'global', all with workspace_id IS NULL, from 4 non-git directories:
    directory sessions
    /mnt/e/DownLoad/snodxz/Test/Hermes 91
    /mnt/e/DownLoad/snodxz/Test/Hermes/ai-grading 48
    /mnt/e/DownLoad/snodxz/Test/Hermes/pure-content 1
    /mnt/e/DownLoad/snodxz/Test/CYT/opencode 9

    Sessions from git worktrees were unaffected: two projects kept stable ids across the upgrade (85 and 73 sessions).

    Isolation

    GET /api/session?limit=500                                        -> 308   (endpoint applies no implicit scope)
    GET /api/session?project=<current project>&parentID=null&order=desc -> 1   (only the session created after the upgrade)
    GET /api/session?directory=<that directory>&parentID=null           -> includes the legacy `global` rows
    

    session move workaround (verified, with a caveat)

    POST /api/session/{sessionID}/move with {"directory": "<the session's own directory>"} re-attributes the row and it reappears in the project-scoped list:

    before: projectID="global"          location.subpath="mnt/e/DownLoad/snodxz/Test/Hermes"
    after:  projectID="89e257..."       (the directory's project)   HTTP 204
    

    Caveat worth knowing before recommending it broadly: move bumps time_updated to now, so every repaired session sorts as "today" and the original activity ordering is lost. Processing oldest-first keeps the relative order roughly intact but still places all repaired sessions after all unrepaired ones. time_created is preserved. The migration approach in #51935 does not have this side effect, which is an extra reason to prefer it.

    Subagent child sessions carry the same project_id = 'global' and were re-attributed together with their parents; parent/child project consistency held afterwards (0 mismatches).

    Edge case for attributeDirectories

    9 of the 149 rows point at a directory that no longer exists (/mnt/e/DownLoad/snodxz/Test/CYT/opencode was deleted). The move API rejects them:

    {"_tag":"InvalidRequestError","message":"Directory does not exist: /mnt/e/DownLoad/snodxz/Test/CYT/opencode"}

    Those rows can only stay under global, which matches the fallback described in #51935 — worth keeping that fallback (and its warning) rather than failing the migration for rows whose directory is gone.

  6. rodbarnard commented on Oct 12, 2026

    @rodbarnard

    Confirming the same problem on 2.0.25, with a correction to the workaround and a verification trap that cost me a wrong attempt.

    The repair needs path = '', not NULL. Re-owning the rows to the directory's project is only half of it. The listing filters on project and subpath, and subpath='' matches path = '' but does not match path IS NULL:

    GET /api/session?limit=500&parentID=null&project=<p>            -> 112
    GET /api/session?limit=500&parentID=null&project=<p>&subpath=   -> 1
    

    The single row in the second case is one that already had path = ''. So clearing path to NULL produces a confusing half-state: the session is owned by the right project but is still filtered out of the listing.

    Verification trap: opencode session list omits subpath, so it reports the sessions as restored while the TUI and sidebar still show none. Verify with the filter the UI actually uses instead:

    opencode api get "/api/session?limit=500&parentID=null&project=<p>&subpath="
    

    Repair (SQLite on macOS/Linux; back the database up first, and stop the service if you hit SQLITE_BUSY):

    -- find the project that owns the directory
    select id, worktree from project where worktree = '/path/to/dir';
    
    -- re-own its stranded sessions and set the subpath
    UPDATE session_v2 SET project_id = '<project id>', path = ''
     WHERE project_id = 'global' AND directory = '/path/to/dir';

    This restored 112 root sessions in one directory and 39 in another, from 1 and 0 respectively. Subagent rows share the parent's directory, so the same statement moves them, which matches what #51935 does.

    Stopgap without touching the database: Ctrl+X then L (session.list, "List all sessions") is unfiltered and shows everything, including rows still under global. That is a different command from Ctrl+O (open.menu), which is project-scoped and reports no matches.

    One gap, also noted on #51935: attributeDirectories selects WHERE project_id = 'global', so rows someone has already moved off global by hand are outside the repair and remain hidden unless path is corrected too.

  7. techwhispers commented on Oct 13, 2026

    @techwhispers

    Confirming on 2.0.28 (channel latest, Linux x86_64) — still present; the fix in #51935 is not released yet.

    Environment

    • opencode v2.0.28, channel latest, upgraded from V1 on 2026-10-13
    • Linux 7.2.5 (x86_64), TERM_PROGRAM=tern, TERM=xterm-256color, /bin/bash
    • Affected directories are all non-git

    Evidence (before repair)

    39 of 40 sessions kept project_id = 'global'; the only non-global row was created after the upgrade.

    query result
    GET /api/session (no filter) 40
    ?project=global 39
    ?project=<directory's project>&parentID=null 0
    ?directory=<directory> N (the legacy rows)

    So the CLI/TUI-scoped view returns nothing while the rows are present and intact, matching the report and the loose_psylocke V1→V2 migration leaving global attribution in place.

    Workaround note: session move is TUI-safe in 2.0.28

    POST /api/session/{id}/move with the session's own directory re-attributes the row, and in 2.0.28 it sets path = '' (project root). That means the repaired rows also satisfy the TUI's project=<p>&subpath= filter — not just opencode session list (which omits subpath). This avoids the path IS NULL half-state described in #54815:

    GET /api/session?project=<p>&parentID=null            -> 10   (CLI-style)
    GET /api/session?project=<p>&parentID=null&subpath=   -> 10   (TUI-style)
    

    Caveats

  8. kiwols59 commented on Oct 14, 2026

    @kiwols59

    Confirming on 2.0.28 (latest), Linux x86_64, no plugins, upgraded from 1.18.35. All affected directories are non-git. 117/117 migrated sessions sat under project_id='global'; opencode session list showed 1 in the directory and 0 in another.

    Adding the session move workaround with a TUI-safety correction and a time_updated caveat:

    sqlite3 ~/.local/share/opencode/opencode.db \
      "SELECT id || '|' || directory FROM session_v2 WHERE project_id='global';" |
    while IFS='|' read -r sid dir; do
      opencode api post "/api/session/$sid/move" -d "{\"directory\":\"$dir\"}"
    done

    After moving, 0 rows remained on global and the sessions reappeared under their directory's project.

    • TUI-safe: unlike a raw DB reassignment, session move writes path = '' for a session moved to its project root, so the subpath filter matches. Verified ?project=<p>&parentID=null and ?project=<p>&parentID=null&subpath= both return the same count (52). The path/subpath half-state noted above applies to manual project_id edits, not to this endpoint.
    • session move bumps time_updated, so sessions re-sort to "now". Snapshot time_updated first and restore it afterward if the chronological order matters.

    Also: #51935 reparents such rows on next start for already-migrated installs, which will make this workaround unnecessary.

  9. marcello-russo commented on Oct 15, 2026

    @marcello-russo

    Same root cause confirmed on 2.0.29 (Linux), with one addition: resetting project_id alone is not enough — the stale path must go too (same finding as in #50551).

    SELECT project_id, count(*) FROM session_v2 WHERE directory='<dir>' GROUP BY project_id; showed ~all history on global plus 1 session on the new per-directory project. After remapping project_id, the TUI picker still showed 1 session, because it queries project=<id>&subpath='' (dialog-session-list.tsx) while migrated rows kept the old path relative to / (e.g. home/user/project).

    Verified fix (all confirmed via the exact TUI query afterwards):

    1. UPDATE session_v2 SET project_id = (SELECT p.id FROM project p WHERE p.worktree = session_v2.directory) WHERE project_id='global' AND ... — 760 + 1988 rows across two passes (project rows appear on first TUI open per directory; IDs are random, never invent them).
    2. UPDATE session_v2 SET path='' WHERE project_id!='global' AND path!='' AND directory=(SELECT worktree FROM project WHERE id=session_v2.project_id); — 798 rows.

    Side note matching this thread: most migrated rows are subagent children (parent_id IS NOT NULL) — only 154 of 3681 are top-level, so the visible list is much shorter than raw counts suggest.

    Full guide: https://gist.github.com/marcello-russo/0b3bd4872a9eac4b0c31ebf7dc332c01

  10. kiwols59 commented on Oct 17, 2026

    @kiwols59

    Worth noting the session move API route does both steps in one call: it reparents to the directory's project and writes path = '', so the separate path UPDATE is only needed for the raw-SQL route.

    Verified on 2.0.28 after repairing 118 sessions across 4 non-git directories: all rows ended with path = '', 0 stale, 0 NULL, and ?project=<id>&parentID=null&subpath= returned the same top-level count as the unscoped CLI list.

  11. jimicze commented on Oct 18, 2026

    @jimicze

    Confirming this from #56760 — still reproduces on v2.0.32 (CLI), desktop app 1.19.3, macOS 15.2, on 2026-10-18, in a non-git directory:

    • Both tables have the same row count: session = session_v2 = 5,241, so the rows are copied but the project link is not.
    • A new project row for the directory is created on the first V2 run (c98ce852…, created 2026-10-18 12:38:22) and has 0 sessions: SELECT count(*) FROM session_v2 WHERE project_id LIKE 'c98ce852%' → 0. The 30 sessions for that directory stay under project_id = 'global'.
    • Total sessions under project_id = 'global': 1,071 (all non-git directories).
    • Same database, cross-client difference: the v1 desktop app lists those sessions, the V2 TUI shows 0. The TUI scopes the list by the resolved project id, so a fallback to directory matching would fix the symptom without changing the migration.

    Screenshots (TUI vs desktop app): TUI · desktop

  12. jimicze commented on Oct 18, 2026

    @jimicze

    Proposed fix

    Root cause is in the V1→V2 importer, packages/core/src/database/v1-migration.bun.ts (run()):

    const projects = new Set((SELECT id FROM project).map((p) => p.id))
    ...
    const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global

    V1 stored non-git sessions with project_id = 'global', and 'global' exists as a project row, so those sessions are copied as project_id = 'global'. Meanwhile V2's Project.resolve (packages/core/src/project.ts) gives a non-git directory a fresh identity:

    // packages/core/src/project.ts, resolve()
    return yield* persist({
      id: ID.make(Hash.fast(`directory:${directory}`)),
      directory,
      canonical: directory,
      vcs: undefined,
    })

    The TUI resolves the cwd to that directory-hash project and lists sessions by project_id, which has 0 rows → "No sessions available". The V1 desktop app still lists by directory/global, so it shows them.

    1. Fix the importer

    When a legacy session's project_id is global (or not present in project), assign the same per-directory id that resolve would use, and create the project row:

    const projectID =
      nextID.project_id === Project.ID.global || !projects.has(nextID.project_id)
        ? Project.ID.make(Hash.fast(`directory:${yield* resolveDirectory(directory)}`))
        : nextID.project_id
    if (projectID !== nextID.project_id) {
      yield* tx.run(sql`
        INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, time_active, sandboxes)
        VALUES (${projectID}, ${directory}, ${now}, ${now}, ${now}, '[]')
      `)
    }

    Two things must match, or the id drifts from what resolve computes at runtime:

    • Resolve the session directory the same way Project.resolve does (fs.resolve / realpath), so case and symlinks match. The observed Opencode-test vs opencode-test split comes from exactly this.
    • Share the derivation instead of duplicating it — export something like Project.directoryID(directory) and call it from both resolve and the importer, so they cannot diverge.

    2. Backfill for already-migrated databases

    The importer is one-shot: MIGRATION_STATE_KEY short-circuits to completed, and it will not re-run for anyone already on V2 (which is why this is still present on 2.0.32). Add a normal versioned migration (like ..._normalize_storage_paths) that reassigns session_v2.project_id for rows still on 'global':

    -- for each distinct directory grouped under 'global'
    --   id = directoryID(directory)
    --   INSERT OR IGNORE INTO project (...); UPDATE session_v2 SET project_id = id WHERE project_id='global' AND directory = dir

    Doing it per directory (not one blanket UPDATE) is what keeps multi-directory 'global' sessions correct.

    3. Defensive runtime fallback

    In the session list query, when the resolved project has no sessions, also include sessions whose directory matches the project worktree. This unblocks users on an already-broken DB even before the backfill ships, and it is the approach the existing runtime PRs likely take.

    4. Edge cases

    • Sessions whose directory no longer exists should still list under their project instead of being dropped.
    • Keep the migration idempotent (INSERT OR IGNORE + guarded UPDATE) so a re-run is a no-op.

    @opencode-agent — filing this here per your request on #56760.

  13. jimicze commented on Oct 18, 2026

    @jimicze

    Correction to my comment above: #51935 already does it, in this exact file, and it handles the path part I missed — it sets path to the subpath ('' at the project root) and re-runs attribution when the state is already completed. So "re-own global sessions to their directory's project and clear path" is existing work, not a new migration. My comment was a duplicate of that PR.

    Where #51935 still looks incomplete (worth checking before merge):

    1. Non-git directory with no project/worktree row yet. attributeDirectories only re-owns a session to an existing owner (SELECT ... FROM worktree UNION ... FROM project). For a non-git dir whose project row does not exist yet, there is no owner, so the session stays global — which is the exact ~/Projects/opencode-test case here. V2 creates that row lazily on first Project.resolve, so the session only reappears after the directory is opened once and opencode restarts (the completed-branch re-runs attribution). Computing the id directly — Hash.fast(\directory:${realpath}`), the same expression as the non-git branch of Project.resolve` — would fix it in one shot instead of depending on a second startup.
    2. WHERE project_id = 'global' AND workspace_id IS NULL skips sessions that have a workspace_id. Intentional? If not, those stay hidden.
    3. normalizeDirectory is a raw string compare (no realpath / no case fold). A session stored as …/Opencode-test will not match a project worktree stored as …/opencode-test, even though on case-insensitive macOS that is the same directory. Resolving to the on-disk realpath on both sides before comparing would close that.

    So: options 1 and 2 in my earlier comment are #51935; options 3 (runtime fallback) and 4 (exist-check edge case) stand on their own. The path requirement reporters found (path = '', #50551) is already in #51935 but not yet released, which is why 2.0.32 still shows the bug.

  14. fahrim commented on Oct 19, 2026

    @fahrim

    Independent confirmation on 2.0.32 (upgraded from V1 1.18.4), Linux x86_64, no plugins, non-git home directory (/root), 10 V1 sessions (8 top-level + 2 subagent children).

    Same picture as the other reports: all 10 rows sat under project_id = 'global' with path = 'root', while V2 created a new project row for the directory (cd31f212…, canonical /root) with 0 sessions. opencode session list showed only the one session created in V2.

    Repair via the supported route, same as documented here:

    opencode api post /api/session/<id>/move --data '{"directory":"/root"}'

    rebound project_id to the directory's project and wrote path = '' in one call — the subpath half-state was not hit. Verified with the TUI-style query, both return the same count:

    GET /api/session?parentID=null&project=<p>            -> 8
    GET /api/session?parentID=null&project=<p>&subpath=   -> 8
    

    One small addition to the time_updated caveat: restoring the original values works while the background service is running — no restart needed, the list shows the original dates immediately.

    UPDATE session_v2
       SET time_updated = (SELECT s.time_updated FROM session s WHERE s.id = session_v2.id)
     WHERE id IN (SELECT id FROM session WHERE version LIKE '1.%');

    (The legacy session table still holds the V1 originals; it is also the source for the time_updated values mentioned above.)

    #57145 is a duplicate of this issue and was closed in favour of it.

  15. 1 remaining item

  16. kiwols59 commented on Oct 21, 2026

    @kiwols59

    One gap worth flagging for already-affected installs.

    This thread is almost entirely people upgrading an existing V1 database. #56322's description says it only changes imports that have not completed: "Completed imports are not rerun or repaired." So it prevents the bug for future V1→V2 imports (including writing the relative path), but it leaves every database already in this state unrepaired.

    #51935 was the change that healed completed imports on the next start, and it was closed on 2026-10-20 to stay within the open-PR limit (the author notes the branch stays on the fork and can be reopened).

    So unless that existing-install repair is folded into #56322 or #51935 is reopened, the session move workaround (reparent + path = '' in one call) remains the only recovery path for installs already migrating. Worth making explicit whatever resolves this issue, so affected users aren't left waiting for a fix that only applies to new imports.

  17. joelabreurojas commented on Oct 22, 2026

    @joelabreurojas
    cp ~/.local/share/opencode/opencode.db /tmp/opencode.db.bak
    
    sqlite3 ~/.local/share/opencode/opencode.db \
      "SELECT id FROM session_v2 WHERE project_id='global';"
    
    opencode api post /api/session/<sessionID>/move --data '{"directory":"[your path]"}'
    

    After that, GET /api/session returns them and they show up in /sessions.

    Side effect: move sets time_updated to "now", so the original ordering by date is lost.

  18. rekram1-node commented on Oct 23, 2026

    @rekram1-node
    Contributor

    Reopening this. #56322 fixes the import for anyone who hasn't upgraded yet, but if you already upgraded, your sessions are still stuck under global. I'm going to add a way to repair already-migrated sessions so you don't need the move command or SQL from this thread.

    Until then the move workaround above works: opencode api post /api/session/<id>/move --data '{"directory":"<dir>"}'. Heads up that it resets each session's updated time.

    Duplicates: #50521 #50551 #50800 #50999 #51176 #51283 #52844 #52919 #53450 #53709 #54521 #54640 #54679 #54774 #54815 #55069 #55081 #55142 #55253 #55275 #55381 #55596 #55633 #55663 #55671 #56030 #56312 #56760 #57068 #57145 #57629, and #57067 is the same root cause.

  19. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I could reproduce this.

    On 2.0.8: I created three sessions with V1 1.18.31 in a non-git directory, and V1 stored them under the global project. Then I started V2 2.0.8 in the same directory. The import kept the sessions under global and created a new project for the directory. As a result, opencode session list there shows nothing, even though all three sessions are still in session_v2. The script below reproduces it.

    On the latest dev (d6867f2):

    • A fresh V1→V2 import now assigns these sessions to the directory's project, so they show up. That comes from fix(core): attribute V1 global sessions during import #56322, which is in v2.1.4 and later.
    • A database that 2.0.x already imported is not repaired. When I opened the database that 2.0.8 had imported with dev, the sessions were still under global and still hidden. So upgrading alone won't bring back history for people who already upgraded.

    Until that's handled, the workarounds you described still work:

    • Ctrl+X L ("List all sessions"), or <leader>l inside Ctrl+O, lists everything.
    • POST /api/session/{id}/move with the session's directory moves a session into the directory's project for good.
    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #49827: V1 sessions created in a non-git directory stay under project "global"
    # after the V2 import, so `opencode session list` (and the TUI picker) in that directory hides them.
    # Needs bun, curl and V2 `opencode` on PATH (bun add -g @opencode/cli@2.0.8).
    set -eu
    W=$(mktemp -d /tmp/opencode/i49827.XXXX); export HOME=$W/home; mkdir -p "$HOME" "$W/nogit" "$W/v1"
    (cd "$W/v1" && bun add opencode-ai@1.18.31 >/dev/null 2>&1)
    cd "$W/nogit"
    "$W/v1/node_modules/.bin/opencode" serve --port 4197 </dev/null >/dev/null 2>&1 & V1PID=$!
    sleep 8
    for i in 1 2 3; do curl -s -X POST localhost:4197/session -H 'content-type: application/json' -d "{\"title\":\"v1 session $i\"}" | grep -o '"projectID":"[^"]*"'; done
    kill $V1PID; sleep 1
    echo "--- V2 session list in $W/nogit (expect the 3 V1 sessions):"
    opencode session list; sleep 3; opencode session list
    bun -e 'const {Database}=require("bun:sqlite");const db=new Database(process.argv[1],{readonly:true});
    console.log(db.query("select title, project_id from session_v2").all()); console.log(db.query("select id, worktree from project").all())' "$HOME/.local/share/opencode/opencode.db"
    opencode service stop >/dev/null 2>&1 || true

    Run it: bash repro.sh

  20. jimicze commented on Oct 29, 2026

    @jimicze

    @opencodejr
    it this issue fixed by #56322 ?

  21. jimicze commented on Nov 2, 2026

    @jimicze

    #56322 is not the solution.
    same for #58846

  22. jonee-chu commented on Nov 4, 2026

    @jonee-chu

    Confirming this on V1 1.19.0 → V2 2.0.28 (macOS arm64, fresh upgrade; V1 data backed up before upgrading).

    It is not limited to a single directory. Every non-git directory I had used in V1 was affected:

    • scratch dir: 58 V1 sessions
    • articles dir: 9
    • a temp dir: 8
    • 3 more project dirs: 4 + 4 + 3
    • 3 single-session dirs

    Total 89 sessions across 9 non-git directories. Sessions in git repos were unaffected. In session_v2 every affected row still had project_id = 'global', while V2 had created a per-directory project; opencode session list filters on the project, while GET /api/session still returned the rows.

    The POST /api/session/{id}/move workaround from this thread repaired all 89 sessions with no message or fork loss. Two caveats that the planned repair path should handle:

    1. move rewrites time_updated to the repair time for every session. After moving, the whole history shows the same timestamp and the picker ordering no longer reflects real last activity. I had to restore the original time_updated values from a pre-repair DB snapshot. A built-in repair should preserve time_updated.
    2. move is per session. The repair should enumerate every row with project_id = 'global' and a non-root directory (excluding /), not only top-level sessions, so child/subagent sessions are re-attributed as well.

    Finally, #56322 only fixes fresh imports. On 2.0.28, and for databases already imported by 2.0.x, the sessions are still stranded under global, so the "repair already-migrated sessions" path mentioned above is still needed.

  23. jimicze commented on Nov 4, 2026

    @jimicze

    Confirming this on V1 1.19.0 → V2 2.0.28 (macOS arm64, fresh upgrade; V1 data backed up before upgrading).

    It is not limited to a single directory. Every non-git directory I had used in V1 was affected:

    • scratch dir: 58 V1 sessions
    • articles dir: 9
    • a temp dir: 8
    • 3 more project dirs: 4 + 4 + 3
    • 3 single-session dirs

    Total 89 sessions across 9 non-git directories. Sessions in git repos were unaffected. In session_v2 every affected row still had project_id = 'global', while V2 had created a per-directory project; opencode session list filters on the project, while GET /api/session still returned the rows.

    The POST /api/session/{id}/move workaround from this thread repaired all 89 sessions with no message or fork loss. Two caveats that the planned repair path should handle:

    1. move rewrites time_updated to the repair time for every session. After moving, the whole history shows the same timestamp and the picker ordering no longer reflects real last activity. I had to restore the original time_updated values from a pre-repair DB snapshot. A built-in repair should preserve time_updated.
    2. move is per session. The repair should enumerate every row with project_id = 'global' and a non-root directory (excluding /), not only top-level sessions, so child/subagent sessions are re-attributed as well.

    Finally, #56322 only fixes fresh imports. On 2.0.28, and for databases already imported by 2.0.x, the sessions are still stranded under global, so the "repair already-migrated sessions" path mentioned above is still needed.

    They are trying to fix it, in #58846 , but not completely again. I did some testing, and its need to fixed again, even in that PR.

  24. jimicze commented on Nov 6, 2026

    @jimicze

    Mirror of the cross-reference I added to #50130, so the two migration tickets stay distinct:

    #49827 (this issue) #50130
    Failure Session is imported, but keeps project_id='global', so per-project session lists do not find it V1 session is never imported — no row in session_v2, GET /api/session/{id} → 404
    Cause The import copies the V1 project_id as-is; V2 resolves a per-directory project instead kv['migration.v1-v2']='completed' is a permanent latch; rows that appear afterwards are never drained
    Fix #56322 (fresh imports; merged, in v2.2.0/v2.2.5) + #58846 (repair already-migrated; open) #54272 (watermark; open)
    Duplicates #56760, #50551, #52919 #58840

    Why #58846 still is not enough as the fix

    Tested against a real already-migrated DB (v2.0.32 import, 17 GB): 1,071 sessions under global across 42 directories (7 of them git repos on disk), all present in the legacy session table, 0 with a workspace_id. The repair's shape is right (project_id + path=''), but these gaps remain:

    1. The timeout path still writes the completion marker. break falls through to the REPAIR_STATE_KEY insert, so every folder after a hung one stays under global for good. Already flagged by the bot and @matt8s — +1 for return without the marker and for removing the importer-side marker write.
    2. Non-timeout failures are treated as success. resolveDirectory returns Some(undefined) for realpath/isDir failures (permission denied, transient unmount) and None only for the 5s timeout. Those folders hit continue, and the loop still marks the repair completed — a transient filesystem error permanently skips the folder. The marker should be withheld whenever any folder failed to resolve, not only on timeout.
    3. The regression test only covers path IS NULL. In this DB 79 rows have path IS NULL and 992 have the old V1-style value (no leading slash). Add a row with the V1-style value and assert it is overwritten with ''; that is the shape 992/1,071 rows depend on.
    4. Case-only directory pairs are untested. Two raw directories that differ only by case are the same inode on a case-insensitive filesystem; Bun's realpath collapses them to one owner. Both raw groups must end at the same project with path=''.
    5. workspace_id IS NOT NULL is silently excluded. This DB has 0 such rows, so that branch is unverified — is a V1 session that got a workspace_id expected to stay under global?
    6. Repair requires the legacy session table to exist and still contain the row (hasLegacySessions + EXISTS). If the V1 table was pruned after the import, V2 rows under global can never be repaired. Might be intentional, but it is a hard boundary worth stating.
    7. One-shot marker with no re-evaluation: even after a successful run, a folder that was missing or temporarily unavailable at repair time is skipped forever (overlaps 1 and 2).

    Preserving time_updated (raw SQL, no ORM bump) is the right call — that was the main casualty of the POST /api/session/{id}/move workaround.

    Real-DB numbers and the test-gap details are also in #58846: #58846 (comment)

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