fix(parent-wake-notifier): drop duplicate wakes during promptAsync gate hold (#4256, #4019)

In v4.1.0+, users observed duplicate assistant streams rendering the same
content in two languages simultaneously (e.g. Chinese + English), most often
at the end of a turn.

Root cause: ParentWakeNotifier.requeueWake() unconditionally requeued ANY
wake that arrived during the background-agent-parent-wake post-dispatch
hold window. When a duplicate completion edge fired during that hold, the
same wake was replayed after the hold expired, triggering a second prompt
dispatch and a parallel assistant stream.

The fix compares the new wake against dispatchedParentWakes.get(sessionID)
and drops identical wakes during the gate hold, while preserving the existing
requeue behavior for genuinely-new wakes and failed-dispatch retries.

Regression test added in given/when/then style covering the duplicate-during-
hold scenario (TDD red-then-green).

Fixes #4256
Fixes #4019
This commit is contained in:
YeonGyu-Kim
2026-05-22 20:40:37 +09:00
parent b31ad3c892
commit 560569e369
2 changed files with 38 additions and 0 deletions
@@ -238,6 +238,13 @@ export class ParentWakeNotifier {
throw promptResult.error
}
if (promptResult.status === "reserved" && promptResult.reservedBy === "background-agent-parent-wake") {
const dispatchedWake = this.dispatchedParentWakes.get(sessionID)
if (dispatchedWake && this.isSameParentWake(latestWake, dispatchedWake)) {
// #4256/#4019: duplicated completion edges can enqueue the same wake
// during the gate hold. Replaying it later starts a second assistant stream.
log("[background-agent] Suppressed duplicate parent wake during promptAsync gate hold:", { sessionID })
return
}
this.requeueWake(sessionID, latestWake)
this.schedulePendingParentWakeFlush(sessionID, 2_000)
log("[background-agent] Requeued parent wake flush reserved by promptAsync gate hold:", { sessionID })
@@ -399,6 +406,12 @@ export class ParentWakeNotifier {
this.dispatchedParentWakeTimers.set(sessionID, timer)
}
private isSameParentWake(left: PendingParentWake, right: PendingParentWake): boolean {
return left.shouldReply === right.shouldReply
&& JSON.stringify(left.notifications) === JSON.stringify(right.notifications)
&& JSON.stringify(left.promptContext) === JSON.stringify(right.promptContext)
}
private async loadParentWakeSessionMessages(sessionID: string): Promise<ParentWakeSessionMessage[]> {
try {
const messagesResp = await messagesInDirectory(this.deps.client, {
@@ -84,6 +84,31 @@ function releaseParentWakeHold(sessionID: string): void {
}
describe("ParentWakeNotifier — same-source reservation requeue (BUG-E)", () => {
test("#given a duplicate parent wake is in post-dispatch hold #when the duplicate fires again #then it is dropped instead of requeued", async () => {
// given
const { notifier, promptAsyncCalls } = createNotifier()
const sessionID = "parent-hold-duplicate-wake"
notifier.queuePendingParentWake(sessionID, "wake A", { agent: "sisyphus" }, true)
try {
await notifier.flushPendingParentWake(sessionID)
expect(promptAsyncCalls).toHaveLength(1)
// when
notifier.queuePendingParentWake(sessionID, "wake A", { agent: "sisyphus" }, true)
await notifier.flushPendingParentWake(sessionID)
releaseParentWakeHold(sessionID)
await notifier.flushPendingParentWake(sessionID)
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(notifier.getPendingParentWakes().has(sessionID)).toBe(false)
} finally {
notifier.shutdown()
releaseAllPromptAsyncReservationsForTesting()
}
})
test("#given a parent wake is in post-dispatch hold #when a new pending wake fires within the hold window #then the new wake is re-enqueued and dispatched after the hold expires", async () => {
// given
const { notifier, promptAsyncCalls } = createNotifier()