# The voice worker

The voice worker is the process that holds a realtime call. The SDK runs each call in its own process, every idle process warms greetings, and every live worker heartbeats its load and the calls it is holding. If nothing has checked in for 90 seconds, admission refuses new calls before a row exists — rather than sending a caller to a fleet that is not there.

- **Status:** Available
- **Audience:** both
- **Channels:** phone
- **In the app:** #/calls, #/phone-advanced
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/phone/voice-worker/

## One process per call

The worker is not a server that multiplexes conversations. The SDK runs each call in its own process, which is what makes a single bad call survivable: a session that wedges takes its own process with it and no other caller notices.

Idle processes are not idle. `prewarm_fnc` runs in every one of them, fetching the workspace's greeting lines and synthesising each into that process's cache, so a call landing in a warm process starts speaking immediately. Process initialisation is given 45 seconds for this, because the default bound was shorter than the warm-up's own 30-second budget — a mismatch that made every restart warm nothing.

**`heartbeat`** — One-minute load per core, plus the calls this worker is holding. Sent on every beat, and read by admission.
**The heartbeat file** — Forgets a process not seen for an hour, so a worker that died does not hold a slot in the fleet's arithmetic forever.
**`drain_timeout`** — 180 seconds. A deploy stops taking new calls and lets the ones in progress finish.
**The service unit** — `ops/connect-voice.service` allows 210 seconds before systemd kills the process — deliberately longer than the drain, so a clean drain is never cut short by the supervisor above it.

## What the worker is and is not responsible for

It holds the audio, speaks, and reports. It does not decide anything about the business: every fact it uses arrives in the brief rendered once per call, and every sentence it says is posted back so that the guardrails can be re-checked on Connect's side, where the authority lives.

It also does housekeeping that would otherwise never happen. The stale call sweep runs from the worker heartbeat as well as from the engine tick — the second path was added because a browser call has no worker to heartbeat at all, and one such call sat `active` for 24.78 hours before anything closed it.

> **Note** The worker's endpoint is authenticated by `MAYA_VOICE_ENGINE_TOKEN` over loopback and answers 503 when the token is unset. A missing configuration closes the door rather than opening it, which is the correct direction for a service that can place calls.

## When no worker has checked in

This is the failure worth designing against, because it is the one where the screen and reality disagreed. A worker that has not checked in means an inbound call reaches nothing at all — and the Phone screen used to still say "ready", so nobody knew until a customer said so.

| Fleet state | Admission | Caller experience | Where it shows |
|---|---|---|---|
| Workers checked in, capacity free | Accepted | A normal call | — |
| Nothing checked in for 90 s | Refused `not_ready` before a row exists | The call is not answered | Line health, as a Needs You item |
| All live workers over the load threshold | Refused | The call is not answered | Line health |
| A worker draining after a deploy | New calls go elsewhere | Unaffected, if another worker is up | — |
| A worker that died | Forgotten after an hour | Unaffected once other workers are up | — |

Refusing before a row exists is deliberate. It means the phone follow-up drain sees a line that is not ready and retries in an hour, rather than recording a failed call against a promise that was never attempted.

## Deploying without cutting a call

1. Stop admitting new calls to the workers being replaced.
   - Result: Calls in progress continue; new ones land on the rest of the fleet.
2. Let the drain run its 180 seconds.
   - Result: Conversations end naturally instead of being cut mid-sentence.
3. Let systemd's own 210-second allowance be the backstop rather than the schedule.
   - Result: A drain that legitimately needs its full time is never killed halfway by the process supervisor.
4. Confirm a worker has heartbeated before treating the deploy as finished.
   - Result: A fleet with nothing checked in refuses calls, and the drain that retries follow-ups only helps if the fleet comes back.

## Questions

### Does a worker restart drop the call it was holding?

A drain lets calls in progress finish, which is what the 180-second window is for. Separately, a model session that ends mid-call can be resumed — session resumption plus context compression let the plugin reconnect carrying the conversation — and where a session is restarted, `model_session_restarted` goes on the record.

### How do I know whether a worker is running without shell access?

Line health is the customer-facing answer. It names four causes and one of them is a worker that has not checked in; the finding appears in Needs You for both audiences and drains by itself when a worker comes back.

### Why does the greeting warm-up matter to the worker's design?

Because it is the only reason an idle process does work. Warming moved the inbound greeting from about three seconds to none, and it is why process initialisation gets 45 seconds rather than the default — a warm-up that times out leaves a process that will synthesise every greeting live.

## Related

- [Capacity and admission control](https://connectbyjbrh.com/docs/phone/worker-capacity/)
- [The realtime voice engine](https://connectbyjbrh.com/docs/phone/realtime-engine/)
- [Greeting warm-up](https://connectbyjbrh.com/docs/phone/greeting-prewarm/)
- [Phone line health](https://connectbyjbrh.com/docs/phone/phone-health/)
- [Stuck calls and the sweep](https://connectbyjbrh.com/docs/phone/stuck-calls/)
- [A background thread that outlives its task](https://connectbyjbrh.com/research/background-threads/)

## What this page is based on

- AGENTS.md §9a — worker process model and heartbeat (via docs-source/sources/PHONE.md §5, §8)
- PROJECT-STATE.md — the 24.78-hour active call and the warm-up timeout (via docs-source/sources/PHONE.md §4, §8)
- `backend/voice_worker/agent.py` — prewarm and process initialisation
- `ops/connect-voice.service` — the 210-second stop allowance
