# Recovering a failed call

Failures on a call path arrive in a fixed order, and the recovery differs at every point: before the ring a refusal is retryable, during the greeting a failure ends the call rather than leaving silence, mid-call a dropped session reconnects carrying the conversation, and after the ending the digest runs in the background. Knowing which stage failed tells you whether to wait, retry or change something.

- **Status:** Available
- **Audience:** both
- **Channels:** phone
- **In the app:** #/calls, #/needs-you
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/workflows/call-recovery/

## The chain, read as a list of places to fail

1. Trigger — a call is due, or a caller dials.
2. External event — the carrier's webhook, or the drain's decision to place a call.
3. Authentication and workspace resolution — a signature checked outside workspace scope reads credentials as empty and refuses everything.
4. Ingest — replayed provider events are absorbed once, by a nonce under a partial unique index.
5. Canonical record — admission control refuses before a row exists, so a refusal is not a half-written call.
6. Reasoning — a model session that will not start is retried, then falls back.
7. Knowledge, memory and rules — an oversized brief is a latency failure, not an error.
8. Autonomy and approval — a budget refusal is spoken, not silent.
9. Action through a provider — dial timeouts are retried once; a leg that never joins is written off.
10. Result — the ending is attributed to one of six actors rather than defaulting to the agent.
11. Relationship, timeline and memory — the digest is deferred so the ending is recorded even when the summary is slow.
12. Audit, usage and Needs You — a stale session is swept and charged; a late webhook cannot overwrite a person's decision.

## Recoveries, in the order the failures happen

| Stage | What you see | What changes | What can fail |
|---|---|---|---|
| Admission | The call is refused before it rings | No `Call` row yet | `not_ready` when no worker checked in for 90 s — retryable on purpose, so a drain retries it |
| Model id | A test call rings and then dies | A refusal with its own word | A malformed or Vertex-path model id is refused as `bad_model` rather than attempted |
| Signature and scope | Every call refused before pickup | Nothing recorded | Credentials read outside the workspace look empty; the fix is scope, not the signature |
| Greeting | The call ends instead of sitting silent | `engine_error {at: greeting}`, `hangup_by=engine`, outcome `not_reached` | A refusal that can be retried is retried once; a line that still cannot be spoken ends the call honestly |
| Session start | A short pause before the greeting | `model_session_restarted` on the record | Opened once more on the same model, then once on the mutable fallback, before the call is given up |
| Dial | The far end does not ring | One further attempt | A carrier timeout is dialled once more; a leg answered but never joined is written off after its timeout |
| Mid-call | Nothing audible | Session resumption plus sliding-window compression | The plugin reconnects on the server's own go-away carrying the conversation, so the session cap is not the call's ceiling |
| Ending | The call closes promptly | Row closed, cost booked, digest marked pending | Waiting on summary and review inside one request logged 'end not recorded' for endings that were recorded |
| Stale | A call stops showing as active | Swept to the configured cap and charged | A browser call has no worker, so the sweep also runs from the engine tick |

## The retry rules worth memorising

- A request a call cannot lose — arrival, the brief, the ending — is retried through a deploy restart or a proxy error.
- **A 4xx is an answer and is never retried.** Retrying a refusal turns one refused call into twenty.
- A dial the carrier answered with a timeout is dialled once more, once only.
- A model session that refuses to start gets one more attempt on the same model and one on the fallback.
- A greeting refusal that is worth retrying — a rate limit, a server error — is retried; a bad key never is.

Replay protection sits underneath all of it. Every normalised provider event is stored once, with the signature nonce under a partial unique index, so a carrier that delivers the same event three times cannot produce three of anything.

## The ceiling, and what is not a failure

A call is no longer cut when the model's session reaches its limit. Session resumption and a sliding-window compression let the plugin reconnect carrying the conversation, so the session's cap belongs to the session and the product's own ceiling — 30 minutes — is the one that ends a call. A call that ends at that ceiling is working as designed.

Neither is a closed line. A switched-off or out-of-hours number answers with the closed-line message in the business's own name and hangs up, and the row is ended with the reason. It used to be a bare refusal that the worker answered by deleting the room, so the caller heard the call connect and drop with no record at all — that was a failure; this is not.

## Questions

### How do I tell a retryable refusal from a real fault?

By the word on the record. `not_ready` means capacity or a missing worker and will be retried. `bad_model` means a setting is wrong and no retry will help. `line_off` and `after_hours` mean the line refused deliberately. Only `not_reached` on a line that should have been available is an operational fault to chase.

### A call was charged even though nobody spoke. Is that a bug?

No. A call that reports no tokens is still charged from its own duration at the published per-minute rate, never at zero, because the provider charged for it. A sweep that closes a stale session charges what it closes for the same reason.

### Does a repeated webhook create a duplicate call?

No. Each normalised event is stored once against its signature nonce under a partial unique index, which is the replay protection for the whole path.

## Related

- [The call never rang](https://connectbyjbrh.com/docs/troubleshooting/call-never-rang/)
- [The voice model refused the session](https://connectbyjbrh.com/docs/troubleshooting/model-session-failed/)
- [The call connected and nobody spoke](https://connectbyjbrh.com/docs/troubleshooting/call-answered-in-silence/)
- [Idempotency for retried telephony webhooks](https://connectbyjbrh.com/research/idempotent-telephony-webhooks/)
- [Inbound phone call, end to end](https://connectbyjbrh.com/docs/workflows/inbound-phone-call/)

## What this page is based on

- AGENTS.md §9a — failure, refusal, retry and attribution
- `backend/app/voice_engine.py`, `voice.py`, `voice_cost.py`
- Connect capability registry (docs-source/facts.py)
