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.
The chain, read as a list of places to fail#
- Trigger — a call is due, or a caller dials.
- External event — the carrier's webhook, or the drain's decision to place a call.
- Authentication and workspace resolution — a signature checked outside workspace scope reads credentials as empty and refuses everything.
- Ingest — replayed provider events are absorbed once, by a nonce under a partial unique index.
- Canonical record — admission control refuses before a row exists, so a refusal is not a half-written call.
- Reasoning — a model session that will not start is retried, then falls back.
- Knowledge, memory and rules — an oversized brief is a latency failure, not an error.
- Autonomy and approval — a budget refusal is spoken, not silent.
- Action through a provider — dial timeouts are retried once; a leg that never joins is written off.
- Result — the ending is attributed to one of six actors rather than defaulting to the agent.
- Relationship, timeline and memory — the digest is deferred so the ending is recorded even when the summary is slow.
- 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.