# Stuck calls and the sweep

A call row still marked active long after the call ended is a row nobody closed — a worker that died, a browser call that never had one, a leg the carrier answered that never joined the room. `sweep_stale` closes them against the configured cap, runs from the engine tick as well as from the worker heartbeat, and charges what it closes.

- **Status:** Available
- **Audience:** both
- **Channels:** phone
- **In the app:** #/calls
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/phone/stuck-calls/

## What leaves a row open

Closing a call is normally the worker's last act. Everything that leaves a row open is a variation on the worker not being there to do it.

**A worker that died mid-call** — The process is gone and nothing posts the ending. The caller's phone has long since hung up; the row has not heard about it.
**A browser call** — There is no voice worker at all. A softphone call is a person talking to a person, bridged by the carrier — nothing in the fleet is watching it, and one such row sat active for **24.78 hours**.
**A leg that never joined** — The carrier answered the dial and the leg never arrived in the room. It is written off after `DIAL_JOIN_TIMEOUT_S` rather than waited for indefinitely.
**A deploy at the wrong moment** — A worker draining past its window is killed. The drain allowance exists so this is rare, not so it is impossible.

## Why the sweep runs from two places

The obvious home for a stale-row sweep is the worker heartbeat: the workers are the things that hold calls, so let them tidy up after each other. That is exactly the design that let a browser call sit active for over a day — no worker ever held it, so no heartbeat ever considered it.

`sweep_stale` therefore runs from the engine tick as well. The engine tick is running whether or not the fleet is, which makes it the right place for cleanup that must happen even when the thing being cleaned up was never part of the fleet.

> **Note** It also holds every provider to its configured cap — a call cannot remain open past the maximum the provider is configured for, whatever the record says. That is a second, independent bound on the same problem, which is the right amount of belt and braces for something that costs money per minute.

## It charges what it closes

A swept call is billed, not forgiven. This follows the same rule the rest of the metering follows: a call that reports no usage is charged from its own duration at the per-minute rate rather than at zero.

The reasoning is that the alternative is a system where the calls that went wrong are the calls that appear free, which makes a fleet problem look like a cost saving on every report. A swept row is visible as a swept row, with a duration and a charge, and a run of them is a signal worth acting on.

## The other kind of unfinished call

A different failure looks similar from a desk and is not the same thing: a call that ended properly but whose *record* did not finish.

`end` used to do the summary and the quality review — two model calls — inside a single request with a 20-second budget. It routinely ran past it and logged "end not recorded" for an ending the engine had, in fact, recorded. The message was wrong and the alarm it caused was real.

`finish(defer=True)` splits the work. The row is closed, the cost is booked, `facts.digest` is marked `pending` and the request answers immediately; a background task runs the digest once. A call showing a pending digest for a minute is normal. A call still marked active an hour later is the sweep's problem, not the digest's.

## Finding them before the sweep does

The fastest audit for this whole class of fault is to read the carrier's own call log next to the `calls` table. A `Completed` call on their side with no matching row on yours is a bug every time — and it is the only place some of these show up, because a missing row does not appear in a list of rows.

The same comparison catches the opposite fault: rows that exist on your side and never happened on theirs, which is what a stuck row looks like from the carrier's point of view once the call is long over.

## Questions

### A call shows as in progress and I know it ended. What do I do?

Nothing, usually — the sweep closes it and books the cost without anybody intervening. What is worth doing is checking whether other calls from the same period are in the same state, because a single stale row is an incident and a run of them is a fleet problem.

### Do browser calls get swept differently from engine calls?

They are swept by the same function from the same engine tick, which is precisely why the sweep does not live only in the worker heartbeat. A softphone call has no worker to notice it has ended.

### Why does a finished call say its digest is pending?

Because the summary and the review run after the row closes rather than inside the request that closes it. The call is complete and its cost is booked; the written summary follows shortly. That split exists so an ending is never reported as unrecorded merely because a model was slow.

## Related

- [What a call costs](https://connectbyjbrh.com/docs/phone/voice-cost/)
- [Session limits and long calls](https://connectbyjbrh.com/docs/phone/session-limits/)
- [The call record](https://connectbyjbrh.com/docs/phone/call-record/)
- [Calling from the browser](https://connectbyjbrh.com/docs/phone/softphone/)
- [Phone line health](https://connectbyjbrh.com/docs/phone/phone-health/)
- [Idempotency for retried telephony webhooks](https://connectbyjbrh.com/research/idempotent-telephony-webhooks/)

## What this page is based on

- Connect phone source pack — `docs-source/sources/PHONE.md` §4, the stale sweep and what it charges
- Connect phone source pack — `docs-source/sources/PHONE.md` §2 and §9, the carrier-log audit and deferred finish
- Connect capability registry — `docs-source/facts.py` (`voice_cost_metering`, `MEASURED.max_call_seconds`)
