From 05630bb93d9f183633bd116045a3a3d7a4eed710 Mon Sep 17 00:00:00 2001 From: Nitay Rabinovich Date: Fri, 21 Aug 2026 10:53:57 +0200 Subject: [PATCH 1/2] fix(server): abort OpenCode child sessions on interrupt --- .../provider/Layers/OpenCodeAdapter.test.ts | 31 +++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 31 +++++++++++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..db82245058e4 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -61,6 +61,7 @@ const runtimeMock = { sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as string[], + sessionChildrenById: new Map>(), closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, promptCalls: [] as Array, @@ -81,6 +82,7 @@ const runtimeMock = { this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; + this.state.sessionChildrenById.clear(); this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; this.state.promptCalls.length = 0; @@ -177,6 +179,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { abort: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.abortCalls.push(sessionID); }, + children: async ({ sessionID }: { sessionID: string }) => ({ + data: runtimeMock.state.sessionChildrenById.get(sessionID) ?? [], + }), promptAsync: async (input: unknown) => { runtimeMock.state.promptCalls.push(input); if (runtimeMock.state.promptAsyncError) { @@ -602,6 +607,32 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("interrupts OpenCode child sessions before the parent session", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-children"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + runtimeMock.state.sessionChildrenById.set("http://127.0.0.1:9999/session", [ + { id: "child-a" }, + { id: "child-b" }, + ]); + + yield* adapter.interruptTurn(threadId); + + NodeAssert.deepEqual( + runtimeMock.state.abortCalls.slice().sort(), + ["child-a", "child-b", "http://127.0.0.1:9999/session"].sort(), + ); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("emits one session.exited event when stopping a session", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..e70d3e4418e8 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1555,12 +1555,37 @@ export function makeOpenCodeAdapter( }; }); + const abortOpenCodeSessionTree = Effect.fn("abortOpenCodeSessionTree")(function* ( + context: OpenCodeSessionContext, + ) { + const children = yield* runOpenCodeSdk("session.children", () => + context.client.session.children({ sessionID: context.openCodeSessionId }), + ).pipe( + Effect.map((response) => response.data ?? []), + Effect.orElseSucceed(() => []), + ); + + // OpenCode sub-agents run as child sessions. Abort them explicitly instead of + // relying on parent-session cancellation propagation, which varies across + // OpenCode releases and can leave provider requests running indefinitely. + yield* Effect.forEach( + children, + (child) => + runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: child.id }), + ).pipe(Effect.ignore), + { concurrency: "unbounded", discard: true }, + ); + + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); + }); + const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = yield* ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); + yield* abortOpenCodeSessionTree(context); if (turnId ?? context.activeTurnId) { yield* emit({ ...(yield* buildEventBase({ From 75efeade62e2546ac5d694e099dc310affff9dae Mon Sep 17 00:00:00 2001 From: Nitay Rabinovich Date: Fri, 21 Aug 2026 12:19:29 +0200 Subject: [PATCH 2/2] fix(server): abort OpenCode parent before children --- .../src/provider/Layers/OpenCodeAdapter.test.ts | 17 ++++++++++++++--- .../src/provider/Layers/OpenCodeAdapter.ts | 14 +++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index db82245058e4..2b5c0717a193 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -61,6 +61,7 @@ const runtimeMock = { sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as string[], + onAbort: null as ((sessionID: string) => void) | null, sessionChildrenById: new Map>(), closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, @@ -82,6 +83,7 @@ const runtimeMock = { this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; + this.state.onAbort = null; this.state.sessionChildrenById.clear(); this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; @@ -178,6 +180,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, abort: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.abortCalls.push(sessionID); + runtimeMock.state.onAbort?.(sessionID); }, children: async ({ sessionID }: { sessionID: string }) => ({ data: runtimeMock.state.sessionChildrenById.get(sessionID) ?? [], @@ -607,7 +610,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("interrupts OpenCode child sessions before the parent session", () => + it.effect("interrupts the parent before all surviving OpenCode child sessions", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-opencode-children"); @@ -621,12 +624,20 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { { id: "child-a" }, { id: "child-b" }, ]); + runtimeMock.state.onAbort = (sessionID) => { + if (sessionID === "http://127.0.0.1:9999/session") { + runtimeMock.state.sessionChildrenById + .get("http://127.0.0.1:9999/session") + ?.push({ id: "child-created-during-parent-abort" }); + } + }; yield* adapter.interruptTurn(threadId); + NodeAssert.equal(runtimeMock.state.abortCalls[0], "http://127.0.0.1:9999/session"); NodeAssert.deepEqual( - runtimeMock.state.abortCalls.slice().sort(), - ["child-a", "child-b", "http://127.0.0.1:9999/session"].sort(), + runtimeMock.state.abortCalls.slice(1).sort(), + ["child-a", "child-b", "child-created-during-parent-abort"].sort(), ); yield* adapter.stopSession(threadId); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index e70d3e4418e8..9d44256e2ad2 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1558,6 +1558,13 @@ export function makeOpenCodeAdapter( const abortOpenCodeSessionTree = Effect.fn("abortOpenCodeSessionTree")(function* ( context: OpenCodeSessionContext, ) { + // Abort the parent first so it cannot spawn another child after we take the + // children snapshot. Older OpenCode releases do not reliably propagate this + // cancellation, so enumerate and abort any surviving children afterwards. + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); + const children = yield* runOpenCodeSdk("session.children", () => context.client.session.children({ sessionID: context.openCodeSessionId }), ).pipe( @@ -1565,9 +1572,6 @@ export function makeOpenCodeAdapter( Effect.orElseSucceed(() => []), ); - // OpenCode sub-agents run as child sessions. Abort them explicitly instead of - // relying on parent-session cancellation propagation, which varies across - // OpenCode releases and can leave provider requests running indefinitely. yield* Effect.forEach( children, (child) => @@ -1576,10 +1580,6 @@ export function makeOpenCodeAdapter( ).pipe(Effect.ignore), { concurrency: "unbounded", discard: true }, ); - - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); }); const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")(