From 7f1497fc43dd791d8ff77b1b0087c798eb75a324 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 4 Apr 2026 14:19:18 +0900 Subject: [PATCH] fix(background-agent): prevent cancelled task from being revived after tmux callback --- src/features/background-agent/manager.test.ts | 100 ++++++++++++++++++ src/features/background-agent/manager.ts | 7 +- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 78b3904fa..56c0fb151 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -2583,6 +2583,106 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0) }) + test("should keep task cancelled when cancelled during tmux callback before running state is assigned", async () => { + // given + const originalTmuxEnvironment = process.env.TMUX + process.env.TMUX = "test-session" + + try { + const createdSessionID = "ses-cancelled-during-tmux-callback" + const abortCalls: string[] = [] + const promptAsyncSessionIDs: string[] = [] + let taskID: string | undefined + let resolveAbortCalled: (() => void) | undefined + const abortCalled = new Promise((resolve) => { + resolveAbortCalled = resolve + }) + + manager.shutdown() + manager = new BackgroundManager( + { + client: { + session: { + create: async () => ({ data: { id: createdSessionID } }), + get: async () => ({ data: { directory: "/test/dir" } }), + prompt: async () => ({}), + promptAsync: async ({ path }: { path: { id: string } }) => { + promptAsyncSessionIDs.push(path.id) + return {} + }, + messages: async () => ({ data: [] }), + todo: async () => ({ data: [] }), + status: async () => ({ data: {} }), + abort: async ({ path }: { path: { id: string } }) => { + abortCalls.push(path.id) + resolveAbortCalled?.() + return {} + }, + }, + }, + directory: tmpdir(), + } as unknown as PluginInput, + { + defaultConcurrency: 1, + }, + { + tmuxConfig: { + enabled: true, + layout: "main-vertical", + main_pane_size: 60, + main_pane_min_width: 120, + agent_pane_min_width: 40, + isolation: "inline", + }, + onSubagentSessionCreated: async () => { + const activeTaskID = taskID ?? Array.from(getTaskMap(manager).keys())[0] + + if (!activeTaskID) { + throw new Error("expected active task during tmux callback") + } + + await manager.cancelTask(activeTaskID, { + source: "test", + abortSession: false, + }) + }, + } + ) + + const input = { + description: "Test task", + prompt: "Do something", + agent: "test-agent", + parentSessionID: "parent-session", + parentMessageID: "parent-message", + } + + const task = await manager.launch(input) + taskID = task.id + + // when + await Promise.race([ + abortCalled, + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 500)), + ]) + await flushBackgroundNotifications() + + // then + const updatedTask = manager.getTask(task.id) + expect(updatedTask?.status).toBe("cancelled") + expect(updatedTask?.sessionID).toBeUndefined() + expect(promptAsyncSessionIDs).not.toContain(createdSessionID) + expect(abortCalls).toEqual([createdSessionID]) + expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0) + } finally { + if (originalTmuxEnvironment === undefined) { + delete process.env.TMUX + } else { + process.env.TMUX = originalTmuxEnvironment + } + } + }) + test("should release descendant quota when task completes", async () => { manager.shutdown() manager = new BackgroundManager( diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 2dc01e959..aa5979cf4 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -491,7 +491,12 @@ export class BackgroundManager { log("[background-agent] SKIP tmux callback - conditions not met") } - // Update task to running state + if (this.tasks.get(task.id)?.status === "cancelled") { + await this.abortSessionWithLogging(sessionID, "cancelled during tmux setup") + this.concurrencyManager.release(concurrencyKey) + return + } + task.status = "running" task.startedAt = new Date() task.sessionID = sessionID