diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 0c70d6343b38a..2c6c62446bc1a 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1092,6 +1092,94 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { event("session.execution.started", { sessionID: CHILD }), ]; + it.effect("settles a background subagent that fails, so no work is left pending", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...launchedAndRunning, + // The parent turn ends, and the background child fails: it reports + // nothing back, so its call has to end with the failure. + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.failed", { + sessionID: CHILD, + error: { type: "provider.quota", message: "Rate limit exceeded." }, + }), + ]); + const watch = yield* watchCalls(runtime, thread); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.until( + () => watch.calls.get("Sleep")?.status === "failed" && watch.state.roster?.length === 0, + ); + assert.deepEqual(watch.state.roster, []); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped), + ); + + it.effect("settles every call joined to a failed background subagent", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + ...launchedAndRunning, + // A second call joins the running child, so its turn answers for both. + ...callAgain({ background: true }), + event("session.execution.succeeded", { sessionID: SESSION }), + event("session.execution.failed", { + sessionID: CHILD, + error: { type: "provider.quota", message: "Rate limit exceeded." }, + }), + ]); + const watch = yield* watchCalls(runtime, thread); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.until( + () => + watch.calls.get("Sleep")?.status === "failed" && + watch.calls.get("Again")?.status === "failed" && + watch.state.roster?.length === 0, + ); + assert.deepEqual(watch.state.roster, []); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped), + ); + + it.effect("settles the background call a foreground call joined to a failed child", () => + Effect.gen(function* () { + const again = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-again" }; + const { runtime, thread } = yield* resumed([ + ...launchedAndRunning, + // A foreground call joins the running background child. It is only + // marked background once its own tool call returns, so the child can + // fail while this call is still the foreground one. + event("session.tool.input.started", { ...again, name: "subagent" }), + event("session.tool.called", { + ...again, + name: "subagent", + input: { description: "Again", prompt: "also this", sessionID: CHILD }, + executed: false, + }), + event("session.tool.progress", { + ...again, + metadata: { sessionID: CHILD, status: "running" }, + }), + out("session.update", { sessionID: CHILD, permissions: "" }), + reply("session.update", null), + // The child fails while the joined call is still the foreground one. + event("session.execution.failed", { + sessionID: CHILD, + error: { type: "provider.quota", message: "Rate limit exceeded." }, + }), + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const watch = yield* watchCalls(runtime, thread); + yield* runtime.startTurn(withLineage(thread)); + yield* watch.until( + () => + watch.calls.get("Sleep")?.status === "failed" && + watch.calls.get("Again")?.status === "failed" && + watch.state.roster?.length === 0, + ); + assert.deepEqual(watch.state.roster, []); + assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread)); + }).pipe(Effect.scoped), + ); + it.effect("settles every call that joined a running subagent with its one report", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index beb14dedb5edc..b664eabd8ab6a 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -799,6 +799,16 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr const runningCalls = (thread: ThreadState): ReadonlyArray => sessionsOf(thread).flatMap((state) => [...state.calls.values()]); + /** + * The calls a subagent session answers for: the one that launched it and + * the ones that joined it while it ran. + */ + const callsAttachedTo = (child: ThreadState): ReadonlyArray => { + const parent = child.subagent?.call.state; + if (parent === undefined) return []; + return [...parent.calls.values()].filter((candidate) => candidate.child === child); + }; + const callsAbove = (sessionId: string) => { const calls: Array = []; let current = threads.get(sessionId); @@ -1557,6 +1567,17 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (!call.background && terminal.status !== "completed") { yield* settleCall(call, terminal.status); } + if (terminal.status === "failed") { + // A background call is settled by the report its parent reads, and a + // child that failed has no report to read: every call attached to it + // has to end with the failure, or the background ones keep the thread + // showing work that is gone. Which of them joined the child last + // does not matter: a foreground call is one until it is marked + // background, and the earlier background call outlives that window. + for (const attached of callsAttachedTo(state)) { + yield* settleCall(attached, terminal.status, terminal.failure.message); + } + } return; } state.providerThread = {