Call-end taxonomy — end_reason → ended_by / outcome
The CANONICAL mapping for how a finished call is described. Code:
server/app/postcall.py (ended_by(), classify_outcome(), is_retryable()).
Keep this file and those sets in sync — every string here has a real producer
in the code; the sets contain nothing else. (Merged 2026-07-06: dead aliases
that nothing produced — bridge_lost, server_draining, unsupported,
unsupported_codec, max_duration_s, max_duration, policy_hangup, busy,
line_busy, user_busy — were deleted; the two real trial reasons were mapped.
2026-08-12: user_own_call deleted with the auto_answer single-key model — the
server no longer force-ends phone-initiated calls, so nothing produces it.
2026-08-24: the outbound watchdog added the concrete time_limit producer.)
The three fields, in one line each
| Field | 白话 | Where it comes from |
|---|---|---|
end_reason |
传输层的机器原因串 — WHAT mechanically ended the call | Stamped once by the observed CallWorld edge (for a real phone hangup this remains hfp_idle) |
ended_by |
人话结论 — WHO/what closed the call | ended_by(end_reason, tool_call events), stamped on the calls row alongside outcome by record_outcome at call end (boot sweep stamps its reaped rows system); top-level on /result + /replay, NULL while live. Snapshot semantics: a mapping change needs a backfill to rewrite history |
outcome |
这通电话办成了什么 — the disposition | classify_outcome(): signal-first (did the callee speak?), reasons as tie-breakers; stamped in the same write |
Jasper-initiated physical hangups also write state events named
termination_requested and termination_confirmed. They preserve the policy
intent (provider_error, time_limit, stopped, or agent_hangup) and source
without replacing the mechanical hfp_idle fact. Firmware cmd_result=ok is
only command acceptance; termination_confirmed is emitted only after the
exact HFP call identity disappears.
end_reason — every value the code can produce
end_reason |
Producer (the ONE place it's set) | Meaning |
|---|---|---|
hfp_idle |
call_manager (firmware reports the phone-level call gone) |
The call ended at the phone level — someone hung up (us via hangup_call → AT+CHUP, or the far end / handset) |
hfp_session_changed |
call_manager |
A DIFFERENT call's identity surfaced on the device (transfer / call-waiting swap / back-to-back dial) — this call was superseded |
link_down |
engine call world (call_world.rs) |
The BT link was DOWN at the instant the firmware let the call identity go — the ordinary BT-drop end. This is the reason a mid-call Bluetooth loss lands on, not identity_grace_expired (see the note under it) |
identity_grace_expired |
call_manager._grace_expire |
OUR 25 s grace (HFP_ID_GRACE_S) ran out. Narrower than it reads: it needs the firmware to still be reporting the held id when our timer fires, and the firmware's own grace (~10 s) is shorter — so on a plain BT drop the id is already gone and the end is link_down. What is left for this reason is the case where snapshots stop arriving entirely mid-hold (a dead bridge) |
device_lost |
routing.py (device WS session dropped) |
The device/bridge connection died. (A dead bridge IS this — its WS session is the device session; there is no separate bridge_lost.) |
stopped |
call_service.cancel_call / force_end_call default |
An explicit Stop from the API/console |
provider_error |
call_handler (terminal AI-provider failure) |
The AI provider couldn't connect/stay up; the call was ended because the "brain" was gone |
time_limit |
call_service._watchdog |
The server's per-call duration policy ended an outbound call |
server_restart |
app.py boot sweep (db.end_stale_calls) |
Rows orphaned by a dead server process, closed at next startup |
trial_ended |
call_manager.end_trial_call (browser trial teardown) |
The user's browser voice-trial session closed |
trial_superseded |
call_manager.create_trial_call |
A new trial call replaced the user's still-open one (one trial per user) |
Anything else → ended_by: unknown and no outcome hint. That's ON PURPOSE
(fail loud): a new end reason must be added here + to the mapping sets + tests,
not silently absorbed.
ended_by — the postcall verdict
Priority order as implemented (postcall.ended_by()):
ended_by |
Condition | 白话 |
|---|---|---|
agent |
hfp_idle AND a successful agent-source hangup_call result |
AI 自己挂的(固件确认生效才算 — 被拒/超时/死链上的 hangup spam 不算) |
admin |
stopped, or hfp_idle after a successful admin-source hangup_call |
人从 API/console 取消的 |
link_lost |
link_down / device_lost / identity_grace_expired |
信号/链路断了,没人挂(link_down 是普通蓝牙掉线走的那条) |
call_switched |
hfp_session_changed / trial_superseded |
电话被另一通顶掉了 |
system |
time_limit, server_restart, provider_error, or hfp_idle after a successful system-source hangup |
服务器策略/故障结束的 |
peer |
hfp_idle without an effective hangup tool / trial_ended |
对方(或本人在 trial 里)挂的 |
unknown |
anything unmapped | 认不出来 — 加映射前先加生产者 |
null |
call still live | 还没定论(和 retryable 一样) |
outcome — the five dispositions
Signal-first: a terminal provider failure forces error; an escalating
report_to_admin forces needs_human; a callee that spoke forces answered —
reasons only break ties for calls where nobody engaged.
outcome |
How | retryable |
|---|---|---|
error |
terminal provider event, end_reason ∈ {device_lost, server_restart, provider_error, time_limit}, or a confirmed time_limit termination intent paired with mechanical hfp_idle |
true iff end_reason ∈ {device_lost, server_restart} (transient); provider_error defers to the provider event's own retryable flag; time_limit is not retryable |
needs_human |
a successfully acknowledged agent report_to_admin with urgency need_input/alert |
false |
answered |
the callee spoke (two-way conversation) | false |
agent_hangup |
nobody engaged and firmware acknowledged the AI's hangup_call |
false |
no_answer |
nobody engaged, no other signal | true |
Known gaps (real behavior the taxonomy can't express yet)
These are NOT missing mappings — they're missing producers. Don't re-add speculative strings to the sets; wire a producer first.
- Inbound policy cut does not end the phone call. The outbound
call_service._watchdognow records atime_limittermination intent before sending its targeted system hangup, and waits for exact HFP confirmation. The independent inbound/globalcall_billing._duration_watchdogonly disconnects Jasper's SCO/provider and intentionally leaves the cellular call to the handset; if that call later ends normally, the phone-level close still readspeer. - Busy lines aren't detected. The firmware reports no busy-distinct state,
so no
busyend_reason or outcome exists. If CIEV callsetup analysis ever lands in the firmware/bridge, add the producer, then the mapping. - A trial the AI hangs up reads
peer.end_trial_callalways stampstrial_ended, so an in-trialhangup_callisn't distinguished. Cosmetic; revisit only if trials grow real hangup semantics.
Related but DIFFERENT strings (don't confuse with end_reason)
server_draining— aplace_callREJECTION code (503) and a Prometheus gauge; a drain never stamps it on a call (the device WS closing landsdevice_lost).unsupported_codec— a per-call-metrics issue code +callscolumn (timing.unsupported_codec); the call it ruins still ends with a normal reason. Seedocs/per_call_metrics.md.max_duration_s— the place_call/assistant PARAMETER (the cap itself), never an end reason (see gap #1).device_busy,dial_timeout,bt_not_connected, … —place_callpreflight rejection codes; the call never existed.