Compare commits

...
5 changed files with 174 additions and 11 deletions
+4
View File
@@ -95,6 +95,10 @@ export const layer = Layer.effect(
),
)
if (result.type === "complete") return
if (result.refreshLocation) {
const moved = yield* store.get(sessionID)
if (moved) yield* locations.invalidate(moved.location)
}
return yield* drain(sessionID, false, result.continuation, promotable)
})
}
+1 -1
View File
@@ -21,7 +21,7 @@ export type Continuation = { readonly step: number }
export type DrainResult =
| { readonly type: "complete" }
| { readonly type: "moved"; readonly continuation?: Continuation }
| { readonly type: "moved"; readonly continuation?: Continuation; readonly refreshLocation?: boolean }
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
+25 -5
View File
@@ -10,10 +10,13 @@ import {
type ProviderErrorEvent,
type ToolCall,
} from "@opencode-ai/ai"
import path from "path"
import { Cause, Config, Data, Effect, Exit, Fiber, FiberMap, Layer, Option, Pull, Schedule, Stream } from "effect"
import { Database } from "../../database/database.js"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
import { Project } from "../../project.js"
import { RelativePath } from "../../schema.js"
import { QuestionTool } from "../../tool/plugin/question.js"
import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
@@ -30,6 +33,7 @@ import { Service, type Continuation } from "./index.js"
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { llmClient } from "../../effect/app-node-platform.js"
import { StepFailedError } from "../error.js"
import { toSessionError } from "../to-session-error.js"
@@ -127,6 +131,8 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const projects = yield* Project.Service
const llm = yield* LLMClient.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
@@ -192,7 +198,8 @@ const layer = Layer.effect(
force = false
continue
}
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
const moved = yield* runPendingMove(input.sessionID, "input")
if (moved) return { type: "moved" as const, ...moved }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation, promotable)
@@ -232,7 +239,8 @@ const layer = Layer.effect(
// steered compaction ends the turn instead of issuing an input-free model call.
while (true) {
if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
const moved = yield* runPendingMove(sessionID, "steer")
if (moved) return { type: "moved" as const, continuation: next, ...moved }
if (!first && !next && !(yield* SessionInbox.has(db, sessionID, "steer")))
return { type: "complete" as const }
const result = yield* runStep(sessionID, promotable, step)
@@ -680,6 +688,16 @@ const layer = Layer.effect(
Effect.gen(function* () {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (pending?.type !== "move") return false
const project = pending.payload.location.workspaceID
? undefined
: yield* projects.resolve(pending.payload.location.directory)
const subpath = project
? RelativePath.make(
path
.relative(project.directory, yield* fs.resolve(pending.payload.location.directory))
.replaceAll("\\", "/"),
)
: pending.payload.subpath
yield* modelTransport.close(sessionID)
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
@@ -688,12 +706,12 @@ const layer = Layer.effect(
{
sessionID,
location: pending.payload.location,
projectID: pending.payload.projectID,
subpath: pending.payload.subpath,
projectID: project?.id ?? pending.payload.projectID,
subpath,
},
],
])
return true
return { refreshLocation: project !== undefined && project.id !== pending.payload.projectID }
}),
)
})
@@ -732,6 +750,8 @@ export const node = makeLocationNode({
layer,
deps: [
Bus.node,
FSUtil.node,
Project.node,
llmClient,
SessionContext.node,
SessionModelRequest.node,
+44 -4
View File
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
@@ -134,6 +135,33 @@ describe("SessionExecution lifecycle", () => {
}),
)
it.effect("rebuilds a moved destination after its project identity changes", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const sessionID = Session.ID.make("ses_move_refresh")
yield* seedSessions(database, [sessionID])
const invalidated: string[] = []
const drained: string[] = []
const scope = yield* Scope.make()
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
const context = yield* buildExecution(
scope,
() =>
Effect.sync(() => {
drained.push("drained")
if (drained.length === 1) return { type: "moved" as const, refreshLocation: true }
}),
{ invalidated },
)
yield* Context.get(context, SessionExecution.Service).resume(sessionID)
expect(invalidated).toEqual(["/project"])
expect(drained).toHaveLength(2)
}),
)
it.effect("reports an idle interrupt as a no-op", () =>
Effect.gen(function* () {
const sessionID = Session.ID.make("ses_idle_cancel")
@@ -529,8 +557,10 @@ function attempts(database: Database.Service["Service"], sessionID: Session.ID)
/** Builds the local execution layer plus the restart actions against the test harness services. */
function buildExecution(
scope: Scope.Closeable,
drain: (input: Parameters<SessionRunner.Interface["drain"]>[0]) => Effect.Effect<void, SessionRunner.RunError>,
options?: SessionRestart.Options,
drain: (
input: Parameters<SessionRunner.Interface["drain"]>[0],
) => Effect.Effect<void | SessionRunner.DrainResult, SessionRunner.RunError>,
options?: SessionRestart.Options & { readonly invalidated?: string[] },
) {
return Effect.gen(function* () {
const database = yield* Database.Service
@@ -538,15 +568,25 @@ function buildExecution(
const store = yield* SessionStore.Service
const runner = Layer.succeed(
SessionRunner.Service,
SessionRunner.Service.of({ drain: (input) => drain(input).pipe(Effect.as({ type: "complete" as const })) }),
SessionRunner.Service.of({
drain: (input) => drain(input).pipe(Effect.map((result) => result ?? { type: "complete" as const })),
}),
)
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
(_location: Location.Ref) =>
// The local execution test only needs the Session runner from the Location graph.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
runner as unknown as Layer.Layer<LocationServices>,
).pipe(
Effect.map((map) => ({
...map,
invalidate: (location: Parameters<typeof map.invalidate>[0]) =>
map
.invalidate(location)
.pipe(Effect.tap(() => Effect.sync(() => options?.invalidated?.push(location.directory)))),
})),
),
)
return yield* Layer.buildWithScope(
+100 -1
View File
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import {
AIError,
LLMEvent,
@@ -28,7 +30,7 @@ import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Form } from "@opencode-ai/core/form"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -39,6 +41,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
import { Money } from "@opencode-ai/schema/money"
import { Workspace } from "@opencode-ai/schema/workspace"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
@@ -81,6 +84,8 @@ import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { git } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { permissionLayer } from "./lib/permission"
import { agentHost, catalogHost, host } from "./plugin/host"
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
@@ -440,6 +445,7 @@ const it = testEffect(
LayerNode.group([
Database.node,
Bus.node,
Project.node,
Form.node,
SessionProjector.node,
SessionStore.node,
@@ -1481,6 +1487,99 @@ describe("SessionRunnerLLM", () => {
}),
)
it.live("resolves a queued move's destination project when it is delivered", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const projects = yield* Project.Service
const db = (yield* Database.Service).db
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(directory) => Effect.promise(() => directory[Symbol.asyncDispose]()),
)
const root = path.join(tmp.path, "repository")
const alias = path.join(tmp.path, "alias")
const destination = AbsolutePath.make(path.join(alias, "packages", "app"))
yield* Effect.promise(() => fs.mkdir(path.join(root, "packages", "app"), { recursive: true }))
yield* Effect.promise(() => fs.symlink(root, alias, process.platform === "win32" ? "junction" : "dir"))
const previous = yield* projects.resolve(destination)
yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: destination }),
projectID: previous.id,
subpath: RelativePath.make(""),
},
delivery: "queue",
},
})
yield* Effect.promise(() => git(root, "init"))
yield* Effect.promise(() =>
git(
root,
"-c",
"user.name=Test",
"-c",
"user.email=test@opencode.test",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"root",
),
)
expect(yield* session.inbox(sessionID)).toMatchObject([{ payload: { projectID: previous.id } }])
yield* session.resume(sessionID)
const moved = yield* session.get(sessionID)
expect(moved).toMatchObject({
location: { directory: destination },
subpath: "packages/app",
})
expect(moved.projectID).not.toBe(previous.id)
expect(moved.projectID).toBe((yield* projects.resolve(destination)).id)
expect(yield* session.inbox(sessionID)).toEqual([])
}),
)
it.effect("preserves the admitted project when moving to an explicit workspace", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({
directory: AbsolutePath.make("/project"),
workspaceID: Workspace.ID.make("wrk_remote"),
}),
projectID: Project.ID.global,
subpath: RelativePath.make("remote/path"),
},
delivery: "queue",
},
})
yield* session.resume(sessionID)
expect(yield* session.get(sessionID)).toMatchObject({
projectID: Project.ID.global,
location: { directory: "/project", workspaceID: "wrk_remote" },
subpath: "remote/path",
})
}),
)
it.effect("preserves a tool continuation across a steered move", () =>
Effect.gen(function* () {
const session = yield* setup