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 = {...@@ -69,6 +69,11 @@ const runtimeMock = {
69 abortImplementation: null as69 abortImplementation: null as
70 | ((sessionID: string, signal?: AbortSignal) => Promise<void>)70 | ((sessionID: string, signal?: AbortSignal) => Promise<void>)
71 | null,71 | 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,
72 closeCalls: [] as string[],77 closeCalls: [] as string[],
73 revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,78 revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,
74 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,79 messageCalls: [] as Array<{ sessionID: string; messageID: string }>,
...@@ -116,6 +121,9 @@ const runtimeMock = {...@@ -116,6 +121,9 @@ const runtimeMock = {
116 this.state.abortCalls.length = 0;121 this.state.abortCalls.length = 0;
117 this.state.abortSignals.length = 0;122 this.state.abortSignals.length = 0;
118 this.state.abortImplementation = null;123 this.state.abortImplementation = null;
124 this.state.sessionChildrenCalls.length = 0;
125 this.state.sessionChildrenById.clear();
126 this.state.sessionChildrenImplementation = null;
119 this.state.closeCalls.length = 0;127 this.state.closeCalls.length = 0;
120 this.state.revertCalls.length = 0;128 this.state.revertCalls.length = 0;
121 this.state.messageCalls.length = 0;129 this.state.messageCalls.length = 0;
...@@ -252,6 +260,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -252,6 +260,14 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
252 }260 }
253 await runtimeMock.state.abortImplementation?.(sessionID, options?.signal);261 await runtimeMock.state.abortImplementation?.(sessionID, options?.signal);
254 },262 },
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 },
255 status: async () => {271 status: async () => {
256 runtimeMock.state.sessionStatusCalls += 1;272 runtimeMock.state.sessionStatusCalls += 1;
257 if (runtimeMock.state.sessionStatusImplementation) {273 if (runtimeMock.state.sessionStatusImplementation) {
...@@ -1129,6 +1145,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -1129,6 +1145,9 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
1129 it.effect("stops a configured-server session without trying to own server lifecycle", () =>1145 it.effect("stops a configured-server session without trying to own server lifecycle", () =>
1130 Effect.gen(function* () {1146 Effect.gen(function* () {
1131 const adapter = yield* OpenCodeAdapter;1147 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" }]);
1132 yield* adapter.startSession({1151 yield* adapter.startSession({
1133 provider: ProviderDriverKind.make("opencode"),1152 provider: ProviderDriverKind.make("opencode"),
1134 threadId: asThreadId("thread-opencode"),1153 threadId: asThreadId("thread-opencode"),
...@@ -1138,10 +1157,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -1138,10 +1157,11 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
1138 yield* adapter.stopSession(asThreadId("thread-opencode"));1157 yield* adapter.stopSession(asThreadId("thread-opencode"));
11391158
1140 NodeAssert.deepEqual(runtimeMock.state.startCalls, []);1159 NodeAssert.deepEqual(runtimeMock.state.startCalls, []);
1141 NodeAssert.deepEqual(1160 NodeAssert.deepEqual(runtimeMock.state.abortCalls, [
1142 runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"),1161 rootSessionId,
1143 true,1162 "ses_stop_child",
1144 );1163 "ses_stop_grandchild",
1164 ]);
1145 }),1165 }),
1146 );1166 );
11471167
...@@ -2999,6 +3019,262 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -2999,6 +3019,262 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
2999 }),3019 }),
3000 );3020 );
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
3002 it.effect("keeps an idle event from completing a turn while its abort request is pending", () =>3278 it.effect("keeps an idle event from completing a turn while its abort request is pending", () =>
3003 Effect.gen(function* () {3279 Effect.gen(function* () {
3004 const adapter = yield* OpenCodeAdapter;3280 const adapter = yield* OpenCodeAdapter;
...@@ -4278,11 +4554,20 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -4278,11 +4554,20 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
4278 const threadId = asThreadId("thread-interrupt-provider-error");4554 const threadId = asThreadId("thread-interrupt-provider-error");
4279 const errorEvent = promiseWithResolvers<unknown>();4555 const errorEvent = promiseWithResolvers<unknown>();
4280 const abortStarted = promiseWithResolvers<void>();4556 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";
4282 runtimeMock.state.subscribedEvents = [errorEvent.promise];4560 runtimeMock.state.subscribedEvents = [errorEvent.promise];
4283 runtimeMock.state.abortImplementation = async () => {4561 runtimeMock.state.sessionChildrenById.set(rootSessionId, [{ id: "ses_error_child" }]);
4284 abortStarted.resolve(undefined);4562 runtimeMock.state.abortImplementation = async (sessionID) => {
4285 await abortRelease.promise;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 }
4286 };4571 };
42874572
4288 const eventsFiber = yield* adapter.streamEvents.pipe(4573 const eventsFiber = yield* adapter.streamEvents.pipe(
...@@ -4313,16 +4598,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -4313,16 +4598,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
4313 id: "evt-provider-error-after-stop",4598 id: "evt-provider-error-after-stop",
4314 type: "session.error",4599 type: "session.error",
4315 properties: {4600 properties: {
4316 sessionID: "http://127.0.0.1:9999/session",4601 sessionID: rootSessionId,
4317 error: {4602 error: {
4318 name: "APIError",4603 name: "APIError",
4319 data: { message: "Upstream failed", isRetryable: false },4604 data: { message: "Upstream failed", isRetryable: false },
4320 },4605 },
4321 },4606 },
4322 });4607 });
4323 yield* Effect.yieldNow;4608 yield* Effect.promise(() => childAbortStarted.promise);
4324 abortRelease.resolve(undefined);
4325 yield* Fiber.join(interruptFiber);
43264609
4327 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));4610 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
4328 NodeAssert.deepEqual(4611 NodeAssert.deepEqual(
...@@ -4341,7 +4624,41 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -4341,7 +4624,41 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
4341 failed?.type === "turn.completed" ? failed.payload.state : undefined,4624 failed?.type === "turn.completed" ? failed.payload.state : undefined,
4342 "failed",4625 "failed",
4343 );4626 );
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;
4345 yield* adapter.stopSession(threadId);4662 yield* adapter.stopSession(threadId);
4346 }),4663 }),
4347 );4664 );
apps/server/src/provider/Layers/OpenCodeAdapter.ts+159-36
...@@ -192,8 +192,10 @@ const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessio...@@ -192,8 +192,10 @@ const decodeOpenCodeSessionStatusMap = Schema.decodeUnknownOption(OpenCodeSessio
192192
193interface OpenCodeCancellation {193interface OpenCodeCancellation {
194 readonly turnId: TurnId | undefined;194 readonly turnId: TurnId | undefined;
195 readonly acknowledgment: Deferred.Deferred<void>;
195 readonly completion: Deferred.Deferred<void, ProviderAdapterRequestError>;196 readonly completion: Deferred.Deferred<void, ProviderAdapterRequestError>;
196 acknowledged?: boolean;197 acknowledged?: boolean;
198 turnSettled?: boolean;
197 deferredIdleEvent?: OpenCodeSessionStatusEvent;199 deferredIdleEvent?: OpenCodeSessionStatusEvent;
198}200}
199201
...@@ -702,10 +704,88 @@ const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellati...@@ -702,10 +704,88 @@ const failPendingOpenCodeCancellation = Effect.fn("failPendingOpenCodeCancellati
702 ).pipe(Effect.ignore);704 ).pipe(Effect.ignore);
703});705});
704706
705const abortOpenCodeSessionForTeardown = (context: OpenCodeSessionContext) =>707const abortOpenCodeDescendants = Effect.fn("abortOpenCodeDescendants")(function* (
706 runOpenCodeSdk("session.abort", (signal) =>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) =>
707 context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),782 context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),
708 ).pipe(Effect.timeout("1 second"), Effect.ignore({ log: true }));783 ).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
710const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* (790const cancelPendingOpenCodePrompt = Effect.fn("cancelPendingOpenCodePrompt")(function* (
711 context: OpenCodeSessionContext,791 context: OpenCodeSessionContext,
...@@ -2158,13 +2238,12 @@ export function makeOpenCodeAdapter(...@@ -2158,13 +2238,12 @@ export function makeOpenCodeAdapter(
2158 if (isOpenCodeAbortError(event.properties.error)) {2238 if (isOpenCodeAbortError(event.properties.error)) {
2159 if (cancellation !== undefined && cancellation.turnId === undefined) {2239 if (cancellation !== undefined && cancellation.turnId === undefined) {
2160 cancellation.acknowledged = true;2240 cancellation.acknowledged = true;
2161 context.cancellation = undefined;2241 yield* Deferred.succeed(cancellation.acknowledgment, undefined).pipe(Effect.ignore);
2162 context.reconcileIdleStatus = true;
2163 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2164 break;2242 break;
2165 }2243 }
2166 if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) {2244 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);
2168 break;2247 break;
2169 }2248 }
2170 if (context.interruptedTurnId !== undefined || context.reconcileIdleStatus) {2249 if (context.interruptedTurnId !== undefined || context.reconcileIdleStatus) {
...@@ -2172,9 +2251,13 @@ export function makeOpenCodeAdapter(...@@ -2172,9 +2251,13 @@ export function makeOpenCodeAdapter(
2172 }2251 }
2173 }2252 }
2174 yield* cancelIdleReconciliation(context);2253 yield* cancelIdleReconciliation(context);
2175 if (activeTurnId !== undefined && cancellation?.turnId === activeTurnId) {2254 const terminalCancellation =
2176 context.cancellation = undefined;2255 activeTurnId !== undefined && cancellation?.turnId === activeTurnId
2177 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);2256 ? cancellation
2257 : undefined;
2258 if (terminalCancellation) {
2259 terminalCancellation.turnSettled = true;
2260 terminalCancellation.acknowledged = true;
2178 }2261 }
2179 context.activeTurnId = undefined;2262 context.activeTurnId = undefined;
2180 context.activeAgent = undefined;2263 context.activeAgent = undefined;
...@@ -2214,6 +2297,11 @@ export function makeOpenCodeAdapter(...@@ -2214,6 +2297,11 @@ export function makeOpenCodeAdapter(
2214 detail: event.properties.error,2297 detail: event.properties.error,
2215 },2298 },
2216 });2299 });
2300 if (terminalCancellation) {
2301 yield* Deferred.succeed(terminalCancellation.acknowledgment, undefined).pipe(
2302 Effect.ignore,
2303 );
2304 }
2217 break;2305 break;
2218 }2306 }
22192307
...@@ -2909,14 +2997,12 @@ export function makeOpenCodeAdapter(...@@ -2909,14 +2997,12 @@ export function makeOpenCodeAdapter(
2909 return;2997 return;
2910 }2998 }
2911 const existingCancellation = context.cancellation;2999 const existingCancellation = context.cancellation;
2912 if (3000 if (existingCancellation !== undefined) {
2913 existingCancellation !== undefined &&
2914 existingCancellation.turnId === interruptedTurnId
2915 ) {
2916 return yield* Deferred.await(existingCancellation.completion);3001 return yield* Deferred.await(existingCancellation.completion);
2917 }3002 }
2918 const cancellation: OpenCodeCancellation = {3003 const cancellation: OpenCodeCancellation = {
2919 turnId: interruptedTurnId,3004 turnId: interruptedTurnId,
3005 acknowledgment: Deferred.makeUnsafe<void>(),
2920 completion: Deferred.makeUnsafe<void, ProviderAdapterRequestError>(),3006 completion: Deferred.makeUnsafe<void, ProviderAdapterRequestError>(),
2921 };3007 };
2922 context.cancellation = cancellation;3008 context.cancellation = cancellation;
...@@ -2929,10 +3015,11 @@ export function makeOpenCodeAdapter(...@@ -2929,10 +3015,11 @@ export function makeOpenCodeAdapter(
2929 yield* Deferred.await(promptAdmission.submissionSettled);3015 yield* Deferred.await(promptAdmission.submissionSettled);
2930 }3016 }
29313017
2932 const abortOutcome = yield* Effect.raceFirst(3018 const parentAbortOutcome = yield* Effect.raceFirst(
2933 runOpenCodeSdk("session.abort", (signal) =>3019 runOpenCodeSdk("session.abort", (signal) =>
2934 context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),3020 context.client.session.abort({ sessionID: context.openCodeSessionId }, { signal }),
2935 ).pipe(3021 ).pipe(
3022 Effect.asVoid,
2936 Effect.timeout("10 seconds"),3023 Effect.timeout("10 seconds"),
2937 Effect.catchTags({3024 Effect.catchTags({
2938 OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)),3025 OpenCodeRuntimeError: (cause) => Effect.fail(toRequestError(cause)),
...@@ -2949,33 +3036,67 @@ export function makeOpenCodeAdapter(...@@ -2949,33 +3036,67 @@ export function makeOpenCodeAdapter(
2949 Effect.exit,3036 Effect.exit,
2950 Effect.map((exit) => ({ source: "request" as const, exit })),3037 Effect.map((exit) => ({ source: "request" as const, exit })),
2951 ),3038 ),
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 ),
2952 Deferred.await(cancellation.completion).pipe(3075 Deferred.await(cancellation.completion).pipe(
2953 Effect.exit,3076 Effect.exit,
2954 Effect.map((exit) => ({ source: "event" as const, exit })),3077 Effect.map((exit) => ({ source: "completion" as const, exit })),
2955 ),3078 ),
2956 );3079 );
2957 if (abortOutcome.source === "event") {3080 if (descendantAbortOutcome.source === "completion") {
2958 return Exit.isFailure(abortOutcome.exit)3081 return Exit.isFailure(descendantAbortOutcome.exit)
2959 ? yield* Effect.failCause(abortOutcome.exit.cause)3082 ? yield* Effect.failCause(descendantAbortOutcome.exit.cause)
2960 : undefined;3083 : undefined;
2961 }3084 }
2962 const abortExit = abortOutcome.exit;3085
2963 if (Exit.isFailure(abortExit)) {3086 const parentAbortFailed = Exit.isFailure(parentAbortExit) && !cancellation.acknowledged;
2964 if (interruptedTurnId && context.interruptedTurnId === interruptedTurnId) {3087 const failedExit = parentAbortFailed
2965 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);3088 ? parentAbortExit
2966 return;3089 : Exit.isFailure(descendantAbortOutcome.exit)
2967 }3090 ? descendantAbortOutcome.exit
2968 if (cancellation.turnId === undefined && cancellation.acknowledged) {3091 : undefined;
2969 if (context.cancellation === cancellation) {3092 if (failedExit !== undefined && Exit.isFailure(failedExit)) {
2970 context.cancellation = undefined;
2971 context.reconcileIdleStatus = true;
2972 }
2973 yield* Deferred.succeed(cancellation.completion, undefined).pipe(Effect.ignore);
2974 return;
2975 }
2976 if (context.cancellation === cancellation) {3093 if (context.cancellation === cancellation) {
2977 context.cancellation = undefined;3094 context.cancellation = undefined;
2978 if (cancellation.turnId !== undefined && cancellation.deferredIdleEvent) {3095 if (
3096 parentAbortFailed &&
3097 cancellation.turnId !== undefined &&
3098 cancellation.deferredIdleEvent
3099 ) {
2979 yield* scheduleIdleReconciliation(3100 yield* scheduleIdleReconciliation(
2980 context,3101 context,
2981 cancellation.turnId,3102 cancellation.turnId,
...@@ -2983,12 +3104,14 @@ export function makeOpenCodeAdapter(...@@ -2983,12 +3104,14 @@ export function makeOpenCodeAdapter(
2983 );3104 );
2984 }3105 }
2985 }3106 }
2986 yield* Deferred.done(cancellation.completion, abortExit).pipe(Effect.ignore);3107 yield* Deferred.done(cancellation.completion, failedExit).pipe(Effect.ignore);
2987 return yield* Effect.failCause(abortExit.cause);3108 return yield* Effect.failCause(failedExit.cause);
2988 }3109 }
29893110
2990 if (context.cancellation === cancellation) {3111 if (context.cancellation === cancellation) {
2991 if (cancellation.turnId !== undefined) {3112 if (cancellation.turnSettled) {
3113 context.cancellation = undefined;
3114 } else if (cancellation.turnId !== undefined) {
2992 yield* interruptOpenCodeTurn(context, cancellation.turnId);3115 yield* interruptOpenCodeTurn(context, cancellation.turnId);
2993 } else {3116 } else {
2994 context.cancellation = undefined;3117 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...@@ -17,6 +17,15 @@ With a server URL, T3 Code connects to that external server and uses only the pa
17provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server.17provider settings. It does not send a local `OPENCODE_SERVER_PASSWORD` to an external server.
18OpenCode uses this password for HTTP Basic authentication.18OpenCode 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
20## Refresh the model list29## Refresh the model list
2130
22T3 Code loads the model list when an enabled OpenCode provider starts and keeps the list in its31T3 Code loads the model list when an enabled OpenCode provider starts and keeps the list in its