Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -486,7 +487,7 @@ export type Patch = Omit<Partial<Info>, "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* () {
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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"
37 changes: 36 additions & 1 deletion packages/opencode/test/session/session.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -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(
Expand All @@ -25,6 +27,7 @@ const it = testEffect(
SessionProjector.node,
CrossSpawnSpawner.node,
InstanceStore.node,
Project.node,
]),
[
[RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })],
Expand Down Expand Up @@ -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 },
)
})
Loading