Repository navigation
Conversation
AgentSession.start() publishes the AgentActivity (and sets agent._activity) before the realtime provider connects. If bring-up then fails, _started is still False, so aclose() returned early: the realtime session stayed open, no close event was emitted, and the agent kept its activity. Retrying start() with the same agent then raised "cannot start agent: an activity is already running". Skip close only when the session never started or was already closed: a session that has an activity but no _started flag is a failed start and still owns resources to release. Add a regression test covering provider connect failure, the close event, realtime session teardown, double aclose, and reusing the agent. Co-Authored-By: Claude Code <noreply@anthropic.com>
A start() that raises during room bring-up has already created the session span and room io, but the activity is only published later. The previous guard keyed only on the activity, so aclose() still returned early and left the half-started RoomIO and session span behind. Treat the session span as the marker of a start attempt: it is created at the top of start() and cleared when the session closes. A session with no span, no activity, and no _started flag never started or is already closed. Add a regression test with a room whose on() raises: start() leaves _activity None and _room_io set, and aclose() now emits the close event, clears room io and the span, and stays safe when called twice. Co-Authored-By: Claude Code <noreply@anthropic.com>
| # host, or activity bring-up) still owns resources to release. the span is set | ||
| # at the top of start() and cleared below, so only a session that never | ||
| # started, or is already closed, is skipped | ||
| if not self._started and self._activity is None and self._session_span is None: |
There was a problem hiding this comment.
π΄ Failed startup leaves closed room streams attached
When activity setup fails after room I/O starts, aclose() leaves its streams attached to the session. A retry treats those closed streams as configured inputs and outputs, disabling fresh room media.
Learn more
RoomIO.start attaches its streams to AgentSession before _update_activity_task creates AgentActivity. AgentActivity construction can reject a realtime configuration with server-side turn detection and interruptions disabled AgentActivity. On that failure, the newly enabled partial close has no activity, so _teardown_activity never clears session inputs and outputs. It then closes RoomIO, leaving its streams attached. A later start sees the non-None streams and disables matching streams on the new RoomIO.
Example: Start with room audio enabled and a realtime model configured to reject disabled interruptions. After startup raises, call aclose(), then retry start() with a valid agent. The retry finds the old audio input and output and disables its own room audio.
Recommended fix: Detach session I/O owned by RoomIO during partial teardown even when no activity exists. Preserve caller-provided I/O and ensure the next start can attach fresh room streams; cover a failure after RoomIO.start but before activity publication.
Was this helpful? React with π or π to provide feedback.
| # host, or activity bring-up) still owns resources to release. the span is set | ||
| # at the top of start() and cleared below, so only a session that never | ||
| # started, or is already closed, is skipped | ||
| if not self._started and self._activity is None and self._session_span is None: |
There was a problem hiding this comment.
π‘ Failed startup retains session host listeners
If activity setup fails before SessionHost.start(), aclose() cannot remove the host's registered listeners. SessionHost.aclose() skips unstarted hosts, so retries accumulate listeners and queue events to abandoned hosts.
Learn more
With a room and session hosting enabled, start calls SessionHost.register_session before it launches activity setup. The activity can fail during construction, before SessionHost.start is reached. The new partial close invokes SessionHost.aclose, but that method returns for an unstarted host before removing the callbacks that register_session already installed. The session keeps the abandoned host alive through those callbacks, and each later event can queue into a channel with no writer.
Example: Start a hosted room session with an invalid realtime turn-detection configuration, call aclose() after the error, then retry with a valid agent. The original host still receives every event, even though only the new host starts a writer.
Recommended fix: Make SessionHost.aclose unregister listeners and release the transport/channel even when start was never called. Add a regression test that fails after register_session and checks listener cleanup after aclose().
Was this helpful? React with π or π to provide feedback.
Problem
AgentSession.start()can fail after it has already created resources, andaclose()then does nothing:_startedis only set at the very end ofstart()(agent_session.py:1189), but before that the method starts the session span, createsroom_io/session_host/RecorderIO, publishes theAgentActivity(settingagent._activity), and connects the realtime provider. Two failure windows are left uncleaned:_startedβ e.g. the realtime provider fails to connect, MCP toolset setup fails, orsession_host.start()fails.aclose()returns at the guard; the realtime session stays open, nocloseevent is emitted,room_io/session_hostare never closed, andagent._activitystays set so reusing the agent raisescannot start agent: an activity is already running.RoomIO.start()._activityis stillNone, so the same guard skips cleanup androom_io(plus its room listeners and the session span) leaks.Observed on
049aea8with a provider that fails to connect:Fix
Only treat a session as nothing-to-close when it has no activity and no session span:
_session_spanis created at the top ofstart()(before room io and the activity) and cleared in_aclose_locked'sfinally, so it is the sentinel that covers both failure windows._activity is Noneand_session_span is None, so they still no-op; a successfulstart()->aclose()path is unchanged.Noneactivity:_teardown_activityskips the activity part,RoomIO.aclosecloses whatever it registered, andcloseis emitted once.Tests
Two regression tests in
tests/test_agent_session.py:test_failed_start_tears_down_partial_sessionβ provider connect failure after the activity is published; asserts the close event, activity/realtime teardown, doubleaclosesafety, and agent reuse in a fresh session.test_failed_start_in_room_bring_up_is_still_closedβ roomon()raises before the activity exists; assertsclose,_room_io is None, and the session span cleared.Receipts on
049aea8: both tests fail atassert closes == [CloseReason.USER_INITIATED](assert [] == [...]); the new pre-activity test also fails on the first commit alone. With the change:tests/test_agent_session.py112 passed; 180 adjacent voice/realtime tests pass unchanged;ruff checkandruff format --checkclean.Note
Compose checks: auto-merges cleanly with #8147, #7956 and #7930; #7931 only conflicts in
voice/agent.py(unrelated hunk) and #7940 only in this test file's import block β both trivial at rebase. Happy to rebase on request.π€ Generated with Claude Code