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()