From 408b45fd5eab7ed94bbb8936276087b303be7ba5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 25 Oct 2026 05:39:52 -0500 Subject: [PATCH 1/2] fix(server): return inbound denial before provider cleanup Co-Authored-By: Paperclip --- doc/idle-voice-inbound-cleanup.md | 5 +++ ...10-25-voice-inbound-cleanup-sleep-audit.md | 27 ++++++++++++++ .../voice-session-store.integration.test.ts | 36 +++++++++++++++++++ .../services/voice/voice-inbound-service.ts | 6 ++-- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/doc/idle-voice-inbound-cleanup.md b/doc/idle-voice-inbound-cleanup.md index a0830f19912..8f657190966 100644 --- a/doc/idle-voice-inbound-cleanup.md +++ b/doc/idle-voice-inbound-cleanup.md @@ -42,6 +42,11 @@ index covers unconfirmed admission identities; startup inventory is paged. ## Deadlines and quiet state +Rejection returns after its decision transaction commits. Its notification wakes +the registered cleanup worker; the HTTP request does not wait for provider account +verification, inspection, or hangup. The saved denial immediately rejects further +unauthorized call input while physical termination remains tracked separately. + Pending unbound admissions retain their original two-minute approval or ten-minute guest-intake expiry. Rejection is immediately due. A cleanup claim saves a 30-second retry before provider I/O. An unconfirmed end receipt keeps that retry; diff --git a/doc/plans/2026-10-25-voice-inbound-cleanup-sleep-audit.md b/doc/plans/2026-10-25-voice-inbound-cleanup-sleep-audit.md index 5f1a1779168..2bed1a020eb 100644 --- a/doc/plans/2026-10-25-voice-inbound-cleanup-sleep-audit.md +++ b/doc/plans/2026-10-25-voice-inbound-cleanup-sleep-audit.md @@ -218,3 +218,30 @@ Database typecheck and migration numbering/safety checks also pass. Local logs: `/tmp/sleep23-migrator-before.log`, `/tmp/sleep23-migrator-after.log`, and `/tmp/sleep23-migrator-types.log`. Fresh remote checks remain required; the original CI failure is not classified as a flake. + +## Denial request latency review repair + +Review finding 4370459974 correctly identified that denial awaited account +verification, provider inspection and endSession, despite already committing the +rejection and its cleanup notification. The request now returns the saved denial; +the registered worker owns physical termination and persists its retry. No detached +promise or second dispatch path is added. Per-attempt account verification stays +with the provider boundary, protecting rotated credentials from a different +account. This patch does not cache provider authority indefinitely. + +A new real-producer regression holds verifyAgent unresolved, proves the committed +denial returns, checks that cleanup is still unconfirmed with a saved retry, then +releases the provider and verifies confirmed termination. It failed before the +fix (`/tmp/sleep23-denial-before.log`) and passed after +(`/tmp/sleep23-denial-after.log`). Existing explicit one-shot tests now invoke +that one-shot path rather than relying on the HTTP decision to do cleanup. +All 130 full voice and app wiring cases pass on a fresh migrated PostgreSQL DB; +production server typecheck passes. Logs: `/tmp/sleep23-denial-acceptance.log` +and `/tmp/sleep23-denial-types.log`. + +Finding 4370459800 about company deletion remains separately unresolved. Missing +credentials and account mismatch retain cleanup debt. There is a tested recovery +path through restoring the original account, but a permanently unavailable +provider needs a deliberate ownership/product policy. A retry cap, elapsed time, +or ambiguous 404 is not evidence that a billable call ended. Do not manufacture +cleanup confirmation or silently discard the obligation to unblock deletion. diff --git a/server/src/__tests__/voice-session-store.integration.test.ts b/server/src/__tests__/voice-session-store.integration.test.ts index 434008cfcbf..95f2df3ca42 100644 --- a/server/src/__tests__/voice-session-store.integration.test.ts +++ b/server/src/__tests__/voice-session-store.integration.test.ts @@ -78,6 +78,39 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu return { companyId, endpointId, agentId, connectionId, issueId, conversationId, callerId, sessionId, store, request, reserved, accept, toolInput, publication }; } + it("returns a committed denial while provider cleanup is still waiting", async () => { + const f = await inboundFixture(); + let release!: () => void; + const pendingProvider = new Promise(resolve => { release = resolve; }); + f.transport.verifyAgent.mockImplementation(async () => { + await pendingProvider; + return { id: "agent_test", organizationId: `org_${f.companyId}`, name: "Test voice" }; + }); + const onError = vi.fn(), coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError }); + let denial: ReturnType | undefined; + try { + await f.service.inbound.registerCleanupWork(coordinator, () => true).ready; + let returned = false; + denial = f.service.inbound.decide(f.companyId, f.endpointId, f.call.id, f.input.caller, + { approve: false, approvalCode: f.call.approvalCode }).then(result => { returned = true; return result; }); + await vi.waitFor(() => expect(returned).toBe(true), { timeout: 2000 }); + expect(await denial).toMatchObject({ state: "denied" }); + await vi.waitFor(() => expect(f.transport.verifyAgent).toHaveBeenCalled(), { timeout: 5000 }); + expect(f.transport.endSession).not.toHaveBeenCalled(); + const [pending] = await db.select().from(chatVoiceInboundCalls).where(eq(chatVoiceInboundCalls.id, f.call.id)); + expect(pending.cleanupConfirmedAt).toBeNull(); + expect(pending.cleanupNextAttemptAt).not.toBeNull(); + release(); + await vi.waitFor(async () => { + const [settled] = await db.select().from(chatVoiceInboundCalls).where(eq(chatVoiceInboundCalls.id, f.call.id)); + expect(settled.cleanupConfirmedAt).not.toBeNull(); + }, { timeout: 5000 }); + expect(f.transport.endSession).toHaveBeenCalledOnce(); expect(onError).not.toHaveBeenCalled(); + } finally { + release(); await denial; await coordinator.stop(); + } + }); + it("wakes inbound cleanup from a real signed pre-call and retains an unconfirmed end", async () => { const f = await inboundFixture(); const ended = f.sign({ type: "call.status", call_id: f.call.providerSessionId, status: "ended" }); @@ -126,6 +159,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu const result = await f.service.inbound.decide(f.companyId, f.endpointId, f.call.id, f.input.caller, { approve: false, approvalCode: f.call.approvalCode }); expect(result.state).toBe("ended"); + await f.service.inbound.reconcile(1000); const [pending] = await db.select().from(chatVoiceInboundCalls).where(eq(chatVoiceInboundCalls.id, f.call.id)); expect(pending.cleanupConfirmedAt).toBeNull(); expect(pending.cleanupNextAttemptAt).not.toBeNull(); await db.update(chatVoiceInboundCalls).set({ expiresAt: new Date(0), updatedAt: new Date(0) }).where(eq(chatVoiceInboundCalls.id, f.call.id)); @@ -150,6 +184,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu f.transport.verifyAgent.mockResolvedValue({ id: "other-agent", organizationId: "another-account", name: "Other voice" }); await f.service.inbound.decide(f.companyId, f.endpointId, f.call.id, f.input.caller, { approve: false, approvalCode: f.call.approvalCode }); + await f.service.inbound.reconcile(1000); expect(f.transport.inspectSession).not.toHaveBeenCalled(); expect(f.transport.endSession).not.toHaveBeenCalled(); const [pending] = await db.select().from(chatVoiceInboundCalls).where(eq(chatVoiceInboundCalls.id, f.call.id)); expect(pending.cleanupConfirmedAt).toBeNull(); expect(pending.cleanupNextAttemptAt).not.toBeNull(); @@ -1436,6 +1471,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu it("denies expired and rejected calls and closes the existing provider leg without creating work", async () => { const f = await inboundFixture(); expect(await f.service.inbound.decide(f.companyId, f.endpointId, f.call.id, f.input.caller, {approve: false, approvalCode: f.call.approvalCode})).toMatchObject({state: "denied"}); + await f.service.inbound.reconcile(1000); expect(f.transport.endSession).toHaveBeenCalledWith(f.event.session_id); expect(await f.tool("submit_request", {text: "Try again"})).toMatchObject({authorization: "denied", requestAccepted: false}); const expired = await inboundFixture(); diff --git a/server/src/services/voice/voice-inbound-service.ts b/server/src/services/voice/voice-inbound-service.ts index aa8f584570d..c43ec1f3782 100644 --- a/server/src/services/voice/voice-inbound-service.ts +++ b/server/src/services/voice/voice-inbound-service.ts @@ -247,9 +247,9 @@ export function voiceInboundService(db: Db, options: { }); if (admission.state === "approved") return projection(admission); if (!input.approve) { - // Bound calls have already been handed to live-session recovery. Unbound - // rejection uses the same durable claim as scheduled cleanup. - await cleanup.check(admission.id).catch(error => logger.warn({ err: error, callId: admission.id }, "Incoming call termination remains pending")); + // The committed admission signal owns cleanup. Return the saved denial + // without waiting for provider account verification or physical hangup. + // Bound calls have already been handed to live-session recovery. return projection(admission); } const row = await endpoint(companyId, endpointId), credentials = await options.credentials(row, admission.credentialFingerprint); From 0dae025e7f0a870af871a6a0ff885cf0131f823f Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 25 Oct 2026 05:41:39 -0500 Subject: [PATCH 2/2] docs: record retention acceptance after denial repair Co-Authored-By: Paperclip --- .../2026-10-25-voice-inbound-retention-sleep-audit.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/doc/plans/2026-10-25-voice-inbound-retention-sleep-audit.md b/doc/plans/2026-10-25-voice-inbound-retention-sleep-audit.md index 6d744aa9233..8a50f9111b8 100644 --- a/doc/plans/2026-10-25-voice-inbound-retention-sleep-audit.md +++ b/doc/plans/2026-10-25-voice-inbound-retention-sleep-audit.md @@ -149,3 +149,14 @@ rejection awaits provider calls. These require explicit disposition/repair at th parent; green checks do not resolve them. An arbitrary retry cap or credential failure is not proof that a provider call ended and must not silently discard the cleanup obligation. Preserve that requirement while assessing the deletion path. + +## Denial worker propagation + +The parent denial-latency fix is incorporated. Retention's real cleanup-receipt +producer case now registers the cleanup worker and waits for its confirmed result, +instead of assuming the decision request itself performs provider I/O. Both +retention and denial regression cases are retained. The initial merge verification +failed to parse unresolved conflict markers; no voice cases ran. After preserving +both sides and completing the merge, all 134 voice/app cases passed on that still +unused voice database. Logs: `/tmp/sleep24-denial-acceptance.log` (parse failure), +`/tmp/sleep24-denial-acceptance-fixed.log` (134 pass).