fix(background-agent): coalesce rapid-fire idle parent notifications

When many background tasks complete in rapid succession while the parent
session is idle, each completion fired its own promptAsync call, stacking
N consecutive `<system-reminder>` user messages with no assistant turn
between. Hyperplan + many parallel explore subagents made this very
visible to the user.

Route the idle-path through the existing pendingParentWakes queue with a
100ms debounce window. Notifications arriving during the debounce join
the same batch, the 150ms settle window also coalesces newcomers, and a
single batched prompt fires to the parent. Busy-path semantics are
unchanged (still 1s retry).

Prior attempts (1c05c60dc, ea55c385b, a337635e3) all coalesced only the
busy-defer path, leaving the idle-immediate-send path uncoalesced.
This commit is contained in:
YeonGyu-Kim
2026-05-14 18:52:28 +09:00
parent b9beea1039
commit 268c89c945
3 changed files with 96 additions and 54 deletions
+12 -1
View File
@@ -278,6 +278,10 @@ async function flushBackgroundNotifications(): Promise<void> {
} }
} }
function waitForCoalescedFlush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 400))
}
function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } { function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
_resetTaskToastManagerForTesting() _resetTaskToastManagerForTesting()
const toastManager = initTaskToastManager(cast<PluginInput["client"]>({ const toastManager = initTaskToastManager(cast<PluginInput["client"]>({
@@ -1303,6 +1307,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
//#when //#when
await (cast<{ notifyParentSession: (value: BackgroundTask) => Promise<void> }>(manager)) await (cast<{ notifyParentSession: (value: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task) .notifyParentSession(task)
await waitForCoalescedFlush()
//#then //#then
expect(capturedBody?.agent).toBe("sisyphus") expect(capturedBody?.agent).toBe("sisyphus")
@@ -1459,6 +1464,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
//#when //#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager)) await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task) .notifyParentSession(task)
await waitForCoalescedFlush()
//#then //#then
expect(promptCalled).toBe(true) expect(promptCalled).toBe(true)
@@ -1501,6 +1507,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
//#when //#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager)) await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task) .notifyParentSession(task)
await waitForCoalescedFlush()
//#then //#then
expect(promptCalled).toBe(true) expect(promptCalled).toBe(true)
@@ -1541,6 +1548,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
//#when //#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager)) await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task) .notifyParentSession(task)
await waitForCoalescedFlush()
//#then //#then
const queuedNotifications = getPendingNotifications(manager).get("session-parent") ?? [] const queuedNotifications = getPendingNotifications(manager).get("session-parent") ?? []
@@ -1652,6 +1660,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
//#when //#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager)) await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task) .notifyParentSession(task)
await waitForCoalescedFlush()
//#then //#then
expect(promptCalls).toHaveLength(1) expect(promptCalls).toHaveLength(1)
@@ -1693,6 +1702,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
//#when //#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager)) await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task) .notifyParentSession(task)
await waitForCoalescedFlush()
//#then //#then
expect(promptCalls).toHaveLength(1) expect(promptCalls).toHaveLength(1)
@@ -2117,7 +2127,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
// then // then
expect(rejectedCount).toBe(0) expect(rejectedCount).toBe(0)
expect(promptBodies.length).toBe(2) expect(promptBodies.length).toBe(1)
expect(promptBodies.filter((body) => body.noReply === false)).toHaveLength(1) expect(promptBodies.filter((body) => body.noReply === false)).toHaveLength(1)
}) })
}) })
@@ -5410,6 +5420,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
//#when //#when
pruneStaleTasksAndNotificationsForTest(manager) pruneStaleTasksAndNotificationsForTest(manager)
await flushBackgroundNotifications() await flushBackgroundNotifications()
await waitForCoalescedFlush()
//#then //#then
const retainedTask = getTaskMap(manager).get(staleTask.id) const retainedTask = getTaskMap(manager).get(staleTask.id)
+28 -36
View File
@@ -111,6 +111,7 @@ type PendingParentWake = {
} }
const PENDING_PARENT_WAKE_RETRY_MS = 1_000 const PENDING_PARENT_WAKE_RETRY_MS = 1_000
const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100
interface MessagePartInfo { interface MessagePartInfo {
id?: string id?: string
@@ -2247,32 +2248,19 @@ The task was re-queued on a fallback model after a retryable failure.
shouldReply, shouldReply,
}) })
} else { } else {
try { this.queuePendingParentWake(
await promptAsyncInDirectory(this.client, { task.parentSessionId,
path: { id: task.parentSessionId }, notification,
body: { parentPromptContext,
noReply: !shouldReply, shouldReply,
...parentPromptContext, PENDING_PARENT_WAKE_DEBOUNCE_MS,
parts: [createInternalAgentTextPart(notification)], )
}, log("[background-agent] Queued notification for short-debounce flush to idle parent:", {
}, this.directory) taskId: task.id,
log("[background-agent] Sent notification to parent session:", { allComplete,
taskId: task.id, isTaskFailure,
allComplete, shouldReply,
isTaskFailure, })
noReply: !shouldReply,
})
} catch (error) {
if (isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionId,
})
this.queuePendingNotification(task.parentSessionId, notification)
} else {
log("[background-agent] Failed to send notification:", error)
}
}
} }
} else { } else {
log("[background-agent] Parent session notifications disabled, skipping prompt injection:", { log("[background-agent] Parent session notifications disabled, skipping prompt injection:", {
@@ -2295,6 +2283,7 @@ The task was re-queued on a fallback model after a retryable failure.
notification: string, notification: string,
promptContext: ParentWakePromptContext, promptContext: ParentWakePromptContext,
shouldReply: boolean, shouldReply: boolean,
delayMs?: number,
): void { ): void {
const pendingWake = this.pendingParentWakes.get(sessionID) const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) { if (pendingWake) {
@@ -2308,12 +2297,11 @@ The task was re-queued on a fallback model after a retryable failure.
shouldReply, shouldReply,
}) })
} }
this.schedulePendingParentWakeFlush(sessionID) this.schedulePendingParentWakeFlush(sessionID, delayMs)
} }
private async flushPendingParentWake(sessionID: string): Promise<void> { private async flushPendingParentWake(sessionID: string): Promise<void> {
const pendingWake = this.pendingParentWakes.get(sessionID) if (!this.pendingParentWakes.has(sessionID)) {
if (!pendingWake) {
this.clearPendingParentWakeTimer(sessionID) this.clearPendingParentWakeTimer(sessionID)
return return
} }
@@ -2323,24 +2311,28 @@ The task was re-queued on a fallback model after a retryable failure.
return return
} }
this.pendingParentWakes.delete(sessionID)
this.clearPendingParentWakeTimer(sessionID) this.clearPendingParentWakeTimer(sessionID)
await settleAfterSessionIdle() await settleAfterSessionIdle()
if (await this.isSessionActive(sessionID)) { if (await this.isSessionActive(sessionID)) {
this.pendingParentWakes.set(sessionID, pendingWake)
this.schedulePendingParentWakeFlush(sessionID) this.schedulePendingParentWakeFlush(sessionID)
return return
} }
const notificationContent = pendingWake.notifications.join("\n\n") const latestWake = this.pendingParentWakes.get(sessionID)
if (!latestWake) {
return
}
this.pendingParentWakes.delete(sessionID)
const notificationContent = latestWake.notifications.join("\n\n")
try { try {
await promptAsyncInDirectory(this.client, { await promptAsyncInDirectory(this.client, {
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
noReply: !pendingWake.shouldReply, noReply: !latestWake.shouldReply,
...pendingWake.promptContext, ...latestWake.promptContext,
parts: [createInternalAgentTextPart(notificationContent)], parts: [createInternalAgentTextPart(notificationContent)],
}, },
}, this.directory) }, this.directory)
@@ -2351,7 +2343,7 @@ The task was re-queued on a fallback model after a retryable failure.
} }
} }
private schedulePendingParentWakeFlush(sessionID: string): void { private schedulePendingParentWakeFlush(sessionID: string, delayMs?: number): void {
if (this.pendingParentWakeTimers.has(sessionID)) { if (this.pendingParentWakeTimers.has(sessionID)) {
return return
} }
@@ -2361,7 +2353,7 @@ The task was re-queued on a fallback model after a retryable failure.
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => { void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error }) log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
}) })
}, PENDING_PARENT_WAKE_RETRY_MS) }, delayMs ?? PENDING_PARENT_WAKE_RETRY_MS)
this.pendingParentWakeTimers.set(sessionID, timer) this.pendingParentWakeTimers.set(sessionID, timer)
} }
@@ -171,6 +171,10 @@ function waitForDeferredWakeRetry(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1_180)) return new Promise((resolve) => setTimeout(resolve, 1_180))
} }
function waitForCoalescedFlush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 400))
}
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> { function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
const timer = getCompletionTimers(manager).get(taskID) const timer = getCompletionTimers(manager).get(taskID)
expect(timer).toBeDefined() expect(timer).toBeDefined()
@@ -225,11 +229,10 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
}) })
describe("#given background tasks for same parent", () => { 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 () => { test("#when two completions arrive back-to-back while parent is idle #then one batched notification is sent with both tasks", async () => {
// given // given
const { manager, promptAsyncCalls } = createManager(true) const { manager, promptAsyncCalls } = createManager(true)
managerUnderTest = manager managerUnderTest = manager
fakeTimers = installFakeTimers()
const taskA = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") }) const taskA = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
const taskB = createTask({ id: "task-b", parentSessionId: "parent-1", description: "task B", status: "running" }) const taskB = createTask({ id: "task-b", parentSessionId: "parent-1", description: "task B", status: "running" })
getTasks(manager).set(taskA.id, taskA) getTasks(manager).set(taskA.id, taskA)
@@ -242,25 +245,60 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
// when // when
await notifyParentSessionForTest(manager, taskB) await notifyParentSessionForTest(manager, taskB)
await waitForCoalescedFlush()
// then // then
expect(promptAsyncCalls).toHaveLength(2) expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(true) const batchedCall = promptAsyncCalls[0]
expect(getCompletionTimers(manager).size).toBe(2) if (!batchedCall) {
const allCompleteCall = promptAsyncCalls[1] throw new Error("Missing batched notification call")
expect(allCompleteCall).toBeDefined()
if (!allCompleteCall) {
throw new Error("Missing all-complete notification call")
} }
expect(batchedCall.body.noReply).toBe(false)
const batchedPayload = JSON.stringify(batchedCall.body.parts)
expect(batchedPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(batchedPayload).toContain(OMO_INTERNAL_INITIATOR_MARKER)
expect(batchedPayload).toContain(taskA.id)
expect(batchedPayload).toContain(taskB.id)
expect(batchedPayload).toContain(taskA.description)
expect(batchedPayload).toContain(taskB.description)
})
expect(allCompleteCall.body.noReply).toBe(false) test("#when many completions arrive in rapid succession while parent is idle #then a single coalesced notification is sent", async () => {
const allCompletePayload = JSON.stringify(allCompleteCall.body.parts) // given
expect(allCompletePayload).toContain("ALL BACKGROUND TASKS COMPLETE") const { manager, promptAsyncCalls } = createManager(true)
expect(allCompletePayload).toContain(OMO_INTERNAL_INITIATOR_MARKER) managerUnderTest = manager
expect(allCompletePayload).toContain(taskA.id) const taskIds = ["task-1", "task-2", "task-3", "task-4", "task-5"]
expect(allCompletePayload).toContain(taskB.id) const tasks = taskIds.map((id, index) => createTask({
expect(allCompletePayload).toContain(taskA.description) id,
expect(allCompletePayload).toContain(taskB.description) parentSessionId: "parent-1",
description: `description ${id}`,
status: "completed",
completedAt: new Date(`2026-03-11T00:01:0${index}.000Z`),
}))
for (const task of tasks) {
getTasks(manager).set(task.id, task)
}
getPendingByParent(manager).set("parent-1", new Set(taskIds))
// when
for (const task of tasks) {
await notifyParentSessionForTest(manager, task)
}
await waitForCoalescedFlush()
// then
expect(promptAsyncCalls).toHaveLength(1)
const batchedCall = promptAsyncCalls[0]
if (!batchedCall) {
throw new Error("Missing batched notification call")
}
expect(batchedCall.body.noReply).toBe(false)
const batchedPayload = JSON.stringify(batchedCall.body.parts)
expect(batchedPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
for (const task of tasks) {
expect(batchedPayload).toContain(task.id)
expect(batchedPayload).toContain(task.description)
}
}) })
test("#when parent session is busy #then all-complete notification does not start an overlapping parent reply", async () => { test("#when parent session is busy #then all-complete notification does not start an overlapping parent reply", async () => {
@@ -362,6 +400,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
// when // when
await notifyParentSessionForTest(manager, task) await notifyParentSessionForTest(manager, task)
await waitForCoalescedFlush()
// then // then
expect(promptAsyncCalls).toHaveLength(1) expect(promptAsyncCalls).toHaveLength(1)