From ce461b8d8fc7d4589690469a532a841cd7dacd33 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:48:05 +0900 Subject: [PATCH] feat(background-agent): integrate team-mode into background manager --- src/features/background-agent/manager.test.ts | 162 ++++++++++++++++++ src/features/background-agent/manager.ts | 20 ++- 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 8c855ebcf..0e3b40ba8 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -6,6 +6,7 @@ afterAll(() => { mock.restore() }) import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state" import { tmpdir } from "node:os" import type { PluginInput } from "@opencode-ai/plugin" +import * as sharedModule from "../../shared" import { _resetForTesting as resetClaudeCodeSessionState, subagentSessions } from "../claude-code-session-state" import type { BackgroundTask, ResumeInput } from "./types" import { MIN_IDLE_TIME_MS } from "./constants" @@ -4229,6 +4230,30 @@ describe("BackgroundManager.handleEvent - session.error", () => { { providers: ["anthropic"], model: "gpt-5.3-codex", variant: "high" }, ] + let logCalls: Array<{ message: string; data?: unknown }> = [] + let logSpy: ReturnType | undefined + let verifySessionExistsSpy: ReturnType | undefined + + beforeEach(() => { + logCalls = [] + logSpy = spyOn(sharedModule, "log").mockImplementation((message: string, data?: unknown) => { + logCalls.push({ message, data }) + }) + }) + + afterEach(() => { + logSpy?.mockRestore() + verifySessionExistsSpy?.mockRestore() + }) + + const mockVerifySessionExists = (manager: BackgroundManager, sessionExists: boolean): void => { + verifySessionExistsSpy?.mockRestore() + verifySessionExistsSpy = spyOn( + manager as unknown as { verifySessionExists: (sessionID: string) => Promise }, + "verifySessionExists", + ).mockResolvedValue(sessionExists) + } + const stubProcessKey = (manager: BackgroundManager) => { ;(manager as unknown as { processKey: (key: string) => Promise }).processKey = async () => {} } @@ -4260,6 +4285,7 @@ describe("BackgroundManager.handleEvent - session.error", () => { test("sets task to error, releases concurrency, and keeps it until delayed cleanup", async () => { //#given const manager = createBackgroundManager() + mockVerifySessionExists(manager, false) const concurrencyManager = getConcurrencyManager(manager) const concurrencyKey = "test-provider/test-model" await concurrencyManager.acquire(concurrencyKey) @@ -4308,6 +4334,7 @@ describe("BackgroundManager.handleEvent - session.error", () => { //#given const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker() const manager = createBackgroundManager() + mockVerifySessionExists(manager, false) const sessionID = "ses_error_toast" const task = createMockTask({ id: "task-session-error-toast", @@ -4390,6 +4417,141 @@ describe("BackgroundManager.handleEvent - session.error", () => { manager.shutdown() }) + test("does not terminate task on session.error when session is still alive", async () => { + //#given + const manager = createBackgroundManager() + mockVerifySessionExists(manager, true) + + const task = createMockTask({ + id: "task-session-error-alive", + sessionID: "ses-alive", + parentSessionID: "parent-session", + parentMessageID: "msg-alive", + description: "task with transient session.error", + agent: "explore", + status: "running", + }) + getTaskMap(manager).set(task.id, task) + + //#when + manager.handleEvent({ + type: "session.error", + properties: { + sessionID: task.sessionID, + error: { + name: "UnknownError", + message: "Out of memory", + }, + }, + }) + + await flushBackgroundNotifications() + + //#then + expect(task.status).toBe("running") + expect(task.error).toBeUndefined() + expect( + logCalls.some((call) => call.message.includes("session.error received but session still alive")), + ).toBe(true) + + manager.shutdown() + }) + + test("terminates task on session.error when session is gone", async () => { + //#given + const manager = createBackgroundManager() + mockVerifySessionExists(manager, false) + + const task = createMockTask({ + id: "task-session-error-gone", + sessionID: "ses-gone", + parentSessionID: "parent-session", + parentMessageID: "msg-gone", + description: "task with fatal session.error", + agent: "explore", + status: "running", + }) + getTaskMap(manager).set(task.id, task) + + //#when + manager.handleEvent({ + type: "session.error", + properties: { + sessionID: task.sessionID, + error: { + name: "UnknownError", + message: "Out of memory", + }, + }, + }) + + await flushBackgroundNotifications() + + //#then + expect(task.status).toBe("error") + expect(task.error).toBe("Out of memory") + + manager.shutdown() + }) + + test("completes task on session.idle after transient session.error", async () => { + //#given + const sessionID = "ses-alive-idle" + const client = { + session: { + prompt: async () => ({}), + promptAsync: async () => ({}), + abort: async () => ({}), + messages: async () => ({ + data: [ + { + info: { role: "assistant" }, + parts: [{ type: "text", text: "ok" }], + }, + ], + }), + todo: async () => ({ data: [] }), + }, + } + + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + stubNotifyParentSession(manager) + mockVerifySessionExists(manager, true) + + const task = createMockTask({ + id: "task-session-error-recovers", + sessionID, + parentSessionID: "parent-session", + parentMessageID: "msg-recovers", + description: "task that recovers after transient error", + agent: "explore", + status: "running", + startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)), + }) + getTaskMap(manager).set(task.id, task) + + //#when + manager.handleEvent({ + type: "session.error", + properties: { + sessionID, + error: { + name: "UnknownError", + message: "Out of memory", + }, + }, + }) + await flushBackgroundNotifications() + manager.handleEvent({ type: "session.idle", properties: { sessionID } }) + await new Promise((resolve) => setTimeout(resolve, 10)) + + //#then + expect(task.status).toBe("completed") + expect(task.error).toBeUndefined() + + manager.shutdown() + }) + test("retry path releases current concurrency slot and prefers current provider in fallback entry", async () => { //#given const manager = createBackgroundManager() diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index cb563d0aa..d4f2186e4 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -362,6 +362,7 @@ export class BackgroundManager { spawnDepth: spawnReservation.spawnContext.childDepth, parentSessionID: input.parentSessionID, parentMessageID: input.parentMessageID, + teamRunId: input.teamRunId, parentModel: input.parentModel, parentAgent: input.parentAgent, parentTools: input.parentTools, @@ -533,7 +534,7 @@ export class BackgroundManager { parentID: input.parentSessionID, }) - if (this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) { + if (!input.suppressTmuxSpawn && this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) { log("[background-agent] Invoking tmux callback NOW", { sessionID }) await this.onSubagentSessionCreated({ sessionID, @@ -545,7 +546,9 @@ export class BackgroundManager { log("[background-agent] tmux callback completed, waiting 200ms") await new Promise(r => setTimeout(r, 200)) } else { - log("[background-agent] SKIP tmux callback - conditions not met") + log("[background-agent] SKIP tmux callback - conditions not met", { + suppressTmuxSpawn: !!input.suppressTmuxSpawn, + }) } if (this.tasks.get(task.id)?.status === "cancelled") { @@ -1320,6 +1323,19 @@ export class BackgroundManager { canRetry, }) + const sessionID = task.sessionID + if (sessionID) { + const sessionStillAlive = await this.verifySessionExists(sessionID) + if (sessionStillAlive) { + log("[background-agent] session.error received but session still alive, treating as transient:", { + taskId: task.id, + sessionID, + errorMessage: errorMsg?.slice(0, 200), + }) + return + } + } + task.status = "error" task.error = errorMsg task.completedAt = new Date()