From 845a1d2a03b36051f8eddb06c52af447b90c6ab8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 19 Dec 2025 01:56:38 +0900 Subject: [PATCH] fix(background-agent): cancel all nested descendant tasks recursively (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, background_cancel(all=true) only cancelled direct child tasks, leaving grandchildren and deeper nested tasks uncancelled. This caused background agents to continue running even when their parent session was cancelled. Changes: - Added getAllDescendantTasks() method to BackgroundTaskManager for recursive task collection - Updated background_cancel to use getAllDescendantTasks instead of getTasksByParentSession - Added comprehensive test coverage for nested task cancellation scenarios 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/features/background-agent/manager.test.ts | 232 ++++++++++++++++++ src/features/background-agent/manager.ts | 13 + src/tools/background-task/tools.ts | 2 +- 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/features/background-agent/manager.test.ts diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts new file mode 100644 index 0000000..2391d79 --- /dev/null +++ b/src/features/background-agent/manager.test.ts @@ -0,0 +1,232 @@ +import { describe, test, expect, beforeEach } from "bun:test" +import type { BackgroundTask } from "./types" + +class MockBackgroundManager { + private tasks: Map = new Map() + + addTask(task: BackgroundTask): void { + this.tasks.set(task.id, task) + } + + getTask(id: string): BackgroundTask | undefined { + return this.tasks.get(id) + } + + getTasksByParentSession(sessionID: string): BackgroundTask[] { + const result: BackgroundTask[] = [] + for (const task of this.tasks.values()) { + if (task.parentSessionID === sessionID) { + result.push(task) + } + } + return result + } + + getAllDescendantTasks(sessionID: string): BackgroundTask[] { + const result: BackgroundTask[] = [] + const directChildren = this.getTasksByParentSession(sessionID) + + for (const child of directChildren) { + result.push(child) + const descendants = this.getAllDescendantTasks(child.sessionID) + result.push(...descendants) + } + + return result + } +} + +function createMockTask(overrides: Partial & { id: string; sessionID: string; parentSessionID: string }): BackgroundTask { + return { + parentMessageID: "mock-message-id", + description: "test task", + prompt: "test prompt", + agent: "test-agent", + status: "running", + startedAt: new Date(), + ...overrides, + } +} + +describe("BackgroundManager.getAllDescendantTasks", () => { + let manager: MockBackgroundManager + + beforeEach(() => { + // #given + manager = new MockBackgroundManager() + }) + + test("should return empty array when no tasks exist", () => { + // #given - empty manager + + // #when + const result = manager.getAllDescendantTasks("session-a") + + // #then + expect(result).toEqual([]) + }) + + test("should return direct children only when no nested tasks", () => { + // #given + const taskB = createMockTask({ + id: "task-b", + sessionID: "session-b", + parentSessionID: "session-a", + }) + manager.addTask(taskB) + + // #when + const result = manager.getAllDescendantTasks("session-a") + + // #then + expect(result).toHaveLength(1) + expect(result[0].id).toBe("task-b") + }) + + test("should return all nested descendants (2 levels deep)", () => { + // #given + // Session A -> Task B -> Task C + const taskB = createMockTask({ + id: "task-b", + sessionID: "session-b", + parentSessionID: "session-a", + }) + const taskC = createMockTask({ + id: "task-c", + sessionID: "session-c", + parentSessionID: "session-b", + }) + manager.addTask(taskB) + manager.addTask(taskC) + + // #when + const result = manager.getAllDescendantTasks("session-a") + + // #then + expect(result).toHaveLength(2) + expect(result.map(t => t.id)).toContain("task-b") + expect(result.map(t => t.id)).toContain("task-c") + }) + + test("should return all nested descendants (3 levels deep)", () => { + // #given + // Session A -> Task B -> Task C -> Task D + const taskB = createMockTask({ + id: "task-b", + sessionID: "session-b", + parentSessionID: "session-a", + }) + const taskC = createMockTask({ + id: "task-c", + sessionID: "session-c", + parentSessionID: "session-b", + }) + const taskD = createMockTask({ + id: "task-d", + sessionID: "session-d", + parentSessionID: "session-c", + }) + manager.addTask(taskB) + manager.addTask(taskC) + manager.addTask(taskD) + + // #when + const result = manager.getAllDescendantTasks("session-a") + + // #then + expect(result).toHaveLength(3) + expect(result.map(t => t.id)).toContain("task-b") + expect(result.map(t => t.id)).toContain("task-c") + expect(result.map(t => t.id)).toContain("task-d") + }) + + test("should handle multiple branches (tree structure)", () => { + // #given + // Session A -> Task B1 -> Task C1 + // -> Task B2 -> Task C2 + const taskB1 = createMockTask({ + id: "task-b1", + sessionID: "session-b1", + parentSessionID: "session-a", + }) + const taskB2 = createMockTask({ + id: "task-b2", + sessionID: "session-b2", + parentSessionID: "session-a", + }) + const taskC1 = createMockTask({ + id: "task-c1", + sessionID: "session-c1", + parentSessionID: "session-b1", + }) + const taskC2 = createMockTask({ + id: "task-c2", + sessionID: "session-c2", + parentSessionID: "session-b2", + }) + manager.addTask(taskB1) + manager.addTask(taskB2) + manager.addTask(taskC1) + manager.addTask(taskC2) + + // #when + const result = manager.getAllDescendantTasks("session-a") + + // #then + expect(result).toHaveLength(4) + expect(result.map(t => t.id)).toContain("task-b1") + expect(result.map(t => t.id)).toContain("task-b2") + expect(result.map(t => t.id)).toContain("task-c1") + expect(result.map(t => t.id)).toContain("task-c2") + }) + + test("should not include tasks from unrelated sessions", () => { + // #given + // Session A -> Task B + // Session X -> Task Y (unrelated) + const taskB = createMockTask({ + id: "task-b", + sessionID: "session-b", + parentSessionID: "session-a", + }) + const taskY = createMockTask({ + id: "task-y", + sessionID: "session-y", + parentSessionID: "session-x", + }) + manager.addTask(taskB) + manager.addTask(taskY) + + // #when + const result = manager.getAllDescendantTasks("session-a") + + // #then + expect(result).toHaveLength(1) + expect(result[0].id).toBe("task-b") + expect(result.map(t => t.id)).not.toContain("task-y") + }) + + test("getTasksByParentSession should only return direct children (not recursive)", () => { + // #given + // Session A -> Task B -> Task C + const taskB = createMockTask({ + id: "task-b", + sessionID: "session-b", + parentSessionID: "session-a", + }) + const taskC = createMockTask({ + id: "task-c", + sessionID: "session-c", + parentSessionID: "session-b", + }) + manager.addTask(taskB) + manager.addTask(taskC) + + // #when + const result = manager.getTasksByParentSession("session-a") + + // #then + expect(result).toHaveLength(1) + expect(result[0].id).toBe("task-b") + }) +}) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index bf0678e..feb233b 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -150,6 +150,19 @@ export class BackgroundManager { return result } + getAllDescendantTasks(sessionID: string): BackgroundTask[] { + const result: BackgroundTask[] = [] + const directChildren = this.getTasksByParentSession(sessionID) + + for (const child of directChildren) { + result.push(child) + const descendants = this.getAllDescendantTasks(child.sessionID) + result.push(...descendants) + } + + return result + } + findBySession(sessionID: string): BackgroundTask | undefined { for (const task of this.tasks.values()) { if (task.sessionID === sessionID) { diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index 4a33bd4..b66d833 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -275,7 +275,7 @@ export function createBackgroundCancel(manager: BackgroundManager, client: Openc } if (cancelAll) { - const tasks = manager.getTasksByParentSession(toolContext.sessionID) + const tasks = manager.getAllDescendantTasks(toolContext.sessionID) const runningTasks = tasks.filter(t => t.status === "running") if (runningTasks.length === 0) {