From e1c22dfabab7befb8e2574d6c1e95e75b93e28b2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 1 Apr 2026 16:22:31 -0700 Subject: [PATCH] [eric] fix browser agent stop: cancel mid-flight API calls, stop children before parent, guard frontend state --- backend/apps/agents/agent_manager.py | 37 +++++++++++++----------- backend/apps/agents/browser_agent.py | 26 ++++++++++++++--- electron/package-lock.json | 4 +-- frontend/src/shared/state/agentsSlice.ts | 9 ++++++ 4 files changed, 53 insertions(+), 23 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 85a85580..c4f7d9a6 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1353,23 +1353,25 @@ class AgentManager: async def stop_agent(self, session_id: str): """Stop a running agent and all its browser-agent children.""" - task = self.tasks.get(session_id) - if task and not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - + # 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: + await self.stop_agent(child.id) + session = self.sessions.get(session_id) if session: + # Set cancel event BEFORE cancelling the task so in-flight + # browser agent loops see it immediately + if hasattr(session, '_cancel_event'): + session._cancel_event.set() + for req in list(session.pending_approvals): ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Agent stopped"}) session.pending_approvals = [] - if hasattr(session, '_cancel_event'): - session._cancel_event.set() - session.status = "stopped" if not session.closed_at: session.closed_at = datetime.now() @@ -1379,12 +1381,13 @@ class AgentManager: "session": session.model_dump(mode="json"), }) - children = [ - s for s in self.sessions.values() - if s.parent_session_id == session_id and s.mode == "browser-agent" - ] - for child in children: - await self.stop_agent(child.id) + task = self.tasks.get(session_id) + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass def handle_approval(self, request_id: str, decision: dict): """Resolve a pending HITL approval.""" diff --git a/backend/apps/agents/browser_agent.py b/backend/apps/agents/browser_agent.py index 7b9ee097..62005fcd 100644 --- a/backend/apps/agents/browser_agent.py +++ b/backend/apps/agents/browser_agent.py @@ -337,18 +337,33 @@ async def run_browser_agent( "message": user_msg.model_dump(mode="json"), }) + async def _cancellable(coro): + """Race any awaitable against the cancel event. Returns None if cancelled.""" + task = asyncio.ensure_future(coro) + cancel_wait = asyncio.ensure_future(cancel_event.wait()) + done, pending = await asyncio.wait( + [task, cancel_wait], return_when=asyncio.FIRST_COMPLETED, + ) + for p in pending: + p.cancel() + if cancel_event.is_set(): + return None + return task.result() + try: for turn in range(MAX_TURNS): if cancel_event.is_set(): break - response = await client.messages.create( + response = await _cancellable(client.messages.create( model=api_model, max_tokens=4096, system=SYSTEM_PROMPT, tools=BROWSER_TOOLS_SCHEMA, messages=messages, - ) + )) + if response is None: + break assistant_content = [] text_parts = [] @@ -444,9 +459,12 @@ async def run_browser_agent( continue start = time.time() - result = await execute_browser_tool( + result = await _cancellable(execute_browser_tool( tu.name, tu.input, browser_id, tab_id, - ) + )) + if result is None: + cancelled = True + break elapsed_ms = int((time.time() - start) * 1000) action_log.append({ diff --git a/electron/package-lock.json b/electron/package-lock.json index 9309677e..7de8f161 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.0.18", + "version": "1.0.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.0.18", + "version": "1.0.19", "hasInstallScript": true, "dependencies": { "electron-updater": "^6.3.0", diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 52ad70fe..84f0acab 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -536,6 +536,11 @@ const agentsSlice = createSlice({ } } const existing = state.sessions[action.payload.id]; + // Don't let a stale "running" message overwrite a terminal status + const terminal = ['stopped', 'error'] as const; + if (existing && terminal.includes(existing.status as any) && action.payload.status === 'running') { + return; + } // Preserve local pending_approvals if the server payload has none but // the frontend has some (avoids race where backend clears approvals // before the frontend processes the removal). @@ -559,6 +564,10 @@ const agentsSlice = createSlice({ ) { const session = state.sessions[action.payload.sessionId]; if (session) { + const terminal = ['stopped', 'error'] as const; + if (terminal.includes(session.status as any) && action.payload.status === 'running') { + return; + } session.status = action.payload.status; } if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.sessionId)) {