Skip to content

Commit e4e6e6c

Browse files
fix(opencode): a failed background subagent no longer pins its thread
An OpenCode background subagent whose own turn failed never reported back to its parent, so the adapter kept the call live forever: the thread kept showing "1 agent working in the background" and a Stop could not clear it, because the roster is only emptied by the adapter that owns the call. Settle every call attached to a failed background child with the failure, so the provider thread's roster drops them and the thread stops showing an agent that is gone. Autonomously generated by opencode/minimax-m3.1-flash-free through the OpenCode harness in T3 Code.
1 parent a71a6c3 commit e4e6e6c

2 files changed

Lines changed: 109 additions & 0 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,94 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
10921092
event("session.execution.started", { sessionID: CHILD }),
10931093
];
10941094

1095+
it.effect("settles a background subagent that fails, so no work is left pending", () =>
1096+
Effect.gen(function* () {
1097+
const { runtime, thread } = yield* resumed([
1098+
...launchedAndRunning,
1099+
// The parent turn ends, and the background child fails: it reports
1100+
// nothing back, so its call has to end with the failure.
1101+
event("session.execution.succeeded", { sessionID: SESSION }),
1102+
event("session.execution.failed", {
1103+
sessionID: CHILD,
1104+
error: { type: "provider.quota", message: "Rate limit exceeded." },
1105+
}),
1106+
]);
1107+
const watch = yield* watchCalls(runtime, thread);
1108+
yield* runtime.startTurn(withLineage(thread));
1109+
yield* watch.until(
1110+
() => watch.calls.get("Sleep")?.status === "failed" && watch.state.roster?.length === 0,
1111+
);
1112+
assert.deepEqual(watch.state.roster, []);
1113+
assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
1114+
}).pipe(Effect.scoped),
1115+
);
1116+
1117+
it.effect("settles every call joined to a failed background subagent", () =>
1118+
Effect.gen(function* () {
1119+
const { runtime, thread } = yield* resumed([
1120+
...launchedAndRunning,
1121+
// A second call joins the running child, so its turn answers for both.
1122+
...callAgain({ background: true }),
1123+
event("session.execution.succeeded", { sessionID: SESSION }),
1124+
event("session.execution.failed", {
1125+
sessionID: CHILD,
1126+
error: { type: "provider.quota", message: "Rate limit exceeded." },
1127+
}),
1128+
]);
1129+
const watch = yield* watchCalls(runtime, thread);
1130+
yield* runtime.startTurn(withLineage(thread));
1131+
yield* watch.until(
1132+
() =>
1133+
watch.calls.get("Sleep")?.status === "failed" &&
1134+
watch.calls.get("Again")?.status === "failed" &&
1135+
watch.state.roster?.length === 0,
1136+
);
1137+
assert.deepEqual(watch.state.roster, []);
1138+
assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
1139+
}).pipe(Effect.scoped),
1140+
);
1141+
1142+
it.effect("settles the background call a foreground call joined to a failed child", () =>
1143+
Effect.gen(function* () {
1144+
const again = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-again" };
1145+
const { runtime, thread } = yield* resumed([
1146+
...launchedAndRunning,
1147+
// A foreground call joins the running background child. It is only
1148+
// marked background once its own tool call returns, so the child can
1149+
// fail while this call is still the foreground one.
1150+
event("session.tool.input.started", { ...again, name: "subagent" }),
1151+
event("session.tool.called", {
1152+
...again,
1153+
name: "subagent",
1154+
input: { description: "Again", prompt: "also this", sessionID: CHILD },
1155+
executed: false,
1156+
}),
1157+
event("session.tool.progress", {
1158+
...again,
1159+
metadata: { sessionID: CHILD, status: "running" },
1160+
}),
1161+
out("session.update", { sessionID: CHILD, permissions: "<any>" }),
1162+
reply("session.update", null),
1163+
// The child fails while the joined call is still the foreground one.
1164+
event("session.execution.failed", {
1165+
sessionID: CHILD,
1166+
error: { type: "provider.quota", message: "Rate limit exceeded." },
1167+
}),
1168+
event("session.execution.succeeded", { sessionID: SESSION }),
1169+
]);
1170+
const watch = yield* watchCalls(runtime, thread);
1171+
yield* runtime.startTurn(withLineage(thread));
1172+
yield* watch.until(
1173+
() =>
1174+
watch.calls.get("Sleep")?.status === "failed" &&
1175+
watch.calls.get("Again")?.status === "failed" &&
1176+
watch.state.roster?.length === 0,
1177+
);
1178+
assert.deepEqual(watch.state.roster, []);
1179+
assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(thread));
1180+
}).pipe(Effect.scoped),
1181+
);
1182+
10951183
it.effect("settles every call that joined a running subagent with its one report", () =>
10961184
Effect.gen(function* () {
10971185
const { runtime, thread } = yield* resumed([

‎packages/provider-opencode/src/server/v2/adapter.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,16 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
799799
const runningCalls = (thread: ThreadState): ReadonlyArray<SubagentCall> =>
800800
sessionsOf(thread).flatMap((state) => [...state.calls.values()]);
801801

802+
/**
803+
* The calls a subagent session answers for: the one that launched it and
804+
* the ones that joined it while it ran.
805+
*/
806+
const callsAttachedTo = (child: ThreadState): ReadonlyArray<SubagentCall> => {
807+
const parent = child.subagent?.call.state;
808+
if (parent === undefined) return [];
809+
return [...parent.calls.values()].filter((candidate) => candidate.child === child);
810+
};
811+
802812
const callsAbove = (sessionId: string) => {
803813
const calls: Array<SubagentCall> = [];
804814
let current = threads.get(sessionId);
@@ -1557,6 +1567,17 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
15571567
if (!call.background && terminal.status !== "completed") {
15581568
yield* settleCall(call, terminal.status);
15591569
}
1570+
if (terminal.status === "failed") {
1571+
// A background call is settled by the report its parent reads, and a
1572+
// child that failed has no report to read: every call attached to it
1573+
// has to end with the failure, or the background ones keep the thread
1574+
// showing work that is gone. Which of them joined the child last
1575+
// does not matter: a foreground call is one until it is marked
1576+
// background, and the earlier background call outlives that window.
1577+
for (const attached of callsAttachedTo(state)) {
1578+
yield* settleCall(attached, terminal.status, terminal.failure.message);
1579+
}
1580+
}
15601581
return;
15611582
}
15621583
state.providerThread = {

0 commit comments

Comments
 (0)