From 893b54212b2d91e0ec589dfbe5bdcd8e18c29cef Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 27 Aug 2026 09:26:05 -0500 Subject: [PATCH] fix(interactions): retain suppressed answer deliveries --- .../question-response-delivery.test.ts | 41 +++++++++++++++++++ .../services/question-response-delivery.ts | 24 +++++------ 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/server/src/__tests__/question-response-delivery.test.ts b/server/src/__tests__/question-response-delivery.test.ts index 62b956c1a82..de798d3b68f 100644 --- a/server/src/__tests__/question-response-delivery.test.ts +++ b/server/src/__tests__/question-response-delivery.test.ts @@ -250,6 +250,47 @@ describeEmbeddedPostgres("question response delivery", () => { }); }); + it("delivers after wake suppression outlasts the bounded error retry limit", async () => { + const seeded = await seed({ sourceStatus: "running" }); + const fallbackRunId = randomUUID(); + let wakeAttempts = 0; + const wakeup = vi.fn().mockImplementation(async () => { + wakeAttempts += 1; + if (wakeAttempts <= 5) return null; + return db.insert(heartbeatRuns).values({ + id: fallbackRunId, + companyId: seeded.companyId, + agentId: seeded.agentId, + invocationSource: "automation", + status: "queued", + runtimeMode: "legacy", + driverKind: "codex", + contextSnapshot: { issueId: seeded.issueId }, + }).returning().then((rows) => rows[0]!); + }); + const service = questionResponseDeliveryService(db, { + heartbeat: { wakeup } as never, + steer: vi.fn(), + }); + + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(service.deliver(seeded.interaction.id)).resolves.toBeNull(); + } + const delivered = await service.deliver(seeded.interaction.id); + + expect(delivered).toMatchObject({ + status: "fallback_queued", + mode: "wake_fallback", + targetRunId: fallbackRunId, + }); + const [delivery] = await db.select().from(issueQuestionResponseDeliveries); + expect(delivery).toMatchObject({ + status: "fallback_queued", + attemptCount: 6, + targetRunId: fallbackRunId, + }); + }); + it("reuses a durable wake receipt instead of issuing a duplicate continuation", async () => { const seeded = await seed({ sourceStatus: "running" }); const [wakeRequest] = await db.insert(agentWakeupRequests).values({ diff --git a/server/src/services/question-response-delivery.ts b/server/src/services/question-response-delivery.ts index b7911e42188..d43c128944e 100644 --- a/server/src/services/question-response-delivery.ts +++ b/server/src/services/question-response-delivery.ts @@ -365,9 +365,13 @@ export function questionResponseDeliveryService( }; } - async function releaseForRetry(delivery: DeliveryRow, errorCode: string) { + async function releaseForRetry( + delivery: DeliveryRow, + errorCode: string, + options: { bounded: boolean } = { bounded: true }, + ) { const at = now(); - const exhausted = delivery.attemptCount >= MAX_DELIVERY_ATTEMPTS; + const exhausted = options.bounded && delivery.attemptCount >= MAX_DELIVERY_ATTEMPTS; if (!exhausted) { await db.update(issueQuestionResponseDeliveries).set({ status: "pending", @@ -630,17 +634,11 @@ export function questionResponseDeliveryService( })); if (!wakeRun && !durableWake) { const errorCode = "question_response_wake_skipped"; - const exhausted = await releaseForRetry(claimed, errorCode); - if (!exhausted) return null; - return recordTerminal({ - delivery: claimed, - interaction, - status: "failed", - mode: null, - targetRunId: null, - adapter, - errorCode, - }); + // Scheduling suppression is an availability state, not a delivery + // failure. Keep the durable receipt retryable until the suppression is + // lifted; the bounded limit remains reserved for actual wake errors. + await releaseForRetry(claimed, errorCode, { bounded: false }); + return null; } const targetRun = wakeRun ?? durableWake?.run ?? queuedSuccessor ?? null; const coalesced = Boolean(queuedSuccessor && targetRun?.id === queuedSuccessor.id);