From 2c2b5a72dba330fefa0d7c9e850515c9ee1dfa60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 19:54:43 -0700 Subject: [PATCH 1/9] Refactor OpenCode lifecycle management - Centralize OpenCode runtime wiring behind an Effect service - Clean up session shutdown and event fibers - Update tests and traits picker model handling --- .../git/Layers/OpenCodeTextGeneration.test.ts | 121 ++- .../src/git/Layers/OpenCodeTextGeneration.ts | 36 +- .../provider/Layers/OpenCodeAdapter.test.ts | 190 ++-- .../src/provider/Layers/OpenCodeAdapter.ts | 958 ++++++++++-------- .../provider/Layers/OpenCodeProvider.test.ts | 150 ++- .../src/provider/Layers/OpenCodeProvider.ts | 479 +++++---- .../src/provider/Layers/ProviderRegistry.ts | 2 + .../src/provider/opencodeRuntime.test.ts | 37 - apps/server/src/provider/opencodeRuntime.ts | 771 +++++++------- apps/server/src/server.ts | 4 + .../components/chat/TraitsPicker.browser.tsx | 43 +- apps/web/src/components/chat/TraitsPicker.tsx | 24 +- packages/shared/src/model.ts | 2 +- 13 files changed, 1562 insertions(+), 1255 deletions(-) delete mode 100644 apps/server/src/provider/opencodeRuntime.test.ts diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts index ab98024567..3752d5cabf 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts @@ -1,18 +1,22 @@ -import type { ChildProcess } from "node:child_process"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Duration, Effect, Layer } from "effect"; import { TestClock } from "effect/testing"; -import { beforeEach, expect, vi } from "vitest"; +import { NetService } from "@t3tools/shared/Net"; +import { beforeEach, expect } from "vitest"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "../../provider/opencodeRuntime.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; -const runtimeMock = vi.hoisted(() => { - const state = { +const runtimeMock = { + state: { startCalls: [] as string[], promptUrls: [] as string[], authHeaders: [] as Array, @@ -20,69 +24,74 @@ const runtimeMock = vi.hoisted(() => { promptResult: undefined as | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } | undefined, - }; - - return { - state, - reset() { - state.startCalls.length = 0; - state.promptUrls.length = 0; - state.authHeaders.length = 0; - state.closeCalls.length = 0; - state.promptResult = undefined; - }, - }; -}); - -vi.mock("../../provider/opencodeRuntime.ts", async () => { - const actual = await vi.importActual( - "../../provider/opencodeRuntime.ts", - ); + }, + reset() { + this.state.startCalls.length = 0; + this.state.promptUrls.length = 0; + this.state.authHeaders.length = 0; + this.state.closeCalls.length = 0; + this.state.promptResult = undefined; + }, +}; - return { - ...actual, - startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { +const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: ({ binaryPath }) => + Effect.sync(() => { const index = runtimeMock.state.startCalls.length + 1; const url = `http://127.0.0.1:${4_300 + index}`; runtimeMock.state.startCalls.push(binaryPath); return { url, - process: {} as ChildProcess, + exitCode: Effect.never, close: () => { runtimeMock.state.closeCalls.push(url); }, }; }), - createOpenCodeSdkClient: vi.fn( - ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ - session: { - create: vi.fn(async () => ({ data: { id: `${baseUrl}/session` } })), - prompt: vi.fn(async () => { - runtimeMock.state.promptUrls.push(baseUrl); - runtimeMock.state.authHeaders.push( - serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, - ); - return ( - runtimeMock.state.promptResult ?? { - data: { - parts: [ - { - type: "text", - text: JSON.stringify({ - subject: "Improve OpenCode reuse", - body: "Reuse one server for the full action.", - }), - }, - ], - }, - } - ); - }), + connectToOpenCodeServer: ({ serverUrl }) => + Effect.succeed({ + url: serverUrl ?? "http://127.0.0.1:4301", + exitCode: null, + external: Boolean(serverUrl), + close() {}, + }), + runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), + createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => + ({ + session: { + create: async () => ({ data: { id: `${baseUrl}/session` } }), + prompt: async () => { + runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return ( + runtimeMock.state.promptResult ?? { + data: { + parts: [ + { + type: "text", + text: JSON.stringify({ + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }), + }, + ], + }, + } + ); }, + }, + }) as unknown as ReturnType, + loadOpenCodeInventory: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", + cause: null, }), ), - }; -}); +}; const DEFAULT_TEST_MODEL_SELECTION = { provider: "opencode" as const, @@ -92,6 +101,7 @@ const DEFAULT_TEST_MODEL_SELECTION = { const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge( ServerSettingsService.layerTest({ providers: { @@ -106,10 +116,12 @@ const OpenCodeTextGenerationTestLayer = OpenCodeTextGenerationLive.pipe( prefix: "t3code-opencode-text-generation-test-", }), ), + Layer.provideMerge(NetService.layer), Layer.provideMerge(NodeServices.layer), ); const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive.pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge( ServerSettingsService.layerTest({ providers: { @@ -126,6 +138,7 @@ const OpenCodeTextGenerationExistingServerTestLayer = OpenCodeTextGenerationLive prefix: "t3code-opencode-text-generation-existing-server-test-", }), ), + Layer.provideMerge(NetService.layer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index d206e59e8d..b9103f0770 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -25,13 +25,14 @@ import { sanitizeThreadTitle, } from "../Utils.ts"; import { - createOpenCodeSdkClient, + OpenCodeRuntime, type OpenCodeServerConnection, type OpenCodeServerProcess, + openCodeRuntimeErrorDetail, parseOpenCodeModelSlug, - startOpenCodeServerProcess, toOpenCodeFileParts, } from "../../provider/opencodeRuntime.ts"; +import { NetService } from "@t3tools/shared/Net"; const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; @@ -87,7 +88,9 @@ interface SharedOpenCodeTextGenerationServerState { const makeOpenCodeTextGeneration = Effect.gen(function* () { const serverConfig = yield* ServerConfig; + const netService = yield* NetService; const serverSettingsService = yield* ServerSettingsService; + const openCodeRuntime = yield* OpenCodeRuntime; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), ); @@ -170,15 +173,21 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { } } - const server = yield* Effect.tryPromise({ - try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }), - catch: (cause) => - new TextGenerationError({ - operation: input.operation, - detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.", - cause, - }), - }); + const server = yield* openCodeRuntime + .startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + }) + .pipe( + Effect.provideService(NetService, netService), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + ), + ); sharedServerState.server = server; sharedServerState.binaryPath = input.binaryPath; @@ -264,7 +273,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { const runAgainstServer = (server: Pick) => Effect.tryPromise({ try: async () => { - const client = createOpenCodeSdkClient({ + const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd, ...(settings.serverUrl.length > 0 && settings.serverPassword @@ -304,8 +313,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { catch: (cause) => new TextGenerationError({ operation: input.operation, - detail: - cause instanceof Error ? cause.message : "OpenCode text generation request failed.", + detail: openCodeRuntimeErrorDetail(cause), cause, }), }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 98691082cf..20f1e27a4e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -3,13 +3,18 @@ import assert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; -import { beforeEach, vi } from "vitest"; +import { beforeEach } from "vitest"; import { ThreadId } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "../opencodeRuntime.ts"; import { appendOpenCodeAssistantTextDelta, makeOpenCodeAdapterLive, @@ -18,16 +23,16 @@ import { const asThreadId = (value: string): ThreadId => ThreadId.make(value); -const runtimeMock = vi.hoisted(() => { - type MessageEntry = { - info: { - id: string; - role: "user" | "assistant"; - }; - parts: Array; +type MessageEntry = { + info: { + id: string; + role: "user" | "assistant"; }; + parts: Array; +}; - const state = { +const runtimeMock = { + state: { startCalls: [] as string[], sessionCreateUrls: [] as string[], authHeaders: [] as Array, @@ -38,44 +43,35 @@ const runtimeMock = vi.hoisted(() => { closeError: null as Error | null, messages: [] as MessageEntry[], subscribedEvents: [] as unknown[], - }; - - return { - state, - reset() { - state.startCalls.length = 0; - state.sessionCreateUrls.length = 0; - state.authHeaders.length = 0; - state.abortCalls.length = 0; - state.closeCalls.length = 0; - state.revertCalls.length = 0; - state.promptAsyncError = null; - state.closeError = null; - state.messages = []; - state.subscribedEvents = []; - }, - }; -}); - -vi.mock("../opencodeRuntime.ts", async () => { - const actual = - await vi.importActual("../opencodeRuntime.ts"); - - return { - ...actual, - startOpenCodeServerProcess: vi.fn(async ({ binaryPath }: { binaryPath: string }) => { + }, + reset() { + this.state.startCalls.length = 0; + this.state.sessionCreateUrls.length = 0; + this.state.authHeaders.length = 0; + this.state.abortCalls.length = 0; + this.state.closeCalls.length = 0; + this.state.revertCalls.length = 0; + this.state.promptAsyncError = null; + this.state.closeError = null; + this.state.messages = []; + this.state.subscribedEvents = []; + }, +}; + +const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: ({ binaryPath }) => + Effect.sync(() => { runtimeMock.state.startCalls.push(binaryPath); return { url: "http://127.0.0.1:4301", - process: { - once() {}, - }, + exitCode: Effect.never, close() {}, }; }), - connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ + connectToOpenCodeServer: ({ serverUrl }) => + Effect.sync(() => ({ url: serverUrl ?? "http://127.0.0.1:4301", - process: null, + exitCode: null, external: Boolean(serverUrl), close() { runtimeMock.state.closeCalls.push(serverUrl ?? "http://127.0.0.1:4301"); @@ -84,59 +80,64 @@ vi.mock("../opencodeRuntime.ts", async () => { } }, })), - createOpenCodeSdkClient: vi.fn( - ({ baseUrl, serverPassword }: { baseUrl: string; serverPassword?: string }) => ({ - session: { - create: vi.fn(async () => { - runtimeMock.state.sessionCreateUrls.push(baseUrl); - runtimeMock.state.authHeaders.push( - serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, - ); - return { data: { id: `${baseUrl}/session` } }; - }), - abort: vi.fn(async ({ sessionID }: { sessionID: string }) => { - runtimeMock.state.abortCalls.push(sessionID); - }), - promptAsync: vi.fn(async () => { - if (runtimeMock.state.promptAsyncError) { - throw runtimeMock.state.promptAsyncError; - } - }), - messages: vi.fn(async () => ({ data: runtimeMock.state.messages })), - revert: vi.fn( - async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { - runtimeMock.state.revertCalls.push({ - sessionID, - ...(messageID ? { messageID } : {}), - }); - if (!messageID) { - runtimeMock.state.messages = []; - return; - } - - const targetIndex = runtimeMock.state.messages.findIndex( - (entry) => entry.info.id === messageID, - ); - runtimeMock.state.messages = - targetIndex >= 0 - ? runtimeMock.state.messages.slice(0, targetIndex + 1) - : runtimeMock.state.messages; - }, - ), + runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), + createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => + ({ + session: { + create: async () => { + runtimeMock.state.sessionCreateUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return { data: { id: `${baseUrl}/session` } }; + }, + abort: async ({ sessionID }: { sessionID: string }) => { + runtimeMock.state.abortCalls.push(sessionID); + }, + promptAsync: async () => { + if (runtimeMock.state.promptAsyncError) { + throw runtimeMock.state.promptAsyncError; + } }, - event: { - subscribe: vi.fn(async () => ({ - stream: (async function* () { - for (const event of runtimeMock.state.subscribedEvents) { - yield event; - } - })(), - })), + messages: async () => ({ data: runtimeMock.state.messages }), + revert: async ({ sessionID, messageID }: { sessionID: string; messageID?: string }) => { + runtimeMock.state.revertCalls.push({ + sessionID, + ...(messageID ? { messageID } : {}), + }); + if (!messageID) { + runtimeMock.state.messages = []; + return; + } + + const targetIndex = runtimeMock.state.messages.findIndex( + (entry) => entry.info.id === messageID, + ); + runtimeMock.state.messages = + targetIndex >= 0 + ? runtimeMock.state.messages.slice(0, targetIndex + 1) + : runtimeMock.state.messages; }, + }, + event: { + subscribe: async () => ({ + stream: (async function* () { + for (const event of runtimeMock.state.subscribedEvents) { + yield event; + } + })(), + }), + }, + }) as unknown as ReturnType, + loadOpenCodeInventory: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", + cause: null, }), ), - }; -}); +}; const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, @@ -148,6 +149,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory }); const OpenCodeAdapterTestLayer = makeOpenCodeAdapterLive().pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge( ServerSettingsService.layerTest({ @@ -374,7 +376,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { close: () => Effect.void, }; - const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( + const adapterLayer = makeOpenCodeAdapterLive({ + nativeEventLogger, + }).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge( ServerSettingsService.layerTest({ @@ -450,7 +455,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { close: () => Effect.void, }; - const adapterLayer = makeOpenCodeAdapterLive({ nativeEventLogger }).pipe( + const adapterLayer = makeOpenCodeAdapterLive({ + nativeEventLogger, + }).pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge( ServerSettingsService.layerTest({ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 4e3c12ef5d..65d13831d2 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -11,7 +11,7 @@ import { TurnId, type UserInputQuestion, } from "@t3tools/contracts"; -import { Cause, Effect, Layer, Queue, Stream } from "effect"; +import { Cause, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -28,9 +28,9 @@ import { import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { buildOpenCodePermissionRules, - connectToOpenCodeServer, - createOpenCodeSdkClient, + OpenCodeRuntime, openCodeQuestionId, + openCodeRuntimeErrorDetail, parseOpenCodeModelSlug, toOpenCodeFileParts, toOpenCodePermissionReply, @@ -45,6 +45,13 @@ interface OpenCodeTurnSnapshot { readonly items: Array; } +type OpenCodeSubscribedEvent = + Awaited> extends { + readonly stream: AsyncIterable; + } + ? TEvent + : never; + interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; @@ -62,6 +69,9 @@ interface OpenCodeSessionContext { activeAgent: string | undefined; activeVariant: string | undefined; stopped: boolean; + readonly sessionScope: Scope.Closeable; + eventsFiber: Fiber.Fiber | undefined; + exitFiber: Fiber.Fiber | undefined; readonly eventsAbortController: AbortController; } @@ -83,6 +93,15 @@ function isProviderAdapterRequestError(cause: unknown): cause is ProviderAdapter ); } +function isProviderAdapterProcessError(cause: unknown): cause is ProviderAdapterProcessError { + return ( + typeof cause === "object" && + cause !== null && + "_tag" in cause && + cause._tag === "ProviderAdapterProcessError" + ); +} + function buildEventBase(input: { readonly threadId: ThreadId; readonly turnId?: TurnId | undefined; @@ -369,28 +388,48 @@ function updateProviderSession( return nextSession; } -async function stopOpenCodeContext(context: OpenCodeSessionContext): Promise { +const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( + context: OpenCodeSessionContext, +) { + if (context.stopped) { + return; + } context.stopped = true; context.eventsAbortController.abort(); - try { - await context.client.session - .abort({ sessionID: context.openCodeSessionId }) - .catch(() => undefined); - } catch {} - context.server.close(); -} -export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { + const eventsFiber = context.eventsFiber; + context.eventsFiber = undefined; + if (eventsFiber && eventsFiber.pollUnsafe() === undefined) { + yield* Fiber.interrupt(eventsFiber).pipe(Effect.ignore); + } + + const exitFiber = context.exitFiber; + context.exitFiber = undefined; + if (exitFiber && exitFiber.pollUnsafe() === undefined) { + yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); + } + + yield* Effect.tryPromise({ + try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }), + catch: () => undefined, + }).pipe(Effect.ignore); + + yield* Scope.close(context.sessionScope, Exit.void); +}); + +export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { return Layer.effect( OpenCodeAdapter, Effect.gen(function* () { const serverConfig = yield* ServerConfig; const serverSettings = yield* ServerSettingsService; - const services = yield* Effect.context(); + const openCodeRuntime = yield* OpenCodeRuntime; + const runtimeContext = yield* Effect.context(); + const runFork = Effect.runForkWith(runtimeContext); const nativeEventLogger = - _options?.nativeEventLogger ?? - (_options?.nativeEventLogPath !== undefined - ? yield* makeEventNdjsonLogger(_options.nativeEventLogPath, { + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native", }) : undefined); @@ -399,43 +438,40 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); - const emitPromise = (event: ProviderRuntimeEvent) => - emit(event).pipe(Effect.runPromiseWith(services)); - const writeNativeEventPromise = ( + const writeNativeEvent = ( threadId: ThreadId, event: { readonly observedAt: string; readonly event: Record; }, - ) => - (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void).pipe( - Effect.runPromiseWith(services), - ); + ) => (nativeEventLogger ? nativeEventLogger.write(event, threadId) : Effect.void); const writeNativeEventBestEffort = ( threadId: ThreadId, event: { readonly observedAt: string; readonly event: Record; }, - ) => writeNativeEventPromise(threadId, event).catch(() => undefined); + ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); - const emitUnexpectedExit = (context: OpenCodeSessionContext, message: string) => { + const emitUnexpectedExit = Effect.fn("emitUnexpectedExit")(function* ( + context: OpenCodeSessionContext, + message: string, + ) { if (context.stopped) { return; } - context.stopped = true; - sessions.delete(context.session.threadId); - context.server.close(); const turnId = context.activeTurnId; - void emitPromise({ + sessions.delete(context.session.threadId); + yield* stopOpenCodeContext(context); + yield* emit({ ...buildEventBase({ threadId: context.session.threadId, turnId }), type: "runtime.error", payload: { message, class: "transport_error", }, - }).catch(() => undefined); - void emitPromise({ + }).pipe(Effect.ignore); + yield* emit({ ...buildEventBase({ threadId: context.session.threadId, turnId }), type: "session.exited", payload: { @@ -443,16 +479,16 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { recoverable: false, exitKind: "error", }, - }).catch(() => undefined); - }; + }).pipe(Effect.ignore); + }); /** Emit content.delta and item.completed events for an assistant text part. */ - const emitAssistantTextDelta = async ( + const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( context: OpenCodeSessionContext, part: Part, turnId: TurnId | undefined, raw: unknown, - ): Promise => { + ) { const text = textFromPart(part); if (text === undefined) { return; @@ -469,7 +505,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { ); } if (deltaToEmit.length > 0) { - await emitPromise({ + yield* emit({ ...buildEventBase({ threadId: context.session.threadId, turnId, @@ -494,7 +530,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { !context.completedAssistantPartIds.has(part.id) ) { context.completedAssistantPartIds.add(part.id); - await emitPromise({ + yield* emit({ ...buildEventBase({ threadId: context.session.threadId, turnId, @@ -511,345 +547,368 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { }, }); } - }; - - const startEventPump = (context: OpenCodeSessionContext) => { - void (async () => { - try { - const subscription = await context.client.event.subscribe(undefined, { - signal: context.eventsAbortController.signal, - }); + }); - for await (const event of subscription.stream) { - const payloadSessionId = - "properties" in event - ? (event.properties as { sessionID?: unknown }).sessionID - : undefined; - if (payloadSessionId !== context.openCodeSessionId) { - continue; - } + const handleSubscribedEvent = Effect.fn("handleSubscribedEvent")(function* ( + context: OpenCodeSessionContext, + event: OpenCodeSubscribedEvent, + ) { + const payloadSessionId = + "properties" in event + ? (event.properties as { sessionID?: unknown }).sessionID + : undefined; + if (payloadSessionId !== context.openCodeSessionId) { + return; + } - const turnId = context.activeTurnId; - await writeNativeEventBestEffort(context.session.threadId, { - observedAt: nowIso(), - event: { - provider: PROVIDER, - threadId: context.session.threadId, - providerThreadId: context.openCodeSessionId, - type: event.type, - ...(turnId ? { turnId } : {}), - payload: event, - }, - }); + const turnId = context.activeTurnId; + yield* writeNativeEventBestEffort(context.session.threadId, { + observedAt: nowIso(), + event: { + provider: PROVIDER, + threadId: context.session.threadId, + providerThreadId: context.openCodeSessionId, + type: event.type, + ...(turnId ? { turnId } : {}), + payload: event, + }, + }); - switch (event.type) { - case "message.updated": { - context.messageRoleById.set(event.properties.info.id, event.properties.info.role); - if (event.properties.info.role === "assistant") { - for (const part of context.partById.values()) { - if (part.messageID !== event.properties.info.id) { - continue; - } - await emitAssistantTextDelta(context, part, turnId, event); - } - } - break; + switch (event.type) { + case "message.updated": { + context.messageRoleById.set(event.properties.info.id, event.properties.info.role); + if (event.properties.info.role === "assistant") { + for (const part of context.partById.values()) { + if (part.messageID !== event.properties.info.id) { + continue; } + yield* emitAssistantTextDelta(context, part, turnId, event); + } + } + break; + } - case "message.removed": { - context.messageRoleById.delete(event.properties.messageID); - break; - } + case "message.removed": { + context.messageRoleById.delete(event.properties.messageID); + break; + } - case "message.part.delta": { - const existingPart = context.partById.get(event.properties.partID); - if (!existingPart) { - break; - } - const role = messageRoleForPart(context, existingPart); - if (role !== "assistant") { - break; - } - const streamKind = resolveTextStreamKind(existingPart); - const delta = event.properties.delta; - if (delta.length === 0) { - break; - } - const previousText = - context.emittedTextByPartId.get(event.properties.partID) ?? - textFromPart(existingPart) ?? - ""; - const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta( - previousText, - delta, - ); - if (deltaToEmit.length === 0) { - break; - } - context.emittedTextByPartId.set(event.properties.partID, nextText); - if (existingPart.type === "text" || existingPart.type === "reasoning") { - context.partById.set(event.properties.partID, { - ...existingPart, - text: nextText, - }); - } - await emitPromise({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - itemId: event.properties.partID, - raw: event, - }), - type: "content.delta", - payload: { - streamKind, - delta: deltaToEmit, - }, - }); - break; - } + case "message.part.delta": { + const existingPart = context.partById.get(event.properties.partID); + if (!existingPart) { + break; + } + const role = messageRoleForPart(context, existingPart); + if (role !== "assistant") { + break; + } + const streamKind = resolveTextStreamKind(existingPart); + const delta = event.properties.delta; + if (delta.length === 0) { + break; + } + const previousText = + context.emittedTextByPartId.get(event.properties.partID) ?? + textFromPart(existingPart) ?? + ""; + const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); + if (deltaToEmit.length === 0) { + break; + } + context.emittedTextByPartId.set(event.properties.partID, nextText); + if (existingPart.type === "text" || existingPart.type === "reasoning") { + context.partById.set(event.properties.partID, { + ...existingPart, + text: nextText, + }); + } + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: event.properties.partID, + raw: event, + }), + type: "content.delta", + payload: { + streamKind, + delta: deltaToEmit, + }, + }); + break; + } - case "message.part.updated": { - const part = event.properties.part; - context.partById.set(part.id, part); - const messageRole = messageRoleForPart(context, part); - - if (messageRole === "assistant") { - await emitAssistantTextDelta(context, part, turnId, event); - } - - if (part.type === "tool") { - const itemType = toToolLifecycleItemType(part.tool); - const title = - part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; - const detail = detailFromToolPart(part); - const payload = { - itemType, - ...(part.state.status === "error" - ? { status: "failed" as const } - : part.state.status === "completed" - ? { status: "completed" as const } - : { status: "inProgress" as const }), - ...(title ? { title } : {}), - ...(detail ? { detail } : {}), - data: { - tool: part.tool, - state: part.state, - }, - }; - const runtimeEvent: ProviderRuntimeEvent = { - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - itemId: part.callID, - createdAt: toolStateCreatedAt(part), - raw: event, - }), - type: - part.state.status === "pending" - ? "item.started" - : part.state.status === "completed" || part.state.status === "error" - ? "item.completed" - : "item.updated", - payload, - }; - appendTurnItem(context, turnId, part); - await emitPromise(runtimeEvent); - } - break; - } + case "message.part.updated": { + const part = event.properties.part; + context.partById.set(part.id, part); + const messageRole = messageRoleForPart(context, part); - case "permission.asked": { - context.pendingPermissions.set(event.properties.id, event.properties); - await emitPromise({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.id, - raw: event, - }), - type: "request.opened", - payload: { - requestType: mapPermissionToRequestType(event.properties.permission), - detail: - event.properties.patterns.length > 0 - ? event.properties.patterns.join("\n") - : event.properties.permission, - args: event.properties.metadata, - }, - }); - break; - } + if (messageRole === "assistant") { + yield* emitAssistantTextDelta(context, part, turnId, event); + } - case "permission.replied": { - context.pendingPermissions.delete(event.properties.requestID); - await emitPromise({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - }), - type: "request.resolved", - payload: { - requestType: "unknown", - decision: mapPermissionDecision(event.properties.reply), - }, - }); - break; - } + if (part.type === "tool") { + const itemType = toToolLifecycleItemType(part.tool); + const title = + part.state.status === "running" ? (part.state.title ?? part.tool) : part.tool; + const detail = detailFromToolPart(part); + const payload = { + itemType, + ...(part.state.status === "error" + ? { status: "failed" as const } + : part.state.status === "completed" + ? { status: "completed" as const } + : { status: "inProgress" as const }), + ...(title ? { title } : {}), + ...(detail ? { detail } : {}), + data: { + tool: part.tool, + state: part.state, + }, + }; + const runtimeEvent: ProviderRuntimeEvent = { + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + itemId: part.callID, + createdAt: toolStateCreatedAt(part), + raw: event, + }), + type: + part.state.status === "pending" + ? "item.started" + : part.state.status === "completed" || part.state.status === "error" + ? "item.completed" + : "item.updated", + payload, + }; + appendTurnItem(context, turnId, part); + yield* emit(runtimeEvent); + } + break; + } - case "question.asked": { - context.pendingQuestions.set(event.properties.id, event.properties); - await emitPromise({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.id, - raw: event, - }), - type: "user-input.requested", - payload: { - questions: normalizeQuestionRequest(event.properties), - }, - }); - break; - } + case "permission.asked": { + context.pendingPermissions.set(event.properties.id, event.properties); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "request.opened", + payload: { + requestType: mapPermissionToRequestType(event.properties.permission), + detail: + event.properties.patterns.length > 0 + ? event.properties.patterns.join("\n") + : event.properties.permission, + args: event.properties.metadata, + }, + }); + break; + } - case "question.replied": { - const request = context.pendingQuestions.get(event.properties.requestID); - context.pendingQuestions.delete(event.properties.requestID); - const answers = Object.fromEntries( - (request?.questions ?? []).map((question, index) => [ - openCodeQuestionId(index, question), - event.properties.answers[index]?.join(", ") ?? "", - ]), - ); - await emitPromise({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - }), - type: "user-input.resolved", - payload: { answers }, - }); - break; - } + case "permission.replied": { + context.pendingPermissions.delete(event.properties.requestID); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "request.resolved", + payload: { + requestType: "unknown", + decision: mapPermissionDecision(event.properties.reply), + }, + }); + break; + } - case "question.rejected": { - context.pendingQuestions.delete(event.properties.requestID); - await emitPromise({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId, - requestId: event.properties.requestID, - raw: event, - }), - type: "user-input.resolved", - payload: { answers: {} }, - }); - break; - } + case "question.asked": { + context.pendingQuestions.set(event.properties.id, event.properties); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.id, + raw: event, + }), + type: "user-input.requested", + payload: { + questions: normalizeQuestionRequest(event.properties), + }, + }); + break; + } - case "session.status": { - if (event.properties.status.type === "busy") { - updateProviderSession(context, { status: "running", activeTurnId: turnId }); - } - - if (event.properties.status.type === "retry") { - await emitPromise({ - ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), - type: "runtime.warning", - payload: { - message: event.properties.status.message, - detail: event.properties.status, - }, - }); - break; - } - - if (event.properties.status.type === "idle" && turnId) { - context.activeTurnId = undefined; - updateProviderSession( - context, - { status: "ready" }, - { clearActiveTurnId: true }, - ); - await emitPromise({ - ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), - type: "turn.completed", - payload: { - state: "completed", - }, - }); - } - break; - } + case "question.replied": { + const request = context.pendingQuestions.get(event.properties.requestID); + context.pendingQuestions.delete(event.properties.requestID); + const answers = Object.fromEntries( + (request?.questions ?? []).map((question, index) => [ + openCodeQuestionId(index, question), + event.properties.answers[index]?.join(", ") ?? "", + ]), + ); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers }, + }); + break; + } - case "session.error": { - const message = sessionErrorMessage(event.properties.error); - const activeTurnId = context.activeTurnId; - context.activeTurnId = undefined; - updateProviderSession( - context, - { - status: "error", - lastError: message, - }, - { clearActiveTurnId: true }, - ); - if (activeTurnId) { - await emitPromise({ - ...buildEventBase({ - threadId: context.session.threadId, - turnId: activeTurnId, - raw: event, - }), - type: "turn.completed", - payload: { - state: "failed", - errorMessage: message, - }, - }); - } - await emitPromise({ - ...buildEventBase({ threadId: context.session.threadId, raw: event }), - type: "runtime.error", - payload: { - message, - class: "provider_error", - detail: event.properties.error, - }, - }); - break; - } + case "question.rejected": { + context.pendingQuestions.delete(event.properties.requestID); + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId, + requestId: event.properties.requestID, + raw: event, + }), + type: "user-input.resolved", + payload: { answers: {} }, + }); + break; + } - default: - break; - } + case "session.status": { + if (event.properties.status.type === "busy") { + updateProviderSession(context, { status: "running", activeTurnId: turnId }); } - } catch (error) { - if (context.eventsAbortController.signal.aborted || context.stopped) { - return; + + if (event.properties.status.type === "retry") { + yield* emit({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "runtime.warning", + payload: { + message: event.properties.status.message, + detail: event.properties.status, + }, + }); + break; } - emitUnexpectedExit( + + if (event.properties.status.type === "idle" && turnId) { + context.activeTurnId = undefined; + updateProviderSession(context, { status: "ready" }, { clearActiveTurnId: true }); + yield* emit({ + ...buildEventBase({ threadId: context.session.threadId, turnId, raw: event }), + type: "turn.completed", + payload: { + state: "completed", + }, + }); + } + break; + } + + case "session.error": { + const message = sessionErrorMessage(event.properties.error); + const activeTurnId = context.activeTurnId; + context.activeTurnId = undefined; + updateProviderSession( context, - error instanceof Error ? error.message : "OpenCode event stream failed.", + { + status: "error", + lastError: message, + }, + { clearActiveTurnId: true }, ); + if (activeTurnId) { + yield* emit({ + ...buildEventBase({ + threadId: context.session.threadId, + turnId: activeTurnId, + raw: event, + }), + type: "turn.completed", + payload: { + state: "failed", + errorMessage: message, + }, + }); + } + yield* emit({ + ...buildEventBase({ threadId: context.session.threadId, raw: event }), + type: "runtime.error", + payload: { + message, + class: "provider_error", + detail: event.properties.error, + }, + }); + break; } - })(); - context.server.process?.once("exit", (code, signal) => { - if (context.stopped) { - return; - } - emitUnexpectedExit( - context, - `OpenCode server exited unexpectedly (${signal ?? code ?? "unknown"}).`, + default: + break; + } + }); + + const startEventPump = (context: OpenCodeSessionContext) => { + let eventsFiber: Fiber.Fiber; + eventsFiber = runFork( + Effect.tryPromise({ + try: () => + context.client.event.subscribe(undefined, { + signal: context.eventsAbortController.signal, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + }).pipe( + Effect.flatMap((subscription) => + Stream.fromAsyncIterable(subscription.stream, (cause) => + cause instanceof Error ? cause : new Error("OpenCode event stream failed."), + ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), + ), + Effect.exit, + Effect.flatMap((exit) => { + if (context.eventsFiber === eventsFiber) { + context.eventsFiber = undefined; + } + if (context.eventsAbortController.signal.aborted || context.stopped) { + return Effect.void; + } + if (Exit.isFailure(exit)) { + const failure = Cause.squash(exit.cause); + return emitUnexpectedExit( + context, + failure instanceof Error ? failure.message : "OpenCode event stream failed.", + ); + } + return Effect.void; + }), + ), + ); + context.eventsFiber = eventsFiber; + + if (context.server.exitCode !== null) { + context.exitFiber = runFork( + context.server.exitCode.pipe( + Effect.flatMap((code) => + context.stopped + ? Effect.void + : emitUnexpectedExit(context, `OpenCode server exited unexpectedly (${code}).`), + ), + ), ); - }); + } }; const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( @@ -871,45 +930,80 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { const directory = input.cwd ?? serverConfig.cwd; const existing = sessions.get(input.threadId); if (existing) { - yield* Effect.tryPromise({ - try: () => stopOpenCodeContext(existing), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: "Failed to stop existing OpenCode session.", - cause, - }), - }); + yield* stopOpenCodeContext(existing).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to stop existing OpenCode session.", + cause, + }), + ), + ); sessions.delete(input.threadId); } - const started = yield* Effect.tryPromise({ - try: async () => { - const server = await connectToOpenCodeServer({ binaryPath, serverUrl }); - const client = createOpenCodeSdkClient({ - baseUrl: server.url, - directory, - ...(server.external && serverPassword ? { serverPassword } : {}), - }); - const openCodeSession = await client.session.create({ - title: `T3 Code ${input.threadId}`, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }); - if (!openCodeSession.data) { - throw new Error("OpenCode session.create returned no session payload."); - } - return { server, client, openCodeSession: openCodeSession.data }; - }, - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: - cause instanceof Error ? cause.message : "Failed to start OpenCode session.", - cause, - }), - }); + const started = yield* Effect.gen(function* () { + const sessionScope = yield* Scope.make(); + const startedExit = yield* Effect.exit( + Effect.gen(function* () { + const server = yield* openCodeRuntime.connectToOpenCodeServer({ + binaryPath, + serverUrl, + }); + yield* Scope.addFinalizer( + sessionScope, + Effect.sync(() => server.close()), + ); + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory, + ...(server.external && serverPassword ? { serverPassword } : {}), + }); + const openCodeSession = yield* Effect.tryPromise({ + try: () => + client.session.create({ + title: `T3 Code ${input.threadId}`, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + }); + if (!openCodeSession.data) { + return yield* Effect.fail( + new Error("OpenCode session.create returned no session payload."), + ); + } + return { sessionScope, server, client, openCodeSession: openCodeSession.data }; + }).pipe(Effect.provideService(Scope.Scope, sessionScope)), + ); + if (startedExit._tag === "Failure") { + yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); + const failure = Cause.squash(startedExit.cause); + return yield* Effect.fail( + failure instanceof Error ? failure : new Error("Failed to start OpenCode session."), + ); + } + return startedExit.value; + }).pipe( + Effect.mapError((cause) => + isProviderAdapterProcessError(cause) + ? cause + : new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: + cause instanceof Error ? cause.message : "Failed to start OpenCode session.", + cause, + }), + ), + ); // Guard against a concurrent startSession call that may have raced // and already inserted a session while we were awaiting async work. @@ -918,13 +1012,10 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { // Another call won the race – clean up the session we just created // (including the remote SDK session) and return the existing one. yield* Effect.tryPromise({ - try: () => - started.client.session - .abort({ sessionID: started.openCodeSession.id }) - .catch(() => undefined), + try: () => started.client.session.abort({ sessionID: started.openCodeSession.id }), catch: () => undefined, }).pipe(Effect.ignore); - started.server.close(); + yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); return raceWinner.session; } @@ -957,6 +1048,9 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { activeAgent: undefined, activeVariant: undefined, stopped: false, + sessionScope: started.sessionScope, + eventsFiber: undefined, + exitFiber: undefined, eventsAbortController: new AbortController(), }; sessions.set(input.threadId, context); @@ -1191,16 +1285,7 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( function* (threadId) { const context = ensureSessionContext(sessions, threadId); - yield* Effect.tryPromise({ - try: () => stopOpenCodeContext(context), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId, - detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode session.", - cause, - }), - }); + yield* stopOpenCodeContext(context); sessions.delete(threadId); yield* emit({ ...buildEventBase({ threadId }), @@ -1288,34 +1373,45 @@ export function makeOpenCodeAdapterLive(_options?: OpenCodeAdapterLiveOptions) { ); const stopAll: OpenCodeAdapterShape["stopAll"] = () => - Effect.tryPromise({ - try: async () => { - const contexts = [...sessions.values()]; - sessions.clear(); - const results = await Promise.allSettled( - contexts.map((context) => stopOpenCodeContext(context)), - ); - const errors = results - .filter((result): result is PromiseRejectedResult => result.status === "rejected") - .map((result) => result.reason); - if (errors.length === 1) { - throw errors[0]; - } - if (errors.length > 1) { - throw new AggregateError( - errors, - `Failed to stop ${errors.length} OpenCode sessions.`, + Effect.gen(function* () { + const contexts = [...sessions.values()]; + sessions.clear(); + const exits = yield* Effect.forEach( + contexts, + (context) => Effect.exit(stopOpenCodeContext(context)), + { concurrency: "unbounded" }, + ); + const failures: Array = []; + for (const exit of exits) { + if (Exit.isFailure(exit)) { + const failure = Cause.squash(exit.cause); + failures.push( + failure instanceof Error + ? failure + : new Error("Failed to stop an OpenCode session."), ); } - }, - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: "*", - detail: cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.", - cause, - }), - }); + } + if (failures.length === 1) { + return yield* Effect.fail(failures[0]!); + } + if (failures.length > 1) { + return yield* Effect.fail( + new AggregateError(failures, `Failed to stop ${failures.length} OpenCode sessions.`), + ); + } + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: "*", + detail: + cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.", + cause, + }), + ), + ); return { provider: PROVIDER, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index cf3d588d9d..929f14528d 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -3,66 +3,82 @@ import assert from "node:assert/strict"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { beforeEach, vi } from "vitest"; +import { beforeEach } from "vitest"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; -import { makeOpenCodeProviderLive } from "./OpenCodeProvider.ts"; - -const runtimeMock = vi.hoisted(() => { - const state = { +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "../opencodeRuntime.ts"; +import { OpenCodeProviderLive, type OpenCodeInventory } from "./OpenCodeProvider.ts"; + +const runtimeMock = { + state: { runVersionError: null as Error | null, inventoryError: null as Error | null, - }; - - return { - state, - reset() { - state.runVersionError = null; - state.inventoryError = null; - }, - }; -}); - -vi.mock("../opencodeRuntime.ts", async () => { - const actual = - await vi.importActual("../opencodeRuntime.ts"); - - return { - ...actual, - runOpenCodeCommand: vi.fn(async () => { - if (runtimeMock.state.runVersionError) { - throw runtimeMock.state.runVersionError; - } - return { stdout: "opencode 1.0.0\n", stderr: "", code: 0 }; + inventory: { + providerList: { connected: [] as string[], all: [] as unknown[], default: {} }, + agents: [] as unknown[], + } as unknown, + }, + reset() { + this.state.runVersionError = null; + this.state.inventoryError = null; + this.state.inventory = { + providerList: { connected: [], all: [] as unknown[], default: {} }, + agents: [] as unknown[], + }; + }, +}; + +const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: () => + Effect.succeed({ + url: "http://127.0.0.1:4301", + exitCode: Effect.never, + close() {}, }), - connectToOpenCodeServer: vi.fn(async ({ serverUrl }: { serverUrl?: string }) => ({ + connectToOpenCodeServer: ({ serverUrl }) => + Effect.succeed({ url: serverUrl ?? "http://127.0.0.1:4301", - process: null, + exitCode: null, external: Boolean(serverUrl), close() {}, - })), - createOpenCodeSdkClient: vi.fn(() => ({})), - loadOpenCodeInventory: vi.fn(async () => { - if (runtimeMock.state.inventoryError) { - throw runtimeMock.state.inventoryError; - } - return { - providerList: { connected: [], all: [] }, - agents: [], - }; }), - flattenOpenCodeModels: vi.fn(() => []), - }; -}); + runOpenCodeCommand: () => + runtimeMock.state.runVersionError + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: runtimeMock.state.runVersionError.message, + cause: runtimeMock.state.runVersionError, + }), + ) + : Effect.succeed({ stdout: "opencode 1.0.0\n", stderr: "", code: 0 }), + createOpenCodeSdkClient: () => + ({}) as unknown as ReturnType, + loadOpenCodeInventory: () => + runtimeMock.state.inventoryError + ? Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: runtimeMock.state.inventoryError.message, + cause: runtimeMock.state.inventoryError, + }), + ) + : Effect.succeed(runtimeMock.state.inventory as OpenCodeInventory), +}; beforeEach(() => { runtimeMock.reset(); }); const makeTestLayer = (settingsOverrides?: Parameters[0]) => - makeOpenCodeProviderLive().pipe( + OpenCodeProviderLive.pipe( + Layer.provideMerge(Layer.succeed(OpenCodeRuntime, OpenCodeRuntimeTestDouble)), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(ServerSettingsService.layerTest(settingsOverrides)), Layer.provideMerge(NodeServices.layer), @@ -92,6 +108,54 @@ it.layer(makeTestLayer())("OpenCodeProviderLive", (it) => { assert.equal(snapshot.message, "Failed to execute OpenCode CLI health check."); }), ); + + it.effect("emits OpenCode variant defaults so trait picker can resolve a visible selection", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["openai"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + variants: { + none: {}, + low: {}, + medium: {}, + high: {}, + xhigh: {}, + }, + }, + }, + }, + ], + default: {}, + }, + agents: [ + { name: "build", hidden: false, mode: "primary" }, + { name: "plan", hidden: false, mode: "primary" }, + ], + }; + + const provider = yield* OpenCodeProvider; + const snapshot = yield* provider.refresh; + const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); + + assert.ok(model); + assert.equal( + model.capabilities?.variantOptions?.find((option) => option.isDefault)?.value, + "medium", + ); + assert.equal( + model.capabilities?.agentOptions?.find((option) => option.isDefault)?.value, + "build", + ); + }), + ); }); it.layer( diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index f196941257..be7e985bf8 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -1,57 +1,54 @@ -import type { OpenCodeSettings, ServerProvider } from "@t3tools/contracts"; -import { Cause, Effect, Equal, Layer, Stream } from "effect"; +import type { + ModelCapabilities, + OpenCodeSettings, + ServerProvider, + ServerProviderModel, +} from "@t3tools/contracts"; +import { Cause, Data, Effect, Equal, Layer, Stream } from "effect"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { buildServerProvider, - isCommandMissingCause, parseGenericCliVersion, providerModelsFromSettings, } from "../providerSnapshot.ts"; import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; -import { - connectToOpenCodeServer, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - createOpenCodeSdkClient, - flattenOpenCodeModels, - loadOpenCodeInventory, - runOpenCodeCommand, -} from "../opencodeRuntime.ts"; +import { OpenCodeRuntime, openCodeRuntimeErrorDetail } from "../opencodeRuntime.ts"; +import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = "opencode" as const; -class OpenCodeProbePromiseError extends Error { - override readonly cause: unknown; +class OpenCodeProbeError extends Data.TaggedError("OpenCodeProbeError")<{ + readonly cause: unknown; + readonly detail: string; +}> {} - constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause)); - this.cause = cause; - this.name = "OpenCodeProbePromiseError"; +function normalizeProbeMessage(message: string): string | undefined { + const trimmed = message.trim(); + if (trimmed.length === 0) { + return undefined; } -} - -function toOpenCodeProbeError(cause: unknown): OpenCodeProbePromiseError { - return new OpenCodeProbePromiseError(cause); + if ( + trimmed === "An error occurred in Effect.tryPromise" || + trimmed === "An error occurred in Effect.try" + ) { + return undefined; + } + return trimmed; } function normalizedErrorMessage(cause: unknown): string | undefined { - if (!(cause instanceof Error)) { - return undefined; + if (cause instanceof OpenCodeProbeError) { + return normalizeProbeMessage(cause.detail); } - const message = cause.message.trim(); - if (message.length === 0) { - return undefined; - } - if ( - message === "An error occurred in Effect.tryPromise" || - message === "An error occurred in Effect.try" - ) { + if (!(cause instanceof Error)) { return undefined; } - return message; + + return normalizeProbeMessage(cause.message); } function formatOpenCodeProbeError(input: { @@ -59,8 +56,8 @@ function formatOpenCodeProbeError(input: { readonly isExternalServer: boolean; readonly serverUrl: string; }): { readonly installed: boolean; readonly message: string } { - const lower = input.cause instanceof Error ? input.cause.message.toLowerCase() : ""; const detail = normalizedErrorMessage(input.cause); + const lower = detail?.toLowerCase() ?? ""; if (input.isExternalServer) { if ( @@ -96,7 +93,7 @@ function formatOpenCodeProbeError(input: { }; } - if (input.cause instanceof Error && isCommandMissingCause(input.cause)) { + if (lower.includes("enoent") || lower.includes("notfound")) { return { installed: false, message: "OpenCode CLI (`opencode`) is not installed or not on PATH.", @@ -127,6 +124,104 @@ function formatOpenCodeProbeError(input: { }; } +export interface OpenCodeInventory { + readonly providerList: ProviderListResponse; + readonly agents: ReadonlyArray; +} + +function titleCaseSlug(value: string): string { + return value + .split(/[-_/]+/) + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); +} + +function inferDefaultVariant( + providerID: string, + variants: ReadonlyArray, +): string | undefined { + if (variants.length === 1) { + return variants[0]; + } + if (providerID === "anthropic" || providerID.startsWith("google")) { + return variants.includes("high") ? "high" : undefined; + } + if (providerID === "openai" || providerID === "opencode") { + return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; + } + return undefined; +} + +function inferDefaultAgent(agents: ReadonlyArray): string | undefined { + return agents.find((agent) => agent.name === "build")?.name ?? agents[0]?.name ?? undefined; +} + +const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; + +function openCodeCapabilitiesForModel(input: { + readonly providerID: string; + readonly model: ProviderListResponse["all"][number]["models"][string]; + readonly agents: ReadonlyArray; +}): ModelCapabilities { + const variantValues = Object.keys(input.model.variants ?? {}); + const defaultVariant = inferDefaultVariant(input.providerID, variantValues); + const variantOptions: ModelCapabilities["variantOptions"] = variantValues.map((value) => + Object.assign( + { value, label: titleCaseSlug(value) }, + defaultVariant === value ? { isDefault: true } : {}, + ), + ); + const primaryAgents = input.agents.filter( + (agent) => !agent.hidden && (agent.mode === "primary" || agent.mode === "all"), + ); + const defaultAgent = inferDefaultAgent(primaryAgents); + const agentOptions: ModelCapabilities["agentOptions"] = primaryAgents.map((agent) => + Object.assign( + { value: agent.name, label: titleCaseSlug(agent.name) }, + defaultAgent === agent.name ? { isDefault: true } : {}, + ), + ); + return { + ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ...(variantOptions.length > 0 ? { variantOptions } : {}), + ...(agentOptions.length > 0 ? { agentOptions } : {}), + }; +} + +function flattenOpenCodeModels(input: OpenCodeInventory): ReadonlyArray { + const connected = new Set(input.providerList.connected); + const models: Array = []; + + for (const provider of input.providerList.all) { + if (!connected.has(provider.id)) { + continue; + } + + for (const model of Object.values(provider.models)) { + models.push({ + slug: `${provider.id}/${model.id}`, + name: model.name, + subProvider: provider.name, + isCustom: false, + capabilities: openCodeCapabilitiesForModel({ + providerID: provider.id, + model, + agents: input.agents, + }), + }); + } + } + + return models.toSorted((left, right) => left.name.localeCompare(right.name)); +} + const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): ServerProvider => { const checkedAt = new Date().toISOString(); const models = providerModelsFromSettings( @@ -170,173 +265,177 @@ const makePendingOpenCodeProvider = (openCodeSettings: OpenCodeSettings): Server }); }; -export function checkOpenCodeProviderStatus(input: { - readonly settings: OpenCodeSettings; - readonly cwd: string; -}): Effect.Effect { - const checkedAt = new Date().toISOString(); - const customModels = input.settings.customModels; - const isExternalServer = input.settings.serverUrl.trim().length > 0; - - const fallback = (cause: unknown, version: string | null = null) => { - const failure = formatOpenCodeProbeError({ - cause, - isExternalServer, - serverUrl: input.settings.serverUrl, - }); - return buildServerProvider({ - provider: PROVIDER, - enabled: input.settings.enabled, - checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), - probe: { - installed: failure.installed, - version, - status: "error", - auth: { status: "unknown" }, - message: failure.message, - }, - }); - }; +export const OpenCodeProviderLive = Layer.effect( + OpenCodeProvider, + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime; - return Effect.gen(function* () { - if (!input.settings.enabled) { - return buildServerProvider({ - provider: PROVIDER, - enabled: false, - checkedAt, - models: providerModelsFromSettings( - [], - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ), - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: isExternalServer - ? "OpenCode is disabled in T3 Code settings. A server URL is configured." - : "OpenCode is disabled in T3 Code settings.", - }, - }); - } + const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* (input: { + readonly settings: OpenCodeSettings; + readonly cwd: string; + }): Effect.fn.Return { + const checkedAt = new Date().toISOString(); + const customModels = input.settings.customModels; + const isExternalServer = input.settings.serverUrl.trim().length > 0; + + const fallback = (cause: unknown, version: string | null = null) => { + const failure = formatOpenCodeProbeError({ + cause, + isExternalServer, + serverUrl: input.settings.serverUrl, + }); + return buildServerProvider({ + provider: PROVIDER, + enabled: input.settings.enabled, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: failure.installed, + version, + status: "error", + auth: { status: "unknown" }, + message: failure.message, + }, + }); + }; + + if (!input.settings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models: providerModelsFromSettings( + [], + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ), + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: isExternalServer + ? "OpenCode is disabled in T3 Code settings. A server URL is configured." + : "OpenCode is disabled in T3 Code settings.", + }, + }); + } - let version: string | null = null; - if (!isExternalServer) { - const versionExit = yield* Effect.exit( - Effect.tryPromise({ - try: () => - runOpenCodeCommand({ + let version: string | null = null; + if (!isExternalServer) { + const versionExit = yield* Effect.exit( + openCodeRuntime + .runOpenCodeCommand({ binaryPath: input.settings.binaryPath, args: ["--version"], - }), - catch: toOpenCodeProbeError, - }), - ); - if (versionExit._tag === "Failure") { - return fallback(Cause.squash(versionExit.cause)); + }) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ), + ); + if (versionExit._tag === "Failure") { + return fallback(Cause.squash(versionExit.cause)); + } + version = parseGenericCliVersion(versionExit.value.stdout) ?? null; } - version = parseGenericCliVersion(versionExit.value.stdout) ?? null; - } - const inventoryExit = yield* Effect.exit( - Effect.acquireUseRelease( - Effect.tryPromise({ - try: () => - connectToOpenCodeServer({ + const inventoryExit = yield* Effect.exit( + Effect.acquireUseRelease( + openCodeRuntime + .connectToOpenCodeServer({ binaryPath: input.settings.binaryPath, serverUrl: input.settings.serverUrl, - }), - catch: toOpenCodeProbeError, - }), - (server) => - Effect.tryPromise({ - try: async () => { - const client = createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(isExternalServer && input.settings.serverPassword - ? { serverPassword: input.settings.serverPassword } - : {}), - }); - return await loadOpenCodeInventory(client); - }, - catch: toOpenCodeProbeError, - }), - (server) => Effect.sync(() => server.close()), - ), - ); - if (inventoryExit._tag === "Failure") { - return fallback(Cause.squash(inventoryExit.cause), version); - } + }) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ), + (server) => + openCodeRuntime + .loadOpenCodeInventory( + openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(isExternalServer && input.settings.serverPassword + ? { serverPassword: input.settings.serverPassword } + : {}), + }), + ) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ), + (server) => Effect.sync(() => server.close()), + ), + ); + if (inventoryExit._tag === "Failure") { + return fallback(Cause.squash(inventoryExit.cause), version); + } - const models = providerModelsFromSettings( - flattenOpenCodeModels(inventoryExit.value), - PROVIDER, - customModels, - DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ); - const connectedCount = inventoryExit.value.providerList.connected.length; - return buildServerProvider({ - provider: PROVIDER, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version, - status: connectedCount > 0 ? "ready" : "warning", - auth: { - status: connectedCount > 0 ? "authenticated" : "unknown", - type: "opencode", + const models = providerModelsFromSettings( + flattenOpenCodeModels(inventoryExit.value), + PROVIDER, + customModels, + DEFAULT_OPENCODE_MODEL_CAPABILITIES, + ); + const connectedCount = inventoryExit.value.providerList.connected.length; + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: connectedCount > 0 ? "ready" : "warning", + auth: { + status: connectedCount > 0 ? "authenticated" : "unknown", + type: "opencode", + }, + message: + connectedCount > 0 + ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` + : isExternalServer + ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." + : "OpenCode is available, but it did not report any connected upstream providers.", }, - message: - connectedCount > 0 - ? `${connectedCount} upstream provider${connectedCount === 1 ? "" : "s"} connected through ${isExternalServer ? "the configured OpenCode server" : "OpenCode"}.` - : isExternalServer - ? "Connected to the configured OpenCode server, but it did not report any connected upstream providers." - : "OpenCode is available, but it did not report any connected upstream providers.", - }, + }); }); - }); -} -export function makeOpenCodeProviderLive() { - return Layer.effect( - OpenCodeProvider, - Effect.gen(function* () { - const serverSettings = yield* ServerSettingsService; - const serverConfig = yield* ServerConfig; - - const getProviderSettings = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.providers.opencode), - ); + const getProviderSettings = serverSettings.getSettings.pipe( + Effect.map((settings) => settings.providers.opencode), + ); - return yield* makeManagedServerProvider({ - getSettings: getProviderSettings.pipe(Effect.orDie), - streamSettings: serverSettings.streamChanges.pipe( - Stream.map((settings) => settings.providers.opencode), - ), - haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), - initialSnapshot: makePendingOpenCodeProvider, - checkProvider: getProviderSettings.pipe( - Effect.flatMap((settings) => - checkOpenCodeProviderStatus({ - settings, - cwd: serverConfig.cwd, - }), - ), + return yield* makeManagedServerProvider({ + getSettings: getProviderSettings.pipe(Effect.orDie), + streamSettings: serverSettings.streamChanges.pipe( + Stream.map((settings) => settings.providers.opencode), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + initialSnapshot: makePendingOpenCodeProvider, + checkProvider: getProviderSettings.pipe( + Effect.flatMap((settings) => + checkOpenCodeProviderStatus({ + settings, + cwd: serverConfig.cwd, + }), ), - }); - }), - ); -} - -export const OpenCodeProviderLive = makeOpenCodeProviderLive(); + ), + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index d068335314..3f83419a38 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -16,6 +16,7 @@ import { CodexProvider } from "../Services/CodexProvider.ts"; import { CursorProvider } from "../Services/CursorProvider.ts"; import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; +import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { hydrateCachedProvider, PROVIDER_CACHE_IDS, @@ -287,6 +288,7 @@ export const ProviderRegistryLive = Layer.unwrap( Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive), Layer.provideMerge(OpenCodeProviderLive), + Layer.provideMerge(OpenCodeRuntimeLive), ), ), ); diff --git a/apps/server/src/provider/opencodeRuntime.test.ts b/apps/server/src/provider/opencodeRuntime.test.ts deleted file mode 100644 index 0e7024efc9..0000000000 --- a/apps/server/src/provider/opencodeRuntime.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DEFAULT_OPENCODE_MODEL_CAPABILITIES, flattenOpenCodeModels } from "./opencodeRuntime.ts"; - -describe("flattenOpenCodeModels", () => { - it("keeps the canonical model name separate from the subprovider label", () => { - const models = flattenOpenCodeModels({ - providerList: { - connected: ["github-copilot"], - all: [ - { - id: "github-copilot", - name: "GitHub Copilot", - models: { - "claude-opus-4.5": { - id: "claude-opus-4.5", - name: "Claude Opus 4.5", - variants: {}, - }, - }, - }, - ], - }, - agents: [], - } as unknown as Parameters[0]); - - expect(models).toEqual([ - { - slug: "github-copilot/claude-opus-4.5", - name: "Claude Opus 4.5", - subProvider: "GitHub Copilot", - isCustom: false, - capabilities: DEFAULT_OPENCODE_MODEL_CAPABILITIES, - }, - ]); - }); -}); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 91a855bce6..7fb4f5329b 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -1,8 +1,3 @@ -import { execFileSync, spawn, type ChildProcess } from "node:child_process"; -import * as FS from "node:fs"; -import { createServer, type AddressInfo } from "node:net"; -import * as OS from "node:os"; -import * as Path from "node:path"; import { pathToFileURL } from "node:url"; import type { @@ -10,7 +5,6 @@ import type { ModelCapabilities, ProviderApprovalDecision, RuntimeMode, - ServerProviderModel, } from "@t3tools/contracts"; import { createOpencodeClient, @@ -22,40 +16,73 @@ import { type QuestionAnswer, type QuestionRequest, } from "@opencode-ai/sdk/v2"; +import { + Cause, + Context, + Data, + Deferred, + Effect, + Exit, + Fiber, + Layer, + Option, + Predicate as P, + Ref, + Result, + Scope, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { isWindowsCommandNotFound } from "../processRunner.ts"; +import { collectStreamAsString } from "./providerSnapshot.ts"; +import { NetService } from "@t3tools/shared/Net"; const OPENCODE_SERVER_READY_PREFIX = "opencode server listening"; const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000; const DEFAULT_HOSTNAME = "127.0.0.1"; -const OPENAI_VARIANTS = ["none", "minimal", "low", "medium", "high", "xhigh"]; -const ANTHROPIC_VARIANTS = ["high", "max"]; -const GOOGLE_VARIANTS = ["low", "high"]; - -export const DEFAULT_OPENCODE_MODEL_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; - export interface OpenCodeServerProcess { readonly url: string; - readonly process: ChildProcess; + readonly exitCode: Effect.Effect; close(): void; } export interface OpenCodeServerConnection { readonly url: string; - readonly process: ChildProcess | null; + readonly exitCode: Effect.Effect | null; readonly external: boolean; close(): void; } -function buildOpenCodeBasicAuthorizationHeader(password: string): string { - return `Basic ${Buffer.from(`opencode:${password}`, "utf8").toString("base64")}`; +export class OpenCodeRuntimeError extends Data.TaggedError("OpenCodeRuntimeError")<{ + readonly operation: + | "runOpenCodeCommand" + | "startOpenCodeServerProcess" + | "connectToOpenCodeServer" + | "loadOpenCodeInventory"; + readonly cause: unknown; + readonly detail: string; +}> {} +const isOpenCodeRuntimeError = (error: unknown): error is OpenCodeRuntimeError => + P.isTagged(error, "OpenCodeRuntimeError"); + +export function openCodeRuntimeErrorDetail(cause: unknown): string { + if (isOpenCodeRuntimeError(cause)) return cause.detail; + if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); + if (cause && typeof cause === "object") { + // SDK v2 throws { response, request, error? } shapes — extract what's useful + const anyCause = cause as Record; + const status = (anyCause.response as { status?: number } | undefined)?.status; + const body = anyCause.error ?? anyCause.data ?? anyCause.body; + try { + return `status=${status ?? "?"} body=${JSON.stringify(body ?? cause)}`; + } catch { + /* fall through */ + } + } + return String(cause); } - export interface OpenCodeCommandResult { readonly stdout: string; readonly stderr: string; @@ -72,12 +99,32 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } -function titleCaseSlug(value: string): string { - return value - .split(/[-_/]+/) - .filter((segment) => segment.length > 0) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(" "); +export interface OpenCodeRuntimeShape { + readonly startOpenCodeServerProcess: (input: { + readonly binaryPath: string; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + }) => Effect.Effect; + readonly connectToOpenCodeServer: (input: { + readonly binaryPath: string; + readonly serverUrl?: string | null; + readonly port?: number; + readonly hostname?: string; + readonly timeoutMs?: number; + }) => Effect.Effect; + readonly runOpenCodeCommand: (input: { + readonly binaryPath: string; + readonly args: ReadonlyArray; + }) => Effect.Effect; + readonly createOpenCodeSdkClient: (input: { + readonly baseUrl: string; + readonly directory: string; + readonly serverPassword?: string; + }) => OpencodeClient; + readonly loadOpenCodeInventory: ( + client: OpencodeClient, + ) => Effect.Effect; } function parseServerUrlFromOutput(output: string): string | null { @@ -91,92 +138,6 @@ function parseServerUrlFromOutput(output: string): string | null { return null; } -function isPrimaryAgent(agent: Agent): boolean { - return !agent.hidden && (agent.mode === "primary" || agent.mode === "all"); -} - -function inferVariantValues(providerID: string): ReadonlyArray { - if (providerID === "anthropic") { - return ANTHROPIC_VARIANTS; - } - if (providerID === "openai" || providerID === "opencode") { - return OPENAI_VARIANTS; - } - if (providerID.startsWith("google")) { - return GOOGLE_VARIANTS; - } - return []; -} - -function inferDefaultVariant( - providerID: string, - variants: ReadonlyArray, -): string | undefined { - if (variants.length === 1) { - return variants[0]; - } - if (providerID === "anthropic" || providerID.startsWith("google")) { - return variants.includes("high") ? "high" : undefined; - } - if (providerID === "openai" || providerID === "opencode") { - return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined; - } - return undefined; -} - -function buildVariantOptions( - providerID: string, - model: ProviderListResponse["all"][number]["models"][string], -) { - const variantValues = Object.keys(model.variants ?? {}); - const resolvedValues = - variantValues.length > 0 ? variantValues : [...inferVariantValues(providerID)]; - const defaultVariant = inferDefaultVariant(providerID, resolvedValues); - - return resolvedValues.map((value) => { - const option: { value: string; label: string; isDefault?: boolean } = { - value, - label: titleCaseSlug(value), - }; - if (defaultVariant === value) { - option.isDefault = true; - } - return option; - }); -} - -function buildAgentOptions(agents: ReadonlyArray) { - const primaryAgents = agents.filter(isPrimaryAgent); - const defaultAgent = - primaryAgents.find((agent) => agent.name === "build")?.name ?? - primaryAgents[0]?.name ?? - undefined; - return primaryAgents.map((agent) => { - const option: { value: string; label: string; isDefault?: boolean } = { - value: agent.name, - label: titleCaseSlug(agent.name), - }; - if (defaultAgent === agent.name) { - option.isDefault = true; - } - return option; - }); -} - -function openCodeCapabilitiesForModel(input: { - readonly providerID: string; - readonly model: ProviderListResponse["all"][number]["models"][string]; - readonly agents: ReadonlyArray; -}): ModelCapabilities { - const variantOptions = buildVariantOptions(input.providerID, input.model); - const agentOptions = buildAgentOptions(input.agents); - return { - ...DEFAULT_OPENCODE_MODEL_CAPABILITIES, - ...(variantOptions.length > 0 ? { variantOptions } : {}), - ...(agentOptions.length > 0 ? { agentOptions } : {}), - }; -} - export function parseOpenCodeModelSlug( slug: string | null | undefined, ): ParsedOpenCodeModelSlug | null { @@ -196,10 +157,6 @@ export function parseOpenCodeModelSlug( }; } -export function toOpenCodeModelSlug(providerID: string, modelID: string): string { - return `${providerID}/${modelID}`; -} - export function openCodeQuestionId( index: number, question: QuestionRequest["questions"][number], @@ -286,289 +243,335 @@ export function toOpenCodeQuestionAnswers( }); } -export async function findAvailablePort(): Promise { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, DEFAULT_HOSTNAME, () => resolve()); - }); - const address = server.address() as AddressInfo; - const port = address.port; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - return port; -} - -export function resolveOpenCodeBinaryPath(binaryPath: string): string { - if (Path.isAbsolute(binaryPath)) { - return binaryPath; - } - return execFileSync("which", [binaryPath], { - encoding: "utf8", - timeout: 3_000, - }).trim(); +function ensureRuntimeError( + operation: OpenCodeRuntimeError["operation"], + detail: string, + cause: unknown, +): OpenCodeRuntimeError { + return isOpenCodeRuntimeError(cause) + ? cause + : new OpenCodeRuntimeError({ operation, detail, cause }); } -export function detectMacosSigkillHint(binaryPath: string): string | null { - try { - // Check for quarantine xattr first. - const resolvedPath = resolveOpenCodeBinaryPath(binaryPath); - const xattr = execFileSync("xattr", ["-l", resolvedPath], { - encoding: "utf8", - timeout: 3_000, - }); - if (xattr.includes("com.apple.quarantine")) { - return ( - `macOS quarantine is blocking the OpenCode binary. ` + - `Run: xattr -d com.apple.quarantine ${resolvedPath}` +const makeOpenCodeRuntime = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const netService = yield* NetService; + const runtimeContext = yield* Effect.context(); + const runFork = Effect.runForkWith(runtimeContext); + + const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => + Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(input.binaryPath, [...input.args], { + shell: process.platform === "win32", + env: process.env, + }), ); - } - - // Look for a recent crash report with the termination reason. - const crashDir = Path.join(OS.homedir(), "Library/Logs/DiagnosticReports"); - const binaryName = Path.basename(resolvedPath); - const recentReports = FS.readdirSync(crashDir) - .filter((f) => f.startsWith(binaryName) && f.endsWith(".ips")) - .toSorted() - .toReversed() - .slice(0, 1); - - for (const report of recentReports) { - const content = FS.readFileSync(Path.join(crashDir, report), "utf8"); - if (content.includes('"namespace":"CODESIGNING"')) { - return ( - "macOS killed the process due to an invalid code signature. " + - "The binary may be corrupted — try reinstalling OpenCode." - ); - } - } - } catch { - // Best-effort detection — don't fail the original error path. - } - return null; -} - -export async function startOpenCodeServerProcess(input: { - readonly binaryPath: string; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; -}): Promise { - const hostname = input.hostname ?? DEFAULT_HOSTNAME; - const port = input.port ?? (await findAvailablePort()); - const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; - const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; - const child = spawn(input.binaryPath, args, { - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - OPENCODE_CONFIG_CONTENT: JSON.stringify({}), - }, - }); - - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - - let stdout = ""; - let stderr = ""; - let closed = false; - const close = () => { - if (closed) { - return; - } - closed = true; - child.kill(); - }; - - const url = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - close(); - reject(new Error(`Timed out waiting for OpenCode server start after ${timeoutMs}ms.`)); - }, timeoutMs); - - const cleanup = () => { - clearTimeout(timeout); - child.stdout.off("data", onStdout); - child.stderr.off("data", onStderr); - child.off("error", onError); - child.off("close", onClose); - }; - - const onStdout = (chunk: string) => { - stdout += chunk; - const parsed = parseServerUrlFromOutput(stdout); - if (!parsed) { - return; + const [stdout, stderr, code] = yield* Effect.all( + [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode], + { concurrency: "unbounded" }, + ); + const exitCode = Number(code); + if (isWindowsCommandNotFound(exitCode, stderr)) { + return yield* new OpenCodeRuntimeError({ + operation: "runOpenCodeCommand", + detail: `spawn ${input.binaryPath} ENOENT`, + cause: new Error(`spawn ${input.binaryPath} ENOENT`), + }); } - cleanup(); - resolve(parsed); - }; - - const onStderr = (chunk: string) => { - stderr += chunk; - }; - - const onError = (error: Error) => { - cleanup(); - reject(error); - }; - - const onClose = (code: number | null, signal: NodeJS.Signals | null) => { - cleanup(); - const exitReason = signal ? `signal: ${signal}` : `code: ${code ?? "unknown"}`; - const hint = - signal === "SIGKILL" && process.platform === "darwin" - ? detectMacosSigkillHint(input.binaryPath) - : null; - reject( - new Error( - [ - `OpenCode server exited before startup completed (${exitReason}).`, - hint, - stdout.trim() ? `stdout:\n${stdout.trim()}` : null, - stderr.trim() ? `stderr:\n${stderr.trim()}` : null, - ] - .filter(Boolean) - .join("\n\n"), + return { + stdout, + stderr, + code: exitCode, + } satisfies OpenCodeCommandResult; + }).pipe( + Effect.scoped, + Effect.mapError((cause) => + ensureRuntimeError( + "runOpenCodeCommand", + `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`, + cause, + ), + ), + ); + + const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => + Effect.gen(function* () { + const hostname = input.hostname ?? DEFAULT_HOSTNAME; + const port = + input.port ?? + (yield* netService.findAvailablePort(0).pipe( + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + }), + ), + )); + const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; + const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; + + const scope = yield* Scope.make(); + + let closed = false; + const closeScope = Effect.sync(() => { + if (closed) { + return false; + } + closed = true; + return true; + }).pipe( + Effect.flatMap((shouldClose) => + shouldClose ? Scope.close(scope, Exit.void).pipe(Effect.ignore) : Effect.void, ), ); - }; - - child.stdout.on("data", onStdout); - child.stderr.on("data", onStderr); - child.once("error", onError); - child.once("close", onClose); - }); - - return { - url, - process: child, - close, - }; -} - -export async function connectToOpenCodeServer(input: { - readonly binaryPath: string; - readonly serverUrl?: string | null; - readonly port?: number; - readonly hostname?: string; - readonly timeoutMs?: number; -}): Promise { - const serverUrl = input.serverUrl?.trim(); - if (serverUrl) { - return { - url: serverUrl, - process: null, - external: true, - close() {}, - }; - } - const server = await startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - ...(input.port !== undefined ? { port: input.port } : {}), - ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), - ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), - }); + const child = yield* spawner + .spawn( + ChildProcess.make(input.binaryPath, args, { + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify({}), + }, + }), + ) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`, + cause, + }), + ), + ); - return { - url: server.url, - process: server.process, - external: false, - close: () => server.close(), - }; -} + const stdoutRef = yield* Ref.make(""); + const stderrRef = yield* Ref.make(""); + const readyDeferred = yield* Deferred.make(); + + const setReadyFromStdoutChunk = (chunk: string) => + Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe( + Effect.flatMap((nextStdout) => { + const parsed = parseServerUrlFromOutput(nextStdout); + return parsed + ? Deferred.succeed(readyDeferred, parsed).pipe(Effect.ignore) + : Effect.void; + }), + ); -export async function runOpenCodeCommand(input: { - readonly binaryPath: string; - readonly args: ReadonlyArray; -}): Promise { - const child = spawn(input.binaryPath, [...input.args], { - stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", - env: process.env, - }); + const stdoutFiber = yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runForEach(setReadyFromStdoutChunk), + Effect.ignore, + Effect.forkIn(scope), + ); + const stderrFiber = yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)), + Effect.ignore, + Effect.forkIn(scope), + ); - child.stdout?.setEncoding("utf8"); - child.stderr?.setEncoding("utf8"); + const exitFiber = yield* child.exitCode.pipe( + Effect.flatMap((code) => + Effect.gen(function* () { + const stdout = yield* Ref.get(stdoutRef); + const stderr = yield* Ref.get(stderrRef); + const exitCode = Number(code); + yield* Deferred.fail( + readyDeferred, + new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: [ + `OpenCode server exited before startup completed (code: ${String(exitCode)}).`, + stdout.trim() ? `stdout:\n${stdout.trim()}` : null, + stderr.trim() ? `stderr:\n${stderr.trim()}` : null, + ] + .filter(Boolean) + .join("\n\n"), + cause: { exitCode, stdout, stderr }, + }), + ).pipe(Effect.ignore); + }), + ), + Effect.ignore, + Effect.forkIn(scope), + ); - const stdoutChunks: Array = []; - const stderrChunks: Array = []; + const readyExit = yield* Effect.exit( + Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), + ); - child.stdout?.on("data", (chunk: string) => stdoutChunks.push(chunk)); - child.stderr?.on("data", (chunk: string) => stderrChunks.push(chunk)); + if (Exit.isFailure(readyExit)) { + yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); + yield* closeScope; + const squashed = Cause.squash(readyExit.cause); + return yield* ensureRuntimeError( + "startOpenCodeServerProcess", + `Failed while waiting for OpenCode server startup: ${openCodeRuntimeErrorDetail(squashed)}`, + squashed, + ); + } - const code = await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (exitCode) => resolve(exitCode ?? 0)); - }); + yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore); + yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore); + + const readyOption = readyExit.value; + if (Option.isNone(readyOption)) { + yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); + yield* closeScope; + return yield* new OpenCodeRuntimeError({ + operation: "startOpenCodeServerProcess", + detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, + cause: { timeoutMs }, + }); + } - return { - stdout: stdoutChunks.join(""), - stderr: stderrChunks.join(""), - code, - }; -} + return { + url: readyOption.value, + exitCode: child.exitCode.pipe( + Effect.map(Number), + Effect.orElseSucceed(() => 0), + ), + close: () => { + runFork(closeScope); + }, + } satisfies OpenCodeServerProcess; + }); -export function createOpenCodeSdkClient(input: { - readonly baseUrl: string; - readonly directory: string; - readonly serverPassword?: string; -}): OpencodeClient { - return createOpencodeClient({ - baseUrl: input.baseUrl, - directory: input.directory, - ...(input.serverPassword - ? { - headers: { - Authorization: buildOpenCodeBasicAuthorizationHeader(input.serverPassword), - }, - } - : {}), - throwOnError: true, - }); -} + const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { + const serverUrl = input.serverUrl?.trim(); + if (serverUrl) { + return Effect.succeed({ + url: serverUrl, + exitCode: null, + external: true, + close() {}, + }); + } -export async function loadOpenCodeInventory(client: OpencodeClient): Promise { - const [providerListResult, agentsResult] = await Promise.all([ - client.provider.list(), - client.app.agents(), - ]); - if (!providerListResult.data) { - throw new Error("OpenCode provider inventory was empty."); - } - return { - providerList: providerListResult.data, - agents: agentsResult.data ?? [], + return startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + ...(input.port !== undefined ? { port: input.port } : {}), + ...(input.hostname !== undefined ? { hostname: input.hostname } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + }).pipe( + Effect.map((server) => ({ + url: server.url, + exitCode: server.exitCode, + external: false, + close: () => server.close(), + })), + ); }; -} - -export function flattenOpenCodeModels( - input: OpenCodeInventory, -): ReadonlyArray { - const connected = new Set(input.providerList.connected); - const models: Array = []; - for (const provider of input.providerList.all) { - if (!connected.has(provider.id)) { - continue; - } + const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => + createOpencodeClient({ + baseUrl: input.baseUrl, + directory: input.directory, + ...(input.serverPassword + ? { + headers: { + Authorization: `Basic ${Buffer.from(`opencode:${input.serverPassword}`, "utf8").toString("base64")}`, + }, + } + : {}), + throwOnError: true, + }); - for (const model of Object.values(provider.models)) { - models.push({ - slug: toOpenCodeModelSlug(provider.id, model.id), - name: model.name, - subProvider: provider.name, - isCustom: false, - capabilities: openCodeCapabilitiesForModel({ - providerID: provider.id, - model, - agents: input.agents, + const loadProviders = (client: OpencodeClient) => + Effect.tryPromise({ + try: async () => client.provider.list(), + catch: (cause) => + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: `Failed to load OpenCode providers: ${openCodeRuntimeErrorDetail(cause)}`, + cause: cause, }), - }); - } - } + }).pipe( + Effect.filterMapOrFail((list) => + list.data + ? Result.succeed(list.data) + : Result.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: "OpenCode provider list was empty.", + cause: new Error("OpenCode provider list was empty."), + }), + ), + ), + ); + + const loadAgents = (client: OpencodeClient) => + Effect.tryPromise({ + try: async () => client.app.agents(), + catch: (cause) => + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: `Failed to load OpenCode agents: ${openCodeRuntimeErrorDetail(cause)}`, + cause: cause, + }), + }).pipe(Effect.map((result) => result.data ?? [])); + + const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => + Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe( + Effect.map( + ([providerList, agents]) => + ({ + providerList, + agents, + }) satisfies OpenCodeInventory, + ), + Effect.mapError( + (cause) => + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: `Failed to load OpenCode inventory: ${openCodeRuntimeErrorDetail(cause)}`, + cause: cause, + }), + ), + ); + // Effect.tryPromise({ + // try: async () => { + // const [providerListResult, agentsResult] = await Promise.all([ + // client.provider.list(), + // client.app.agents(), + // ]); + // console.log(JSON.stringify(providerListResult, null, 4)); + // console.log(JSON.stringify(agentsResult, null, 4)); + // if (!providerListResult.data) { + // throw new Error("OpenCode provider inventory was empty."); + // } + // return { + // providerList: providerListResult.data, + // agents: agentsResult.data ?? [], + // } satisfies OpenCodeInventory; + // }, + // catch: (cause) => + // new OpenCodeRuntimeError({ + // operation: "loadOpenCodeInventory", + // detail: `Failed to load OpenCode inventory: ${openCodeRuntimeErrorDetail(cause)}`, + // cause: cause, + // }), + // }); - return models.toSorted((left, right) => left.name.localeCompare(right.name)); -} + return { + startOpenCodeServerProcess, + connectToOpenCodeServer, + runOpenCodeCommand, + createOpenCodeSdkClient, + loadOpenCodeInventory, + } satisfies OpenCodeRuntimeShape; +}); + +export class OpenCodeRuntime extends Context.Service()( + "t3/provider/OpenCodeRuntime", +) {} + +export const OpenCodeRuntimeLive = Layer.effect(OpenCodeRuntime, makeOpenCodeRuntime).pipe( + Layer.provide(NetService.layer), +); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9aee0b987f..f278bf0b4a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -72,10 +72,12 @@ import { makePersistedServerRuntimeState, persistServerRuntimeState, } from "./serverRuntimeState.ts"; +import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { orchestrationDispatchRouteLayer, orchestrationSnapshotRouteLayer, } from "./orchestration/http.ts"; +import { NetService } from "@t3tools/shared/Net"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -225,6 +227,7 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services + Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), @@ -243,6 +246,7 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(OpenLive), Layer.provideMerge(ServerLifecycleEventsLive), + Layer.provide(NetService.layer), ); const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index f39d0defc7..dbfbf09287 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -630,7 +630,11 @@ describe("TraitsPicker (Codex)", () => { // ── OpenCode TraitsPicker tests ─────────────────────────────────────── -async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeModelOptions }) { +async function mountOpenCodePicker(props: { + model?: string; + options?: OpenCodeModelOptions; + models?: ServerProvider["models"]; +}) { const threadId = ThreadId.make("thread-opencode-traits"); const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); const threadKey = scopedThreadKey(threadRef); @@ -665,7 +669,7 @@ async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeMo const screen = await render( { expect(text).not.toContain("Medium · plan"); }); }); + + it("does not show a leading separator when only agent options are available", async () => { + await using _ = await mountOpenCodePicker({ + model: "openai/gpt-5.4", + models: [ + { + slug: "openai/gpt-5.4", + name: "OpenAI · GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + variantOptions: [], + agentOptions: [ + { value: "build", label: "Build", isDefault: true }, + { value: "plan", label: "Plan" }, + ], + }, + }, + ], + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Agent"); + expect(text).toContain("Build (default)"); + expect(text).toContain("Plan"); + expect(document.querySelectorAll('[data-slot="menu-separator"]')).toHaveLength(0); + }); + }); }); describe("TraitsPicker (Cursor)", () => { diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 77cb1b9a26..d30fba832e 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -91,7 +91,7 @@ function resolveNamedOption( return matchingOption; } } - return options.find((option) => option.isDefault) ?? null; + return options.find((option) => option.isDefault) ?? options[0] ?? null; } function getRawContextWindow( @@ -301,6 +301,12 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ allowPromptInjectedEffort, }); const defaultEffort = getDefaultEffort(caps); + const showsEffortSection = showEffort; + const showsThinkingSection = !showEffort && showThinking; + const showsFastModeSection = showFastMode; + const showsContextWindowSection = showContextWindow; + const hasSectionsBeforeAgent = + showsEffortSection || showsThinkingSection || showsFastModeSection || showsContextWindowSection; const handleEffortChange = useCallback( (value: string) => { @@ -348,7 +354,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ return ( <> - {showEffort ? ( + {showsEffortSection ? ( <>
@@ -378,7 +384,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ - ) : showThinking ? ( + ) : showsThinkingSection ? (
Thinking
) : null} - {showFastMode ? ( + {showsFastModeSection ? ( <> - {showEffort || showThinking ? : null} + {showsEffortSection || showsThinkingSection ? : null}
Fast Mode
) : null} - {showContextWindow ? ( + {showsContextWindowSection ? ( <> - {showEffort || showThinking || showFastMode ? : null} + {showsEffortSection || showsThinkingSection || showsFastModeSection ? ( + + ) : null}
Context Window @@ -442,7 +450,7 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ) : null} {agentOptions.length > 0 ? ( <> - + {hasSectionsBeforeAgent ? : null}
Agent
option.value === raw)) { return raw; } - return options.find((option) => option.isDefault)?.value; + return options.find((option) => option.isDefault)?.value ?? options[0]?.value; } export function normalizeOpenCodeModelOptionsWithCapabilities( From a5bc8ab3d0abf00cdbc8bd9ba9b888b35af0b6e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 20:35:42 -0700 Subject: [PATCH 2/9] Tie OpenCode lifetimes to effect scopes - Remove manual close handles from runtime server/session flows - Add scope-based cleanup for shared servers and adapter shutdown - Update tests for lifecycle finalizers and shutdown behavior --- .../git/Layers/OpenCodeTextGeneration.test.ts | 13 ++- .../src/git/Layers/OpenCodeTextGeneration.ts | 80 ++++++++++------ .../provider/Layers/OpenCodeAdapter.test.ts | 60 ++++++++---- .../src/provider/Layers/OpenCodeAdapter.ts | 27 +++++- .../provider/Layers/OpenCodeProvider.test.ts | 2 - .../src/provider/Layers/OpenCodeProvider.ts | 32 +++---- apps/server/src/provider/opencodeRuntime.ts | 94 ++++++------------- apps/server/src/server.test.ts | 9 +- 8 files changed, 176 insertions(+), 141 deletions(-) diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts index 3752d5cabf..28ee0a3e6f 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts @@ -36,16 +36,20 @@ const runtimeMock = { const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { startOpenCodeServerProcess: ({ binaryPath }) => - Effect.sync(() => { + Effect.gen(function* () { const index = runtimeMock.state.startCalls.length + 1; const url = `http://127.0.0.1:${4_300 + index}`; runtimeMock.state.startCalls.push(binaryPath); + // The production runtime binds server lifetime to the caller's scope. + // Mirror that here so the closeCalls probe observes scope close. + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + }), + ); return { url, exitCode: Effect.never, - close: () => { - runtimeMock.state.closeCalls.push(url); - }, }; }), connectToOpenCodeServer: ({ serverUrl }) => @@ -53,7 +57,6 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { url: serverUrl ?? "http://127.0.0.1:4301", exitCode: null, external: Boolean(serverUrl), - close() {}, }), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index b9103f0770..19eb892808 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -32,7 +32,6 @@ import { parseOpenCodeModelSlug, toOpenCodeFileParts, } from "../../provider/opencodeRuntime.ts"; -import { NetService } from "@t3tools/shared/Net"; const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; @@ -81,6 +80,13 @@ function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): str interface SharedOpenCodeTextGenerationServerState { server: OpenCodeServerProcess | null; + /** + * The scope that owns the shared server's lifetime. Closing this scope + * terminates the OpenCode child process and interrupts any fibers the + * runtime forked during startup. We don't hold a `close()` function on + * the server handle anymore — the scope is the only lifecycle handle. + */ + serverScope: Scope.Closeable | null; binaryPath: string | null; activeRequests: number; idleCloseFiber: Fiber.Fiber | null; @@ -88,7 +94,6 @@ interface SharedOpenCodeTextGenerationServerState { const makeOpenCodeTextGeneration = Effect.gen(function* () { const serverConfig = yield* ServerConfig; - const netService = yield* NetService; const serverSettingsService = yield* ServerSettingsService; const openCodeRuntime = yield* OpenCodeRuntime; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => @@ -97,18 +102,21 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { const sharedServerMutex = yield* Semaphore.make(1); const sharedServerState: SharedOpenCodeTextGenerationServerState = { server: null, + serverScope: null, binaryPath: null, activeRequests: 0, idleCloseFiber: null, }; - const closeSharedServer = (server: OpenCodeServerProcess) => { - if (sharedServerState.server === server) { - sharedServerState.server = null; - sharedServerState.binaryPath = null; + const closeSharedServer = Effect.fn("closeSharedServer")(function* () { + const scope = sharedServerState.serverScope; + sharedServerState.server = null; + sharedServerState.serverScope = null; + sharedServerState.binaryPath = null; + if (scope !== null) { + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); } - server.close(); - }; + }); const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { const idleCloseFiber = sharedServerState.idleCloseFiber; @@ -125,12 +133,12 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe( Effect.andThen( sharedServerMutex.withPermit( - Effect.sync(() => { + Effect.gen(function* () { if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { return; } sharedServerState.idleCloseFiber = null; - closeSharedServer(server); + yield* closeSharedServer(); }), ), ), @@ -157,7 +165,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { sharedServerState.binaryPath !== input.binaryPath && sharedServerState.activeRequests === 0 ) { - closeSharedServer(existingServer); + yield* closeSharedServer(); } else { if (sharedServerState.binaryPath !== input.binaryPath) { yield* Effect.logWarning( @@ -173,23 +181,35 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { } } - const server = yield* openCodeRuntime - .startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - }) - .pipe( - Effect.provideService(NetService, netService), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), + // Create a fresh scope that owns this shared server. The runtime + // will attach its child-process and fiber finalizers to this scope; + // closing it kills the server and interrupts those fibers. + const serverScope = yield* Scope.make(); + const startedExit = yield* Effect.exit( + openCodeRuntime + .startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + }) + .pipe( + Effect.provideService(Scope.Scope, serverScope), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + ), ), - ); + ); + if (startedExit._tag === "Failure") { + yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(startedExit.cause); + } + const server = startedExit.value; sharedServerState.server = server; + sharedServerState.serverScope = serverScope; sharedServerState.binaryPath = input.binaryPath; sharedServerState.activeRequests = 1; return server; @@ -209,17 +229,15 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { }), ); + // Module-level finalizer: on layer shutdown, cancel the idle close fiber + // and close the shared server scope. Consumers therefore cannot leak + // the shared OpenCode server by forgetting to call anything. yield* Effect.addFinalizer(() => sharedServerMutex.withPermit( Effect.gen(function* () { yield* cancelIdleCloseFiber(); - const server = sharedServerState.server; - sharedServerState.server = null; - sharedServerState.binaryPath = null; sharedServerState.activeRequests = 0; - if (server !== null) { - server.close(); - } + yield* closeSharedServer(); }), ), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 20f1e27a4e..8b696285e6 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -60,26 +60,43 @@ const runtimeMock = { const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { startOpenCodeServerProcess: ({ binaryPath }) => - Effect.sync(() => { + Effect.gen(function* () { runtimeMock.state.startCalls.push(binaryPath); + const url = "http://127.0.0.1:4301"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + if (runtimeMock.state.closeError) { + throw runtimeMock.state.closeError; + } + }), + ); return { - url: "http://127.0.0.1:4301", + url, exitCode: Effect.never, - close() {}, }; }), connectToOpenCodeServer: ({ serverUrl }) => - Effect.sync(() => ({ - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), - close() { - runtimeMock.state.closeCalls.push(serverUrl ?? "http://127.0.0.1:4301"); - if (runtimeMock.state.closeError) { - throw runtimeMock.state.closeError; - } - }, - })), + Effect.gen(function* () { + const url = serverUrl ?? "http://127.0.0.1:4301"; + // Unconditionally register a scope finalizer for test observability — + // preserves the `closeCalls` / `closeError` probes that the existing + // suites rely on. Production code never attaches a finalizer to an + // external server (it simply returns `Effect.succeed(...)`). + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + if (runtimeMock.state.closeError) { + throw runtimeMock.state.closeError; + } + }), + ); + return { + url, + exitCode: null, + external: Boolean(serverUrl), + }; + }), runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ @@ -475,7 +492,13 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { Layer.provideMerge(NodeServices.layer), ); - const sessions = yield* Effect.gen(function* () { + // Capture closeCalls *inside* the provided layer scope: the adapter's + // layer finalizer now tears down any live sessions when the layer + // closes (which is exactly what we want for leak prevention), so + // inspecting closeCalls after `Effect.provide` completes would observe + // the teardown — not the behavior under test. We care that the event + // pump kept the session alive while logging was failing. + const { sessions, closeCallsDuringRun } = yield* Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; yield* adapter.startSession({ provider: "opencode", @@ -483,12 +506,15 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { runtimeMode: "full-access", }); yield* sleep(10); - return yield* adapter.listSessions(); + return { + sessions: yield* adapter.listSessions(), + closeCallsDuringRun: [...runtimeMock.state.closeCalls], + }; }).pipe(Effect.provide(adapterLayer)); assert.equal(sessions.length, 1); assert.equal(sessions[0]?.threadId, "thread-native-log-failure"); - assert.deepEqual(runtimeMock.state.closeCalls, []); + assert.deepEqual(closeCallsDuringRun, []); }), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 65d13831d2..6419f796eb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -436,6 +436,24 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const runtimeEvents = yield* Queue.unbounded(); const sessions = new Map(); + // Layer-level finalizer: when the adapter layer shuts down, stop every + // session. Each session's `Scope.close` tears down its spawned OpenCode + // server (via the `ChildProcessSpawner` finalizer installed in + // `startOpenCodeServerProcess`) and interrupts the forked event/exit + // fibers. Consumers that can't reason about Effect scopes therefore + // cannot leak OpenCode child processes by forgetting to call `stopAll`. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const contexts = [...sessions.values()]; + sessions.clear(); + yield* Effect.forEach( + contexts, + (context) => Effect.ignore(stopOpenCodeContext(context)), + { concurrency: "unbounded", discard: true }, + ); + }), + ); + const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); const writeNativeEvent = ( @@ -898,7 +916,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { ); context.eventsFiber = eventsFiber; - if (context.server.exitCode !== null) { + if (!context.server.external && context.server.exitCode !== null) { context.exitFiber = runFork( context.server.exitCode.pipe( Effect.flatMap((code) => @@ -948,14 +966,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const sessionScope = yield* Scope.make(); const startedExit = yield* Effect.exit( Effect.gen(function* () { + // The runtime binds the server's lifetime to the Scope.Scope + // we provide below — closing `sessionScope` kills the child + // process automatically. No manual `server.close()` needed. const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, serverUrl, }); - yield* Scope.addFinalizer( - sessionScope, - Effect.sync(() => server.close()), - ); const client = openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, directory, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 929f14528d..97b69e57e2 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -39,14 +39,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { Effect.succeed({ url: "http://127.0.0.1:4301", exitCode: Effect.never, - close() {}, }), connectToOpenCodeServer: ({ serverUrl }) => Effect.succeed({ url: serverUrl ?? "http://127.0.0.1:4301", exitCode: null, external: Boolean(serverUrl), - close() {}, }), runOpenCodeCommand: () => runtimeMock.state.runVersionError diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index be7e985bf8..e8e4d03d06 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -351,20 +351,20 @@ export const OpenCodeProviderLive = Layer.effect( } const inventoryExit = yield* Effect.exit( - Effect.acquireUseRelease( - openCodeRuntime - .connectToOpenCodeServer({ - binaryPath: input.settings.binaryPath, - serverUrl: input.settings.serverUrl, - }) - .pipe( - Effect.mapError( - (cause) => - new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), - ), - ), - (server) => - openCodeRuntime + Effect.scoped( + Effect.gen(function* () { + const server = yield* openCodeRuntime + .connectToOpenCodeServer({ + binaryPath: input.settings.binaryPath, + serverUrl: input.settings.serverUrl, + }) + .pipe( + Effect.mapError( + (cause) => + new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), + ), + ); + return yield* openCodeRuntime .loadOpenCodeInventory( openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, @@ -379,8 +379,8 @@ export const OpenCodeProviderLive = Layer.effect( (cause) => new OpenCodeProbeError({ cause, detail: openCodeRuntimeErrorDetail(cause) }), ), - ), - (server) => Effect.sync(() => server.close()), + ); + }), ), ); if (inventoryExit._tag === "Failure") { diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 7fb4f5329b..d079477012 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -1,11 +1,6 @@ import { pathToFileURL } from "node:url"; -import type { - ChatAttachment, - ModelCapabilities, - ProviderApprovalDecision, - RuntimeMode, -} from "@t3tools/contracts"; +import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts"; import { createOpencodeClient, type Agent, @@ -45,14 +40,12 @@ const DEFAULT_HOSTNAME = "127.0.0.1"; export interface OpenCodeServerProcess { readonly url: string; readonly exitCode: Effect.Effect; - close(): void; } export interface OpenCodeServerConnection { readonly url: string; readonly exitCode: Effect.Effect | null; readonly external: boolean; - close(): void; } export class OpenCodeRuntimeError extends Data.TaggedError("OpenCodeRuntimeError")<{ @@ -100,19 +93,30 @@ export interface ParsedOpenCodeModelSlug { } export interface OpenCodeRuntimeShape { + /** + * Spawns a local OpenCode server process. Its lifetime is bound to the caller's + * `Scope.Scope` — the child is killed automatically when that scope closes. + * Consumers that want a long-lived server must create and hold a scope explicitly + * (see {@link Scope.make}) and close it when done. + */ readonly startOpenCodeServerProcess: (input: { readonly binaryPath: string; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; + /** + * Returns a handle to either an externally-managed OpenCode server (when + * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a + * freshly spawned local server whose lifetime is bound to the caller's scope. + */ readonly connectToOpenCodeServer: (input: { readonly binaryPath: string; readonly serverUrl?: string | null; readonly port?: number; readonly hostname?: string; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly runOpenCodeCommand: (input: { readonly binaryPath: string; readonly args: ReadonlyArray; @@ -256,8 +260,6 @@ function ensureRuntimeError( const makeOpenCodeRuntime = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const netService = yield* NetService; - const runtimeContext = yield* Effect.context(); - const runFork = Effect.runForkWith(runtimeContext); const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) => Effect.gen(function* () { @@ -297,6 +299,11 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) => Effect.gen(function* () { + // Bind this server's lifetime to the caller's scope. When the caller's + // scope closes, the spawned child is killed and all associated fibers + // are interrupted automatically — no `close()` method needed. + const runtimeScope = yield* Scope.Scope; + const hostname = input.hostname ?? DEFAULT_HOSTNAME; const port = input.port ?? @@ -313,21 +320,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS; const args = ["serve", `--hostname=${hostname}`, `--port=${port}`]; - const scope = yield* Scope.make(); - - let closed = false; - const closeScope = Effect.sync(() => { - if (closed) { - return false; - } - closed = true; - return true; - }).pipe( - Effect.flatMap((shouldClose) => - shouldClose ? Scope.close(scope, Exit.void).pipe(Effect.ignore) : Effect.void, - ), - ); - const child = yield* spawner .spawn( ChildProcess.make(input.binaryPath, args, { @@ -338,7 +330,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }), ) .pipe( - Effect.provideService(Scope.Scope, scope), + Effect.provideService(Scope.Scope, runtimeScope), Effect.mapError( (cause) => new OpenCodeRuntimeError({ @@ -367,13 +359,13 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Stream.decodeText(), Stream.runForEach(setReadyFromStdoutChunk), Effect.ignore, - Effect.forkIn(scope), + Effect.forkIn(runtimeScope), ); const stderrFiber = yield* child.stderr.pipe( Stream.decodeText(), Stream.runForEach((chunk) => Ref.update(stderrRef, (stderr) => `${stderr}${chunk}`)), Effect.ignore, - Effect.forkIn(scope), + Effect.forkIn(runtimeScope), ); const exitFiber = yield* child.exitCode.pipe( @@ -399,16 +391,21 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }), ), Effect.ignore, - Effect.forkIn(scope), + Effect.forkIn(runtimeScope), ); const readyExit = yield* Effect.exit( Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)), ); + // Startup-time fibers are no longer needed once ready has resolved (either + // way). The exit fiber is only interrupted on failure; on success it keeps + // the caller's `exitCode` effect observable until the scope closes. + yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore); + yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore); + if (Exit.isFailure(readyExit)) { yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); - yield* closeScope; const squashed = Cause.squash(readyExit.cause); return yield* ensureRuntimeError( "startOpenCodeServerProcess", @@ -417,13 +414,9 @@ const makeOpenCodeRuntime = Effect.gen(function* () { ); } - yield* Fiber.interrupt(stdoutFiber).pipe(Effect.ignore); - yield* Fiber.interrupt(stderrFiber).pipe(Effect.ignore); - const readyOption = readyExit.value; if (Option.isNone(readyOption)) { yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); - yield* closeScope; return yield* new OpenCodeRuntimeError({ operation: "startOpenCodeServerProcess", detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, @@ -437,20 +430,17 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map(Number), Effect.orElseSucceed(() => 0), ), - close: () => { - runFork(closeScope); - }, } satisfies OpenCodeServerProcess; }); const connectToOpenCodeServer: OpenCodeRuntimeShape["connectToOpenCodeServer"] = (input) => { const serverUrl = input.serverUrl?.trim(); if (serverUrl) { + // We don't own externally-configured servers — no scope interaction. return Effect.succeed({ url: serverUrl, exitCode: null, external: true, - close() {}, }); } @@ -464,7 +454,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { url: server.url, exitCode: server.exitCode, external: false, - close: () => server.close(), })), ); }; @@ -535,29 +524,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }), ), ); - // Effect.tryPromise({ - // try: async () => { - // const [providerListResult, agentsResult] = await Promise.all([ - // client.provider.list(), - // client.app.agents(), - // ]); - // console.log(JSON.stringify(providerListResult, null, 4)); - // console.log(JSON.stringify(agentsResult, null, 4)); - // if (!providerListResult.data) { - // throw new Error("OpenCode provider inventory was empty."); - // } - // return { - // providerList: providerListResult.data, - // agents: agentsResult.data ?? [], - // } satisfies OpenCodeInventory; - // }, - // catch: (cause) => - // new OpenCodeRuntimeError({ - // operation: "loadOpenCodeInventory", - // detail: `Failed to load OpenCode inventory: ${openCodeRuntimeErrorDetail(cause)}`, - // cause: cause, - // }), - // }); return { startOpenCodeServerProcess, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 66d776152e..47e159d303 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1830,7 +1830,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assertTrue(result._tag === "Failure"); - assertInclude(String(result.failure), "SocketOpenError"); + const failureMessage = String(result.failure); + assertTrue( + failureMessage.includes("SocketOpenError") || failureMessage.includes("SocketCloseError"), + ); + assertTrue( + failureMessage.includes("Unauthorized") || + failureMessage.includes("An error occurred during Open"), + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); From 73a76eb0a6a83a980080cd6d1fd8a6bed830a106 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 20:51:14 -0700 Subject: [PATCH 3/9] nit --- .../src/provider/Layers/OpenCodeAdapter.ts | 20 +++++++++++-------- .../src/provider/Layers/OpenCodeProvider.ts | 11 +++++----- apps/server/src/provider/opencodeRuntime.ts | 4 +--- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 6419f796eb..9a6b33ee7a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -993,9 +993,11 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }), }); if (!openCodeSession.data) { - return yield* Effect.fail( - new Error("OpenCode session.create returned no session payload."), - ); + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "OpenCode session.create returned no session payload.", + }); } return { sessionScope, server, client, openCodeSession: openCodeSession.data }; }).pipe(Effect.provideService(Scope.Scope, sessionScope)), @@ -1003,9 +1005,12 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { if (startedExit._tag === "Failure") { yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); const failure = Cause.squash(startedExit.cause); - return yield* Effect.fail( - failure instanceof Error ? failure : new Error("Failed to start OpenCode session."), - ); + return yield* new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: openCodeRuntimeErrorDetail(failure), + cause: startedExit.cause, + }); } return startedExit.value; }).pipe( @@ -1015,8 +1020,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { : new ProviderAdapterProcessError({ provider: PROVIDER, threadId: input.threadId, - detail: - cause instanceof Error ? cause.message : "Failed to start OpenCode session.", + detail: openCodeRuntimeErrorDetail(cause), cause, }), ), diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index e8e4d03d06..5e51eae028 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -15,7 +15,11 @@ import { providerModelsFromSettings, } from "../providerSnapshot.ts"; import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; -import { OpenCodeRuntime, openCodeRuntimeErrorDetail } from "../opencodeRuntime.ts"; +import { + OpenCodeRuntime, + openCodeRuntimeErrorDetail, + type OpenCodeInventory, +} from "../opencodeRuntime.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; const PROVIDER = "opencode" as const; @@ -124,11 +128,6 @@ function formatOpenCodeProbeError(input: { }; } -export interface OpenCodeInventory { - readonly providerList: ProviderListResponse; - readonly agents: ReadonlyArray; -} - function titleCaseSlug(value: string): string { return value .split(/[-_/]+/) diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index d079477012..111fbae38a 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -54,7 +54,7 @@ export class OpenCodeRuntimeError extends Data.TaggedError("OpenCodeRuntimeError | "startOpenCodeServerProcess" | "connectToOpenCodeServer" | "loadOpenCodeInventory"; - readonly cause: unknown; + readonly cause?: unknown; readonly detail: string; }> {} const isOpenCodeRuntimeError = (error: unknown): error is OpenCodeRuntimeError => @@ -278,7 +278,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { return yield* new OpenCodeRuntimeError({ operation: "runOpenCodeCommand", detail: `spawn ${input.binaryPath} ENOENT`, - cause: new Error(`spawn ${input.binaryPath} ENOENT`), }); } return { @@ -489,7 +488,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { new OpenCodeRuntimeError({ operation: "loadOpenCodeInventory", detail: "OpenCode provider list was empty.", - cause: new Error("OpenCode provider list was empty."), }), ), ), From 4c95573d99d741a51881759f30e8723aaf227d13 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 20:53:39 -0700 Subject: [PATCH 4/9] fix tsc --- apps/server/src/provider/Layers/OpenCodeProvider.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index 97b69e57e2..ffce708434 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -13,7 +13,8 @@ import { OpenCodeRuntimeError, type OpenCodeRuntimeShape, } from "../opencodeRuntime.ts"; -import { OpenCodeProviderLive, type OpenCodeInventory } from "./OpenCodeProvider.ts"; +import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; +import type { OpenCodeInventory } from "../opencodeRuntime.ts"; const runtimeMock = { state: { From 53d4c4708bba892233eb3ec816217cc8cf8a2f81 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 21:27:47 -0700 Subject: [PATCH 5/9] Bind OpenCode session fibers to the session scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OpenCodeSessionContext` kept three parallel lifecycle handles next to `sessionScope`: `eventsFiber`, `exitFiber`, and `eventsAbortController`. They existed because the event-pump and server-exit fibers were forked via `Effect.runForkWith(runtimeContext)`, which bypasses the scope tree, so cleanup had to interrupt each fiber and abort the controller explicitly. Switch both fibers to `Effect.forkIn(context.sessionScope)` and register `AbortController.abort()` as a `Scope.addFinalizer` on the same scope. Closing the session scope now atomically interrupts the fibers, aborts the in-flight `event.subscribe` fetch, and — for scope-owned servers — tears down the child process. The context shrinks to just `sessionScope` plus a `Ref` for the race-safe stop flag. Along the way: replace hand-written `isProviderAdapter*Error` typeguards with `Schema.is(...)`, matching how the rest of the codebase derives tagged-error predicates. Co-Authored-By: Claude Opus 4.7 --- .../src/provider/Layers/OpenCodeAdapter.ts | 176 +++++++++--------- 1 file changed, 91 insertions(+), 85 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 9a6b33ee7a..22772c9b20 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -11,7 +11,7 @@ import { TurnId, type UserInputQuestion, } from "@t3tools/contracts"; -import { Cause, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"; +import { Cause, Effect, Exit, Layer, Queue, Ref, Schema, Scope, Stream } from "effect"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -68,11 +68,21 @@ interface OpenCodeSessionContext { activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; - stopped: boolean; + /** + * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. + * The session lifecycle is owned by `sessionScope`; this Ref exists only + * so concurrent callers can race the transition safely via `getAndSet`. + */ + readonly stopped: Ref.Ref; + /** + * Sole lifecycle handle for the session. Closing this scope: + * - aborts the `AbortController` registered as a finalizer + * (cancels the in-flight `event.subscribe` fetch), + * - interrupts the event-pump and server-exit fibers forked + * via `Effect.forkIn(sessionScope)`, + * - tears down the OpenCode server process for scope-owned servers. + */ readonly sessionScope: Scope.Closeable; - eventsFiber: Fiber.Fiber | undefined; - exitFiber: Fiber.Fiber | undefined; - readonly eventsAbortController: AbortController; } export interface OpenCodeAdapterLiveOptions { @@ -84,23 +94,8 @@ function nowIso(): string { return new Date().toISOString(); } -function isProviderAdapterRequestError(cause: unknown): cause is ProviderAdapterRequestError { - return ( - typeof cause === "object" && - cause !== null && - "_tag" in cause && - cause._tag === "ProviderAdapterRequestError" - ); -} - -function isProviderAdapterProcessError(cause: unknown): cause is ProviderAdapterProcessError { - return ( - typeof cause === "object" && - cause !== null && - "_tag" in cause && - cause._tag === "ProviderAdapterProcessError" - ); -} +const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); +const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); function buildEventBase(input: { readonly threadId: ThreadId; @@ -224,7 +219,10 @@ function ensureSessionContext( if (!session) { throw new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); } - if (session.stopped) { + // `ensureSessionContext` is a sync gate used from both sync helpers and + // Effect bodies. `Ref.getUnsafe` is an atomic read of the backing cell — + // no fiber suspension required, which keeps this callable everywhere. + if (Ref.getUnsafe(session.stopped)) { throw new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); } return session; @@ -391,29 +389,22 @@ function updateProviderSession( const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( context: OpenCodeSessionContext, ) { - if (context.stopped) { + // Race-safe one-shot: first caller flips the flag, everyone else no-ops. + if (yield* Ref.getAndSet(context.stopped, true)) { return; } - context.stopped = true; - context.eventsAbortController.abort(); - - const eventsFiber = context.eventsFiber; - context.eventsFiber = undefined; - if (eventsFiber && eventsFiber.pollUnsafe() === undefined) { - yield* Fiber.interrupt(eventsFiber).pipe(Effect.ignore); - } - - const exitFiber = context.exitFiber; - context.exitFiber = undefined; - if (exitFiber && exitFiber.pollUnsafe() === undefined) { - yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore); - } + // Best-effort remote abort. The scope close below tears down the local + // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), + // but we still want to tell OpenCode that this session is done. yield* Effect.tryPromise({ try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }), catch: () => undefined, }).pipe(Effect.ignore); + // Closing the session scope interrupts every fiber forked into it and + // runs each finalizer we registered — the `AbortController.abort()` call, + // the child-process termination, etc. yield* Scope.close(context.sessionScope, Exit.void); }); @@ -424,8 +415,6 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const serverConfig = yield* ServerConfig; const serverSettings = yield* ServerSettingsService; const openCodeRuntime = yield* OpenCodeRuntime; - const runtimeContext = yield* Effect.context(); - const runFork = Effect.runForkWith(runtimeContext); const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -475,7 +464,11 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { context: OpenCodeSessionContext, message: string, ) { - if (context.stopped) { + // Two fibers can race here (the event-pump on stream failure and the + // server-exit watcher). Whoever loses the race observes `true` and + // returns — this also makes the `stopOpenCodeContext` call below a + // no-op for the loser. + if (yield* Ref.get(context.stopped)) { return; } const turnId = context.activeTurnId; @@ -874,60 +867,76 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { } }); - const startEventPump = (context: OpenCodeSessionContext) => { - let eventsFiber: Fiber.Fiber; - eventsFiber = runFork( - Effect.tryPromise({ - try: () => - context.client.event.subscribe(undefined, { - signal: context.eventsAbortController.signal, - }), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: context.session.threadId, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - }).pipe( - Effect.flatMap((subscription) => - Stream.fromAsyncIterable(subscription.stream, (cause) => - cause instanceof Error ? cause : new Error("OpenCode event stream failed."), - ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), - ), - Effect.exit, - Effect.flatMap((exit) => { - if (context.eventsFiber === eventsFiber) { - context.eventsFiber = undefined; - } - if (context.eventsAbortController.signal.aborted || context.stopped) { - return Effect.void; + const startEventPump = Effect.fn("startEventPump")(function* ( + context: OpenCodeSessionContext, + ) { + // One AbortController per session scope. The finalizer fires when + // the scope closes (explicit stop, unexpected exit, or layer + // shutdown) and cancels the in-flight `event.subscribe` fetch so + // the async iterable unwinds cleanly. + const eventsAbortController = new AbortController(); + yield* Scope.addFinalizer( + context.sessionScope, + Effect.sync(() => eventsAbortController.abort()), + ); + + // Fibers forked into `context.sessionScope` are interrupted + // automatically when the scope closes — no bookkeeping required. + yield* Effect.tryPromise({ + try: () => + context.client.event.subscribe(undefined, { + signal: eventsAbortController.signal, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: context.session.threadId, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + }).pipe( + Effect.flatMap((subscription) => + Stream.fromAsyncIterable(subscription.stream, (cause) => + cause instanceof Error ? cause : new Error("OpenCode event stream failed."), + ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), + ), + Effect.exit, + Effect.flatMap((exit) => + Effect.gen(function* () { + // Expected paths: caller aborted the fetch or the session + // has already been marked stopped. Treat as a clean exit. + if (eventsAbortController.signal.aborted || (yield* Ref.get(context.stopped))) { + return; } if (Exit.isFailure(exit)) { const failure = Cause.squash(exit.cause); - return emitUnexpectedExit( + yield* emitUnexpectedExit( context, failure instanceof Error ? failure.message : "OpenCode event stream failed.", ); } - return Effect.void; }), ), + Effect.forkIn(context.sessionScope), ); - context.eventsFiber = eventsFiber; if (!context.server.external && context.server.exitCode !== null) { - context.exitFiber = runFork( - context.server.exitCode.pipe( - Effect.flatMap((code) => - context.stopped - ? Effect.void - : emitUnexpectedExit(context, `OpenCode server exited unexpectedly (${code}).`), - ), + yield* context.server.exitCode.pipe( + Effect.flatMap((code) => + Effect.gen(function* () { + if (yield* Ref.get(context.stopped)) { + return; + } + yield* emitUnexpectedExit( + context, + `OpenCode server exited unexpectedly (${code}).`, + ); + }), ), + Effect.forkIn(context.sessionScope), ); } - }; + }); const startSession: OpenCodeAdapterShape["startSession"] = Effect.fn("startSession")( function* (input) { @@ -1068,14 +1077,11 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { activeTurnId: undefined, activeAgent: undefined, activeVariant: undefined, - stopped: false, + stopped: yield* Ref.make(false), sessionScope: started.sessionScope, - eventsFiber: undefined, - exitFiber: undefined, - eventsAbortController: new AbortController(), }; sessions.set(input.threadId, context); - startEventPump(context); + yield* startEventPump(context); yield* emit({ ...buildEventBase({ threadId: input.threadId }), From f7b79280611cd7919072b7544be1a85da4db1ee9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 21:34:47 -0700 Subject: [PATCH 6/9] rm redundant error map --- apps/server/src/provider/Layers/OpenCodeAdapter.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 22772c9b20..71400b9900 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1022,18 +1022,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }); } return startedExit.value; - }).pipe( - Effect.mapError((cause) => - isProviderAdapterProcessError(cause) - ? cause - : new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - ), - ); + }); // Guard against a concurrent startSession call that may have raced // and already inserted a session while we were awaiting async work. From 23ca46063059ac22c5b2455e43ab12e59ef8a6bc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 22:15:03 -0700 Subject: [PATCH 7/9] Harden OpenCode session cleanup and error mapping - Swallow finalizer defects during session teardown so cleanup completes - Route SDK calls through shared runtime error wrappers - Simplify lifecycle failure handling in the adapter --- .../provider/Layers/OpenCodeAdapter.test.ts | 11 +- .../src/provider/Layers/OpenCodeAdapter.ts | 361 +++++++----------- apps/server/src/provider/opencodeRuntime.ts | 90 ++--- 3 files changed, 179 insertions(+), 283 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 8b696285e6..9a391b5539 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -230,7 +230,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); - it.effect("clears session state when stopAll cleanup fails", () => + it.effect("clears session state even when cleanup finalizers throw", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; yield* adapter.startSession({ @@ -245,11 +245,14 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); runtimeMock.state.closeError = new Error("close failed"); - const error = yield* adapter.stopAll().pipe(Effect.flip); + // `stopAll` relies on `stopOpenCodeContext`, which is typed as + // never-failing. A throwing finalizer surfaces as a defect — `Effect.exit` + // captures it so the assertions can still run. The key invariant we're + // validating is "the sessions map and close-call probes reflect cleanup + // attempts regardless of finalizer outcome". + yield* Effect.exit(adapter.stopAll()); const sessions = yield* adapter.listSessions(); - assert.equal(error._tag, "ProviderAdapterProcessError"); - assert.equal(error.detail, "Failed to stop 2 OpenCode sessions."); assert.deepEqual(runtimeMock.state.closeCalls, [ "http://127.0.0.1:9999", "http://127.0.0.1:9999", diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 71400b9900..5438e0045e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -11,7 +11,7 @@ import { TurnId, type UserInputQuestion, } from "@t3tools/contracts"; -import { Cause, Effect, Exit, Layer, Queue, Ref, Schema, Scope, Stream } from "effect"; +import { Cause, Effect, Exit, Layer, Queue, Ref, Scope, Stream } from "effect"; import type { OpencodeClient, Part, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -29,9 +29,11 @@ import { OpenCodeAdapter, type OpenCodeAdapterShape } from "../Services/OpenCode import { buildOpenCodePermissionRules, OpenCodeRuntime, + OpenCodeRuntimeError, openCodeQuestionId, openCodeRuntimeErrorDetail, parseOpenCodeModelSlug, + runOpenCodeSdk, toOpenCodeFileParts, toOpenCodePermissionReply, toOpenCodeQuestionAnswers, @@ -94,8 +96,33 @@ function nowIso(): string { return new Date().toISOString(); } -const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); -const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); +/** + * Map a tagged OpenCodeRuntimeError produced by {@link runOpenCodeSdk} into + * the adapter-boundary `ProviderAdapterRequestError`. SDK-method-level call + * sites pipe through this in `Effect.mapError` so they never build the error + * shape by hand. + */ +const toRequestError = (cause: OpenCodeRuntimeError): ProviderAdapterRequestError => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: cause.operation, + detail: cause.detail, + cause: cause.cause, + }); + +/** + * Map a `Cause.squash`-ed failure into a `ProviderAdapterProcessError`. The + * typed cause is usually an `OpenCodeRuntimeError` (from {@link runOpenCodeSdk}), + * in which case we preserve its `detail`; otherwise we fall back to + * {@link openCodeRuntimeErrorDetail} for unknown causes (defects, etc.). + */ +const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProcessError => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause), + cause, + }); function buildEventBase(input: { readonly threadId: ThreadId; @@ -397,10 +424,9 @@ const stopOpenCodeContext = Effect.fn("stopOpenCodeContext")(function* ( // Best-effort remote abort. The scope close below tears down the local // handles (event-pump fiber, server-exit fiber, event-subscribe fetch), // but we still want to tell OpenCode that this session is done. - yield* Effect.tryPromise({ - try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }), - catch: () => undefined, - }).pipe(Effect.ignore); + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.ignore({ log: true })); // Closing the session scope interrupts every fiber forked into it and // runs each finalizer we registered — the `AbortController.abort()` call, @@ -435,9 +461,12 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { Effect.gen(function* () { const contexts = [...sessions.values()]; sessions.clear(); + // `ignoreCause` swallows both typed failures (none here) and defects + // from throwing scope finalizers so a sibling's death can't interrupt + // the remaining cleanups. yield* Effect.forEach( contexts, - (context) => Effect.ignore(stopOpenCodeContext(context)), + (context) => Effect.ignoreCause(stopOpenCodeContext(context)), { concurrency: "unbounded", discard: true }, ); }), @@ -882,24 +911,23 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { // Fibers forked into `context.sessionScope` are interrupted // automatically when the scope closes — no bookkeeping required. - yield* Effect.tryPromise({ - try: () => + yield* Effect.flatMap( + runOpenCodeSdk("event.subscribe", () => context.client.event.subscribe(undefined, { signal: eventsAbortController.signal, }), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: context.session.threadId, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - }).pipe( - Effect.flatMap((subscription) => - Stream.fromAsyncIterable(subscription.stream, (cause) => - cause instanceof Error ? cause : new Error("OpenCode event stream failed."), - ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), ), + (subscription) => + Stream.fromAsyncIterable( + subscription.stream, + (cause) => + new OpenCodeRuntimeError({ + operation: "event.subscribe", + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + ).pipe(Stream.runForEach((event) => handleSubscribedEvent(context, event))), + ).pipe( Effect.exit, Effect.flatMap((exit) => Effect.gen(function* () { @@ -909,10 +937,9 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { return; } if (Exit.isFailure(exit)) { - const failure = Cause.squash(exit.cause); yield* emitUnexpectedExit( context, - failure instanceof Error ? failure.message : "OpenCode event stream failed.", + openCodeRuntimeErrorDetail(Cause.squash(exit.cause)), ); } }), @@ -957,17 +984,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const directory = input.cwd ?? serverConfig.cwd; const existing = sessions.get(input.threadId); if (existing) { - yield* stopOpenCodeContext(existing).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: "Failed to stop existing OpenCode session.", - cause, - }), - ), - ); + yield* stopOpenCodeContext(existing); sessions.delete(input.threadId); } @@ -987,39 +1004,24 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { directory, ...(server.external && serverPassword ? { serverPassword } : {}), }); - const openCodeSession = yield* Effect.tryPromise({ - try: () => - client.session.create({ - title: `T3 Code ${input.threadId}`, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - }); + const openCodeSession = yield* runOpenCodeSdk("session.create", () => + client.session.create({ + title: `T3 Code ${input.threadId}`, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); if (!openCodeSession.data) { - return yield* new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, + return yield* new OpenCodeRuntimeError({ + operation: "session.create", detail: "OpenCode session.create returned no session payload.", }); } return { sessionScope, server, client, openCodeSession: openCodeSession.data }; }).pipe(Effect.provideService(Scope.Scope, sessionScope)), ); - if (startedExit._tag === "Failure") { + if (Exit.isFailure(startedExit)) { yield* Scope.close(sessionScope, Exit.void).pipe(Effect.ignore); - const failure = Cause.squash(startedExit.cause); - return yield* new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: openCodeRuntimeErrorDetail(failure), - cause: startedExit.cause, - }); + return yield* toProcessError(input.threadId, Cause.squash(startedExit.cause)); } return startedExit.value; }); @@ -1030,10 +1032,9 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { if (raceWinner) { // Another call won the race – clean up the session we just created // (including the remote SDK session) and return the existing one. - yield* Effect.tryPromise({ - try: () => started.client.session.abort({ sessionID: started.openCodeSession.id }), - catch: () => undefined, - }).pipe(Effect.ignore); + yield* runOpenCodeSdk("session.abort", () => + started.client.session.abort({ sessionID: started.openCodeSession.id }), + ).pipe(Effect.ignore); yield* Scope.close(started.sessionScope, Exit.void).pipe(Effect.ignore); return raceWinner.session; } @@ -1153,59 +1154,44 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }, }); - const promptExit = yield* Effect.exit( - Effect.tryPromise({ - try: async () => { - await context.client.session.promptAsync({ - sessionID: context.openCodeSessionId, - model: parsedModel, - ...(context.activeAgent ? { agent: context.activeAgent } : {}), - ...(context.activeVariant ? { variant: context.activeVariant } : {}), - parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], - }); - }, - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.promptAsync", - detail: cause instanceof Error ? cause.message : "Failed to send OpenCode turn.", - cause, - }), + yield* runOpenCodeSdk("session.promptAsync", () => + context.client.session.promptAsync({ + sessionID: context.openCodeSessionId, + model: parsedModel, + ...(context.activeAgent ? { agent: context.activeAgent } : {}), + ...(context.activeVariant ? { variant: context.activeVariant } : {}), + parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], }), - ); - if (promptExit._tag === "Failure") { - const failure = Cause.squash(promptExit.cause); - const requestError = isProviderAdapterRequestError(failure) - ? failure - : new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.promptAsync", - detail: - failure instanceof Error ? failure.message : "Failed to send OpenCode turn.", - cause: failure, + ).pipe( + Effect.mapError(toRequestError), + // On failure: clear active-turn state, flip the session back to ready + // with lastError set, emit turn.aborted, then let the typed error + // propagate. We don't need to rebuild the error here — `toRequestError` + // already produced the right shape. + Effect.tapError((requestError) => + Effect.gen(function* () { + context.activeTurnId = undefined; + context.activeAgent = undefined; + context.activeVariant = undefined; + updateProviderSession( + context, + { + status: "ready", + model: modelSelection?.model ?? context.session.model, + lastError: requestError.detail, + }, + { clearActiveTurnId: true }, + ); + yield* emit({ + ...buildEventBase({ threadId: input.threadId, turnId }), + type: "turn.aborted", + payload: { + reason: requestError.detail, + }, }); - const failureMessage = requestError.detail; - context.activeTurnId = undefined; - context.activeAgent = undefined; - context.activeVariant = undefined; - updateProviderSession( - context, - { - status: "ready", - model: modelSelection?.model ?? context.session.model, - lastError: failureMessage, - }, - { clearActiveTurnId: true }, - ); - yield* emit({ - ...buildEventBase({ threadId: input.threadId, turnId }), - type: "turn.aborted", - payload: { - reason: failureMessage, - }, - }); - return yield* requestError; - } + }), + ), + ); return { threadId: input.threadId, @@ -1216,16 +1202,9 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { const context = ensureSessionContext(sessions, threadId); - yield* Effect.tryPromise({ - try: () => context.client.session.abort({ sessionID: context.openCodeSessionId }), - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.abort", - detail: cause instanceof Error ? cause.message : "Failed to abort OpenCode turn.", - cause, - }), - }); + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); if (turnId ?? context.activeTurnId) { yield* emit({ ...buildEventBase({ threadId, turnId: turnId ?? context.activeTurnId }), @@ -1250,23 +1229,12 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }); } - yield* Effect.tryPromise({ - try: () => - context.client.permission.reply({ - requestID: requestId, - reply: toOpenCodePermissionReply(decision), - }), - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "permission.reply", - detail: - cause instanceof Error - ? cause.message - : "Failed to submit OpenCode permission reply.", - cause, - }), - }); + yield* runOpenCodeSdk("permission.reply", () => + context.client.permission.reply({ + requestID: requestId, + reply: toOpenCodePermissionReply(decision), + }), + ).pipe(Effect.mapError(toRequestError)); }); const respondToUserInput: OpenCodeAdapterShape["respondToUserInput"] = Effect.fn( @@ -1282,20 +1250,12 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { }); } - yield* Effect.tryPromise({ - try: () => - context.client.question.reply({ - requestID: requestId, - answers: toOpenCodeQuestionAnswers(request, answers), - }), - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "question.reply", - detail: cause instanceof Error ? cause.message : "Failed to submit OpenCode answers.", - cause, - }), - }); + yield* runOpenCodeSdk("question.reply", () => + context.client.question.reply({ + requestID: requestId, + answers: toOpenCodeQuestionAnswers(request, answers), + }), + ).pipe(Effect.mapError(toRequestError)); }); const stopSession: OpenCodeAdapterShape["stopSession"] = Effect.fn("stopSession")( @@ -1324,16 +1284,9 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = ensureSessionContext(sessions, threadId); - const messages = yield* Effect.tryPromise({ - try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.messages", - detail: cause instanceof Error ? cause.message : "Failed to read OpenCode thread.", - cause, - }), - }); + const messages = yield* runOpenCodeSdk("session.messages", () => + context.client.session.messages({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); const turns = (messages.data ?? []) .filter((entry) => entry.info.role === "assistant") @@ -1352,37 +1305,21 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = ensureSessionContext(sessions, threadId); - const messages = yield* Effect.tryPromise({ - try: () => context.client.session.messages({ sessionID: context.openCodeSessionId }), - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.messages", - detail: - cause instanceof Error ? cause.message : "Failed to inspect OpenCode thread.", - cause, - }), - }); + const messages = yield* runOpenCodeSdk("session.messages", () => + context.client.session.messages({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); const assistantMessages = (messages.data ?? []).filter( (entry) => entry.info.role === "assistant", ); const targetIndex = assistantMessages.length - numTurns - 1; const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; - yield* Effect.tryPromise({ - try: () => - context.client.session.revert({ - sessionID: context.openCodeSessionId, - ...(target ? { messageID: target.info.id } : {}), - }), - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "session.revert", - detail: cause instanceof Error ? cause.message : "Failed to revert OpenCode turn.", - cause, - }), - }); + yield* runOpenCodeSdk("session.revert", () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + ...(target ? { messageID: target.info.id } : {}), + }), + ).pipe(Effect.mapError(toRequestError)); return yield* readThread(threadId); }, @@ -1392,42 +1329,16 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { Effect.gen(function* () { const contexts = [...sessions.values()]; sessions.clear(); - const exits = yield* Effect.forEach( + // `stopOpenCodeContext` is typed as never-failing — SDK aborts are + // already `Effect.ignore`'d inside it. `ignoreCause` here also + // swallows defects from throwing finalizers so one bad close can't + // interrupt the sibling fibers. Same pattern as the layer finalizer. + yield* Effect.forEach( contexts, - (context) => Effect.exit(stopOpenCodeContext(context)), - { concurrency: "unbounded" }, + (context) => Effect.ignoreCause(stopOpenCodeContext(context)), + { concurrency: "unbounded", discard: true }, ); - const failures: Array = []; - for (const exit of exits) { - if (Exit.isFailure(exit)) { - const failure = Cause.squash(exit.cause); - failures.push( - failure instanceof Error - ? failure - : new Error("Failed to stop an OpenCode session."), - ); - } - } - if (failures.length === 1) { - return yield* Effect.fail(failures[0]!); - } - if (failures.length > 1) { - return yield* Effect.fail( - new AggregateError(failures, `Failed to stop ${failures.length} OpenCode sessions.`), - ); - } - }).pipe( - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: "*", - detail: - cause instanceof Error ? cause.message : "Failed to stop OpenCode sessions.", - cause, - }), - ), - ); + }); return { provider: PROVIDER, diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 111fbae38a..9f10738dbf 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -48,20 +48,18 @@ export interface OpenCodeServerConnection { readonly external: boolean; } -export class OpenCodeRuntimeError extends Data.TaggedError("OpenCodeRuntimeError")<{ - readonly operation: - | "runOpenCodeCommand" - | "startOpenCodeServerProcess" - | "connectToOpenCodeServer" - | "loadOpenCodeInventory"; +const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError"; +export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{ + readonly operation: string; readonly cause?: unknown; readonly detail: string; -}> {} -const isOpenCodeRuntimeError = (error: unknown): error is OpenCodeRuntimeError => - P.isTagged(error, "OpenCodeRuntimeError"); +}> { + static readonly is = (u: unknown): u is OpenCodeRuntimeError => + P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG); +} export function openCodeRuntimeErrorDetail(cause: unknown): string { - if (isOpenCodeRuntimeError(cause)) return cause.detail; + if (OpenCodeRuntimeError.is(cause)) return cause.detail; if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim(); if (cause && typeof cause === "object") { // SDK v2 throws { response, request, error? } shapes — extract what's useful @@ -76,6 +74,17 @@ export function openCodeRuntimeErrorDetail(cause: unknown): string { } return String(cause); } + +export const runOpenCodeSdk = ( + operation: string, + fn: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: (cause) => + new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }), + }).pipe(Effect.withSpan(`opencode.${operation}`)); + export interface OpenCodeCommandResult { readonly stdout: string; readonly stderr: string; @@ -252,7 +261,7 @@ function ensureRuntimeError( detail: string, cause: unknown, ): OpenCodeRuntimeError { - return isOpenCodeRuntimeError(cause) + return OpenCodeRuntimeError.is(cause) ? cause : new OpenCodeRuntimeError({ operation, detail, cause }); } @@ -419,7 +428,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () { return yield* new OpenCodeRuntimeError({ operation: "startOpenCodeServerProcess", detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`, - cause: { timeoutMs }, }); } @@ -472,55 +480,29 @@ const makeOpenCodeRuntime = Effect.gen(function* () { }); const loadProviders = (client: OpencodeClient) => - Effect.tryPromise({ - try: async () => client.provider.list(), - catch: (cause) => - new OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: `Failed to load OpenCode providers: ${openCodeRuntimeErrorDetail(cause)}`, - cause: cause, - }), - }).pipe( - Effect.filterMapOrFail((list) => - list.data - ? Result.succeed(list.data) - : Result.fail( - new OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: "OpenCode provider list was empty.", - }), - ), + runOpenCodeSdk("provider.list", () => client.provider.list()).pipe( + Effect.filterMapOrFail( + (list) => + list.data + ? Result.succeed(list.data) + : Result.fail( + new OpenCodeRuntimeError({ + operation: "provider.list", + detail: "OpenCode provider list was empty.", + }), + ), + (result) => result, ), ); const loadAgents = (client: OpencodeClient) => - Effect.tryPromise({ - try: async () => client.app.agents(), - catch: (cause) => - new OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: `Failed to load OpenCode agents: ${openCodeRuntimeErrorDetail(cause)}`, - cause: cause, - }), - }).pipe(Effect.map((result) => result.data ?? [])); + runOpenCodeSdk("app.agents", () => client.app.agents()).pipe( + Effect.map((result) => result.data ?? []), + ); const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe( - Effect.map( - ([providerList, agents]) => - ({ - providerList, - agents, - }) satisfies OpenCodeInventory, - ), - Effect.mapError( - (cause) => - new OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: `Failed to load OpenCode inventory: ${openCodeRuntimeErrorDetail(cause)}`, - cause: cause, - }), - ), + Effect.map(([providerList, agents]) => ({ providerList, agents })), ); return { From 421c81a178f6068a9de0ae8b53699e7722c90ae7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 22:29:02 -0700 Subject: [PATCH 8/9] Harden OpenCode lifecycle cleanup - Guard server start and shutdown with one-shot scope cleanup - Emit runtime errors before closing provider scopes - Remove unused OpenCode runtime wiring from server layer --- .../src/git/Layers/OpenCodeTextGeneration.ts | 69 +++++++++++-------- .../src/provider/Layers/OpenCodeAdapter.ts | 22 ++++-- apps/server/src/server.ts | 2 - 3 files changed, 58 insertions(+), 35 deletions(-) diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index 19eb892808..c23fb4d00a 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -184,35 +184,50 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { // Create a fresh scope that owns this shared server. The runtime // will attach its child-process and fiber finalizers to this scope; // closing it kills the server and interrupts those fibers. - const serverScope = yield* Scope.make(); - const startedExit = yield* Effect.exit( - openCodeRuntime - .startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - }) - .pipe( - Effect.provideService(Scope.Scope, serverScope), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), + // + // The `Scope.make` / spawn / record-or-close transitions run inside + // `uninterruptibleMask` so an interrupt arriving between any two + // steps can't orphan the scope (and the child process attached to + // it) before we either close it on failure or hand ownership to + // `sharedServerState`. `restore` keeps the actual spawn + // interruptible; an interrupt during the spawn is captured by + // `Effect.exit` and drives us through the failure branch that + // closes the fresh scope. + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const serverScope = yield* Scope.make(); + const startedExit = yield* Effect.exit( + restore( + openCodeRuntime + .startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + }) + .pipe( + Effect.provideService(Scope.Scope, serverScope), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + ), + ), ), - ), - ); - if (startedExit._tag === "Failure") { - yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); - return yield* Effect.failCause(startedExit.cause); - } + ); + if (startedExit._tag === "Failure") { + yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(startedExit.cause); + } - const server = startedExit.value; - sharedServerState.server = server; - sharedServerState.serverScope = serverScope; - sharedServerState.binaryPath = input.binaryPath; - sharedServerState.activeRequests = 1; - return server; + const server = startedExit.value; + sharedServerState.server = server; + sharedServerState.serverScope = serverScope; + sharedServerState.binaryPath = input.binaryPath; + sharedServerState.activeRequests = 1; + return server; + }), + ); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 5438e0045e..8f8e0aa297 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -493,16 +493,19 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { context: OpenCodeSessionContext, message: string, ) { - // Two fibers can race here (the event-pump on stream failure and the - // server-exit watcher). Whoever loses the race observes `true` and - // returns — this also makes the `stopOpenCodeContext` call below a - // no-op for the loser. - if (yield* Ref.get(context.stopped)) { + // Atomic one-shot: two fibers can race here (the event-pump on stream + // failure and the server-exit watcher). `getAndSet` flips the flag in + // a single step so the loser observes `true` and returns; a plain + // `Ref.get` would let both racers slip past and emit duplicates. + if (yield* Ref.getAndSet(context.stopped, true)) { return; } const turnId = context.activeTurnId; sessions.delete(context.session.threadId); - yield* stopOpenCodeContext(context); + // Emit lifecycle events BEFORE tearing down the scope. Both call sites + // run this inside a fiber forked via `Effect.forkIn(context.sessionScope)`; + // closing that scope triggers the fiber-interrupt finalizer, so any + // subsequent yield point would unwind and silently drop these emits. yield* emit({ ...buildEventBase({ threadId: context.session.threadId, turnId }), type: "runtime.error", @@ -520,6 +523,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { exitKind: "error", }, }).pipe(Effect.ignore); + // Inline the teardown that `stopOpenCodeContext` would do; we can't + // delegate to it because our `getAndSet` above already flipped the + // one-shot guard, so the call would no-op. + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.ignore({ log: true })); + yield* Scope.close(context.sessionScope, Exit.void); }); /** Emit content.delta and item.completed events for an assistant text part. */ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f278bf0b4a..f94bbb34b5 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -72,7 +72,6 @@ import { makePersistedServerRuntimeState, persistServerRuntimeState, } from "./serverRuntimeState.ts"; -import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { orchestrationDispatchRouteLayer, orchestrationSnapshotRouteLayer, @@ -227,7 +226,6 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services - Layer.provideMerge(OpenCodeRuntimeLive), Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), From 2d4eab383c31d6033b8fd88c00e12c6c5a6d12ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 22:42:10 -0700 Subject: [PATCH 9/9] Preserve owned loggers during adapter shutdown - Keep caller-provided native event loggers alive - Close only adapter-managed loggers after session teardown - Use effect duration strings for idle text-generation shutdown --- apps/server/src/git/Layers/OpenCodeTextGeneration.ts | 6 +++--- apps/server/src/provider/Layers/OpenCodeAdapter.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts index c23fb4d00a..fd28188d60 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts @@ -1,4 +1,4 @@ -import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; +import { Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"; import * as Semaphore from "effect/Semaphore"; import { @@ -33,7 +33,7 @@ import { toOpenCodeFileParts, } from "../../provider/opencodeRuntime.ts"; -const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; +const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; function getOpenCodePromptErrorMessage(error: unknown): string | null { if (!error || typeof error !== "object") { @@ -130,7 +130,7 @@ const makeOpenCodeTextGeneration = Effect.gen(function* () { server: OpenCodeServerProcess, ) { yield* cancelIdleCloseFiber(); - const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe( + const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( Effect.andThen( sharedServerMutex.withPermit( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f8e0aa297..5081495dcb 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -448,6 +448,10 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { stream: "native", }) : undefined); + // Only close loggers we created. If the caller passed one in via + // `options.nativeEventLogger`, they own its lifecycle. + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const runtimeEvents = yield* Queue.unbounded(); const sessions = new Map(); @@ -469,6 +473,13 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) { (context) => Effect.ignoreCause(stopOpenCodeContext(context)), { concurrency: "unbounded", discard: true }, ); + // Close the logger AFTER session teardown so any final lifecycle + // events emitted during shutdown still get written. `close` flushes + // the `Logger.batched` window and closes each per-thread + // `RotatingFileSink` handle owned by the logger's internal scope. + if (managedNativeEventLogger !== undefined) { + yield* managedNativeEventLogger.close(); + } }), );