authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-09-28 21:12:40-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-29 01:12:40+00:00
loge518866d28603da273a9357516dd1aef919f1353
tree5f4703eaa3aeed8324e4750b4388e7bc663ebdae
parentadfc9240eab7c5db376a87591b497d8144289257
signature Signed by PGP key B5690EEEBB952194

fix(server): OpenCode stop no longer hangs when a turn is interrupted before submission (#12003)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>

2 files changed, 63 insertions(+), 1 deletions(-)

apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+51
......@@ -2,6 +2,7 @@ import * as NodeAssert from "node:assert/strict";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import { it } from "@effect/vitest";
44import * as Cause from "effect/Cause";
5import * as Clock from "effect/Clock";
56import * as Context from "effect/Context";
67import * as Crypto from "effect/Crypto";
78import * as Deferred from "effect/Deferred";
......@@ -781,6 +782,56 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
781782 }),
782783 );
783784
785 it.effect(
786 "stopSession completes after sendTurn is interrupted before the prompt is submitted",
787 () =>
788 Effect.gen(function* () {
789 const adapter = yield* OpenCodeAdapter;
790 const threadId = asThreadId("thread-opencode-interrupted-before-submit");
791 runtimeMock.state.createdSessionIds.push("ses_interrupted_before_submit");
792 yield* adapter.startSession({
793 provider: ProviderDriverKind.make("opencode"),
794 threadId,
795 runtimeMode: "full-access",
796 });
797
798 const baseClock = yield* Clock.clockWith(Effect.succeed);
799 const reached = yield* Deferred.make<void>();
800 const release = yield* Deferred.make<void>();
801 let calls = 0;
802 const gatedClock: Clock.Clock = {
803 ...baseClock,
804 currentTimeMillisUnsafe: () => baseClock.currentTimeMillisUnsafe(),
805 currentTimeMillis: Effect.suspend(() => {
806 calls += 1;
807 return calls === 2
808 ? Deferred.succeed(reached, undefined).pipe(
809 Effect.andThen(Deferred.await(release)),
810 Effect.andThen(baseClock.currentTimeMillis),
811 )
812 : baseClock.currentTimeMillis;
813 }),
814 };
815
816 const sendFiber = yield* adapter
817 .sendTurn({
818 threadId,
819 input: "This prompt must not be submitted",
820 modelSelection: createModelSelection(
821 ProviderInstanceId.make("opencode"),
822 "opencode/kimi-k3",
823 ),
824 })
825 .pipe(Effect.provideService(Clock.Clock, gatedClock), Effect.exit, Effect.forkChild);
826 yield* Deferred.await(reached);
827 yield* Fiber.interrupt(sendFiber);
828
829 const stopExit = yield* adapter.stopSession(threadId).pipe(Effect.exit);
830 NodeAssert.equal(Exit.isSuccess(stopExit), true);
831 NodeAssert.equal(yield* adapter.hasSession(threadId), false);
832 }),
833 );
834
784835 it.effect("aborts a held teardown request before closing the session scope", () =>
785836 Effect.gen(function* () {
786837 const adapter = yield* OpenCodeAdapter;
apps/server/src/provider/Layers/OpenCodeAdapter.ts+12-1
......@@ -3531,7 +3531,18 @@ export function makeOpenCodeAdapter(
35313531 ? { resumeCursor: context.session.resumeCursor }
35323532 : {}),
35333533 };
3534 }),
3534 }).pipe(
3535 // stopSession waits on submissionSettled; an interrupted sendTurn
3536 // must not leave it pending.
3537 Effect.ensuring(
3538 Effect.suspend(() => {
3539 const admission = context.promptAdmission;
3540 return admission
3541 ? Deferred.succeed(admission.submissionSettled, undefined).pipe(Effect.ignore)
3542 : Effect.void;
3543 }),
3544 ),
3545 ),
35353546 );
35363547 });
35373548