diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..1ae5b7f0b311 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,6 +32,7 @@ import { MessageV2 } from "./message-v2" import type { InstanceContext } from "../project/instance-context" import { InstanceState } from "@/effect/instance-state" import { Snapshot } from "@/snapshot" +import { Project } from "@/project/project" import { ProjectV2 } from "@opencode-ai/core/project" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { SessionID, MessageID, PartID } from "./schema" @@ -486,7 +487,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" const layer: Layer.Layer< Service, never, - BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service + BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Project.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -495,6 +496,7 @@ const layer: Layer.Layer< const background = yield* BackgroundJob.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const projects = yield* Project.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -509,11 +511,25 @@ const layer: Layer.Layer< permission?: PermissionV1.Ruleset }) { const ctx = yield* InstanceState.context + // The instance context caches the project id resolved when the instance + // was loaded. Another opencode process can rekey the project (a git + // remote change migrates the row and deletes the previous one), leaving + // this process with a deleted project id. Verify the row still exists and + // re-resolve the directory when it does not, so a new session can never + // reference a missing project. + const projectID = (yield* db + .select({ id: ProjectTable.id }) + .from(ProjectTable) + .where(eq(ProjectTable.id, ctx.project.id)) + .get() + .pipe(Effect.orDie)) + ? ctx.project.id + : (yield* projects.fromDirectory(input.directory)).project.id const result: Info = { id: SessionID.descending(input.id), slug: Slug.create(), version: InstallationVersion, - projectID: ctx.project.id, + projectID, directory: input.directory, path: input.path, workspaceID: input.workspaceID, @@ -1010,7 +1026,7 @@ function listByProject( export const node = LayerNode.make({ service: Service, layer: layer, - deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], + deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, Project.node], }) export * as Session from "./session" diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index c4301d20401c..3cd2cb01052f 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { $ } from "bun" import { SessionV1 } from "@opencode-ai/core/v1/session" import { EventV2 } from "@opencode-ai/core/event" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -7,7 +8,7 @@ import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" @@ -16,6 +17,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" +import { Project } from "@/project/project" const it = testEffect( AppNodeBuilder.build( @@ -25,6 +27,7 @@ const it = testEffect( SessionProjector.node, CrossSpawnSpawner.node, InstanceStore.node, + Project.node, ]), [ [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })], @@ -283,3 +286,35 @@ describe("Session", () => { }), ) }) + +describe("Session.create with a stale project id", () => { + it.instance( + "uses the current project when the cached project row was deleted", + () => + Effect.gen(function* () { + const session = yield* SessionNs.Service + const project = yield* Project.Service + const instance = yield* TestInstance + const created = yield* Effect.acquireRelease(session.create({ title: "before-rekey" }), (info) => + session.remove(info.id).pipe(Effect.ignore), + ) + + // Rekey the project the way another opencode process would: adding a + // remote changes the resolved id and fromDirectory migrates the row, + // deleting the id this instance still holds. + yield* Effect.promise(() => + $`git remote add origin git@github.com:opencode-test/stale-project-id.git`.cwd(instance.directory).quiet(), + ) + const migrated = yield* project.fromDirectory(instance.directory) + expect(migrated.project.id).not.toBe(created.projectID) + + const after = yield* Effect.acquireRelease(session.create({ title: "after-rekey" }), (info) => + session.remove(info.id).pipe(Effect.ignore), + ) + + expect(after.projectID).toBe(migrated.project.id) + expect((yield* session.get(after.id)).projectID).toBe(migrated.project.id) + }), + { git: true }, + ) +})