Skip to content

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.

  1. Inbound policy cut does not end the phone call. The outbound call_service._watchdog now records a time_limit termination intent before sending its targeted system hangup, and waits for exact HFP confirmation. The independent inbound/global call_billing._duration_watchdog only 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 reads peer.
  2. Busy lines aren't detected. The firmware reports no busy-distinct state, so no busy end_reason or outcome exists. If CIEV callsetup analysis ever lands in the firmware/bridge, add the producer, then the mapping.
  3. A trial the AI hangs up reads peer. end_trial_call always stamps trial_ended, so an in-trial hangup_call isn't distinguished. Cosmetic; revisit only if trials grow real hangup semantics.
  • server_draining — a place_call REJECTION code (503) and a Prometheus gauge; a drain never stamps it on a call (the device WS closing lands device_lost).
  • unsupported_codec — a per-call-metrics issue code + calls column (timing.unsupported_codec); the call it ruins still ends with a normal reason. See docs/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_call preflight rejection codes; the call never existed.

Download the taxonomy as Markdown