From 02c2976df23b2e2386fcc36d54ca96099f9b387b Mon Sep 17 00:00:00 2001 From: Maciej Pruchnik <45501415+Mach1212@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:44:17 +0000 Subject: [PATCH 1/2] fix(opencode): OpenCode 1.x background subagents no longer show as done at launch A background task call returns as soon as its subagent is launched. The 1.x adapter took the subagent's status from that call, so a subagent that was still working showed as completed and the thread looked finished. The adapter now keeps a launched subagent running until its child session goes idle, and ends it as interrupted or failed when the child is stopped or the event stream is lost. --- .../src/server/adapter.test.ts | 63 +++++++++++--- .../provider-opencode/src/server/adapter.ts | 84 ++++++++++++++++++- 2 files changed, 135 insertions(+), 12 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 47cedeefc1c9e..aca9e55f47239 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -1370,7 +1370,12 @@ describe("OpenCodeAdapterV2", () => { ); // OpenCode 1.18.32 background tasks complete at launch; children stay busy after root settlement. - it.effect("reports a background task child as pending work after the root turn settles", () => + // Event order from a live 1.19.5 run: the child reports busy only after the launch completes. + it.effect.each([ + { case: "finishes", ending: "idle", outcome: "completed" }, + { case: "is aborted", ending: "abort", outcome: "interrupted" }, + { case: "never starts and the thread is stopped", ending: "stop", outcome: "interrupted" }, + ])("keeps a background task's subagent running until its child $case", ({ ending, outcome }) => Effect.gen(function* () { const nativeEvents = asyncEventStream(); const push = (event: unknown) => Effect.promise(() => nativeEvents.push(event)); @@ -1398,9 +1403,14 @@ describe("OpenCodeAdapterV2", () => { return yield* Effect.die("OpenCode runtime must expose hasPendingBackgroundWork."); } yield* harness.startTurn(); - const terminal = yield* harness.runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, + const received = yield* harness.runtime.events.pipe( + Stream.takeUntil( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "subagent" && + event.turnItem.status !== "running", + ), + Stream.runCollect, Effect.forkScoped, ); const taskPart = (status: "running" | "completed") => ({ @@ -1442,7 +1452,6 @@ describe("OpenCodeAdapterV2", () => { }, }); yield* push(status(root, "busy")); - yield* push(taskPart("running")); yield* push({ type: "session.created", properties: { @@ -1450,15 +1459,49 @@ describe("OpenCodeAdapterV2", () => { info: { id: child, parentID: root, time: { created: 2, updated: 2 } }, }, }); - yield* push(status(child, "busy")); + yield* push(taskPart("running")); + const starts = ending !== "stop"; + if (starts) { + yield* push({ + type: "message.updated", + properties: { + sessionID: child, + info: { id: "msg_child_prompt", sessionID: child, role: "user", time: { created: 2 } }, + }, + }); + } yield* push(taskPart("completed")); + if (starts) yield* push(status(child, "busy")); yield* push(status(root, "idle")); - assert.equal(Option.getOrUndefined(yield* Fiber.join(terminal))?.status, "completed"); - assert.isTrue(yield* hasPendingBackgroundWork, "the running child must pin idle release"); + assert.equal(yield* hasPendingBackgroundWork, starts, "a running child pins idle release"); - yield* push(status(child, "idle")); - yield* push({ type: "session.idle", properties: { sessionID: child } }); + if (ending === "stop") { + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: ProviderTurnId.make("provider-turn:settled"), + requestRuntimeRestart: true, + }); + } else { + if (ending === "abort") { + yield* push({ + type: "session.error", + properties: { sessionID: child, error: { name: "MessageAbortedError", data: {} } }, + }); + } + yield* push(status(child, "idle")); + yield* push({ type: "session.idle", properties: { sessionID: child } }); + } assert.isFalse(yield* hasPendingBackgroundWork, "an idle child must not pin idle release"); + assert.deepEqual( + (yield* Fiber.join(received)).flatMap((event) => + event.type === "subagent.updated" + ? [`subagent ${event.subagent.status}`] + : event.type === "turn.terminal" + ? [`root turn ${event.status}`] + : [], + ), + ["subagent running", "subagent running", "root turn completed", `subagent ${outcome}`], + ); }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer))), ); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index b017a8861f408..5554968510760 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -366,6 +366,8 @@ interface OpenCodeSubagentContext { childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; model: string | null; result: string | null; + // A background task call returns at launch; held here until its child session stops. + launch: ToolPart | null; } interface OpenCodeThreadState { @@ -1014,6 +1016,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const relatedSessionOwners = new Map(); // Busy children outlive parent settlement; keep their server open. const busySessionIds = new Set(); + // A background task returns before its new child session reports busy. + const unstartedSessionIds = new Set(); // Remember settled requests across routing retries to prevent resurrection; only recent IDs matter. const settledNativeRequestIds = new Set(); const pendingChildRequestRoutes = new Set(); @@ -1329,6 +1333,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function state: OpenCodeThreadState, turn: ActiveOpenCodeTurn, part: ToolPart, + childOutcome?: TerminalTurnStatus, ) { const now = yield* DateTime.now; const nativeItemRef = providerRef(part.id); @@ -1359,6 +1364,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function childProviderThreadId: null, model: null, result: null, + launch: null, }; subagentsByNativeItemId.set(part.id, context); } @@ -1470,8 +1476,27 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function } const output = toolOutput(part); if (part.state.status === "completed" && output !== undefined) context.result = output; - const status = toolStatus(part); - const completedAt = toolCompletedAt(part, now); + const launchStatus = toolStatus(part); + context.launch = + childOutcome === undefined && + launchStatus.item === "completed" && + context.childSessionId !== null && + (busySessionIds.has(context.childSessionId) || + unstartedSessionIds.has(context.childSessionId)) + ? part + : null; + const status = + context.launch !== null + ? ({ node: "running", item: "running" } as const) + : childOutcome === undefined + ? launchStatus + : { node: childOutcome, item: childOutcome }; + const completedAt = + context.launch !== null + ? null + : childOutcome === undefined + ? toolCompletedAt(part, now) + : now; const subagentStatus: OrchestrationV2Subagent["status"] = status.item; const subagent: OrchestrationV2Subagent = { id: nodeId, @@ -1550,6 +1575,46 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }); }); + const settleLaunchedSubagents = Effect.fnUntraced(function* ( + childSessionId: string, + outcome?: TerminalTurnStatus, + ) { + const lastTurn = Array.from(threads.get(childSessionId)?.providerTurns.values() ?? []).at( + -1, + )?.status; + const childOutcome = + outcome ?? + (lastTurn === "interrupted" || lastTurn === "failed" || lastTurn === "cancelled" + ? lastTurn + : "completed"); + for (const context of subagentsByNativeItemId.values()) { + if (context.childSessionId === childSessionId && context.launch !== null) { + yield* emitSubagent( + context.parentState, + context.parentTurn, + context.launch, + childOutcome, + ); + } + } + }); + + // No idle event settles a launched child that Stop aborts before it reports busy. + const interruptLaunchedSubagents = Effect.fnUntraced(function* (rootSessionId: string) { + for (const context of subagentsByNativeItemId.values()) { + let owner = context.parentState; + while (owner.parentSubagent !== null) owner = owner.parentSubagent.parentState; + if (owner.nativeSessionId === rootSessionId && context.launch !== null) { + yield* emitSubagent( + context.parentState, + context.parentTurn, + context.launch, + "interrupted", + ); + } + } + }); + const emitToolPart = Effect.fnUntraced(function* ( state: OpenCodeThreadState, turn: ActiveOpenCodeTurn, @@ -2636,6 +2701,9 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function case "session.created": case "session.updated": { const info = event.properties.info; + if (event.type === "session.created" && info.parentID !== undefined) { + unstartedSessionIds.add(info.id); + } if (info.parentID !== undefined && !threads.has(info.id)) { const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID); if (owner !== undefined) { @@ -2647,9 +2715,12 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function case "session.deleted": relatedSessionOwners.delete(event.properties.info.id); busySessionIds.delete(event.properties.info.id); + unstartedSessionIds.delete(event.properties.info.id); + yield* settleLaunchedSubagents(event.properties.info.id, "interrupted"); return; case "session.status": { const sessionId = event.properties.sessionID; + unstartedSessionIds.delete(sessionId); switch (event.properties.status.type) { case "busy": case "retry": @@ -2678,10 +2749,14 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function state.activeTurn.interrupted ? "interrupted" : "completed", ); } + if (event.properties.status.type === "idle") { + yield* settleLaunchedSubagents(sessionId); + } return; } case "session.idle": { busySessionIds.delete(event.properties.sessionID); + unstartedSessionIds.delete(event.properties.sessionID); const state = threads.get(event.properties.sessionID); if (state?.activeTurn !== null && state?.activeTurn !== undefined) { if (state.activeTurn.admissionPending) { @@ -2694,6 +2769,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function state.activeTurn.interrupted ? "interrupted" : "completed", ); } + yield* settleLaunchedSubagents(event.properties.sessionID); return; } case "session.error": { @@ -2775,6 +2851,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function Effect.flatMap((exit) => Effect.gen(function* () { busySessionIds.clear(); + unstartedSessionIds.clear(); if (closing || abortController.signal.aborted) return; const detail = Exit.isSuccess(exit) ? "OpenCode event stream ended unexpectedly." @@ -2790,6 +2867,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function failure: makeProviderFailure({ message: detail, class: "transport_error" }), threadDisposition: "broken", }); + yield* settleLaunchedSubagents(state.nativeSessionId, "failed"); } yield* Queue.end(events); }), @@ -3544,6 +3622,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function for (const controller of commandControllers.get(sessionId) ?? []) controller.abort(); yield* abortDescendants(sessionId); + yield* interruptLaunchedSubagents(sessionId); return; } if ( @@ -3582,6 +3661,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ); // Root abort leaves children running; report incomplete cleanup even after root termination. yield* abortDescendants(sessionId); + yield* interruptLaunchedSubagents(sessionId); }, (effect, interruptInput) => effect.pipe( From 197f784b37bd88231284dd408187297f61116a2d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 06:44:05 +0000 Subject: [PATCH 2/2] fix(opencode): a background subagent whose child fails before its first turn settles as failed Co-Authored-By: Claude Opus 5.5 --- .../provider-opencode/src/server/adapter.test.ts | 13 ++++++++++--- packages/provider-opencode/src/server/adapter.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 11223b40b1730..4e547d952a756 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -1391,6 +1391,7 @@ describe("OpenCodeAdapterV2", () => { it.effect.each([ { case: "finishes", ending: "idle", outcome: "completed" }, { case: "is aborted", ending: "abort", outcome: "interrupted" }, + { case: "fails before its first turn", ending: "error", outcome: "failed" }, { case: "never starts and the thread is stopped", ending: "stop", outcome: "interrupted" }, ])("keeps a background task's subagent running until its child $case", ({ ending, outcome }) => Effect.gen(function* () { @@ -1477,7 +1478,7 @@ describe("OpenCodeAdapterV2", () => { }, }); yield* push(taskPart("running")); - const starts = ending !== "stop"; + const starts = ending !== "stop" && ending !== "error"; if (starts) { yield* push({ type: "message.updated", @@ -1499,10 +1500,16 @@ describe("OpenCodeAdapterV2", () => { requestRuntimeRestart: true, }); } else { - if (ending === "abort") { + if (ending !== "idle") { yield* push({ type: "session.error", - properties: { sessionID: child, error: { name: "MessageAbortedError", data: {} } }, + properties: { + sessionID: child, + error: + ending === "abort" + ? { name: "MessageAbortedError", data: {} } + : { name: "UnknownError", data: { message: "model unavailable" } }, + }, }); } yield* push(status(child, "idle")); diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index fd8db378e0bb7..3fed50f96e436 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -1043,6 +1043,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const busySessionIds = new Set(); // A background task returns before its new child session reports busy. const unstartedSessionIds = new Set(); + // A child can error before any turn of it is projected; its next idle settles the launch. + const sessionErrorOutcomes = new Map(); // Remember settled requests across routing retries to prevent resurrection; only recent IDs matter. const settledNativeRequestIds = new Set(); const pendingChildRequestRoutes = new Set(); @@ -1612,8 +1614,11 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const lastTurn = Array.from(threads.get(childSessionId)?.providerTurns.values() ?? []).at( -1, )?.status; + const errorOutcome = sessionErrorOutcomes.get(childSessionId); + sessionErrorOutcomes.delete(childSessionId); const childOutcome = outcome ?? + errorOutcome ?? (lastTurn === "interrupted" || lastTurn === "failed" || lastTurn === "cancelled" ? lastTurn : "completed"); @@ -2827,6 +2832,12 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function }); return; } + if (event.properties.sessionID !== undefined) { + sessionErrorOutcomes.set( + event.properties.sessionID, + isMessageAbortedError(event) ? "interrupted" : "failed", + ); + } const states = event.properties.sessionID === undefined ? Array.from(threads.values()).filter((state) => state.activeTurn !== null) @@ -2892,6 +2903,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function Effect.gen(function* () { busySessionIds.clear(); unstartedSessionIds.clear(); + sessionErrorOutcomes.clear(); if (closing || abortController.signal.aborted) return; const detail = Exit.isSuccess(exit) ? "OpenCode event stream ended unexpectedly."