Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,41 @@ def _get_runtime(self, session: AgentSession) -> _RuntimeState:
self._runtime[session_id] = _RuntimeState()
return self._runtime[session_id]

async def release_session(self, session_id: str, *, cancel_running: bool = True) -> None:
"""Release all runtime state for a session to prevent runtime leaks.

Args:
session_id: The session ID to release.
cancel_running: If True, cancel pending asyncio.Tasks safely.
"""


runtime = self._runtime.get(session_id)
if runtime is None:
return

tasks = list(runtime.in_flight_tasks.values())

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.

Could teardown race with an active parent run? While this method awaits the snapshotted tasks, an existing tool closure can add work to the same runtime; that task is never cancelled or awaited, and clearing its tracking reference leaves it executing arbitrary agent side effects after the session has been released. Concurrent releases can also let a stale call pop a replacement runtime created for the same session, so could we mark the old runtime closed and remove it only if the mapping still points to that instance?

pending = [t for t in tasks if not t.done()]

if pending and not cancel_running:
raise RuntimeError(
f"Cannot release session {session_id}: {len(pending)} tasks still running."
)

if cancel_running and pending:

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.

What bounds teardown when an agent suppresses CancelledError? Cancellation is cooperative, and both awaits are unbounded, so one cancellation-resistant background task can prevent release_session() from ever returning and permanently retain the session runtime. Could the API expose a bounded shutdown policy so a buggy agent cannot wedge host eviction or shutdown indefinitely?

for task in pending:
task.cancel()

await asyncio.wait(pending, return_when=asyncio.ALL_COMPLETED)

if tasks:
await asyncio.gather(*tasks, return_exceptions=True)

self._runtime.pop(session_id, None)

runtime.in_flight_tasks.clear()
runtime.background_sessions.clear()

async def before_run(
self,
*,
Expand Down
86 changes: 86 additions & 0 deletions python/packages/core/tests/core/test_harness_background_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,3 +542,89 @@ def test_task_status_enum_values() -> None:
assert BackgroundTaskStatus.COMPLETED == "completed"
assert BackgroundTaskStatus.FAILED == "failed"
assert BackgroundTaskStatus.LOST == "lost"


async def test_release_session_cancels_and_clears() -> None:
"""Should cancel pending tasks and clear runtime state."""
provider = _make_provider(_FakeAgent("Slow", delay=10.0))
session = _make_session()
tools = await _get_tools(provider, session)

await _invoke_tool(
tools["background_agents_start_task"],
agent_name="Slow",
input="task",
description="long running",
)

runtime = provider._runtime.get(session.session_id)
assert runtime is not None
assert len(runtime.in_flight_tasks) == 1

task = next(iter(runtime.in_flight_tasks.values()))

await provider.release_session(session.session_id, cancel_running=True)

assert task.done()
assert task.cancelled()
assert runtime.in_flight_tasks == {}
assert runtime.background_sessions == {}

assert session.session_id not in provider._runtime


async def test_release_session_raises_if_cancel_running_false() -> None:
"""Should raise RuntimeError if cancel_running=False and tasks are pending."""
provider = _make_provider(_FakeAgent("Slow", delay=10.0))
session = _make_session()
tools = await _get_tools(provider, session)

await _invoke_tool(
tools["background_agents_start_task"],
agent_name="Slow",
input="task",
description="long running",
)

with pytest.raises(RuntimeError, match="tasks still running"):
await provider.release_session(session.session_id, cancel_running=False)

assert session.session_id in provider._runtime
await provider.release_session(session.session_id, cancel_running=True)


async def test_release_session_idempotent() -> None:
"""Should not raise when releasing an unknown or already released session."""
provider = _make_provider(_FakeAgent("Worker"))
session = _make_session()

await provider.release_session("non_existent_session")

await provider.release_session(session.session_id)
await provider.release_session(session.session_id)


async def test_release_session_isolation() -> None:
"""Releasing one session should not affect another."""
provider = _make_provider(_FakeAgent("Worker", delay=10.0))
session_a = AgentSession(session_id="session_a")
session_b = AgentSession(session_id="session_b")

tools_a = await _get_tools(provider, session_a)
tools_b = await _get_tools(provider, session_b)

await _invoke_tool(
tools_a["background_agents_start_task"],
agent_name="Worker", input="A", description="A",
)
await _invoke_tool(
tools_b["background_agents_start_task"],
agent_name="Worker", input="B", description="B",
)

await provider.release_session("session_a", cancel_running=True)

assert "session_a" not in provider._runtime
assert "session_b" in provider._runtime

await provider.release_session("session_b", cancel_running=True)
Loading