diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts
index 540c65d58..071a33cd9 100644
--- a/src/features/background-agent/manager.ts
+++ b/src/features/background-agent/manager.ts
@@ -106,6 +106,8 @@ const BACKGROUND_PARENT_WAKE_PROMPT = `
A background task notification was already added to this session. Continue from that notification.
`
+const PENDING_PARENT_WAKE_RETRY_MS = 1_000
+
interface MessagePartInfo {
id?: string
sessionID?: string
@@ -228,6 +230,7 @@ export class BackgroundManager {
private idleDeferralTimers: Map> = new Map()
private notificationQueueByParent: Map> = new Map()
private pendingParentWakes: Map = new Map()
+ private pendingParentWakeTimers: Map> = new Map()
private observedOutputSessions: Set = new Set()
private observedIncompleteTodosBySession: Map = new Map()
private rootDescendantCounts: Map
@@ -2233,6 +2236,7 @@ The task was re-queued on a fallback model after a retryable failure.
})
if (shouldDeferReply) {
this.pendingParentWakes.set(task.parentSessionId, parentPromptContext)
+ this.schedulePendingParentWakeFlush(task.parentSessionId)
}
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
@@ -2296,17 +2300,23 @@ The task was re-queued on a fallback model after a retryable failure.
private async flushPendingParentWake(sessionID: string): Promise {
const wakeContext = this.pendingParentWakes.get(sessionID)
- if (!wakeContext) return
+ if (!wakeContext) {
+ this.clearPendingParentWakeTimer(sessionID)
+ return
+ }
if (await this.isSessionActive(sessionID)) {
+ this.schedulePendingParentWakeFlush(sessionID)
return
}
this.pendingParentWakes.delete(sessionID)
+ this.clearPendingParentWakeTimer(sessionID)
await settleAfterSessionIdle()
if (await this.isSessionActive(sessionID)) {
this.pendingParentWakes.set(sessionID, wakeContext)
+ this.schedulePendingParentWakeFlush(sessionID)
return
}
@@ -2325,6 +2335,31 @@ The task was re-queued on a fallback model after a retryable failure.
}
}
+ private schedulePendingParentWakeFlush(sessionID: string): void {
+ if (this.pendingParentWakeTimers.has(sessionID)) {
+ return
+ }
+
+ const timer = setTimeout(() => {
+ this.pendingParentWakeTimers.delete(sessionID)
+ void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
+ log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
+ })
+ }, PENDING_PARENT_WAKE_RETRY_MS)
+
+ this.pendingParentWakeTimers.set(sessionID, timer)
+ }
+
+ private clearPendingParentWakeTimer(sessionID: string): void {
+ const timer = this.pendingParentWakeTimers.get(sessionID)
+ if (!timer) {
+ return
+ }
+
+ clearTimeout(timer)
+ this.pendingParentWakeTimers.delete(sessionID)
+ }
+
private pruneStaleTasksAndNotifications(allStatuses?: SessionStatusMap): void {
pruneStaleTasksAndNotifications({
tasks: this.tasks,
@@ -2638,6 +2673,11 @@ The task was re-queued on a fallback model after a retryable failure.
}
this.idleDeferralTimers.clear()
+ for (const timer of this.pendingParentWakeTimers.values()) {
+ clearTimeout(timer)
+ }
+ this.pendingParentWakeTimers.clear()
+
for (const sessionID of trackedSessionIDs) {
subagentSessions.delete(sessionID)
SessionCategoryRegistry.remove(sessionID)
diff --git a/src/features/background-agent/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts
index f8cdf51a5..881b35d1d 100644
--- a/src/features/background-agent/task-completion-cleanup.test.ts
+++ b/src/features/background-agent/task-completion-cleanup.test.ts
@@ -155,6 +155,10 @@ function waitForDeferredWake(): Promise {
return new Promise((resolve) => setTimeout(resolve, 180))
}
+function waitForDeferredWakeRetry(): Promise {
+ return new Promise((resolve) => setTimeout(resolve, 1_180))
+}
+
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType {
const timer = getCompletionTimers(manager).get(taskID)
expect(timer).toBeDefined()
@@ -208,7 +212,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
})
})
- describe("#given 2 tasks for same parent and both completed", () => {
+ describe("#given background tasks for same parent", () => {
test("#when the second completion notification is sent #then ALL BACKGROUND TASKS COMPLETE notification still works correctly", async () => {
// given
const { manager, promptAsyncCalls } = createManager(true)
@@ -290,6 +294,31 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY")
expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE")
})
+
+ test("#when a single background task finishes during a stale busy parent status #then wake prompt is sent after the parent becomes idle", async () => {
+ // given
+ const sessionStatuses: Record = {
+ "parent-1": { type: "busy" },
+ }
+ const { manager, promptAsyncCalls } = createManager(true, sessionStatuses)
+ managerUnderTest = manager
+ const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
+ getTasks(manager).set(task.id, task)
+ getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
+
+ // when
+ await notifyParentSessionForTest(manager, task)
+ sessionStatuses["parent-1"] = { type: "idle" }
+ await waitForDeferredWakeRetry()
+
+ // then
+ expect(promptAsyncCalls).toHaveLength(2)
+ expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
+ expect(promptAsyncCalls[1]?.body.noReply).toBe(false)
+ const wakePayload = JSON.stringify(promptAsyncCalls[1]?.body.parts)
+ expect(wakePayload).toContain("BACKGROUND TASK NOTIFICATION READY")
+ expect(wakePayload).not.toContain("ALL BACKGROUND TASKS COMPLETE")
+ })
})
describe("#given a completed task with cleanup timer scheduled", () => {