Skip to content

fix(voice): tear down a partially started AgentSession - #8266

Open
BlueX888 wants to merge 2 commits into
livekit:mainfrom
BlueX888:fix/agent-session-failed-start-cleanup
Open

BlueX888 wants to merge 2 commits into
livekit:mainfrom
BlueX888:fix/agent-session-failed-start-cleanup

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

Problem

AgentSession.start() can fail after it has already created resources, and aclose() then does nothing:

async with self._lock:
    if not self._started:
        return

_started is only set at the very end of start() (agent_session.py:1189), but before that the method starts the session span, creates room_io / session_host / RecorderIO, publishes the AgentActivity (setting agent._activity), and connects the realtime provider. Two failure windows are left uncleaned:

  1. After the activity is published, before _started β€” e.g. the realtime provider fails to connect, MCP toolset setup fails, or session_host.start() fails. aclose() returns at the guard; the realtime session stays open, no close event is emitted, room_io/session_host are never closed, and agent._activity stays set so reusing the agent raises cannot start agent: an activity is already running.
  2. Before the activity is published β€” e.g. room bring-up fails in RoomIO.start(). _activity is still None, so the same guard skips cleanup and room_io (plus its room listeners and the session span) leaks.

Observed on 049aea8 with a provider that fails to connect:

after start() failure: session._started=False, activity set, rt session created=1 closed=False
after aclose():        close events=[], rt session still open, agent._activity still set

Fix

Only treat a session as nothing-to-close when it has no activity and no session span:

if not self._started and self._activity is None and self._session_span is None:
    return
  • _session_span is created at the top of start() (before room io and the activity) and cleared in _aclose_locked's finally, so it is the sentinel that covers both failure windows.
  • A never-started session and a normally closed session both have _activity is None and _session_span is None, so they still no-op; a successful start() -> aclose() path is unchanged.
  • Teardown already tolerates a None activity: _teardown_activity skips the activity part, RoomIO.aclose closes whatever it registered, and close is 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, double aclose safety, and agent reuse in a fresh session.
  • test_failed_start_in_room_bring_up_is_still_closed β€” room on() raises before the activity exists; asserts close, _room_io is None, and the session span cleared.

Receipts on 049aea8: both tests fail at assert closes == [CloseReason.USER_INITIATED] (assert [] == [...]); the new pre-activity test also fails on the first commit alone. With the change: tests/test_agent_session.py 112 passed; 180 adjacent voice/realtime tests pass unchanged; ruff check and ruff format --check clean.

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

BlueX888 and others added 2 commits October 30, 2026 00:30
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>
@BlueX888
BlueX888 requested a review from a team as a code owner October 29, 2026 17:06

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

# 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”΄ 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.

Devin Review


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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 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().

Devin Review


Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant