From d46946c85f65e015bc634f70021d6d97b3af718b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 11 Mar 2026 18:01:23 +0900 Subject: [PATCH] fix(background-agent): keep stale-pruned tasks through notification cleanup Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/features/background-agent/manager.test.ts | 50 ++++++++++++++++++- src/features/background-agent/manager.ts | 33 +++++++----- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 472098773..bedb7d84d 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -3422,7 +3422,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas manager.shutdown() }) - test("removes stale task from toast manager", () => { + test("removes stale task from toast manager", async () => { //#given const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker() const manager = createBackgroundManager() @@ -3437,6 +3437,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas //#when pruneStaleTasksAndNotificationsForTest(manager) + await flushBackgroundNotifications() //#then expect(removeTaskCalls).toContain(staleTask.id) @@ -3444,6 +3445,53 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas manager.shutdown() resetToastManager() }) + + test("keeps stale task until notification cleanup after notifying parent", async () => { + //#given + const notifications: string[] = [] + const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker() + const client = { + session: { + prompt: async () => ({}), + promptAsync: async (args: { path: { id: string }; body: Record & { noReply?: boolean; parts?: unknown[] } }) => { + const firstPart = args.body.parts?.[0] + if (firstPart && typeof firstPart === "object" && "text" in firstPart && typeof firstPart.text === "string") { + notifications.push(firstPart.text) + } + return {} + }, + abort: async () => ({}), + messages: async () => ({ data: [] }), + }, + } + const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) + const staleTask = createMockTask({ + id: "task-stale-notify-cleanup", + sessionID: "session-stale-notify-cleanup", + parentSessionID: "parent-stale-notify-cleanup", + status: "running", + startedAt: new Date(Date.now() - 31 * 60 * 1000), + }) + getTaskMap(manager).set(staleTask.id, staleTask) + getPendingByParent(manager).set(staleTask.parentSessionID, new Set([staleTask.id])) + + //#when + pruneStaleTasksAndNotificationsForTest(manager) + await flushBackgroundNotifications() + + //#then + const retainedTask = getTaskMap(manager).get(staleTask.id) + expect(retainedTask?.status).toBe("error") + expect(getTaskMap(manager).has(staleTask.id)).toBe(true) + expect(notifications).toHaveLength(1) + expect(notifications[0]).toContain("[ALL BACKGROUND TASKS COMPLETE]") + expect(notifications[0]).toContain(staleTask.description) + expect(getCompletionTimers(manager).has(staleTask.id)).toBe(true) + expect(removeTaskCalls).toContain(staleTask.id) + + manager.shutdown() + resetToastManager() + }) }) describe("BackgroundManager.completionTimers - Memory Leak Fix", () => { diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 36c9d123b..dd038f7cf 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1382,8 +1382,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea } const timer = setTimeout(() => { this.completionTimers.delete(taskId) - if (this.tasks.has(taskId)) { + const taskToRemove = this.tasks.get(taskId) + if (taskToRemove) { this.clearNotificationsForTask(taskId) + if (taskToRemove.sessionID) { + subagentSessions.delete(taskToRemove.sessionID) + SessionCategoryRegistry.remove(taskToRemove.sessionID) + } this.tasks.delete(taskId) log("[background-agent] Removed completed task from memory:", taskId) } @@ -1418,11 +1423,21 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea task.status = "error" task.error = errorMessage task.completedAt = new Date() + this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt }) if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) task.concurrencyKey = undefined } - this.cleanupPendingByParent(task) + const existingTimer = this.completionTimers.get(taskId) + if (existingTimer) { + clearTimeout(existingTimer) + this.completionTimers.delete(taskId) + } + const idleTimer = this.idleDeferralTimers.get(taskId) + if (idleTimer) { + clearTimeout(idleTimer) + this.idleDeferralTimers.delete(taskId) + } if (wasPending) { const key = task.model ? `${task.model.providerID}/${task.model.modelID}` @@ -1438,16 +1453,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea } } } - this.clearNotificationsForTask(taskId) - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.removeTask(taskId) - } - this.tasks.delete(taskId) - if (task.sessionID) { - subagentSessions.delete(task.sessionID) - SessionCategoryRegistry.remove(task.sessionID) - } + this.markForNotification(task) + this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => { + log("[background-agent] Error in notifyParentSession for stale-pruned task:", { taskId: task.id, error: err }) + }) }, }) }