Repository navigation
feat(calls): end a call left to one person unless they choose to stay - #8884
peterchinman wants to merge 19 commits into
Conversation
Why: on iOS, pressing Leave did not stick. After each Leave the lifecycle saw a native snapshot that still reported the same call and rejoined it, either by restoring it while the old server leave settled or by adopting it once idle. In the 2026-10-21 incident PostHog shows three `joined` events with no tap within 3.5 minutes of four Leaves. A leave whose media teardown outlasted the 10s timeout also never told the server, which kept the user in the call. What: a call the user leaves, or that native ends, is recorded as ended. A native snapshot that still reports it is ended natively (at most twice) instead of being adopted or restored; only an explicit join of the channel lets it back in. The leave timeout now sends the server leave. `joined`/`left` analytics carry the call id, what triggered the join, and why the leave ran, and the in-channel Join button reports `join_clicked`.
Why: on 2026-10-21 an iPhone kept showing a call PiP after the call had ended on the server. Tapping it opened a drawer whose Leave did nothing, and only a force-quit cleared it. The drawer's End no-oped once the session had no call. PiP re-armed on every trip to the background with no call check and could outlive teardown. Restoring it always expanded the drawer. Late room and camera callbacks re-presented the drawer, and a connect that finished after its room was replaced stayed connected with no handle. All native logs went to print(), so nothing survived to debug this. What: - Drawer End uses the coordinator's end path (session fallback, failed request, 5s timeout) and then tears down whatever media or UI is left, even with no call. - PiP and the drawer only appear while a call is active: prepare and start are gated, stop always stops and keeps the controller until AVKit reports it stopped, and restore with no call stops PiP. - Stale room callbacks and the camera toggle no longer touch the overlay, and a stale connect completion disconnects its room. - Logs go to os.Logger (persisted, sysdiagnose-visible) with no identities, names or device names. - Answered calls report callId and answeredAtMs, and JS drops answers replayed more than 60s late, so a stale CallKit answer cannot join a call on foreground. - Adds the native alone-in-call alert and background notice (show/dismiss_alone_prompt, watch_alone_prompt_response), used by the alone-call timeout.
Why: LiveKit recreates a deleted room when a still-valid 6h token
connects, for example a stale VoIP answer after the call ended. Macro
never tracked or cleaned up such rooms. A user stranded in one saw a
live call, and Leave returned 404 without touching LiveKit. Separately,
a sole participant could hold a call open indefinitely.
What:
- A user or guest joining the room of an archived call is removed and
the room deleted. The sweeper deletes such rooms after the reconcile
grace. A leave that finds no active call also removes the user from
the channel's latest archived room. A room is treated as orphaned
only when it is named after an archived call record, so meeting
preparations, get-or-create candidates and other deployments' rooms
are untouched.
- Alone timeout: after 5 minutes as the only connected person, a Macro
user gets `call_alone_warning` (re-sent each sweep). The call ends
2 minutes later unless they POST /call/record/{call_id}/stay.
Company clears the warning with `call_alone_cleared`. A lone guest
never times out. Transitions are conditional writes, so only one
sweeper instance acts, and the end checks for a stay under the
call's row lock. Adds archive reason `alone_timeout`.
- Spans `calls.sweep_orphan_rooms` and `calls.alone_timeout`, and warn
logs for evictions and forced ends, without identities.
Why: the server now ends a call whose only person has been alone for
5 minutes, unless they choose to stay within 2 minutes of its warning.
The client has to surface that warning and the choice.
What: `call_alone_warning` / `call_alone_cleared` are parsed with the
other call websocket events. A single watcher under CallProvider tracks
the warning for the call this client is in. On the web, desktop and
Android it shows the shared confirm dialog (a drawer on phones) with a
countdown, Stay in call, and Dismiss, which keeps the deadline. On iOS
CallKit calls it drives the native alert, whose Stay comes back over
the plugin channel. Stay posts /call/record/{callId}/stay. The calls
agent guide covers leaving native iOS calls, reading CallKit logs, the
new analytics fields, and the alone timeout.
Why: the per-webhook info log printed the participant's identity, which is `macro|<email>`. The observability rule forbids emails in logs, and the line would start leaking them as soon as prod raised its log level to diagnose calls. What: log whether the participant is a user, a guest or neither.
# Conflicts: # packages/sdk/generated/storage/index.ts
…clear Why: review found five ways the fixes in this branch could still fail. - A Join refused while a leave was in flight cleared the left call's record, so it could be adopted again once the leave settled. - Two distinct CallKit answers in the same channel within 2s were deduplicated as one. - stopAndReset stopped only the current PiP controller, not one replaced by a source-view change that was still on screen. - Native logs could print participant ids that fall back to the identity (`macro|<email>`), and the ring poller logged unvalidated server text, both as public log entries. - clear_alone could null `alone_since` after another instance recorded a warning between its two statements, leaving a warning with no alone period that could end the call later without a fresh warning. What: - Ended calls are forgotten only when an explicit join is accepted. - Answers are deduplicated by call id when native provides one. - stopAndReset also stops retiring PiP controllers. - Participant ids are replaced by counts and booleans in native logs, and unknown ring statuses get a fixed label. - The fallback clear skips rows with a warning.
# Conflicts: # apps/web/tauri/callkit_plugin/permissions/autogenerated/reference.md # apps/web/tauri/callkit_plugin/permissions/schemas/schema.json # packages/sdk/generated/storage/index.ts
Why: Biome's safe fix for noUselessUndefinedInitialization removed `= undefined` from the leave scope's result, which TypeScript needs: the result is read on paths that never assign it (TS2454). What: restore the initializer.
# Conflicts: # packages/sdk/generated/storage/index.ts
Why: the test failed once in a full-suite run after merging main. It asserted the countdown reads exactly 2:00 and gave the dialog 1s to close, but the countdown is a live 1s timer and the dialog loads lazily, so a loaded run can tick past 2:00 or close late. What: accept the first minute of the countdown, and give the close the same 2s budget as the deadline test.
# Conflicts: # crates/code_execution/src/outbound/deno/sdk.mjs # packages/sdk/generated/storage/index.ts # packages/sdk/generated/storage/sdk.gen.ts
Why: the alone timeout is a new product behavior with a schema migration, while the rest of this branch fixes the stranded-call incident. Splitting them lets each be reviewed and reverted on its own. What: removes the server sweep, migration and stay endpoint, the generated SDK and API clients for it, the web prompt and watcher, and the native iOS alert and backstop. It returns in a stacked pull request on top of this branch.
Why: a call whose only remaining person is one Macro user keeps running, holding their LiveKit room and native call UI until they notice. In #8500's incident the user was left alone in a call that kept running after the other person had gone. What: the stale-call sweeper marks a call alone once its only connected person is one Macro user, sends a `call_alone_warning` after 5 minutes, and archives the call 2 minutes later (`AloneTimeout`) unless they call `POST /call/record/{call_id}/stay`. Someone joining, or staying, clears the warning with `call_alone_cleared`. A call whose only remaining person is a guest never times out. Every transition is a conditional update, so only the sweeper instance that wins it sends the event. The migration adds `calls.alone_since` and `calls.alone_warned_at`; the generated SDK and API clients gain `stayInCall`.
Why: the server now ends a call 2 minutes after warning its last person, so the warning has to reach them with a way to keep the call. What: `CallAloneWatcher`, mounted in Root, listens for `call_alone_warning` and `call_alone_cleared` on the active call and opens `CallAlonePrompt`, a ConfirmDialog with a live countdown and **Stay in call**. On iOS CallKit calls it hands the warning to the native alert instead and relays its Stay through the same `stayInCall` request. Dismiss hides the prompt but keeps the deadline; a new deadline reopens it.
Why: on iOS the native call drawer covers the web view, and while the app is in the background the web view is suspended and never sees the server's warning. What: `CallAlonePromptController` presents the warning as an alert above the call drawer with Stay and Leave, or as a local notification in the background. The session also starts its own 5-minute backstop whenever the room has no other human, so the phone warns even if the server's event never reaches JS. Adds the `show_alone_prompt`, `dismiss_alone_prompt` and `watch_alone_prompt_response` commands and their iOS capabilities.
Why: the watcher imports its dialog lazily, and after the latest main merge that import can take longer than the 400ms deadline in the "closes at the deadline" test, so the dialog never appeared. What: the suite imports `CallAlonePrompt` once in `beforeAll`.
|
No actionable comments were generated in the recent review.
|
Stacked on #8500. Merge that first; this PR's diff is only the alone timeout.
Why
A call whose only remaining person is one Macro user keeps running until that
user notices. Their LiveKit room stays open, and on iOS so do the CallKit call,
the drawer and the PiP. In #8500's incident, the user was left alone in a call
that kept running after the other person had gone. Split out of #8500 because
this is new product behavior with a schema migration, while #8500 is bug fixes.
How it works
The per-minute stale-call sweeper runs on every instance. It enforces the
timeout with conditional writes, so one instance wins each transition and only
that instance sends the event:
A call whose only remaining person is a guest never times out.
What
crates/call:alone.rsadds the timeout, called fromreconcile_call.It also adds
POST /call/record/{call_id}/stayand the archive reasonalone_timeout.archive_sessiontakes anArchiveCondition, so an alonearchive under the call lock yields to a stay that won the lock. The
calls.alone_timeoutspan records each transition.20261021212125_calls_alone_timeout.sqladds two nullablecolumns,
calls.alone_sinceandcalls.alone_warned_at.CallAloneWatcheris mounted in Root and loads its dialog lazily. Itshows the shared
ConfirmDialogwith a countdown, Stay in call andDismiss; Dismiss keeps the deadline. On iOS CallKit calls it hands off to
the native alert.
show_alone_prompt,dismiss_alone_promptandwatch_alone_prompt_response.itself to the front.
receives the server's warning.
stayInCallis skipped in SDK coverage, like the other call-controlendpoints.
docs/agent-guide/calls.md, and the sweepergotcha in
crates/call/AGENTS.md.Labeled
skip-ios-update: the native alert ships with the next iOS release.Until then, iOS CallKit users on older binaries get no alert, and their call
ends 7 minutes after they are left alone.
Follow-ups
notification and the backstop.