Skip to content

feat(calls): end a call left to one person unless they choose to stay - #8884

Open
peterchinman wants to merge 19 commits into
mainfrom
peter/call-alone-timeout
Open

peterchinman wants to merge 19 commits into
mainfrom
peter/call-alone-timeout

Conversation

@peterchinman

Copy link
Copy Markdown
Contributor

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:

1 Macro user, no guests, for 5 min ──▶ call_alone_warning(ends_at = +2 min), re-sent each sweep
        │ POST /call/record/{id}/stay            │ someone joins
        ▼                                        ▼
   clock restarts, call_alone_cleared      call_alone_cleared
        │ no stay by ends_at
        ▼
   archive (reason alone_timeout), kick, delete room, call_ended

A call whose only remaining person is a guest never times out.

What

  • crates/call: alone.rs adds the timeout, called from reconcile_call.
    It also adds POST /call/record/{call_id}/stay and the archive reason
    alone_timeout. archive_session takes an ArchiveCondition, so an alone
    archive under the call lock yields to a stay that won the lock. The
    calls.alone_timeout span records each transition.
  • Migration 20261021212125_calls_alone_timeout.sql adds two nullable
    columns, calls.alone_since and calls.alone_warned_at.
  • Web: CallAloneWatcher is mounted in Root and loads its dialog lazily. It
    shows the shared ConfirmDialog with a countdown, Stay in call and
    Dismiss; Dismiss keeps the deadline. On iOS CallKit calls it hands off to
    the native alert.
  • iOS CallKit plugin: adds show_alone_prompt, dismiss_alone_prompt and
    watch_alone_prompt_response.
    • The alert lives in its own alert-level window, because the overlay brings
      itself to the front.
    • In the background it becomes a local notification.
    • A native 5-minute backstop notice covers a suspended webview that never
      receives the server's warning.
  • Generated: storage OpenAPI, zod, SDK, sdk-docs and the code-mode SDK.
    stayInCall is skipped in SDK coverage, like the other call-control
    endpoints.
  • Docs: the alone timeout in docs/agent-guide/calls.md, and the sweeper
    gotcha 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

  • Device verification on a physical iPhone is still needed for the alert, its
    notification and the backstop.

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`.
@peterchinman
peterchinman requested a review from a team as a code owner October 26, 2026 23:27
@peterchinman peterchinman added skip-ios-update Tauri changes here do not break the native/web contract; skip the App Store gate skip-android-update Android native change that does not break the native/web contract; skip the Play gate labels Oct 26, 2026
@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 70b847b and 038dbf4.

Files ignored due to path filters: 20
  • .sqlx/query-2fc574da7d7d0f35b8dd3f83569fbd37ac9ef0f9ce74573c91797e10a54e1bfb.json is excluded by !**/.sqlx/**
  • .sqlx/query-3ac7cf11b88c5f2494c53caa222e95fb1c8800dbb7fa7c2815ef034ee2609b05.json is excluded by !**/.sqlx/**
  • .sqlx/query-4d15ca0795033bb24a3af998b3bb8971adf4aea953a4a4c76193b4920fc439ca.json is excluded by !**/.sqlx/**
  • .sqlx/query-6764fca9d3f7173a067ecc51a345dcfdcff53871ebd2f920539339d0ed23458d.json is excluded by !**/.sqlx/**
  • .sqlx/query-6851d68a1210fdb8fe3f95f3f6799e9b030ed34b04b92093f6d74c66063d300a.json is excluded by !**/.sqlx/**
  • .sqlx/query-74271a83718d06d0d3bb339f77454049e6ff07db492aeb9827275874807a8207.json is excluded by !**/.sqlx/**
  • .sqlx/query-97f934c5957986d60aee5348a3e00fb359b52d8d9c225d10f487b232260cb035.json is excluded by !**/.sqlx/**
  • .sqlx/query-c5673fae2426a1320ae8fdccbdc762c30705a348a75e1700268210c3c6372e5d.json is excluded by !**/.sqlx/**
  • .sqlx/query-c9c891b445f42d44c472098c2f484bc11064e5266af3ba714022722c5de63e2a.json is excluded by !**/.sqlx/**
  • .sqlx/query-e6015d4b2246fd4337eedb93175f1ace312ca80de8fd43aab77ff52845508d3f.json is excluded by !**/.sqlx/**
  • apps/web/src/lib/service-clients/service-storage/generated/zod.ts is excluded by !**/generated/**, !apps/web/src/lib/service-clients/**/generated/**
  • packages/sdk-docs/reference/generated/storage/types.gen/README.md is excluded by !**/generated/**
  • packages/sdk-docs/reference/generated/storage/types.gen/type-aliases/StayInCallData.md is excluded by !**/generated/**
  • packages/sdk-docs/reference/generated/storage/types.gen/type-aliases/StayInCallError.md is excluded by !**/generated/**
  • packages/sdk-docs/reference/generated/storage/types.gen/type-aliases/StayInCallErrors.md is excluded by !**/generated/**
  • packages/sdk-docs/reference/generated/storage/types.gen/type-aliases/StayInCallResponse.md is excluded by !**/generated/**
  • packages/sdk-docs/reference/generated/storage/types.gen/type-aliases/StayInCallResponses.md is excluded by !**/generated/**
  • packages/sdk/generated/storage/index.ts is excluded by !**/generated/**
  • packages/sdk/generated/storage/sdk.gen.ts is excluded by !**/generated/**, !**/*.gen.ts
  • packages/sdk/generated/storage/types.gen.ts is excluded by !**/generated/**, !**/*.gen.ts
Files selected for processing: 68
  • apps/web/src/features/channel/Call/CallAlonePrompt.tsx
  • apps/web/src/features/channel/Call/CallAloneWatcher.tsx
  • apps/web/src/features/channel/Call/CallContext.tsx
  • apps/web/src/features/channel/Call/ChannelCallAutoJoin.tsx
  • apps/web/src/features/channel/Call/call-events.ts
  • apps/web/src/features/channel/Call/call-lifecycle.ts
  • apps/web/src/features/channel/Call/tests/call-alone-watcher.test.tsx
  • apps/web/src/features/channel/Call/tests/call-events.test.ts
  • apps/web/src/features/channel/Call/tests/call-lifecycle.test.ts
  • apps/web/src/features/channel/Call/tests/callkit-answered-recovery.test.ts
  • apps/web/src/features/channel/Call/tests/native-call-lifecycle.test.ts
  • apps/web/src/features/channel/Call/use-call.ts
  • apps/web/src/features/channel/Call/use-callkit.ts
  • apps/web/src/features/channel/Channel/ActiveCallMessage.tsx
  • apps/web/src/lib/queries/call/call.ts
  • apps/web/src/lib/service-clients/service-call/client.ts
  • apps/web/src/lib/service-clients/service-storage/openapi.json
  • apps/web/src/routes/Root.tsx
  • apps/web/tauri/callkit_plugin/build.rs
  • apps/web/tauri/callkit_plugin/ios/Sources/CallAlonePromptController.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/CallAudioRouteController.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/CallKitLog.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/CallKitModels.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/CallKitPlugin.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/CallPictureInPictureController.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/CallVideoOverlayController.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/IncomingCallCoordinator.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/NativeLiveKitCallSession.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/RingDeclineReporter.swift
  • apps/web/tauri/callkit_plugin/ios/Sources/RingStatePoller.swift
  • apps/web/tauri/callkit_plugin/ios/Tests/OverlayHarness/HarnessApp.swift
  • apps/web/tauri/callkit_plugin/ios/Tests/OverlayHarness/project.yml
  • apps/web/tauri/callkit_plugin/permissions/autogenerated/commands/dismiss_alone_prompt.toml
  • apps/web/tauri/callkit_plugin/permissions/autogenerated/commands/show_alone_prompt.toml
  • apps/web/tauri/callkit_plugin/permissions/autogenerated/commands/watch_alone_prompt_response.toml
  • apps/web/tauri/callkit_plugin/permissions/autogenerated/reference.md
  • apps/web/tauri/callkit_plugin/permissions/schemas/schema.json
  • apps/web/tauri/src-tauri/capabilities/ios.json
  • crates/agent_code_mode/src/domain/operations.json
  • crates/ai_tools/src/tool_context.rs
  • crates/call/AGENTS.md
  • crates/call/src/domain/events.rs
  • crates/call/src/domain/events/test.rs
  • crates/call/src/domain/models.rs
  • crates/call/src/domain/ports.rs
  • crates/call/src/domain/service.rs
  • crates/call/src/domain/service/alone.rs
  • crates/call/src/domain/service/meetings.rs
  • crates/call/src/domain/service/orphan_rooms.rs
  • crates/call/src/domain/service/reconcile.rs
  • crates/call/src/domain/service/test.rs
  • crates/call/src/domain/service/test/alone_timeout.rs
  • crates/call/src/domain/service/test/orphan_rooms.rs
  • crates/call/src/inbound/axum_router.rs
  • crates/call/src/inbound/axum_router/test.rs
  • crates/call/src/inbound/stale_call_sweeper.rs
  • crates/call/src/outbound/livekit_rtc_client.rs
  • crates/call/src/outbound/livekit_rtc_client/test.rs
  • crates/call/src/outbound/pg_call_repo.rs
  • crates/call/src/outbound/pg_call_repo/alone.rs
  • crates/call/src/outbound/pg_call_repo/alone/test.rs
  • crates/call/src/outbound/pg_call_repo/lifecycle.rs
  • crates/code_execution/src/outbound/deno/sdk.mjs
  • crates/macro_db_client/migrations/20261021212125_calls_alone_timeout.sql
  • docs/agent-guide/calls.md
  • packages/sdk/specs/storage.json
  • packages/sdk/src/coverage/skipped.ts
  • services/document_storage_service/src/api/swagger.rs

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

📝 Summary

Summary by CodeRabbit

  • New Features
    • Calls now warn when you’re the only participant, with a two-minute countdown to ending. Choose Stay in call to restart the timer; the warning also appears through native iOS call alerts.
    • Calls automatically end if you remain alone after the warning period. Guest-only calls are not affected.
    • Joining a call clears its alone warning.
  • Bug Fixes
    • Improved handling of calls that end or reconnect, including cleanup of abandoned call rooms and more reliable native call controls.
  • Documentation
    • Added guidance on alone-in-call warnings and iOS call behavior.

The changes add a server-enforced timeout for calls with one connected Macro user, warning events, and a stay endpoint. Web and iOS clients handle the warning through prompts. The changes also update call lifecycle and CallKit answer handling, add RTC room cleanup for archived calls, and revise iOS call logging and presentation behavior.

Priority: Normal

Merge Risk: Minimal · up to 038db

The timeout, stay flow, and call cleanup changes have no established merge-blocking defect. Complete normal checks and the planned physical-iPhone validation for native alerts and call teardown.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title uses the conventional commit format, clearly describes the alone-in-call timeout, and is 69 characters long, which is under the 72-character limit.
Description check Passed The description directly explains the alone-call timeout, server and client behavior, iOS handling, migration, endpoint, testing status, and follow-ups.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Finishing Touches: 💡 1
⚔️ Resolve merge conflicts: 💡
  • Resolve merge conflict in branch peter/call-alone-timeout
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Base automatically changed from peter/orphan-call to main October 27, 2026 19:02

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cloud-storage skip-android-update Android native change that does not break the native/web contract; skip the Play gate skip-ios-update Tauri changes here do not break the native/web contract; skip the App Store gate web-app

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant