Merge branch 'codex/sleep-24-voice-inbound-retention' into codex/sleep-25-voice-call-reports

* codex/sleep-24-voice-inbound-retention:
  docs: record retention acceptance after denial repair
  fix(server): return inbound denial before provider cleanup
This commit is contained in:
Dotta committed 2026-10-25 05:41:41 -05:00
commit ef4695b77f
5 files changed
+84 -3

No files matched your search

+5
View File
@@ -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;
@@ -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.
@@ -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).
@@ -208,8 +208,10 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
const ended = f.sign({ type: "call.status", call_id: f.call.providerSessionId, status: "ended" });
await f.service.inbound.lifecycle(f.endpoint.publicId, ended.body, ended.headers);
} else {
await f.service.inbound.registerCleanupWork(coordinator, () => true).ready;
await f.service.inbound.decide(f.companyId, f.endpointId, f.call.id, f.input.caller,
{ approve: false, approvalCode: f.call.approvalCode });
await settle();
expect(f.transport.endSession).toHaveBeenCalledWith(f.call.providerSessionId);
}
await settle();
@@ -223,6 +225,39 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
} finally { await coordinator.stop(); vi.useRealTimers(); }
});
it("returns a committed denial while provider cleanup is still waiting", async () => {
const f = await inboundFixture();
let release!: () => void;
const pendingProvider = new Promise<void>(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<typeof f.service.inbound.decide> | 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" });
@@ -271,6 +306,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));
@@ -295,6 +331,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();
@@ -1581,6 +1618,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();
@@ -249,9 +249,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);