From 560569e3696e0ace251f1e15d928c66f5a7803ec Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 22 May 2026 20:40:37 +0900 Subject: [PATCH] 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 --- .../background-agent/parent-wake-notifier.ts | 13 ++++++++++ .../parent-wake-same-source-requeue.test.ts | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/features/background-agent/parent-wake-notifier.ts b/src/features/background-agent/parent-wake-notifier.ts index 727261043..ce43ec7a7 100644 --- a/src/features/background-agent/parent-wake-notifier.ts +++ b/src/features/background-agent/parent-wake-notifier.ts @@ -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 { try { const messagesResp = await messagesInDirectory(this.deps.client, { diff --git a/src/features/background-agent/parent-wake-same-source-requeue.test.ts b/src/features/background-agent/parent-wake-same-source-requeue.test.ts index 3470f030b..ae4735505 100644 --- a/src/features/background-agent/parent-wake-same-source-requeue.test.ts +++ b/src/features/background-agent/parent-wake-same-source-requeue.test.ts @@ -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()