diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index bca3f85720..4d8eda23c0 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,4 +1,5 @@ import { Binary } from "@opencode-ai/core/util/binary" +import { batch } from "solid-js" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { Message, @@ -302,21 +303,25 @@ export function applyDirectoryEvent(input: { if (!result.found) break const field = props.field as keyof (typeof parts)[number] const current = parts[result.index]?.[field] - input.setStore( - "part_text_accum_delta", - props.partID, - (existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta, - ) - input.setStore( - "part", - props.messageID, - produce((draft) => { - const part = draft[result.index] - const field = props.field as keyof typeof part - const existing = part[field] as string | undefined - ;(part[field] as string) = (existing ?? "") + props.delta - }), - ) + // Hot path: one batch per delta so subscribers see a single reactive + // notification instead of two (accumulator + part) per streamed token. + batch(() => { + input.setStore( + "part_text_accum_delta", + props.partID, + (existing) => (existing ?? (typeof current === "string" ? current : "")) + props.delta, + ) + input.setStore( + "part", + props.messageID, + produce((draft) => { + const part = draft[result.index] + const field = props.field as keyof typeof part + const existing = part[field] as string | undefined + ;(part[field] as string) = (existing ?? "") + props.delta + }), + ) + }) break } case "vcs.branch.updated": { diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index ddeafa5265..a8abcbef1e 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -546,7 +546,6 @@ export async function handler( return provider }) .filter((p) => p.priority <= topPriority) - .flatMap((provider) => Array(provider.weight).fill(provider)) // Use the last 4 characters of session ID to select a provider let h = 0 @@ -554,8 +553,12 @@ export async function handler( for (let i = l - 4; i < l; i++) { h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int } - const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1 - const provider = providers[index || 0] + // Weighted pick over cumulative weights — same slot layout as + // physically expanding each provider `weight` times, without the + // O(Σweight) array allocation per request. + const totalWeight = providers.reduce((sum, p) => sum + p.weight, 0) + let slot = totalWeight > 0 ? (h >>> 0) % totalWeight : 0 + const provider = providers.find((p) => (slot -= p.weight) < 0) ?? providers[0] // sticky provider does not exist => use selected provider if (!stickyProviderId) return provider diff --git a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts index 83e46015dd..441f954a8a 100644 --- a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts +++ b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts @@ -121,7 +121,23 @@ export async function convertToOpenAIResponsesInput({ const reasoningMessages: Record = {} const toolCallParts: Record = {} - for (const part of content) { + // Request-build hot path: parse provider options for all reasoning + // parts in one batch instead of awaiting once per part inside the + // loop. Each parse is independent pure validation, so batching keeps + // ordering and the cross-part reasoningMessages state intact. + const reasoningOptions = await Promise.all( + content.map((part) => + part.type === "reasoning" + ? parseProviderOptions({ + provider: "copilot", + providerOptions: part.providerOptions, + schema: openaiResponsesReasoningProviderOptionsSchema, + }) + : Promise.resolve(undefined), + ), + ) + + for (const [partIndex, part] of content.entries()) { switch (part.type) { case "text": { input.push({ @@ -183,11 +199,7 @@ export async function convertToOpenAIResponsesInput({ } case "reasoning": { - const providerOptions = await parseProviderOptions({ - provider: "copilot", - providerOptions: part.providerOptions, - schema: openaiResponsesReasoningProviderOptionsSchema, - }) + const providerOptions = reasoningOptions[partIndex] const reasoningId = providerOptions?.itemId diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 9e29ae71ee..7dd87587d0 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -8,7 +8,7 @@ import { isContextOverflowFailure, type ProviderErrorEvent, } from "@opencode-ai/llm" -import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" +import { Cause, DateTime, Deferred, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { Database } from "../../database/database" @@ -136,7 +136,17 @@ export const layer = Layer.effect( }) const awaitToolFibers = (fibers: FiberSet.FiberSet) => - Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers)) + Effect.raceFirst( + FiberSet.join(fibers), + // awaitEmpty also succeeds when the last fiber failed: the FiberSet + // observer deletes from the backing set before completing the join + // deferred, so both racers become ready in the same tick. Re-check the + // deferred so a lost race cannot swallow a tool settlement failure. + FiberSet.awaitEmpty(fibers).pipe( + Effect.andThen(Deferred.isDone(fibers.deferred)), + Effect.flatMap((failed) => (failed ? FiberSet.join(fibers) : Effect.void)), + ), + ) // Match V1: dismissing a question halts the loop instead of becoming model-facing tool output. const isQuestionRejected = (cause: Cause.Cause) => diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 36e8375f5c..02a47c043f 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -606,7 +606,13 @@ function makeUsageService(sdk: OpencodeClient) { ) as Record return UsageService.findContextLimit(providers, params.providerID, params.modelID) }) - .catch(() => undefined) + .catch(() => { + // A transient lookup failure must not be cached forever — drop the + // in-flight entry so the next call retries instead of permanently + // disabling context usage for this model/directory combination. + if (limits.get(key) === next) limits.delete(key) + return undefined + }) limits.set(key, next) return yield* Effect.promise(() => next) }, diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts index e4817f514d..987120e86c 100644 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ b/packages/opencode/src/cli/cmd/run/stream.transport.ts @@ -456,6 +456,15 @@ function createLayer(input: StreamInput) { let replayDisabled = false let replayPending: SessionResizeReplayInput | undefined const buffered: Event[] = [] + // Untracked-session events are re-buffered indefinitely by + // drainBuffered (they only become useful if their session turns into + // a tracked subagent tab shortly after). Cap the backlog dropping the + // oldest so unrelated sessions cannot grow memory without bound. + const BUFFERED_EVENT_LIMIT = 1000 + const buffer = (event: Event) => { + buffered.push(event) + if (buffered.length > BUFFERED_EVENT_LIMIT) buffered.splice(0, buffered.length - BUFFERED_EVENT_LIMIT) + } const replayedParts = new Set() const recovering = new Set() const tracked = (sessionID: string | undefined) => @@ -560,7 +569,19 @@ function createLayer(input: StreamInput) { recovering.add(partID) try { + // Recovery is bounded: the question request either already exists + // server-side (found within a few polls) or will never appear. An + // unbounded loop polls one HTTP list call per 250ms until session + // close whenever the server fails to register the question. + const deadline = Date.now() + 120_000 while (!closed && !abort.signal.aborted && !input.footer.isClosed) { + if (Date.now() >= deadline) { + input.trace?.write("question.recover.timeout", { + sessionID: input.sessionID, + partID, + }) + return + } if (state.data.questions.length > 0 || !state.data.tools.has(partID)) { return } @@ -865,8 +886,20 @@ function createLayer(input: StreamInput) { }) const poll = Effect.fn("RunStreamTransport.poll")(function* (next: Wait, signal: AbortSignal) { + // Prompt sends arm the wait but leave live=false until a session + // event arrives (promptAsync is durable admission — the session can + // still be idle right after the HTTP call returns, so setting live + // eagerly would let the completion gate fire before the drain + // starts). If the server stays idle with no events long past the + // admission window, no drain is coming; escape instead of hanging + // the turn forever. + let idleStreak = 0 while (state.wait === next && !signal.aborted && !input.footer.isClosed && !closed) { yield* Effect.sleep("250 millis") + if (next.armed && !next.live) { + idleStreak = (yield* idle(false)) ? idleStreak + 1 : 0 + if (idleStreak >= 8) next.live = true + } yield* complete(next, false) } }) @@ -1151,7 +1184,7 @@ function createLayer(input: StreamInput) { if (booting || replaying) { if (sessionID) { input.trace?.write("recv.event", event) - buffered.push(event) + buffer(event) } return } @@ -1159,7 +1192,7 @@ function createLayer(input: StreamInput) { if (!tracked(sessionID)) { if (sessionID) { input.trace?.write("recv.event", event) - buffered.push(event) + buffer(event) } return } diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 329874791d..dfd39d9156 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -207,6 +207,20 @@ export const TuiThreadCommand = cmd({ worker.terminate() } + // A dead worker leaves every pending RPC hanging forever; surface the + // crash and exit instead of freezing the TUI with no diagnostics. + const fatal = (reason: string) => { + if (stopped) return + stopped = true + process.off("SIGUSR2", reload) + worker.terminate() + UI.error("server worker crashed: " + reason) + process.exit(1) + } + client.on<{ message: string; stack?: string }>("worker.fatal", (data) => fatal(data.message)) + worker.addEventListener("error", (event) => fatal(errorMessage(event.error ?? event.message))) + worker.addEventListener("close", () => fatal("worker exited unexpectedly")) + const prompt = await input(args.prompt) const config = await TuiConfig.get() diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 4cf6b2d446..c42bf2d2a7 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -10,12 +10,40 @@ import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" import { Effect } from "effect" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" +import { Global } from "@opencode-ai/core/global" +import { appendFileSync } from "node:fs" +import path from "node:path" Heap.start() -const onUnhandledRejection = (_error: unknown) => {} +// Crash observability: swallowing these silently leaves the worker running in a +// corrupt state with no diagnostic trail (TUI appears hung with zero logs). +// Log to the shared log file synchronously so the line survives process.exit. +const logFatal = (kind: string, error: unknown) => { + const detail = error instanceof Error ? (error.stack ?? error.message) : String(error) + const line = `timestamp=${new Date().toISOString()} level=ERROR service=tui-worker kind=${kind} error=${JSON.stringify(detail)}\n` + try { + appendFileSync(path.join(Global.Path.log, "opencode.log"), line) + } catch { + // The log directory may be gone; never let the crash handler itself throw. + } +} + +const onUnhandledRejection = (error: unknown) => { + // Keep the worker alive: stray rejections from background tasks are not + // proof of corrupt state, but they must be observable. + logFatal("unhandledRejection", error) +} -const onUncaughtException = (_error: Error) => {} +const onUncaughtException = (error: Error) => { + // Process state is unknown past this point; notify the parent and exit so + // the TUI can surface the failure instead of hanging on dead RPC calls. + logFatal("uncaughtException", error) + try { + Rpc.emit("worker.fatal", { message: error.message, stack: error.stack }) + } catch {} + process.exit(1) +} process.on("unhandledRejection", onUnhandledRejection) process.on("uncaughtException", onUncaughtException) diff --git a/packages/opencode/src/dag/dag.ts b/packages/opencode/src/dag/dag.ts index 5d9c7fd2eb..d5e954a2af 100644 --- a/packages/opencode/src/dag/dag.ts +++ b/packages/opencode/src/dag/dag.ts @@ -8,6 +8,7 @@ import { DagStore } from "@opencode-ai/core/dag/store" import { EventV2Bridge } from "@/event-v2-bridge" import { Database } from "@opencode-ai/core/database/database" import { KeyedMutex } from "@opencode-ai/core/effect/keyed-mutex" +import { isRecord } from "@/util/record" import { validateRequiredNodes } from "@opencode-ai/core/dag/core/required-validator" import { buildGraph, WorkflowRuntime, toSchedulingNodes } from "@opencode-ai/core/dag/core/scheduling" import { CycleError } from "@opencode-ai/core/dag/core/graph" @@ -186,7 +187,18 @@ const parseJsonOption = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) export function parseWorkflowConfig(raw: string): WorkflowConfig | undefined { const parsed = parseJsonOption(raw) - if (Option.isNone(parsed) || typeof parsed.value !== "object" || parsed.value === null) return undefined + if (Option.isNone(parsed)) return undefined + const candidate: unknown = parsed.value + if (!isRecord(candidate)) return undefined + // Guard the invariants every caller relies on (config.nodes.map, node.id, + // depends_on iteration) instead of trusting the persisted row blindly. Kept + // structural rather than a full strict schema: rejecting a legacy row that + // callers could still consume would break recovery of in-flight workflows. + if (!Array.isArray(candidate.nodes)) return undefined + const nodesValid = candidate.nodes.every( + (node) => isRecord(node) && typeof node.id === "string" && Array.isArray(node.depends_on), + ) + if (!nodesValid) return undefined return parsed.value as WorkflowConfig } diff --git a/packages/opencode/src/dag/runtime/loop.ts b/packages/opencode/src/dag/runtime/loop.ts index 11cbdd5138..0431ab7419 100644 --- a/packages/opencode/src/dag/runtime/loop.ts +++ b/packages/opencode/src/dag/runtime/loop.ts @@ -809,20 +809,27 @@ export const layer = Layer.effect( // in spawnReady, settle+spawnReady in the terminal handlers), // so reading the five conditions under the same lock waits // out the markRunning→fibers.set window instead of misreading - // it as a stalled orchestrator. dag.fail stays outside the - // lock to avoid holding evalLock across the KeyedMutex. - const shouldFail = yield* entry.evalLock.withPermits(1)( - Effect.sync(() => - !entry.runtime.isPaused() - && !entry.runtime.isStepMode() - // Suppress the net only when current-process execution - // ownership proves that a running node is making progress. - && !entry.runtime.hasRunningMatching((id) => entry.fibers.has(id)) - && entry.runtime.getReadyNodes().length === 0 - && !entry.runtime.isComplete(), - ), + // it as a stalled orchestrator. dag.fail must run under the + // SAME permit: releasing the lock between the check and the + // fail lets a terminal-event handler spawn new ready nodes in + // the window, and the stale verdict would then kill a + // progressing workflow. evalLock→workflowLock nesting is the + // established order here (checkCompletion inside evalLock + // takes the same KeyedMutex); dag.ts never acquires evalLock, + // so no reverse ordering exists. + yield* entry.evalLock.withPermits(1)( + Effect.gen(function* () { + const shouldFail = + !entry.runtime.isPaused() + && !entry.runtime.isStepMode() + // Suppress the net only when current-process execution + // ownership proves that a running node is making progress. + && !entry.runtime.hasRunningMatching((id) => entry.fibers.has(id)) + && entry.runtime.getReadyNodes().length === 0 + && !entry.runtime.isComplete() + if (shouldFail) yield* dag.fail(dagID, "orchestrator_unresponsive").pipe(Effect.ignore) + }), ) - if (shouldFail) yield* dag.fail(dagID, "orchestrator_unresponsive").pipe(Effect.ignore) } } return @@ -927,8 +934,16 @@ export const layer = Layer.effect( // Terminal rows can survive a process crash after projection but before // parent delivery. Re-enter the normal serialized drain for every // affected parent session without waiting for a new status event. + // Store failures here are defects (the store's error channel is never); + // absorb them with a warning so layer construction survives, but never + // silently — a swallowed failure means wake redelivery is lost until + // the next process restart. const pendingWakeSessions = yield* store.getSessionsWithUnreportedWakes().pipe( - Effect.catch(() => Effect.succeed([] as string[])), + Effect.catchCause((cause) => + Effect.logWarning("DagLoop failed to list sessions with unreported wakes", { cause }).pipe( + Effect.as([] as string[]), + ), + ), ) for (const sessionID of pendingWakeSessions) { // Cross-instance guard: wake redelivery is store-global. A session's @@ -936,7 +951,11 @@ export const layer = Layer.effect( // snapshot's own workflow rows carry the ownership proof — only // drain sessions whose unreported workflows belong to this project. const snapshot = yield* store.getWakeSnapshot(sessionID).pipe( - Effect.catch(() => Effect.succeed({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("DagLoop failed to read wake snapshot", { sessionID, cause }).pipe( + Effect.as({ nodes: [], workflows: [] } satisfies DagStore.WakeSnapshot), + ), + ), ) if (!snapshot.workflows.some((wf) => wf.projectId === ctx.project.id)) continue yield* tryDeliverWake(sessionID).pipe(Effect.forkScoped) diff --git a/packages/opencode/src/dag/runtime/recovery.ts b/packages/opencode/src/dag/runtime/recovery.ts index c6ad8d5584..f30b46d590 100644 --- a/packages/opencode/src/dag/runtime/recovery.ts +++ b/packages/opencode/src/dag/runtime/recovery.ts @@ -22,6 +22,7 @@ import { Dag } from "../dag" import type { NodeConfig } from "../dag" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" +import type { SessionV1 } from "@opencode-ai/core/v1/session" import type { DagStore } from "@opencode-ai/core/dag/store" import { isTransitionRejection } from "@opencode-ai/core/dag/core/types" import { reviewImplementationFingerprint } from "../review-lifecycle" @@ -81,9 +82,9 @@ export function reconcileWorkflow( continue } - const sessionStatus = yield* checkSessionStatus(node.childSessionId).pipe( - Effect.catch(() => Effect.succeed("unknown" as const)), - ) + // Checker failures propagate: aborting this reconcile (callers log and + // retry on next access) beats inventing a nodeFailed from a read error. + const sessionStatus = yield* checkSessionStatus(node.childSessionId) if (sessionStatus === "completed") { const nodeConfig = workflowConfig?.nodes.find((n) => n.id === node.id) @@ -179,12 +180,15 @@ export function makeSessionStatusChecker( ): (childSessionID: string) => Effect.Effect<"active" | "completed" | "failed" | "unknown", Error> { return (childSessionID) => Effect.gen(function* () { + // Only a missing session is legitimate "unknown"; any other failure must + // propagate so recovery aborts instead of inventing node failures from + // fabricated evidence. DB-level errors are already defects (orDie). const info = yield* sessions.get(SessionID.make(childSessionID)).pipe( - Effect.catch(() => Effect.succeed(undefined)), + Effect.catchTag("NotFoundError", () => Effect.succeed(undefined)), ) if (!info) return "unknown" as const const msgs = yield* sessions.messages({ sessionID: SessionID.make(childSessionID), limit: 1 }).pipe( - Effect.catch(() => Effect.succeed([] as never)), + Effect.catchTag("NotFoundError", () => Effect.succeed([] as SessionV1.WithParts[])), ) if (msgs.length === 0) return "unknown" as const const last = msgs[msgs.length - 1] diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index b40168fbc2..db3bb4b60b 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -28,6 +28,12 @@ export const make = ( ): Effect.Effect>, never, R | Scope.Scope> => Effect.gen(function* () { const cache = yield* ScopedCache.make({ + // Deliberately unbounded: eviction is coordinated per-directory by + // InstanceStore.disposeDirectory (via the disposer registered below), + // which tears down every service's state for that directory together. + // A capacity/TTL here would evict one service's entry independently — + // closing its scope (releasing resources still held by live fibers) + // while sibling services keep their old state for the same directory. capacity: Number.POSITIVE_INFINITY, lookup: () => Effect.gen(function* () { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 0f71b39a9d..bcbd244c07 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -250,11 +250,23 @@ export const layer = Layer.effect( const unsubscribe = yield* events.listen((event) => { if (event.location?.directory !== ctx.directory) return Effect.void - return Effect.sync(() => { - for (const hook of hooks) { - void hook["event"]?.({ event: { id: event.id, type: event.type, properties: event.data } as any }) - } - }) + // Fire-and-forget by design (bus delivery must not block on plugin + // hooks), but rejections have to be observed: a bare `void promise` + // produces unhandledRejection — crashing headless runs and leaving + // only a log line in TUI worker runs. + return Effect.forEach( + hooks, + (hook) => + Effect.tryPromise({ + try: () => Promise.resolve(hook["event"]?.({ event: { id: event.id, type: event.type, properties: event.data } as any })), + catch: errorMessage, + }).pipe( + Effect.tapError((error) => Effect.logError("plugin event hook failed", { type: event.type, error })), + Effect.ignore, + Effect.forkDetach, + ), + { discard: true }, + ) }) yield* Effect.addFinalizer(() => unsubscribe) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2554315908..2da543a384 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -886,6 +886,13 @@ export const layer = Layer.effect( const match = yield* readToolCall(toolCallID) if (!match) continue const part = match.part + // The tool may have settled between the grace period and this read; + // never overwrite a completed/errored part with a forced abort. + if (part.state.status !== "running" && part.state.status !== "pending") continue + // Claim the call synchronously so a concurrent completeToolCall or + // failToolCall (both re-read ctx.toolcalls) can no longer settle it + // while we publish and persist the forced error below. + delete ctx.toolcalls[toolCallID] if (mirrorAssistant && match.call.assistantMessageID) { yield* events.publish(SessionEvent.Tool.Failed, { sessionID: ctx.sessionID, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3f7718c653..b8b2557a79 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1711,14 +1711,17 @@ export const layer = Layer.effect( yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const [skills, env, instructions, mcpInstructions, hooksDocs, modelMsgs] = yield* Effect.all([ - sys.skills(agent), - sys.environment(model), - instruction.system().pipe(Effect.orDie), - sys.mcp(agent, session.permission), - sys.hooks(), - MessageV2.toModelMessagesEffect(msgs, model), - ]) + const [skills, env, instructions, mcpInstructions, hooksDocs, modelMsgs] = yield* Effect.all( + [ + sys.skills(agent), + sys.environment(model), + instruction.system().pipe(Effect.orDie), + sys.mcp(agent, session.permission), + sys.hooks(), + MessageV2.toModelMessagesEffect(msgs, model), + ], + { concurrency: "unbounded" }, + ) const system = [ ...env, ...instructions, diff --git a/packages/opencode/src/util/rpc.ts b/packages/opencode/src/util/rpc.ts index 02586ebcfc..0c0993eccc 100644 --- a/packages/opencode/src/util/rpc.ts +++ b/packages/opencode/src/util/rpc.ts @@ -6,8 +6,15 @@ export function listen(rpc: Definition) { onmessage = async (evt) => { const parsed = JSON.parse(evt.data) if (parsed.type === "rpc.request") { - const result = await rpc[parsed.method](parsed.input) - postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) + // Propagate handler failures to the caller instead of letting them + // escape as unhandledRejection while the client promise hangs forever. + try { + const result = await rpc[parsed.method](parsed.input) + postMessage(JSON.stringify({ type: "rpc.result", result, id: parsed.id })) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + postMessage(JSON.stringify({ type: "rpc.result", error: message, id: parsed.id })) + } } } } @@ -20,16 +27,17 @@ export function client(target: { postMessage: (data: string) => void | null onmessage: ((this: Worker, ev: MessageEvent) => any) | null }) { - const pending = new Map void>() + const pending = new Map void; reject: (error: Error) => void }>() const listeners = new Map void>>() let id = 0 target.onmessage = async (evt) => { const parsed = JSON.parse(evt.data) if (parsed.type === "rpc.result") { - const resolve = pending.get(parsed.id) - if (resolve) { - resolve(parsed.result) + const entry = pending.get(parsed.id) + if (entry) { pending.delete(parsed.id) + if (typeof parsed.error === "string") entry.reject(new Error(parsed.error)) + else entry.resolve(parsed.result) } } if (parsed.type === "rpc.event") { @@ -44,8 +52,8 @@ export function client(target: { return { call(method: Method, input: Parameters[0]): Promise> { const requestId = id++ - return new Promise((resolve) => { - pending.set(requestId, resolve) + return new Promise((resolve, reject) => { + pending.set(requestId, { resolve, reject }) target.postMessage(JSON.stringify({ type: "rpc.request", method, input, id: requestId })) }) },