Skip to content

Mobile voice: after hanging up, a late tool keeps polling and moves the app #9464

Open

Description

@Bruno-rose

Where did this happen?

Other

Version

Source build, main at 19db630

What happened?

If a voice tool such as start_agent or create_machine_workspace finishes after you hang up, the ended session starts a 6 s listSessions poll that never stops, and it navigates the app to the workspace. The poll keeps the controller alive and keeps the old organization's data after a user or org switch.

Expected: nothing the session owns runs or navigates after End.

Steps to reproduce

  1. In the iOS app, start a voice session.
  2. Ask it to start an agent.
  3. Hang up before the tool finishes.
  4. See the app move to the workspace, and listSessions called every 6 s.

Logs, screenshots, or recordings

Found by model checking every order of events, then reproduced as a failing test against the real code. A fix with that regression test is ready.

Implementation notes

Files

  • apps/mobile/lib/voice/session/VoiceSessionController.ts watchSession, checkWatchedOnce (returns on ended before it removes entries), and applyDirective.

Approach

Return early from watchSession and applyDirective when the session has ended.

Activity

  1. github-actions commented on Nov 7, 2026

    @github-actions
    Contributor

    Summary

    When a voice session ends while a tool call is still running, that tool can still change things after the user hangs up. If it calls watchSession (as start_agent / create_machine_workspace do), a new 6 s setInterval starts. That interval never clears itself, and it keeps calling listSessions against the data client that was bound to the old user and org. The tool's UI directive also still goes through applyDirective, so the app navigates to the workspace after the session has ended. I read through the code and the report holds up.

    Affected code

    apps/mobile/lib/voice/session/VoiceSessionController.ts

    • end() at lines 97–111 sets ended, calls clearInterval(this.watchTimer) and this.watched.clear(). It does not cancel tool calls that are still in flight from onToolCall.
    • onToolCall at lines 263–295 awaits executeTool(...). When that resolves after end(), nothing checks this.ended:
      • The tool calls the watchSession callback (line 273), and watchSession (lines 297–308) adds an entry to watched and runs this.watchTimer ??= setInterval(...).
      • this.applyDirective(result.ui) (line 293) calls applyUiDirective and router.push, which navigates.
    • checkWatchedOnce at lines 321–364 returns at line 326 (if (this.ended) return;) right after listSessions. It never reaches the entry removal or the clearInterval at lines 360–363, so the interval runs forever.

    apps/mobile/lib/voice/useVoiceSession/useVoiceSession.ts

    • end (lines 89–93) and the owner-change effect (lines 95–99) set current = null. The interval callback still holds the controller, and with it the createVoiceData({ organizationId, userId, ... }) instance from line 56. That is why the old org's data stays alive after a user or org switch.

    Root cause

    1. No ended guard after the tool await. watchSession and applyDirective can both run after end().
    2. The poll exits on ended before its own cleanup. The early return at line 326 (and also line 350) skips the clearInterval at line 361.

    The leak depends on timing. end() clears watchTimer but does not set it to null (line 101). If a watch was already running before End, ??= sees the stale id and no new interval starts. The leak only happens when the first watch of the session arrives after End, which is the case in the steps above. Navigation happens in both cases.

    Reproduction (bun:test sketch)

    This uses the existing harness in VoiceSessionController.test.ts:

    1. Build with a data override whose startAgent returns a promise you resolve by hand, and whose listSessions counts its calls.
    2. await controller.start(), then emit a start_agent tool call through transports[0].emit(...).
    3. Call controller.end(), then resolve startAgent, then await flush().
    4. Assert that routes is empty. It currently contains a push …/workspace/….
    5. Assert that no interval is pending: either inject a timer, or call controller.checkWatched() and check that listSessions is not called. It currently is called, and the timer stays set.

    Suggested fix

    • Add if (this.ended) return; at the top of watchSession and of applyDirective. Also check in onToolCall after the await, so settleTool and the rest don't act on an ended session.
    • Make the poll defensive: in checkWatchedOnce, on ended, clear the interval and set watchTimer to null instead of returning bare. Or start checkWatched with if (this.ended) { stop timer; return; }.
    • In end(), set this.watchTimer = null after clearing it, so ??= can't trip on a stale id.

    The early-return approach the reporter proposed matches the first point. Together with the poll guard it also protects against an interval that slips through some other way.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions