From dc9a6391e29492eace094363974ac1370faaa6d4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 7 Sep 2026 18:32:58 -0700 Subject: [PATCH] [eric] sessions: stopping, closing or deleting a chat stops every session it spawned, not only its browser agents; a SpawnAgent tree outlived a killed parent (Haik, exp.9) Co-Authored-By: Claude Fable 5.1 --- backend/apps/agents/manager/SessionControl.py | 10 +-- .../manager/session/SessionLifecycle.py | 18 ++---- .../agents/manager/session/descendants.py | 13 ++++ .../test_stop_cascades_to_every_descendant.py | 62 +++++++++++++++++++ 4 files changed, 84 insertions(+), 19 deletions(-) create mode 100644 backend/apps/agents/manager/session/descendants.py create mode 100644 backend/tests/test_stop_cascades_to_every_descendant.py diff --git a/backend/apps/agents/manager/SessionControl.py b/backend/apps/agents/manager/SessionControl.py index b74ee0f5..6b1ec9ce 100644 --- a/backend/apps/agents/manager/SessionControl.py +++ b/backend/apps/agents/manager/SessionControl.py @@ -21,13 +21,9 @@ from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtoco class SessionControl(AgentManagerProtocol): @typechecked async def stop_agent(self, session_id: str): - """Stop a running agent and all its browser-agent children.""" - # Stop children first so browser agents get cancelled before parent - children = [ - s for s in self.sessions.values() - if s.parent_session_id == session_id and s.mode == "browser-agent" - ] - for child in children: + """Stop a running agent and every session it spawned, leaves first.""" + from backend.apps.agents.manager.session.descendants import children_of + for child in children_of(self.sessions, session_id): await self.stop_agent(child.id) session = self.sessions.get(session_id) diff --git a/backend/apps/agents/manager/session/SessionLifecycle.py b/backend/apps/agents/manager/session/SessionLifecycle.py index bfaec573..b7584ab9 100644 --- a/backend/apps/agents/manager/session/SessionLifecycle.py +++ b/backend/apps/agents/manager/session/SessionLifecycle.py @@ -39,12 +39,9 @@ class SessionLifecycle(AgentManagerProtocol): @typechecked async def close_session(self, session_id: str) -> None: """Close a session: pause the agent if running, persist to JSON file, - and remove from in-memory state. Also stops browser-agent children.""" - children = [ - s for s in self.sessions.values() - if s.parent_session_id == session_id and s.mode == "browser-agent" - ] - for child in children: + and remove from in-memory state. Also stops every session it spawned.""" + from backend.apps.agents.manager.session.descendants import children_of + for child in children_of(self.sessions, session_id): await self.stop_agent(child.id) task = self.tasks.get(session_id) @@ -112,14 +109,11 @@ class SessionLifecycle(AgentManagerProtocol): @typechecked async def delete_session(self, session_id: str) -> None: """Permanently delete a session: remove from memory and JSON file. - Also stops browser-agent children first.""" + Also stops every session it spawned first.""" from backend.apps.agents.core.flight_recorder import drop_session drop_session(session_id) - children = [ - s for s in self.sessions.values() - if s.parent_session_id == session_id and s.mode == "browser-agent" - ] - for child in children: + from backend.apps.agents.manager.session.descendants import children_of + for child in children_of(self.sessions, session_id): await self.stop_agent(child.id) task = self.tasks.get(session_id) diff --git a/backend/apps/agents/manager/session/descendants.py b/backend/apps/agents/manager/session/descendants.py new file mode 100644 index 00000000..9bb8ea7a --- /dev/null +++ b/backend/apps/agents/manager/session/descendants.py @@ -0,0 +1,13 @@ +"""Every session born from a parent, whatever kind it is. Stop, close and delete used to walk only +browser-agent children, so a SpawnAgent tree outlived the parent that was killed (Haik, exp.9).""" + +from typing import Dict, List + +from typeguard import typechecked + +from backend.apps.agents.core.models import AgentSession + + +@typechecked +def children_of(sessions: Dict[str, AgentSession], parent_id: str) -> List[AgentSession]: + return [s for s in sessions.values() if s.parent_session_id == parent_id] diff --git a/backend/tests/test_stop_cascades_to_every_descendant.py b/backend/tests/test_stop_cascades_to_every_descendant.py new file mode 100644 index 00000000..9987d1f4 --- /dev/null +++ b/backend/tests/test_stop_cascades_to_every_descendant.py @@ -0,0 +1,62 @@ +"""Haik, exp.9: killing a parent left its SpawnAgent children running. Stop, close and delete walked +only browser-agent children, so a sub-agent tree (and anything under it) outlived the parent.""" + +import asyncio +import inspect + +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentSession +from backend.apps.agents.manager import SessionControl +from backend.apps.agents.manager.session import SessionLifecycle, descendants + + +def p_tree() -> tuple[AgentSession, AgentSession, AgentSession, AgentSession]: + parent = AgentSession(name="parent", model="opus-4-8", status="running") + child = AgentSession(name="child", model="opus-4-8", status="running", mode="sub-agent", parent_session_id=parent.id) + grandchild = AgentSession(name="grandchild", model="opus-4-8", status="running", mode="browser-agent", parent_session_id=child.id) + stranger = AgentSession(name="stranger", model="opus-4-8", status="running", mode="sub-agent") + for s in (parent, child, grandchild, stranger): + agent_manager.sessions[s.id] = s + return parent, child, grandchild, stranger + + +def p_park(session_id: str) -> asyncio.Task: + async def forever() -> None: + await asyncio.sleep(3600) + task = asyncio.get_running_loop().create_task(forever()) + agent_manager.tasks[session_id] = task + return task + + +def test_stopping_the_parent_stops_the_whole_tree_and_nobody_else(): + parent, child, grandchild, stranger = p_tree() + + async def run() -> None: + tasks = [p_park(s.id) for s in (parent, child, grandchild, stranger)] + await agent_manager.stop_agent(parent.id) + await asyncio.sleep(0) + # Checked inside the loop: closing it cancels every task that is left, which would fake the stranger's cancel. + assert [s.status for s in (parent, child, grandchild)] == ["stopped", "stopped", "stopped"] + assert stranger.status == "running", "an unrelated running chat is not the tree" + assert all(t.cancelled() or t.done() for t in tasks[:3]) + assert not tasks[3].cancelled() and not tasks[3].done() + for s in (parent, child, grandchild): + assert s.id not in agent_manager.tasks + tasks[3].cancel() + + asyncio.run(run()) + + +def test_children_of_walks_every_kind_not_just_browser_agents(): + parent, child, grandchild, stranger = p_tree() + assert [s.id for s in descendants.children_of(agent_manager.sessions, parent.id)] == [child.id] + assert [s.id for s in descendants.children_of(agent_manager.sessions, child.id)] == [grandchild.id] + assert descendants.children_of(agent_manager.sessions, stranger.id) == [] + + +def test_close_and_delete_take_the_same_walk(): + """Three doors, one walk: a mode filter in any of them is the bug coming back.""" + for fn in (SessionControl.SessionControl.stop_agent, SessionLifecycle.SessionLifecycle.close_session, SessionLifecycle.SessionLifecycle.delete_session): + src = inspect.getsource(fn) + assert "children_of(self.sessions, session_id)" in src, fn.__name__ + assert 'mode == "browser-agent"' not in src, fn.__name__