authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-01 04:05:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 04:05:55-07:00
log62d39bf00d5ddd83a9b36a81321fe9aa4d2502bb
tree59066a36270d8d48f3972c6671bb0a37ea355289
parent8b033de48247086c1b6c6968ce7cf33b358a1ec5
signature Signed by PGP key B5690EEEBB952194

fix(server): stop OpenCode child sessions (#9005)


3 files changed, 497 insertions(+), 48 deletions(-)

apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+329-12
......@@ -69,6 +69,11 @@ const runtimeMock = {
6969 abortImplementation: null as
7070 | ((sessionID: string, signal?: AbortSignal) => Promise<void>)
7171 | null,
72 sessionChildrenCalls: [] as string[],
73 sessionChildrenById: new Map<string, Array<{ id: string }>>(),
74 sessionChildrenImplementation: null as
75 | ((sessionID: string) => Promise<Array<{ id: string }>>)
76 | null,
7277 closeCalls: [] as string[],
7378 revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,
7479 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
......@@ -116,6 +121,9 @@ const runtimeMock = {
116121 this.state.abortCalls.length = 0;
117122 this.state.abortSignals.length = 0;
118123 this.state.abortImplementation = null;
124 this.state.sessionChildrenCalls.length = 0;
125 this.state.sessionChildrenById.clear();
126 this.state.sessionChildrenImplementation = null;
119127 this.state.closeCalls.length = 0;
120128 this.state.revertCalls.length = 0;
121129 this.state.messageCalls.length = 0;
......@@ -252,6 +260,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
252260 }
253261 await runtimeMock.state.abortImplementation?.(sessionID, options?.signal);
254262 },
263 children: async ({ sessionID }: { sessionID: string }) => {
264 runtimeMock.state.sessionChildrenCalls.push(sessionID);
265 return {
266 data: runtimeMock.state.sessionChildrenImplementation
267 ? await runtimeMock.state.sessionChildrenImplementation(sessionID)
268 : (runtimeMock.state.sessionChildrenById.get(sessionID) ?? []),
269 };
270 },
255271 status: async () => {
256272 runtimeMock.state.sessionStatusCalls += 1;
257273 if (runtimeMock.state.sessionStatusImplementation) {
......@@ -1129,6 +1145,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
11291145 it.effect("stops a configured-server session without trying to own server lifecycle", () =>
11301146 Effect.gen(function* () {
11311147 const adapter = yield* OpenCodeAdapter;
1148 const rootSessionId = "http://127.0.0.1:9999/session";
1149 runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_stop_child" }]);
1150 runtimeMock.state.sessionChildrenById.set("ses_stop_child", [{ id: "ses_stop_grandchild" }]);
11321151 yield* adapter.startSession({
11331152 provider: ProviderDriverKind.make("opencode"),
11341153 threadId: asThreadId("thread-opencode"),
......@@ -1138,10 +1157,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
11381157 yield* adapter.stopSession(asThreadId("thread-opencode"));
11391158
11401159 NodeAssert.deepEqual(runtimeMock.state.startCalls, []);
1141 NodeAssert.deepEqual(
1142 runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"),
1143 true,
1144 );
1160 NodeAssert.deepEqual(runtimeMock.state.abortCalls, [
1161 rootSessionId,
1162 "ses_stop_child",
1163 "ses_stop_grandchild",
1164 ]);
11451165 }),
11461166 );
11471167
......@@ -2999,6 +3019,262 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
29993019 }),
30003020 );
30013021
3022 it.effect("stops the full OpenCode child tree before it completes the interrupt", () =>
3023 Effect.gen(function* () {
3024 const adapter = yield* OpenCodeAdapter;
3025 const threadId = asThreadId("thread-interrupt-child-tree");
3026 const parentAbortEvent = promiseWithResolvers<unknown>();
3027 const markerEvent = promiseWithResolvers<unknown>();
3028 const parentAbortStarted = promiseWithResolvers<void>();
3029 const parentAbortRelease = promiseWithResolvers<void>();
3030 const childAbortStarted = promiseWithResolvers<void>();
3031 const childAbortRelease = promiseWithResolvers<void>();
3032 const rootSessionId = "http://127.0.0.1:9999/session";
3033 runtimeMock.state.subscribedEvents = [parentAbortEvent.promise, markerEvent.promise];
3034 runtimeMock.state.sessionChildrenById.set(rootSessionId, [
3035 { id: "ses_child_a" },
3036 { id: "ses_child_b" },
3037 ]);
3038 runtimeMock.state.sessionChildrenById.set("ses_child_a", [{ id: "ses_grandchild" }]);
3039 runtimeMock.state.sessionChildrenById.set("ses_unrelated", [{ id: "ses_unrelated_child" }]);
3040 runtimeMock.state.abortImplementation = async (sessionID) => {
3041 if (sessionID === rootSessionId) {
3042 parentAbortStarted.resolve(undefined);
3043 await parentAbortRelease.promise;
3044 }
3045 if (sessionID === "ses_child_a") {
3046 childAbortStarted.resolve(undefined);
3047 await childAbortRelease.promise;
3048 }
3049 };
3050
3051 const markerFiber = yield* adapter.streamEvents.pipe(
3052 Stream.filter(
3053 (event) => event.threadId === threadId && event.type === "thread.metadata.updated",
3054 ),
3055 Stream.runHead,
3056 Effect.forkChild,
3057 );
3058 yield* adapter.startSession({
3059 provider: ProviderDriverKind.make("opencode"),
3060 threadId,
3061 runtimeMode: "full-access",
3062 });
3063 const turn = yield* adapter.sendTurn({
3064 threadId,
3065 input: "Run child agents",
3066 modelSelection: createModelSelection(
3067 ProviderInstanceId.make("opencode"),
3068 "opencode/kimi-k3",
3069 ),
3070 });
3071
3072 const interruptFiber = yield* adapter
3073 .interruptTurn(threadId, turn.turnId)
3074 .pipe(Effect.result, Effect.forkChild);
3075 yield* Effect.promise(() => parentAbortStarted.promise);
3076 runtimeMock.state.sessionChildrenById.get(rootSessionId)?.push({ id: "ses_late_child" });
3077 parentAbortEvent.resolve({
3078 id: "evt-parent-aborted",
3079 type: "session.error",
3080 properties: {
3081 sessionID: rootSessionId,
3082 error: { name: "MessageAbortedError", data: { message: "Aborted" } },
3083 },
3084 });
3085 markerEvent.resolve({
3086 id: "evt-after-parent-abort",
3087 type: "session.updated",
3088 properties: { info: { id: rootSessionId, title: "Parent abort received" } },
3089 });
3090 yield* Fiber.join(markerFiber);
3091
3092 NodeAssert.equal(interruptFiber.pollUnsafe(), undefined);
3093 yield* Effect.promise(() => childAbortStarted.promise);
3094 NodeAssert.equal(interruptFiber.pollUnsafe(), undefined);
3095 NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated"), false);
3096 NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_unrelated_child"), false);
3097 const sessionsDuringCleanup = yield* adapter.listSessions();
3098 const sessionDuringCleanup = sessionsDuringCleanup.find(
3099 (candidate) => candidate.threadId === threadId,
3100 );
3101 NodeAssert.equal(sessionDuringCleanup?.status, "running");
3102 NodeAssert.equal(sessionDuringCleanup?.activeTurnId, turn.turnId);
3103 const nextTurnFiber = yield* adapter
3104 .sendTurn({
3105 threadId,
3106 input: "Start after every child stops",
3107 modelSelection: createModelSelection(
3108 ProviderInstanceId.make("opencode"),
3109 "opencode/kimi-k3",
3110 ),
3111 })
3112 .pipe(Effect.forkChild);
3113 yield* Effect.yieldNow;
3114 NodeAssert.equal(runtimeMock.state.promptCalls.length, 1);
3115
3116 childAbortRelease.resolve(undefined);
3117 parentAbortRelease.resolve(undefined);
3118 const result = yield* Fiber.join(interruptFiber);
3119 const nextTurn = yield* Fiber.join(nextTurnFiber);
3120 NodeAssert.equal(result._tag, "Success");
3121 NodeAssert.notEqual(nextTurn.turnId, turn.turnId);
3122 NodeAssert.equal(runtimeMock.state.promptCalls.length, 2);
3123 NodeAssert.equal(runtimeMock.state.abortCalls[0], rootSessionId);
3124 NodeAssert.deepEqual(
3125 new Set(runtimeMock.state.abortCalls.slice(1)),
3126 new Set(["ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]),
3127 );
3128 NodeAssert.deepEqual(
3129 new Set(runtimeMock.state.sessionChildrenCalls),
3130 new Set([rootSessionId, "ses_child_a", "ses_child_b", "ses_grandchild", "ses_late_child"]),
3131 );
3132 const sessions = yield* adapter.listSessions();
3133 const session = sessions.find((candidate) => candidate.threadId === threadId);
3134 NodeAssert.equal(session?.status, "running");
3135 NodeAssert.equal(session?.activeTurnId, nextTurn.turnId);
3136
3137 runtimeMock.state.abortImplementation = null;
3138 yield* adapter.stopSession(threadId);
3139 }),
3140 );
3141
3142 it.effect("limits SDK requests across the full OpenCode child tree", () =>
3143 Effect.gen(function* () {
3144 const adapter = yield* OpenCodeAdapter;
3145 const threadId = asThreadId("thread-interrupt-child-request-limit");
3146 const rootSessionId = "http://127.0.0.1:9999/session";
3147 const requestRelease = promiseWithResolvers<void>();
3148 const limitReached = promiseWithResolvers<void>();
3149 let inFlight = 0;
3150 let maxInFlight = 0;
3151 const holdRequest = async <T>(result: T): Promise<T> => {
3152 inFlight += 1;
3153 maxInFlight = Math.max(maxInFlight, inFlight);
3154 if (inFlight === 8) {
3155 limitReached.resolve(undefined);
3156 }
3157 await requestRelease.promise;
3158 inFlight -= 1;
3159 return result;
3160 };
3161
3162 const children = Array.from({ length: 8 }, (_, index) => ({ id: `ses_child_${index}` }));
3163 runtimeMock.state.sessionChildrenById.set(rootSessionId, children);
3164 for (const child of children.slice(1)) {
3165 runtimeMock.state.sessionChildrenById.set(
3166 child.id,
3167 Array.from({ length: 8 }, (_, index) => ({ id: `${child.id}_nested_${index}` })),
3168 );
3169 }
3170 runtimeMock.state.abortImplementation = async (sessionID) => {
3171 if (sessionID.includes("_nested_")) {
3172 await holdRequest(undefined);
3173 }
3174 };
3175 runtimeMock.state.sessionChildrenImplementation = async (sessionID) => {
3176 if (sessionID === "ses_child_0") {
3177 return await holdRequest([]);
3178 }
3179 return runtimeMock.state.sessionChildrenById.get(sessionID) ?? [];
3180 };
3181
3182 yield* adapter.startSession({
3183 provider: ProviderDriverKind.make("opencode"),
3184 threadId,
3185 runtimeMode: "full-access",
3186 });
3187 const turn = yield* adapter.sendTurn({
3188 threadId,
3189 input: "Run a nested child tree",
3190 modelSelection: createModelSelection(
3191 ProviderInstanceId.make("opencode"),
3192 "opencode/kimi-k3",
3193 ),
3194 });
3195
3196 const interruptFiber = yield* adapter
3197 .interruptTurn(threadId, turn.turnId)
3198 .pipe(Effect.forkChild);
3199 yield* Effect.promise(() => limitReached.promise);
3200 yield* Effect.yieldNow;
3201
3202 NodeAssert.equal(inFlight, 8);
3203 NodeAssert.equal(maxInFlight, 8);
3204
3205 requestRelease.resolve(undefined);
3206 yield* Fiber.join(interruptFiber);
3207
3208 runtimeMock.state.abortImplementation = null;
3209 runtimeMock.state.sessionChildrenImplementation = null;
3210 runtimeMock.state.sessionChildrenById.clear();
3211 yield* adapter.stopSession(threadId);
3212 }),
3213 );
3214
3215 it.effect("attempts every child abort and fails the interrupt when one child abort fails", () =>
3216 Effect.gen(function* () {
3217 const adapter = yield* OpenCodeAdapter;
3218 const threadId = asThreadId("thread-interrupt-child-failure");
3219 const rootSessionId = "http://127.0.0.1:9999/session";
3220 const failingChildStarted = promiseWithResolvers<void>();
3221 const failingChildRelease = promiseWithResolvers<void>();
3222 const siblingAbortStarted = promiseWithResolvers<void>();
3223 runtimeMock.state.sessionChildrenById.set(rootSessionId, [
3224 { id: "ses_failing_child" },
3225 { id: "ses_surviving_sibling" },
3226 ]);
3227 runtimeMock.state.abortImplementation = async (sessionID) => {
3228 if (sessionID === "ses_failing_child") {
3229 failingChildStarted.resolve(undefined);
3230 await failingChildRelease.promise;
3231 throw new Error("child abort failed");
3232 }
3233 if (sessionID === "ses_surviving_sibling") {
3234 siblingAbortStarted.resolve(undefined);
3235 }
3236 };
3237
3238 yield* adapter.startSession({
3239 provider: ProviderDriverKind.make("opencode"),
3240 threadId,
3241 runtimeMode: "full-access",
3242 });
3243 const turn = yield* adapter.sendTurn({
3244 threadId,
3245 input: "Run child agents",
3246 modelSelection: createModelSelection(
3247 ProviderInstanceId.make("opencode"),
3248 "opencode/kimi-k3",
3249 ),
3250 });
3251
3252 const interruptFiber = yield* adapter
3253 .interruptTurn(threadId, turn.turnId)
3254 .pipe(Effect.result, Effect.forkChild);
3255 yield* Effect.promise(() => failingChildStarted.promise);
3256 yield* Effect.promise(() => siblingAbortStarted.promise);
3257 NodeAssert.equal(interruptFiber.pollUnsafe(), undefined);
3258 failingChildRelease.resolve(undefined);
3259 const result = yield* Fiber.join(interruptFiber);
3260
3261 NodeAssert.equal(result._tag, "Failure");
3262 if (result._tag === "Failure") {
3263 NodeAssert.equal(result.failure._tag, "ProviderAdapterRequestError");
3264 NodeAssert.equal(result.failure.detail, "child abort failed");
3265 }
3266 NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_failing_child"), true);
3267 NodeAssert.equal(runtimeMock.state.abortCalls.includes("ses_surviving_sibling"), true);
3268 const sessions = yield* adapter.listSessions();
3269 const session = sessions.find((candidate) => candidate.threadId === threadId);
3270 NodeAssert.equal(session?.status, "running");
3271 NodeAssert.equal(session?.activeTurnId, turn.turnId);
3272
3273 runtimeMock.state.abortImplementation = null;
3274 yield* adapter.stopSession(threadId);
3275 }),
3276 );
3277
30023278 it.effect("keeps an idle event from completing a turn while its abort request is pending", () =>
30033279 Effect.gen(function* () {
30043280 const adapter = yield* OpenCodeAdapter;
......@@ -4278,11 +4554,20 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
42784554 const threadId = asThreadId("thread-interrupt-provider-error");
42794555 const errorEvent = promiseWithResolvers<unknown>();
42804556 const abortStarted = promiseWithResolvers<void>();
4281 const abortRelease = promiseWithResolvers<void>();
4557 const childAbortStarted = promiseWithResolvers<void>();
4558 const childAbortRelease = promiseWithResolvers<void>();
4559 const rootSessionId = "http://127.0.0.1:9999/session";
42824560 runtimeMock.state.subscribedEvents = [errorEvent.promise];
4283 runtimeMock.state.abortImplementation = async () => {
4284 abortStarted.resolve(undefined);
4285 await abortRelease.promise;
4561 runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_error_child" }]);
4562 runtimeMock.state.abortImplementation = async (sessionID) => {
4563 if (sessionID === rootSessionId) {
4564 abortStarted.resolve(undefined);
4565 await new Promise<void>(() => {});
4566 }
4567 if (sessionID === "ses_error_child") {
4568 childAbortStarted.resolve(undefined);
4569 await childAbortRelease.promise;
4570 }
42864571 };
42874572
42884573 const eventsFiber = yield* adapter.streamEvents.pipe(
......@@ -4313,16 +4598,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
43134598 id: "evt-provider-error-after-stop",
43144599 type: "session.error",
43154600 properties: {
4316 sessionID: "http://127.0.0.1:9999/session",
4601 sessionID: rootSessionId,
43174602 error: {
43184603 name: "APIError",
43194604 data: { message: "Upstream failed", isRetryable: false },
43204605 },
43214606 },
43224607 });
4323 yield* Effect.yieldNow;
4324 abortRelease.resolve(undefined);
4325 yield* Fiber.join(interruptFiber);
4608 yield* Effect.promise(() => childAbortStarted.promise);
43264609
43274610 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
43284611 NodeAssert.deepEqual(
......@@ -4341,7 +4624,41 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
43414624 failed?.type === "turn.completed" ? failed.payload.state : undefined,
43424625 "failed",
43434626 );
4627 const sessionsDuringCleanup = yield* adapter.listSessions();
4628 const sessionDuringCleanup = sessionsDuringCleanup.find(
4629 (candidate) => candidate.threadId === threadId,
4630 );
4631 NodeAssert.equal(sessionDuringCleanup?.status, "error");
4632 NodeAssert.equal(sessionDuringCleanup?.activeTurnId, undefined);
4633
4634 const secondInterruptFiber = yield* adapter.interruptTurn(threadId).pipe(Effect.forkChild);
4635 const nextTurnFiber = yield* adapter
4636 .sendTurn({
4637 threadId,
4638 input: "Start after child cleanup",
4639 modelSelection: createModelSelection(
4640 ProviderInstanceId.make("opencode"),
4641 "opencode/kimi-k3",
4642 ),
4643 })
4644 .pipe(Effect.forkChild);
4645 yield* Effect.yieldNow;
4646 NodeAssert.equal(
4647 runtimeMock.state.abortCalls.filter((sessionID) => sessionID === rootSessionId).length,
4648 1,
4649 );
4650 NodeAssert.equal(secondInterruptFiber.pollUnsafe(), undefined);
4651 NodeAssert.equal(nextTurnFiber.pollUnsafe(), undefined);
4652 NodeAssert.equal(runtimeMock.state.promptCalls.length, 1);
43444653
4654 childAbortRelease.resolve(undefined);
4655 yield* Fiber.join(interruptFiber);
4656 yield* Fiber.join(secondInterruptFiber);
4657 const nextTurn = yield* Fiber.join(nextTurnFiber);
4658 NodeAssert.notEqual(nextTurn.turnId, turn.turnId);
4659 NodeAssert.equal(runtimeMock.state.promptCalls.length, 2);
4660
4661 runtimeMock.state.abortImplementation = null;
43454662 yield* adapter.stopSession(threadId);
43464663 }),
43474664 );
apps/server/src/provider/Layers/OpenCodeAdapter.ts+159-36
......@@ -192,8 +192,10 @@ const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessio
192192
193193interface OpenCodeCancellation {
194194 readonly turnId: TurnId | undefined;
195 readonly acknowledgment: Deferred.Deferred<void>;
195196 readonly completion: Deferred.Deferred<void, ProviderAdapterRequestError>;
196197 acknowledged?: boolean;
198 turnSettled?: boolean;
197199 deferredIdleEvent?: OpenCodeSessionStatusEvent;
198200}
199201
......@@ -702,10 +704,88 @@ const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellati
702704 ).pipe(Effect.ignore);
703705});
704706
705const abortOpenCodeSessionForTeardown = (context: OpenCodeSessionContext) =>
706 runOpenCodeSdk("session.abort", (signal) =>
707const abortOpenCodeDescendants = Effect.fn("abortOpenCodeDescendants")(function* (
708 context: OpenCodeSessionContext,
709) {
710 const visited = new Set([context.openCodeSessionId]);
711 const requestSemaphore = Semaphore.makeUnsafe(8);
712
713 const visit = (
714 sessionId: string,
715 abortSession: boolean,
716 ): Effect.Effect<OpenCodeRuntimeError | undefined> =>
717 Effect.gen(function* () {
718 let firstFailure: OpenCodeRuntimeError | undefined;
719 if (abortSession) {
720 const abortResult = yield* requestSemaphore
721 .withPermit(
722 runOpenCodeSdk("session.abort", (signal) =>
723 context.client.session.abort({ sessionID: sessionId }, { signal }),
724 ),
725 )
726 .pipe(
727 Effect.catchIf(
728 (cause) => isOpenCodeNotFound(cause),
729 () => Effect.void,
730 ),
731 Effect.result,
732 );
733 if (abortResult._tag === "Failure") {
734 firstFailure = abortResult.failure;
735 }
736 }
737
738 const childrenResult = yield* requestSemaphore
739 .withPermit(
740 runOpenCodeSdk("session.children", (signal) =>
741 context.client.session.children({ sessionID: sessionId }, { signal }),
742 ),
743 )
744 .pipe(
745 Effect.catchIf(
746 (cause) => isOpenCodeNotFound(cause),
747 () => Effect.void,
748 ),
749 Effect.result,
750 );
751 if (childrenResult._tag === "Failure") {
752 return firstFailure ?? childrenResult.failure;
753 }
754
755 const children = childrenResult.success?.data ?? [];
756 const newChildren = children.filter((child) => {
757 if (visited.has(child.id)) {
758 return false;
759 }
760 visited.add(child.id);
761 return true;
762 });
763 const childFailures = yield* Effect.forEach(newChildren, (child) => visit(child.id, true), {
764 concurrency: 8,
765 });
766 firstFailure ??= childFailures.find((failure) => failure !== undefined);
767 return firstFailure;
768 });
769
770 const firstFailure = yield* visit(context.openCodeSessionId, false);
771 if (firstFailure) {
772 return yield* firstFailure;
773 }
774});
775
776const abortOpenCodeSessionForTeardown = Effect.fn("abortOpenCodeSessionForTeardown")(function* (
777 context: OpenCodeSessionContext,
778) {
779 // Stop the parent before the snapshot so it cannot add another child after
780 // the adapter reads the tree.
781 yield* runOpenCodeSdk("session.abort", (signal) =>
707782 context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),
708783 ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true }));
784 yield* abortOpenCodeDescendants(context).pipe(
785 Effect.timeout("1 second"),
786 Effect.ignore({ log: true }),
787 );
788});
709789
710790const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* (
711791 context: OpenCodeSessionContext,
......@@ -2158,13 +2238,12 @@ export function makeOpenCodeAdapter(
21582238 if (isOpenCodeAbortError(event.properties.error)) {
21592239 if (cancellation !== undefined && cancellation.turnId === undefined) {
21602240 cancellation.acknowledged = true;
2161 context.cancellation = undefined;
2162 context.reconcileIdleStatus = true;
2163 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2241 yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore);
21642242 break;
21652243 }
21662244 if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) {
2167 yield* interruptOpenCodeTurn(context, activeTurnId, event);
2245 cancellation.acknowledged = true;
2246 yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore);
21682247 break;
21692248 }
21702249 if (context.interruptedTurnId !== undefined || context.reconcileIdleStatus) {
......@@ -2172,9 +2251,13 @@ export function makeOpenCodeAdapter(
21722251 }
21732252 }
21742253 yield* cancelIdleReconciliation(context);
2175 if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) {
2176 context.cancellation = undefined;
2177 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2254 const terminalCancellation =
2255 activeTurnId !== undefined && cancellation?.turnId === activeTurnId
2256 ? cancellation
2257 : undefined;
2258 if (terminalCancellation) {
2259 terminalCancellation.turnSettled = true;
2260 terminalCancellation.acknowledged = true;
21782261 }
21792262 context.activeTurnId = undefined;
21802263 context.activeAgent = undefined;
......@@ -2214,6 +2297,11 @@ export function makeOpenCodeAdapter(
22142297 detail: event.properties.error,
22152298 },
22162299 });
2300 if (terminalCancellation) {
2301 yield* Deferred.succeed(terminalCancellation.acknowledgment, undefined).pipe(
2302 Effect.ignore,
2303 );
2304 }
22172305 break;
22182306 }
22192307
......@@ -2909,14 +2997,12 @@ export function makeOpenCodeAdapter(
29092997 return;
29102998 }
29112999 const existingCancellation = context.cancellation;
2912 if (
2913 existingCancellation !== undefined &&
2914 existingCancellation.turnId === interruptedTurnId
2915 ) {
3000 if (existingCancellation !== undefined) {
29163001 return yield* Deferred.await(existingCancellation.completion);
29173002 }
29183003 const cancellation: OpenCodeCancellation = {
29193004 turnId: interruptedTurnId,
3005 acknowledgment: Deferred.makeUnsafe<void>(),
29203006 completion: Deferred.makeUnsafe<void, ProviderAdapterRequestError>(),
29213007 };
29223008 context.cancellation = cancellation;
......@@ -2929,10 +3015,11 @@ export function makeOpenCodeAdapter(
29293015 yield* Deferred.await(promptAdmission.submissionSettled);
29303016 }
29313017
2932 const abortOutcome = yield* Effect.raceFirst(
3018 const parentAbortOutcome = yield* Effect.raceFirst(
29333019 runOpenCodeSdk("session.abort", (signal) =>
29343020 context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),
29353021 ).pipe(
3022 Effect.asVoid,
29363023 Effect.timeout("10 seconds"),
29373024 Effect.catchTags({
29383025 OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)),
......@@ -2949,33 +3036,67 @@ export function makeOpenCodeAdapter(
29493036 Effect.exit,
29503037 Effect.map((exit) => ({ source: "request" as const, exit })),
29513038 ),
3039 Effect.raceFirst(
3040 Deferred.await(cancellation.acknowledgment).pipe(
3041 Effect.map(() => ({ source: "acknowledgment" as const })),
3042 ),
3043 Deferred.await(cancellation.completion).pipe(
3044 Effect.exit,
3045 Effect.map((exit) => ({ source: "completion" as const, exit })),
3046 ),
3047 ),
3048 );
3049 if (parentAbortOutcome.source === "completion") {
3050 return Exit.isFailure(parentAbortOutcome.exit)
3051 ? yield* Effect.failCause(parentAbortOutcome.exit.cause)
3052 : undefined;
3053 }
3054 const parentAbortExit =
3055 parentAbortOutcome.source === "request" ? parentAbortOutcome.exit : Exit.void;
3056
3057 const descendantAbortOutcome = yield* Effect.raceFirst(
3058 abortOpenCodeDescendants(context).pipe(
3059 Effect.timeout("10 seconds"),
3060 Effect.catchTags({
3061 OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)),
3062 TimeoutError: (cause) =>
3063 Effect.fail(
3064 new ProviderAdapterRequestError({
3065 provider: PROVIDER,
3066 method: "session.abort",
3067 detail: "OpenCode child session cleanup did not complete within 10 seconds.",
3068 cause,
3069 }),
3070 ),
3071 }),
3072 Effect.exit,
3073 Effect.map((exit) => ({ source: "request" as const, exit })),
3074 ),
29523075 Deferred.await(cancellation.completion).pipe(
29533076 Effect.exit,
2954 Effect.map((exit) => ({ source: "event" as const, exit })),
3077 Effect.map((exit) => ({ source: "completion" as const, exit })),
29553078 ),
29563079 );
2957 if (abortOutcome.source === "event") {
2958 return Exit.isFailure(abortOutcome.exit)
2959 ? yield* Effect.failCause(abortOutcome.exit.cause)
3080 if (descendantAbortOutcome.source === "completion") {
3081 return Exit.isFailure(descendantAbortOutcome.exit)
3082 ? yield* Effect.failCause(descendantAbortOutcome.exit.cause)
29603083 : undefined;
29613084 }
2962 const abortExit = abortOutcome.exit;
2963 if (Exit.isFailure(abortExit)) {
2964 if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) {
2965 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2966 return;
2967 }
2968 if (cancellation.turnId === undefined && cancellation.acknowledged) {
2969 if (context.cancellation === cancellation) {
2970 context.cancellation = undefined;
2971 context.reconcileIdleStatus = true;
2972 }
2973 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2974 return;
2975 }
3085
3086 const parentAbortFailed = Exit.isFailure(parentAbortExit) && !cancellation.acknowledged;
3087 const failedExit = parentAbortFailed
3088 ? parentAbortExit
3089 : Exit.isFailure(descendantAbortOutcome.exit)
3090 ? descendantAbortOutcome.exit
3091 : undefined;
3092 if (failedExit !== undefined && Exit.isFailure(failedExit)) {
29763093 if (context.cancellation === cancellation) {
29773094 context.cancellation = undefined;
2978 if (cancellation.turnId !== undefined && cancellation.deferredIdleEvent) {
3095 if (
3096 parentAbortFailed &&
3097 cancellation.turnId !== undefined &&
3098 cancellation.deferredIdleEvent
3099 ) {
29793100 yield* scheduleIdleReconciliation(
29803101 context,
29813102 cancellation.turnId,
......@@ -2983,12 +3104,14 @@ export function makeOpenCodeAdapter(
29833104 );
29843105 }
29853106 }
2986 yield* Deferred.done(cancellation.completion, abortExit).pipe(Effect.ignore);
2987 return yield* Effect.failCause(abortExit.cause);
3107 yield* Deferred.done(cancellation.completion, failedExit).pipe(Effect.ignore);
3108 return yield* Effect.failCause(failedExit.cause);
29883109 }
29893110
29903111 if (context.cancellation === cancellation) {
2991 if (cancellation.turnId !== undefined) {
3112 if (cancellation.turnSettled) {
3113 context.cancellation = undefined;
3114 } else if (cancellation.turnId !== undefined) {
29923115 yield* interruptOpenCodeTurn(context, cancellation.turnId);
29933116 } else {
29943117 context.cancellation = undefined;
docs/user/providers-opencode.md+9
......@@ -17,6 +17,15 @@ With a server URL, T3 Code connects to that external server and uses only the pa
1717provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server.
1818OpenCode uses this password for HTTP Basic authentication.
1919
20## Stop a turn
21
22When you select **Stop**, T3 Code stops the main OpenCode session and all nested child sessions.
23T3 Code waits for this cleanup before it marks the turn as stopped or sends the next prompt. It
24does not stop unrelated OpenCode sessions.
25
26Stop reports an error if OpenCode cannot list or stop a child session. When T3 Code closes an
27OpenCode session, it also tries to stop the child sessions, but this teardown is best effort.
28
2029## Refresh the model list
2130
2231T3 Code loads the model list when an enabled OpenCode provider starts and keeps the list in its