fix(background-agent): cancel all nested descendant tasks recursively (#107)
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)
This commit is contained in:
232
src/features/background-agent/manager.test.ts
Normal file
232
src/features/background-agent/manager.test.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { describe, test, expect, beforeEach } from "bun:test"
|
||||||
|
import type { BackgroundTask } from "./types"
|
||||||
|
|
||||||
|
class MockBackgroundManager {
|
||||||
|
private tasks: Map<string, BackgroundTask> = 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<BackgroundTask> & { 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -150,6 +150,19 @@ export class BackgroundManager {
|
|||||||
return result
|
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 {
|
findBySession(sessionID: string): BackgroundTask | undefined {
|
||||||
for (const task of this.tasks.values()) {
|
for (const task of this.tasks.values()) {
|
||||||
if (task.sessionID === sessionID) {
|
if (task.sessionID === sessionID) {
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function createBackgroundCancel(manager: BackgroundManager, client: Openc
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cancelAll) {
|
if (cancelAll) {
|
||||||
const tasks = manager.getTasksByParentSession(toolContext.sessionID)
|
const tasks = manager.getAllDescendantTasks(toolContext.sessionID)
|
||||||
const runningTasks = tasks.filter(t => t.status === "running")
|
const runningTasks = tasks.filter(t => t.status === "running")
|
||||||
|
|
||||||
if (runningTasks.length === 0) {
|
if (runningTasks.length === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user