From 85aa744c8a6b45f6d68239a2f55db7ea78b62519 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Mar 2026 11:39:04 +0900 Subject: [PATCH] fix(background-agent): clear toast tracking when tasks stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/features/background-agent/manager.test.ts | 27 +++++++++++++++++++ src/features/background-agent/manager.ts | 14 +++++++--- .../remove-task-toast-tracking.ts | 8 ++++++ .../background-agent/task-poller.test.ts | 25 +++++++++++++++++ src/features/background-agent/task-poller.ts | 16 ++++++++++- 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 src/features/background-agent/remove-task-toast-tracking.ts diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 8a6ddc358..da04e5181 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1334,6 +1334,33 @@ describe("BackgroundManager.tryCompleteTask", () => { expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined() }) + test("should remove toast tracking before notifying completed task", async () => { + // given + const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker() + + const task: BackgroundTask = { + id: "task-toast-complete", + sessionID: "session-toast-complete", + parentSessionID: "parent-toast-complete", + parentMessageID: "msg-1", + description: "toast completion task", + prompt: "test", + agent: "explore", + status: "running", + startedAt: new Date(), + } + + try { + // when + await tryCompleteTaskForTest(manager, task) + + // then + expect(removeTaskCalls).toContain(task.id) + } finally { + resetToastManager() + } + }) + test("should avoid overlapping promptAsync calls when tasks complete concurrently", async () => { // given type PromptAsyncBody = Record & { noReply?: boolean } diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 9e2baa173..503d29800 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -47,6 +47,7 @@ import { MESSAGE_STORAGE } from "../hook-message-injector" import { join } from "node:path" import { pruneStaleTasksAndNotifications } from "./task-poller" import { checkAndInterruptStaleTasks } from "./task-poller" +import { removeTaskToastTracking } from "./remove-task-toast-tracking" type OpencodeClient = PluginInput["client"] @@ -384,6 +385,8 @@ export class BackgroundManager { existingTask.concurrencyKey = undefined } + removeTaskToastTracking(existingTask.id) + // Abort the session to prevent infinite polling hang this.client.session.abort({ path: { id: sessionID }, @@ -653,6 +656,8 @@ export class BackgroundManager { existingTask.concurrencyKey = undefined } + removeTaskToastTracking(existingTask.id) + // Abort the session to prevent infinite polling hang if (existingTask.sessionID) { this.client.session.abort({ @@ -1104,11 +1109,9 @@ export class BackgroundManager { SessionCategoryRegistry.remove(task.sessionID) } + removeTaskToastTracking(task.id) + if (options?.skipNotification) { - const toastManager = getTaskToastManager() - if (toastManager) { - toastManager.removeTask(task.id) - } log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id) return true } @@ -1194,6 +1197,8 @@ export class BackgroundManager { task.completedAt = new Date() this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt }) + removeTaskToastTracking(task.id) + // Release concurrency BEFORE any async operations to prevent slot leaks if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) @@ -1439,6 +1444,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea this.concurrencyManager.release(task.concurrencyKey) task.concurrencyKey = undefined } + removeTaskToastTracking(task.id) this.cleanupPendingByParent(task) if (wasPending) { const key = task.model diff --git a/src/features/background-agent/remove-task-toast-tracking.ts b/src/features/background-agent/remove-task-toast-tracking.ts new file mode 100644 index 000000000..00392a648 --- /dev/null +++ b/src/features/background-agent/remove-task-toast-tracking.ts @@ -0,0 +1,8 @@ +import { getTaskToastManager } from "../task-toast-manager" + +export function removeTaskToastTracking(taskId: string): void { + const toastManager = getTaskToastManager() + if (toastManager) { + toastManager.removeTask(taskId) + } +} diff --git a/src/features/background-agent/task-poller.test.ts b/src/features/background-agent/task-poller.test.ts index d411cb240..521a85904 100644 --- a/src/features/background-agent/task-poller.test.ts +++ b/src/features/background-agent/task-poller.test.ts @@ -391,6 +391,31 @@ describe("checkAndInterruptStaleTasks", () => { expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6") expect(task.concurrencyKey).toBeUndefined() }) + + it("should invoke interruption callback immediately when stale task is cancelled", async () => { + //#given + const task = createRunningTask({ + progress: { + toolCalls: 1, + lastUpdate: new Date(Date.now() - 200_000), + }, + }) + const onTaskInterrupted = mock(() => {}) + + //#when + await checkAndInterruptStaleTasks({ + tasks: [task], + client: mockClient as never, + config: { staleTimeoutMs: 180_000 }, + concurrencyManager: mockConcurrencyManager as never, + notifyParentSession: mockNotify, + onTaskInterrupted, + }) + + //#then + expect(task.status).toBe("cancelled") + expect(onTaskInterrupted).toHaveBeenCalledWith(task) + }) }) describe("pruneStaleTasksAndNotifications", () => { diff --git a/src/features/background-agent/task-poller.ts b/src/features/background-agent/task-poller.ts index eca83bc66..c27cd4874 100644 --- a/src/features/background-agent/task-poller.ts +++ b/src/features/background-agent/task-poller.ts @@ -11,6 +11,7 @@ import { MIN_RUNTIME_BEFORE_STALE_MS, TASK_TTL_MS, } from "./constants" +import { removeTaskToastTracking } from "./remove-task-toast-tracking" export function pruneStaleTasksAndNotifications(args: { tasks: Map @@ -66,8 +67,17 @@ export async function checkAndInterruptStaleTasks(args: { concurrencyManager: ConcurrencyManager notifyParentSession: (task: BackgroundTask) => Promise sessionStatuses?: SessionStatusMap + onTaskInterrupted?: (task: BackgroundTask) => void }): Promise { - const { tasks, client, config, concurrencyManager, notifyParentSession, sessionStatuses } = args + const { + tasks, + client, + config, + concurrencyManager, + notifyParentSession, + sessionStatuses, + onTaskInterrupted = (task) => removeTaskToastTracking(task.id), + } = args const staleTimeoutMs = config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS const now = Date.now() @@ -98,6 +108,8 @@ export async function checkAndInterruptStaleTasks(args: { task.concurrencyKey = undefined } + onTaskInterrupted(task) + client.session.abort({ path: { id: sessionID } }).catch(() => {}) log(`[background-agent] Task ${task.id} interrupted: no progress since start`) @@ -127,6 +139,8 @@ export async function checkAndInterruptStaleTasks(args: { task.concurrencyKey = undefined } + onTaskInterrupted(task) + client.session.abort({ path: { id: sessionID } }).catch(() => {}) log(`[background-agent] Task ${task.id} interrupted: stale timeout`)