diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 7b3820a97..125b6d352 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1640,7 +1640,7 @@ The fallback retry session is now created and can be inspected directly. if (!sessionID) return const resolved = this.resolveTaskAttemptBySession(sessionID) - if (!resolved?.isCurrent) { + if (this.parentWakeNotifier.getDispatchedParentWakes().has(sessionID) || !resolved?.isCurrent) { void this.requeueDispatchedParentWake(sessionID, "session.error").catch((error) => { log("[background-agent] Failed to requeue dispatched parent wake:", { sessionID, error }) }) diff --git a/src/features/background-agent/subagent-failure-parent-isolation.test.ts b/src/features/background-agent/subagent-failure-parent-isolation.test.ts new file mode 100644 index 000000000..20ae4d526 --- /dev/null +++ b/src/features/background-agent/subagent-failure-parent-isolation.test.ts @@ -0,0 +1,161 @@ +/// + +import { tmpdir } from "node:os" +import { afterEach, describe, expect, test } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" +import { BackgroundManager } from "./manager" +import type { BackgroundTask } from "./types" +import { releaseAllPromptAsyncReservationsForTesting } from "../../hooks/shared/prompt-async-gate" + +type PromptAsyncCall = { + path: { id: string } + body: { + noReply?: boolean + parts?: unknown[] + } +} + +type PendingParentWakeForTest = { + notifications: string[] + shouldReply: boolean +} + +let managerUnderTest: BackgroundManager | undefined + +afterEach(() => { + managerUnderTest?.shutdown() + releaseAllPromptAsyncReservationsForTesting() + managerUnderTest = undefined +}) + +function createTask(overrides: Partial & { id: string; parentSessionId: string }): BackgroundTask { + const id = overrides.id + const parentSessionID = overrides.parentSessionId + const { id: _ignoredID, parentSessionId: _ignoredParentSessionID, ...rest } = overrides + + return { + parentMessageId: overrides.parentMessageId ?? "parent-message-id", + description: overrides.description ?? overrides.id, + prompt: overrides.prompt ?? `Prompt for ${overrides.id}`, + agent: overrides.agent ?? "test-agent", + status: overrides.status ?? "running", + startedAt: overrides.startedAt ?? new Date("2026-05-26T00:00:00.000Z"), + ...rest, + id, + parentSessionId: parentSessionID, + } +} + +function createManager(): { + manager: BackgroundManager + promptAsyncCalls: PromptAsyncCall[] +} { + const promptAsyncCalls: PromptAsyncCall[] = [] + const client = { + session: { + messages: async () => [ + { + info: { role: "assistant", finish: "stop", time: { created: 1_000 } }, + parts: [{ type: "text", text: "done" }], + }, + ], + status: async () => ({ data: { "main-session": { type: "idle" }, "subagent-session": { type: "idle" } } }), + get: async (input: { path: { id: string } }) => ({ data: input.path.id === "subagent-session" ? null : { id: input.path.id } }), + prompt: async () => ({}), + promptAsync: async (call: PromptAsyncCall) => { + promptAsyncCalls.push(call) + return {} + }, + abort: async () => ({}), + }, + } + const ctx: PluginInput = { + client: client as unknown as PluginInput["client"], + project: {} as PluginInput["project"], + directory: tmpdir(), + worktree: tmpdir(), + experimental_workspace: { register: () => {} }, + serverUrl: new URL("http://localhost"), + $: {} as PluginInput["$"], + } + + return { + manager: new BackgroundManager({ pluginContext: ctx, config: undefined, enableParentSessionNotifications: true }), + promptAsyncCalls, + } +} + +function getTasks(manager: BackgroundManager): Map { + return Reflect.get(manager, "tasks") as Map +} + +function getPendingByParent(manager: BackgroundManager): Map> { + return Reflect.get(manager, "pendingByParent") as Map> +} + +function getPendingParentWakes(manager: BackgroundManager): Map { + const parentWakeNotifier = Reflect.get(manager, "parentWakeNotifier") as { + getPendingParentWakes: () => Map + } + return parentWakeNotifier.getPendingParentWakes() +} + +async function notifyParentSessionForTest(manager: BackgroundManager, task: BackgroundTask): Promise { + const notifyParentSession = Reflect.get(manager, "notifyParentSession") as (task: BackgroundTask) => Promise + return notifyParentSession.call(manager, task) +} + +async function flushPendingParentWakeForTest(manager: BackgroundManager, sessionID: string): Promise { + const flushPendingParentWake = Reflect.get(manager, "flushPendingParentWake") as (sessionID: string) => Promise + return flushPendingParentWake.call(manager, sessionID) +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 5; index++) { + await Promise.resolve() + } +} + +describe("BackgroundManager subagent failure parent isolation", () => { + test("#given nested background wake prompt errors in a subagent session #when the subagent is also a parent task #then the main session is not notified or cancelled", async () => { + // given + const { manager, promptAsyncCalls } = createManager() + managerUnderTest = manager + const outerTask = createTask({ + id: "bg-main", + parentSessionId: "main-session", + sessionId: "subagent-session", + description: "Draft fresh shipping plan", + status: "running", + }) + const nestedFailure = createTask({ + id: "bg-momus", + parentSessionId: "subagent-session", + description: "Momus re-review v2 (bg)", + status: "error", + error: "UnknownError: UnknownError", + completedAt: new Date("2026-05-26T00:00:01.000Z"), + }) + getTasks(manager).set(outerTask.id, outerTask) + getPendingByParent(manager).set(nestedFailure.parentSessionId, new Set([nestedFailure.id])) + await notifyParentSessionForTest(manager, nestedFailure) + await flushPendingParentWakeForTest(manager, "subagent-session") + + // when + manager.handleEvent({ + type: "session.error", + properties: { + sessionID: "subagent-session", + error: { name: "UnknownError", message: "UnknownError" }, + }, + }) + await flushMicrotasks() + + // then + expect(promptAsyncCalls).toHaveLength(1) + expect(promptAsyncCalls[0]?.path.id).toBe("subagent-session") + expect(JSON.stringify(promptAsyncCalls[0]?.body.parts)).toContain("[ALL BACKGROUND TASKS FINISHED - 1 FAILED]") + expect(outerTask.status).toBe("running") + expect(getPendingParentWakes(manager).has("main-session")).toBe(false) + }) +})