You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Mobile voice: after hanging up, a late tool keeps polling and moves the app #9464
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
In the iOS app, start a voice session.
Ask it to start an agent.
Hang up before the tool finishes.
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.tswatchSession, checkWatchedOnce (returns on ended before it removes entries), and applyDirective.
Approach
Return early from watchSession and applyDirective when the session has ended.
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.
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.
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
No ended guard after the tool await.watchSession and applyDirective can both run after end().
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:
Build with a data override whose startAgent returns a promise you resolve by hand, and whose listSessions counts its calls.
await controller.start(), then emit a start_agent tool call through transports[0].emit(...).
Call controller.end(), then resolve startAgent, then await flush().
Assert that routes is empty. It currently contains a push …/workspace/….
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.
Activity
github-actions commentedon Nov 7, 2026
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(asstart_agent/create_machine_workspacedo), a new 6 ssetIntervalstarts. That interval never clears itself, and it keeps callinglistSessionsagainst the data client that was bound to the old user and org. The tool's UI directive also still goes throughapplyDirective, 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.tsend()at lines 97–111 setsended, callsclearInterval(this.watchTimer)andthis.watched.clear(). It does not cancel tool calls that are still in flight fromonToolCall.onToolCallat lines 263–295 awaitsexecuteTool(...). When that resolves afterend(), nothing checksthis.ended:watchSessioncallback (line 273), andwatchSession(lines 297–308) adds an entry towatchedand runsthis.watchTimer ??= setInterval(...).this.applyDirective(result.ui)(line 293) callsapplyUiDirectiveandrouter.push, which navigates.checkWatchedOnceat lines 321–364 returns at line 326 (if (this.ended) return;) right afterlistSessions. It never reaches the entry removal or theclearIntervalat lines 360–363, so the interval runs forever.apps/mobile/lib/voice/useVoiceSession/useVoiceSession.tsend(lines 89–93) and the owner-change effect (lines 95–99) setcurrent = null. The interval callback still holds the controller, and with it thecreateVoiceData({ organizationId, userId, ... })instance from line 56. That is why the old org's data stays alive after a user or org switch.Root cause
endedguard after the toolawait.watchSessionandapplyDirectivecan both run afterend().endedbefore its own cleanup. The earlyreturnat line 326 (and also line 350) skips theclearIntervalat line 361.The leak depends on timing.
end()clearswatchTimerbut does not set it tonull(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:testsketch)This uses the existing harness in
VoiceSessionController.test.ts:dataoverride whosestartAgentreturns a promise you resolve by hand, and whoselistSessionscounts its calls.await controller.start(), then emit astart_agenttool call throughtransports[0].emit(...).controller.end(), then resolvestartAgent, thenawait flush().routesis empty. It currently contains apush …/workspace/….controller.checkWatched()and check thatlistSessionsis not called. It currently is called, and the timer stays set.Suggested fix
if (this.ended) return;at the top ofwatchSessionand ofapplyDirective. Also check inonToolCallafter theawait, sosettleTooland the rest don't act on an ended session.checkWatchedOnce, onended, clear the interval and setwatchTimertonullinstead of returning bare. Or startcheckWatchedwithif (this.ended) { stop timer; return; }.end(), setthis.watchTimer = nullafter 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.