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
+28 -36
View File
@@ -111,6 +111,7 @@ type PendingParentWake = {
}
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100
interface MessagePartInfo {
id?: string
@@ -2247,32 +2248,19 @@ The task was re-queued on a fallback model after a retryable failure.
shouldReply,
})
} else {
try {
await promptAsyncInDirectory(this.client, {
path: { id: task.parentSessionId },
body: {
noReply: !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
}, this.directory)
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
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)
}
}
this.queuePendingParentWake(
task.parentSessionId,
notification,
parentPromptContext,
shouldReply,
PENDING_PARENT_WAKE_DEBOUNCE_MS,
)
log("[background-agent] Queued notification for short-debounce flush to idle parent:", {
taskId: task.id,
allComplete,
isTaskFailure,
shouldReply,
})
}
} else {
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,
promptContext: ParentWakePromptContext,
shouldReply: boolean,
delayMs?: number,
): void {
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
@@ -2308,12 +2297,11 @@ The task was re-queued on a fallback model after a retryable failure.
shouldReply,
})
}
this.schedulePendingParentWakeFlush(sessionID)
this.schedulePendingParentWakeFlush(sessionID, delayMs)
}
private async flushPendingParentWake(sessionID: string): Promise<void> {
const pendingWake = this.pendingParentWakes.get(sessionID)
if (!pendingWake) {
if (!this.pendingParentWakes.has(sessionID)) {
this.clearPendingParentWakeTimer(sessionID)
return
}
@@ -2323,24 +2311,28 @@ The task was re-queued on a fallback model after a retryable failure.
return
}
this.pendingParentWakes.delete(sessionID)
this.clearPendingParentWakeTimer(sessionID)
await settleAfterSessionIdle()
if (await this.isSessionActive(sessionID)) {
this.pendingParentWakes.set(sessionID, pendingWake)
this.schedulePendingParentWakeFlush(sessionID)
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 {
await promptAsyncInDirectory(this.client, {
path: { id: sessionID },
body: {
noReply: !pendingWake.shouldReply,
...pendingWake.promptContext,
noReply: !latestWake.shouldReply,
...latestWake.promptContext,
parts: [createInternalAgentTextPart(notificationContent)],
},
}, 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)) {
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) => {
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)
}