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 } {
_resetTaskToastManagerForTesting()
const toastManager = initTaskToastManager(cast<PluginInput["client"]>({
@@ -1303,6 +1307,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
//#when
await (cast<{ notifyParentSession: (value: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task)
await waitForCoalescedFlush()
//#then
expect(capturedBody?.agent).toBe("sisyphus")
@@ -1459,6 +1464,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
//#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task)
await waitForCoalescedFlush()
//#then
expect(promptCalled).toBe(true)
@@ -1501,6 +1507,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
//#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task)
await waitForCoalescedFlush()
//#then
expect(promptCalled).toBe(true)
@@ -1541,6 +1548,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
//#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task)
await waitForCoalescedFlush()
//#then
const queuedNotifications = getPendingNotifications(manager).get("session-parent") ?? []
@@ -1652,6 +1660,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
//#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task)
await waitForCoalescedFlush()
//#then
expect(promptCalls).toHaveLength(1)
@@ -1693,6 +1702,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
//#when
await (cast<{ notifyParentSession: (task: BackgroundTask) => Promise<void> }>(manager))
.notifyParentSession(task)
await waitForCoalescedFlush()
//#then
expect(promptCalls).toHaveLength(1)
@@ -2117,7 +2127,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
// then
expect(rejectedCount).toBe(0)
expect(promptBodies.length).toBe(2)
expect(promptBodies.length).toBe(1)
expect(promptBodies.filter((body) => body.noReply === false)).toHaveLength(1)
})
})
@@ -5410,6 +5420,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
//#when
pruneStaleTasksAndNotificationsForTest(manager)
await flushBackgroundNotifications()
await waitForCoalescedFlush()
//#then
const retainedTask = getTaskMap(manager).get(staleTask.id)