From 9517ff1054bfbc185a8f8240ef7389ad388cfe16 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 31 Aug 2026 12:14:45 -0400 Subject: [PATCH] fix(core): preserve active session continuation when moving --- packages/core/src/session.ts | 3 +- packages/core/test/session-move.test.ts | 64 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 0ffac276d23..1ff70c3032c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -468,7 +468,8 @@ const layer = Layer.effect( Effect.gen(function* () { const latest = yield* result.get(input.sessionID) const source = yield* fs.stat(latest.location.directory).pipe(Effect.orElseSucceed(() => undefined)) - if (!source || source.type !== "Directory") { + // Active runners must hand off at a step boundary to retain their continuation. + if ((!source || source.type !== "Directory") && !(yield* execution.isActive(input.sessionID))) { const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map( (item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const, ) diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index a46ead5c962..b625986efb8 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -15,6 +15,7 @@ import { Session } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionRunner } from "@opencode-ai/core/session/runner/index" import { SessionStore } from "@opencode-ai/core/session/store" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { tmpdirScoped } from "./fixture/tmpdir" @@ -30,6 +31,34 @@ const it = testEffect( ], ), ) +const itWithActiveExecution = testEffect( + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + Bus.node, + SessionProjector.node, + SessionStore.node, + SessionExecution.node, + Session.node, + ]), + [ + [Project.node, globalProjectNode], + [ + LocationServiceMap.node, + Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + (ref: Location.Ref) => + Layer.merge( + LayerNode.compile(Location.boundNode(ref), [[Project.node, globalProjectNode]]), + Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }), + ) as unknown as Layer.Layer, + ), + ), + ], + ], + ), +) const unavailableLocations = Layer.effect( LocationServiceMap.Service, LayerMap.make( @@ -104,6 +133,41 @@ describe("Session.move", () => { ), ) + itWithActiveExecution.live("defers an active move when the source directory no longer exists", () => + tmpdirScoped().pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const session = yield* Session.Service + const execution = yield* SessionExecution.Service + const source = AbsolutePath.make(path.join(tmp.path, "source")) + const destination = AbsolutePath.make(tmp.path) + yield* Effect.promise(() => mkdir(source)) + const created = yield* session.create({ location: Location.Ref.make({ directory: source }) }) + + // Hold real execution open so the move cannot be consumed before admission is checked. + yield* execution.wake(created.id) + expect(yield* execution.isActive(created.id)).toBe(true) + yield* Effect.promise(() => rm(source, { recursive: true })) + + yield* session.move({ sessionID: created.id, directory: destination }) + + expect((yield* session.get(created.id)).location.directory).toBe(source) + expect(yield* session.inbox(created.id)).toMatchObject([ + { + type: "move", + delivery: "steer", + payload: { location: { directory: destination } }, + }, + ]) + expect(yield* execution.isActive(created.id)).toBe(true) + + yield* execution.interrupt(created.id) + yield* execution.awaitIdle(created.id) + }), + ), + ), + ) + it.effect("keeps a moved session out of its former directory's new identity", () => tmpdirScoped().pipe( Effect.flatMap((tmp) =>