Skip to content

fix(core): repair V1 global sessions left by completed imports - #58846

Open
rekram1-node wants to merge 1 commit into
devfrom
v1-global-repair
Open

rekram1-node wants to merge 1 commit into
devfrom
v1-global-repair

Conversation

@rekram1-node

@rekram1-node rekram1-node commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Issue for this PR

Fixes #49827 for people who already upgraded.

Type of change

  • Bug fix

What does this PR do?

#56322 attributes V1 global sessions to their folder's project during the import, but only for imports that hadn't finished. Anyone who already upgraded still has those sessions under global, where the per-folder session lists can't find them.

This adds a one-time repair that runs in the background on server start, after the import:

if the import is completed and the repair hasn't run:
    for each folder of V2 sessions still under global that came from V1:
        look up the folder's V2 project (same lookup as the import: 5s limit, stop after a timeout)
        nothing found, or it's global again -> skip
        one transaction:
            insert the project row if missing (never replace)
            set project_id + path, only where the session is still global; time_updated is kept
    mark the repair done
  • Only touches session_v2 rows that are still global, have no workspace, and also exist in the V1 session table. Sessions created in V2 are left alone.
  • Lookups run outside transactions, and each folder is its own short transaction. If it stops partway, finished folders stay fixed and the next start does the rest.
  • If the repair hits a database error it logs a warning, leaves the migration status alone, and retries next start.
  • Fresh imports mark the repair done in their completion transaction, since they already attribute sessions.
  • Undoable: the V1 session table still has the original global project ids.

Also points the orphan-session test at a missing folder inside its own temp dir instead of /tmp/orphan, which could exist on a machine.

How did you verify your code works?

  • New test: a completed import with V1 global sessions in a plain folder, a git repo subfolder, a missing folder, plus a V2-native global session. Only the first two move, time_updated is kept, and the repair is marked done. Fails without the change.
  • Ran the repair on a copy of a real already-migrated database (kv, project, session, session_v2 only): 346 sessions moved in about 100ms, 9 new projects, no other session column changed, no non-global session changed, 138 stayed global because their folders are gone.
  • bun run check passes; v1-migration and project tests pass with --rerun-each 3.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Added by the opencode bot (AI-generated).

Related: #57067

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The repair works. I built an already-upgraded database (import marked completed, V1 global sessions in an existing folder and in a missing folder, plus a V2-native global session) and ran serve plus session list from each checkout. On the base the folder's session list was empty. With this PR the V1 session moves to the folder's project with time_updated kept and the repair is marked done. The missing-folder and V2-native sessions stay under global. v1-migration tests pass (the new one fails on the base) and the packages/core type check passes.

Public API: unchanged. The only addition is an internal kv key, migration.v1-v2.global-sessions.

One thing worth fixing (inline): after a lookup timeout the repair still marks itself done, so the remaining folders are never repaired.

Related: #57067

updateProgress({ label: "Repairing V1 sessions", numerator: index, denominator: folders.length })
const resolved = yield* resolveDirectory(folder.directory)
// One hung folder (e.g. a dead network mount) usually means more; leave the rest under global.
if (Option.isNone(resolved)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After a timeout this breaks and then still writes REPAIR_STATE_KEY as completed below. So every folder after the hung one stays under global for good, even healthy local ones. Folders are processed in alphabetical order, so one dead mount early in the list can block many real projects. This also differs from the description ("the next start does the rest").

Two possible fixes:

  • return without marking done on timeout. The next start retries, and since it runs in the background a dead mount only costs ~5s per start.
  • Skip just that folder and continue.

The import can afford to give up after a timeout because it runs once anyway. This repair is the only chance to fix these sessions.

@matt8s matt8s left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two unfinished cases still receive the repair-completed marker:

  • In the completed-import repair, the timeout break reaches the marker write below it. Returning instead preserves earlier folder commits and lets the next run retry the remainder.
  • An initial import can time out, or resume past already-copied global sessions, and still mark repair complete. Letting the follow-up repair alone write its completion marker covers both cases.

The follow-up commits are 14fa58c and fa9e374, on the tested branch. Beyond this PR, the production change is one early return and removal of the importer's repair-marker write.

The eight-case synthetic regression covers completed, fresh, and resumed imports, timeouts at the first and second lookup, healthy completion, and retry/idempotence. The tests check parent-child links, timestamps, transcripts, committed ownership changes, and ownership exclusions. The new cases fail without the fixes; all 450 repeated migration tests pass with them.

On the branch merged with current dev, the full Core suite passed: 7,064 passed, 25 skipped, zero failures, with an isolated OAuth callback port. Repository-root bun run check passed all 35 tasks; Core's pinned TypeScript check and changed-file formatting also passed.

Could these follow-ups be incorporated here? The existing repair and its author attribution are preserved; this avoids opening a competing implementation.

@jimicze

jimicze commented Nov 2, 2026 •

Copy link
Copy Markdown

Tested the repair logic against a real already-migrated DB, not a synthetic one (v2.0.32 import, 17 GB, 5,241 session_v2 rows). Scope matches what the issue describes:

  • 1,071 sessions still under global, all present in the V1 session table, all workspace_id IS NULL, none with an empty directory.
  • 42 raw directories → 42 lookups; all still exist; 7 are git repos, 35 are plain directories.
  • The path column is the part that matters: 79 rows have path IS NULL and 992 carry the old V1-style value (no leading slash). Overwriting both with owner.path ('' at the project root) is required, not cosmetic: SessionStore.list adds path = subpath when listing by project + subpath (packages/core/src/session/store.ts:154), so NULL would stay hidden too. This PR handles both shapes.
  • Two raw directories differ only by case (same inode on a case-insensitive filesystem). Bun's realpathSync canonicalizes the case, so both groups resolve to the same owner and both UPDATEs are consistent. A regression case here would be cheap: two raw casings → one project id, both rows updated, path=''.

Two gaps/questions beyond the timeout marker (already flagged by the bot and @matt8s — +1 for return without marking, and dropping the importer-side marker write):

  1. The new test inserts session_v2 rows with the default path (NULL) only. Adding one row with a V1-style non-empty path would pin the overwrite-to-'' behavior that 992/1,071 of my rows depend on.
  2. workspace_id IS NOT NULL is excluded from the repair. My DB has 0 such rows, so I cannot tell whether that is intentional. Is a V1 session that got a workspace_id during import expected to stay under global?

If useful, I can run this branch against a copy of the DB and report concrete before/after counts (project_id, path, and whether session list shows all 1,071 in their directories). It is a decent real-world fixture because it has both path shapes, a case-only directory pair, git and non-git folders, and an import that completed long before #56322.

@jimicze

jimicze commented Nov 6, 2026 •

Copy link
Copy Markdown

Here is a patch on top of 2373d05757 that takes @matt8s's two fixes:
return without writing the marker on timeout, and dropping the importer-side marker,
and adds coverage for the path shapes that already-migrated databases actually contain.

New test coverage:

  • a legacy non-empty path on both a root session and a git-subdirectory session, asserting they become '' / pkg/a;
  • a second raw directory that is a symlink to the same folder, asserting both groups resolve to one owner and path='';
  • a second run() call, asserting the marker makes it a no-op and time_updated stays put.

Verified locally on packages/core: bun run test test/v1-migration.test.ts → 37 pass, bun run typecheck:tsc clean.

The code part overlaps with @matt8s's commits, so treat this as one consolidated diff rather than a competing fix. Apply with git apply from the gist: https://gist.github.com/jimicze/43a523fa38e850604b4699472e6ea67c

Patch
diff --git a/packages/core/src/database/v1-migration.bun.ts b/packages/core/src/database/v1-migration.bun.ts
index 760aa98b17..d5faf088bd 100644
--- a/packages/core/src/database/v1-migration.bun.ts
+++ b/packages/core/src/database/v1-migration.bun.ts
@@ -762,12 +762,6 @@ export function run(
                 set: { value: { phase: "completed" }, time_updated: Date.now() },
               })
               .run()
-            // This import already attributed global sessions to their folders' projects.
-            yield* tx
-              .insert(KVTable)
-              .values({ key: REPAIR_STATE_KEY, value: { phase: "completed" } })
-              .onConflictDoNothing()
-              .run()
           }),
         )
         .pipe(Effect.orDie)
@@ -792,12 +786,13 @@ function repairGlobalSessions(db: Database.Interface["db"]) {
     for (const [index, folder] of folders.entries()) {
       updateProgress({ label: "Repairing V1 sessions", numerator: index, denominator: folders.length })
       const resolved = yield* resolveDirectory(folder.directory)
-      // One hung folder (e.g. a dead network mount) usually means more; leave the rest under global.
+      // One hung folder (e.g. a dead network mount) usually means more; leave the rest under global
+      // and retry on the next start instead of marking the repair complete.
       if (Option.isNone(resolved)) {
         yield* Effect.logWarning("Stopped repairing V1 global sessions after a timeout", {
           directory: folder.directory,
         })
-        break
+        return
       }
       const owner = resolved.value
       if (!owner || owner.project.id === Project.ID.global) continue
diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts
index af05eb16e1..423e8831b6 100644
--- a/packages/core/test/v1-migration.test.ts
+++ b/packages/core/test/v1-migration.test.ts
@@ -1263,6 +1263,8 @@ describe("V1Migration database workflow", () => {
     await fs.mkdir(plain, { recursive: true })
     await fs.mkdir(path.join(repo, "pkg", "a"), { recursive: true })
     await initRepo(repo)
+    const linked = path.join(tmp.path, "plain-link")
+    await fs.symlink(plain, linked, process.platform === "win32" ? "junction" : undefined)
     await database(
       Effect.gen(function* () {
         const { db } = yield* Database.Service
@@ -1274,21 +1276,22 @@ describe("V1Migration database workflow", () => {
           sql`INSERT INTO kv (key, value, time_created, time_updated) VALUES ('migration.v1-v2', '{"phase":"completed"}', 1, 1)`,
         )
         const sessions = [
-          { id: "ses_plain", directory: plain, v1: true },
-          { id: "ses_repo", directory: path.join(repo, "pkg", "a"), v1: true },
-          { id: "ses_missing", directory: missing, v1: true },
-          { id: "ses_native", directory: plain, v1: false },
+          { id: "ses_plain", directory: plain, path: "legacy/plain", v1: true },
+          { id: "ses_linked", directory: linked, path: "legacy/linked", v1: true },
+          { id: "ses_repo", directory: path.join(repo, "pkg", "a"), path: "legacy/repo", v1: true },
+          { id: "ses_missing", directory: missing, path: "legacy/missing", v1: true },
+          { id: "ses_native", directory: plain, path: null, v1: false },
         ]
         yield* Effect.forEach(sessions, (input) =>
           Effect.gen(function* () {
             if (input.v1)
               yield* db.run(sql`
-                INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
-                VALUES (${input.id}, 'global', ${input.id}, ${input.directory}, 'Test', '1', 1, 2)
+                INSERT INTO session (id, project_id, slug, directory, path, title, version, time_created, time_updated)
+                VALUES (${input.id}, 'global', ${input.id}, ${input.directory}, ${input.path}, 'Test', '1', 1, 2)
               `)
             yield* db.run(sql`
-              INSERT INTO session_v2 (id, project_id, slug, directory, title, version, time_created, time_updated)
-              VALUES (${input.id}, 'global', ${input.id}, ${input.directory}, 'Test', '1', 1, 2)
+              INSERT INTO session_v2 (id, project_id, slug, directory, path, title, version, time_created, time_updated)
+              VALUES (${input.id}, 'global', ${input.id}, ${input.directory}, ${input.path}, 'Test', '1', 1, 2)
             `)
           }),
         )
@@ -1298,12 +1301,16 @@ describe("V1Migration database workflow", () => {
           plain: yield* projects.resolve(AbsolutePath.make(plain)),
           repo: yield* projects.resolve(AbsolutePath.make(repo)),
         }
-        expect(yield* db.all(sql`SELECT id, project_id, path, time_updated FROM session_v2 ORDER BY id`)).toEqual([
-          { id: "ses_missing", project_id: "global", path: null, time_updated: 2 },
+        const repaired = [
+          { id: "ses_linked", project_id: owners.plain.id, path: "", time_updated: 2 },
+          { id: "ses_missing", project_id: "global", path: "legacy/missing", time_updated: 2 },
           { id: "ses_native", project_id: "global", path: null, time_updated: 2 },
           { id: "ses_plain", project_id: owners.plain.id, path: "", time_updated: 2 },
           { id: "ses_repo", project_id: owners.repo.id, path: "pkg/a", time_updated: 2 },
-        ])
+        ]
+        expect(yield* db.all(sql`SELECT id, project_id, path, time_updated FROM session_v2 ORDER BY id`)).toEqual(repaired)
+        expect(yield* V1Migration.run()).toEqual({ status: "completed" })
+        expect(yield* db.all(sql`SELECT id, project_id, path, time_updated FROM session_v2 ORDER BY id`)).toEqual(repaired)
         expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2.global-sessions'`)).toEqual({
           value: '{"phase":"completed"}',
         })

@matt8s

matt8s commented Nov 6, 2026

Copy link
Copy Markdown

@jimicze I’ll incorporate your complementary legacy-path and canonical-alias coverage into the existing follow-up branch, retaining its timeout, parent/child, transcript and timestamp tests. The two production changes in your patch are already in that branch, so no competing repair implementation is needed.

The non-timeout retry gap is also present: an unresolved owner is skipped, then the repair writes its completed marker. I’ll add regression coverage and withhold that marker when any eligible folder remains unresolved, while continuing to repair healthy folders. This also retries missing directories on a later startup if they return; no background polling is added.

@rekram1-node I’m retaining the existing workspace and legacy-row eligibility safeguards. Is that still the intended scope? Expanding repair to explicit workspaces or rows without proof of V1 origin needs a separate placement/provenance rule. I’ll report validation against current dev before asking you to incorporate the follow-up.

@matt8s

matt8s commented Nov 6, 2026 •

Copy link
Copy Markdown

The follow-up is implemented in f021b04, atop the existing timeout fixes. It incorporates @jimicze’s legacy-path and alias coverage while retaining the NULL-path, parent/child, transcript and timestamp checks.

Unresolved eligible folders now withhold the repair-completed marker without blocking healthy folders in the same pass. Tests cover permission errors, failed directory checks, project-resolution defects, and a missing folder that returns on a later startup. Workspace and legacy-provenance exclusions are retained. The existing stop-on-timeout policy is unchanged; no API, schema, dependency or polling worker is added.

Hosted validation on integration commit 79145615e8123c375ace2479e844b0b5bc5497f6, merged with dev at 7a250a15397823fd9df71fb6779b43ccb0af328e:

  • Repeated migration runs: Linux 480 passes, Windows 490, macOS 486; zero failures. Alias coverage probes filesystem case sensitivity rather than assuming it from the OS.
  • Full Linux Core: 6,999 passed, 337 skipped, zero failures.
  • Core official compiler: passed on all three platforms.
  • Canonical checks: 32/32 tasks passed; Linux Core package build passed.

The source is on the existing repair-timeout branch. Could the follow-up commits be incorporated here? They preserve this PR’s original history and attribution. These results cover the stated revisions, not a newer upstream merge or the skipped tests.

Update: after three more upstream commits landed, integration 68d92746fd75bea3d9caacdfb09faa935cc17a09 against dev at 07f492b048 also passed Linux validation: 7,003 Core passes, 337 skips, zero failures; 480 repeated migration passes; official compiler, all 32 canonical tasks and Core build passed. The migration source and tests are unchanged from the three-platform run.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants