From 3dfdd9dce9c0dade5f27922c19f39d77b8978625 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 5 Aug 2026 18:29:17 -0400 Subject: [PATCH] fix(core): reassign orphaned sessions --- docs/design/v1-v2-database-migration.md | 10 ++-- packages/core/src/database/v1-migration.ts | 58 +++++++++++----------- packages/core/test/v1-migration.test.ts | 14 ++++-- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/docs/design/v1-v2-database-migration.md b/docs/design/v1-v2-database-migration.md index 9d8be886076..f3d5b4e4adc 100644 --- a/docs/design/v1-v2-database-migration.md +++ b/docs/design/v1-v2-database-migration.md @@ -269,8 +269,7 @@ Before transforming V1 rows, look for `opencode-next.db` in the data directory. builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions. Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its -maximum message sequence. Commit each imported Session independently and leave the source database untouched. Skip and -warn for beta Sessions whose referenced project row is missing rather than blocking the remaining migration. +maximum message sequence. Commit each imported Session independently and leave the source database untouched. The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor: the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed. @@ -289,9 +288,10 @@ session-level backfills, `event_sequence` watermark, and cursor update. If inter rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored cursor. Mark the migration complete after the final session and return immediately on later calls. -Process every `session` row, including archived, root, child, and empty sessions, as well as sessions whose messages are -all skipped or internal. Skip and warn for V1 Sessions whose referenced project row is missing. Each successfully -committed or skipped session advances the cursor. +Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session` +row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal. +Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each +successfully committed session advances the cursor. ## Testing diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts index 4b07285b4e5..f024ce38574 100644 --- a/packages/core/src/database/v1-migration.ts +++ b/packages/core/src/database/v1-migration.ts @@ -13,6 +13,7 @@ import { Global } from "@opencode-ai/util/global" import { existsSync } from "node:fs" import path from "node:path" import type { Database as SQLiteDatabase } from "bun:sqlite" +import { Project } from "@opencode-ai/schema/project" export type SourceMessage = { readonly id: string @@ -458,7 +459,13 @@ export function run(options: Options = {}): Effect.Effect(sql`SELECT id FROM project`)).map((project) => project.id)) while (true) { const cursor = yield* db .select({ value: KVTable.value }) @@ -467,10 +474,10 @@ export function run(options: Options = {}): Effect.Effect( + const nextID = yield* db.get<{ id: string; project_id: string }>( cursorValue === undefined - ? sql`SELECT id FROM session ORDER BY id DESC LIMIT 1` - : sql`SELECT id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`, + ? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1` + : sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`, ) if (!nextID) break yield* db @@ -481,18 +488,12 @@ export function run(options: Options = {}): Effect.Effect( - sql`SELECT project_id FROM session WHERE id = ${nextID.id}`, - ) - if (!source) return yield* Effect.die(new Error(`Missing V1 session ${nextID.id}`)) - const project = yield* tx.get<{ id: string }>(sql`SELECT id FROM project WHERE id = ${source.project_id}`) - if (!project) { - yield* Effect.logWarning("Skipped V1 session with missing project", { + const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global + if (projectID !== nextID.project_id) + yield* Effect.logWarning("Reassigned V1 session with missing project", { sessionID: nextID.id, - projectID: source.project_id, + projectID: nextID.project_id, }) - return - } yield* tx.run(sql` INSERT OR IGNORE INTO session_v2 ( id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url, @@ -501,7 +502,7 @@ export function run(options: Options = {}): Effect.Effect( @@ -647,16 +646,17 @@ function importNextDatabase( yield* db .transaction((tx) => Effect.gen(function* () { - yield* tx.run(sql` - INSERT OR IGNORE INTO project ( - id, worktree, vcs, name, icon_url, icon_url_override, icon_color, - time_created, time_updated, time_initialized, sandboxes, commands - ) VALUES ( - ${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url}, - ${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated}, - ${project.time_initialized}, ${project.sandboxes}, ${project.commands} - ) - `) + if (project) + yield* tx.run(sql` + INSERT OR IGNORE INTO project ( + id, worktree, vcs, name, icon_url, icon_url_override, icon_color, + time_created, time_updated, time_initialized, sandboxes, commands + ) VALUES ( + ${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url}, + ${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated}, + ${project.time_initialized}, ${project.sandboxes}, ${project.commands} + ) + `) const existing = yield* tx .select({ id: SessionTable.id }) .from(SessionTable) @@ -671,7 +671,7 @@ function importNextDatabase( tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived, time_suspended ) VALUES ( - ${session.id}, ${session.project_id}, ${session.workspace_id}, ${session.parent_id}, + ${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id}, ${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory}, ${session.path}, ${session.title}, ${session.version}, ${session.share_url}, ${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files}, diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index fa7c3b2dcf8..896535ef40a 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -10,6 +10,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" +import { Global } from "@opencode-ai/util/global" import { Effect, Logger, Schema } from "effect" import { eq, sql } from "drizzle-orm" import type { SqlClient } from "effect/unstable/sql/SqlClient" @@ -890,7 +891,9 @@ describe("V1Migration database workflow", () => { expect(yield* db.all(sql`SELECT id FROM session_message WHERE session_id = 'ses_existing'`)).toEqual([ { id: "msg_current_existing" }, ]) - expect(yield* db.get(sql`SELECT id FROM session_v2 WHERE id = 'ses_orphan'`)).toBeUndefined() + expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({ + project_id: "global", + }) expect(yield* db.get(sql`SELECT name, worktree FROM project WHERE id = 'next-project'`)).toEqual({ name: "Current project", worktree: "/tmp/current", @@ -932,7 +935,7 @@ describe("V1Migration database workflow", () => { ) }) - test("skips V1 sessions whose projects are missing", async () => { + test("reassigns V1 sessions whose projects are missing to the global project", async () => { await database( Effect.gen(function* () { const { db } = yield* Database.Service @@ -941,7 +944,12 @@ describe("V1Migration database workflow", () => { ) expect(yield* V1Migration.run()).toEqual({ status: "completed" }) - expect(yield* db.get(sql`SELECT id FROM session_v2 WHERE id = 'ses_orphan'`)).toBeUndefined() + expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({ + project_id: "global", + }) + expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ + worktree: path.parse(Global.Path.data).root, + }) expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2.completed'`)).toEqual({ value: "true", })