From 9040383da7e561d2d06a2fce674bfe3516438dbd Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 7 Feb 2026 19:10:49 +0900 Subject: [PATCH] fix: cascade cancel descendant tasks when parent session is deleted (#114) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 63 +++++++++++++++++++ src/features/background-agent/manager.ts | 61 +++++++++++------- 2 files changed, 100 insertions(+), 24 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 334e64178..014f9df66 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -2284,6 +2284,69 @@ describe("BackgroundManager.shutdown session abort", () => { }) }) +describe("BackgroundManager.handleEvent - session.deleted cascade", () => { + test("should cancel descendant tasks when parent session is deleted", () => { + // given + const manager = createBackgroundManager() + const parentSessionID = "session-parent" + const childTask = createMockTask({ + id: "task-child", + sessionID: "session-child", + parentSessionID, + status: "running", + }) + const siblingTask = createMockTask({ + id: "task-sibling", + sessionID: "session-sibling", + parentSessionID, + status: "running", + }) + const grandchildTask = createMockTask({ + id: "task-grandchild", + sessionID: "session-grandchild", + parentSessionID: "session-child", + status: "pending", + startedAt: undefined, + queuedAt: new Date(), + }) + const unrelatedTask = createMockTask({ + id: "task-unrelated", + sessionID: "session-unrelated", + parentSessionID: "other-parent", + status: "running", + }) + + const taskMap = getTaskMap(manager) + taskMap.set(childTask.id, childTask) + taskMap.set(siblingTask.id, siblingTask) + taskMap.set(grandchildTask.id, grandchildTask) + taskMap.set(unrelatedTask.id, unrelatedTask) + + const pendingByParent = getPendingByParent(manager) + pendingByParent.set(parentSessionID, new Set([childTask.id, siblingTask.id])) + pendingByParent.set("session-child", new Set([grandchildTask.id])) + + // when + manager.handleEvent({ + type: "session.deleted", + properties: { info: { id: parentSessionID } }, + }) + + // then + expect(taskMap.has(childTask.id)).toBe(false) + expect(taskMap.has(siblingTask.id)).toBe(false) + expect(taskMap.has(grandchildTask.id)).toBe(false) + expect(taskMap.has(unrelatedTask.id)).toBe(true) + expect(childTask.status).toBe("cancelled") + expect(siblingTask.status).toBe("cancelled") + expect(grandchildTask.status).toBe("cancelled") + expect(pendingByParent.get(parentSessionID)).toBeUndefined() + expect(pendingByParent.get("session-child")).toBeUndefined() + + manager.shutdown() + }) +}) + describe("BackgroundManager.completionTimers - Memory Leak Fix", () => { function getCompletionTimers(manager: BackgroundManager): Map> { return (manager as unknown as { completionTimers: Map> }).completionTimers diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 5b5bdbaf1..e58189a50 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -736,34 +736,47 @@ export class BackgroundManager { if (!info || typeof info.id !== "string") return const sessionID = info.id - const task = this.findBySession(sessionID) - if (!task) return - - if (task.status === "running") { - task.status = "cancelled" - task.completedAt = new Date() - task.error = "Session deleted" + const tasksToCancel = new Map() + const directTask = this.findBySession(sessionID) + if (directTask) { + tasksToCancel.set(directTask.id, directTask) + } + for (const descendant of this.getAllDescendantTasks(sessionID)) { + tasksToCancel.set(descendant.id, descendant) } - if (task.concurrencyKey) { - this.concurrencyManager.release(task.concurrencyKey) - task.concurrencyKey = undefined - } - const existingTimer = this.completionTimers.get(task.id) - if (existingTimer) { - clearTimeout(existingTimer) - this.completionTimers.delete(task.id) - } + if (tasksToCancel.size === 0) return - const idleTimer = this.idleDeferralTimers.get(task.id) - if (idleTimer) { - clearTimeout(idleTimer) - this.idleDeferralTimers.delete(task.id) + for (const task of tasksToCancel.values()) { + if (task.status === "running" || task.status === "pending") { + void this.cancelTask(task.id, { + source: "session.deleted", + reason: "Session deleted", + skipNotification: true, + }).catch(err => { + log("[background-agent] Failed to cancel task on session.deleted:", { taskId: task.id, error: err }) + }) + } + + const existingTimer = this.completionTimers.get(task.id) + if (existingTimer) { + clearTimeout(existingTimer) + this.completionTimers.delete(task.id) + } + + const idleTimer = this.idleDeferralTimers.get(task.id) + if (idleTimer) { + clearTimeout(idleTimer) + this.idleDeferralTimers.delete(task.id) + } + + this.cleanupPendingByParent(task) + this.tasks.delete(task.id) + this.clearNotificationsForTask(task.id) + if (task.sessionID) { + subagentSessions.delete(task.sessionID) + } } - this.cleanupPendingByParent(task) - this.tasks.delete(task.id) - this.clearNotificationsForTask(task.id) - subagentSessions.delete(sessionID) } }