From 7ab3dd04ad6b224a0e605022bc57a9fc4fe41a59 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 24 Jul 2026 12:55:43 -0400 Subject: [PATCH] refactor(core): unify tool fiber bookkeeping into one owned structure (#38719) --- packages/core/src/session/runner/llm.ts | 26 +++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 7c96a1853d..ed358e6666 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,6 +1,6 @@ export * as SessionRunnerLLM from "./llm" -import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent } from "@opencode-ai/ai" +import { LLMClient, LLMError, LLMEvent, isContextOverflowFailure, type ProviderErrorEvent, type ToolCall } from "@opencode-ai/ai" import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Semaphore, Stream } from "effect" import { Database } from "../../database/database" import { EventV2 } from "../../event" @@ -203,8 +203,9 @@ const layer = Layer.effect( context: loaded, step: currentStep, }) - const toolFibers = yield* FiberSet.make() - const ownedToolFibers: Array> = [] + // Every local tool call forked here is owned until it reaches one durable settlement. + const toolRuns: Array<{ readonly call: ToolCall; readonly fiber: Fiber.Fiber }> = [] + const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber))) let needsContinuation = false const startSnapshot = yield* snapshots.capture() const publisher = createLLMEventPublisher(events, { @@ -277,8 +278,9 @@ const layer = Layer.effect( // continuation depends only on remaining Step allowance. if (!prepared.stepLimitReached) needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) - ownedToolFibers.push( - yield* Effect.uninterruptibleMask((restore) => + toolRuns.push({ + call: event, + fiber: yield* Effect.uninterruptibleMask((restore) => restore( prepared.executeTool({ sessionID: session.id, @@ -290,8 +292,8 @@ const layer = Layer.effect( ).pipe( Effect.flatMap((execution) => serialized(publisher.toolExecution(event.id, event.name, execution))), ), - ).pipe(FiberSet.run(toolFibers)), - ) + ).pipe(Effect.forkScoped), + }) }), ), Effect.ensuring(serialized(publisher.flush())), @@ -339,13 +341,13 @@ const layer = Layer.effect( // Provider error events only arrive from the stream, so the flag is final here. const providerFailed = publisher.hasProviderError() - // Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain - // the individual fibers and await all exits before publishing the terminal step event. - if (streamInterrupted) yield* FiberSet.clear(toolFibers) + // Settle every owned tool run: await all exits, not just the first failure, + // before publishing the terminal step event. + if (streamInterrupted) yield* interruptTools const settled = yield* restore( - Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }), + Effect.forEach(toolRuns, (run) => Fiber.await(run.fiber), { concurrency: "unbounded" }), ).pipe(Effect.exit) - if (settled._tag === "Failure") yield* FiberSet.clear(toolFibers) + if (settled._tag === "Failure") yield* interruptTools const tools = classifyToolExits(settled) if (tools.declined || streamInterrupted || tools.interrupted) {