diff --git a/backend/api/server.py b/backend/api/server.py index 7f40d26..96747e8 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -927,6 +927,28 @@ class AgentState: self._run_locks[key] = lock return lock + def abandon_session_runtime( + self, + account_id: str | None = None, + session_id: str | None = None, + ) -> bool: + """Stop routing future requests through the currently held runtime. + + A user cancel can happen while the worker thread is blocked inside a + model HTTP request or a remote runtime call. Python cannot safely + release a Lock owned by another thread, so the correct cancellation + behavior is to abandon that lock object for future turns. + + The agent instance is abandoned together with the lock. The old worker + may still mutate its LocalCodingAgent.tool_context during unwind; a new + user turn must not share that object. + """ + key = self._session_key(account_id, session_id) + with self._lock: + removed_lock = self._run_locks.pop(key, None) is not None + removed_agent = self._agents.pop(key, None) is not None + return removed_lock or removed_agent + def update( self, *, @@ -2322,6 +2344,7 @@ def create_app(state: AgentState) -> FastAPI: if payload.run_id and state.run_manager.cancel_run(payload.run_id): cancelled_ids.add(payload.run_id) cancelled_ids.update(state.run_manager.cancel_session(account_key, safe_id)) + abandoned_runtime = state.abandon_session_runtime(account_key, safe_id) cancelled_bg_task_ids = await _bash_bg_manager.cancel_session( state, account_key, @@ -2334,7 +2357,7 @@ def create_app(state: AgentState) -> FastAPI: stage='用户已取消', ) cancelled_ids.update(stored_cancelled) - cancelled = bool(cancelled_ids or cancelled_bg_task_ids) + cancelled = bool(cancelled_ids or cancelled_bg_task_ids or abandoned_runtime) if cancelled: _mark_session_interrupted( state.account_paths(payload.account_id)['sessions'], @@ -2346,6 +2369,7 @@ def create_app(state: AgentState) -> FastAPI: 'cancelled': cancelled, 'cancelled_run_ids': sorted(cancelled_ids), 'cancelled_bg_task_ids': sorted(cancelled_bg_task_ids), + 'abandoned_runtime': abandoned_runtime, } def _run_chat_payload( diff --git a/frontend/app/app/assistant.tsx b/frontend/app/app/assistant.tsx index 2d11245..182190d 100644 --- a/frontend/app/app/assistant.tsx +++ b/frontend/app/app/assistant.tsx @@ -255,12 +255,15 @@ function AssistantWorkspace() { setActiveSessionId(next); }; const handleChanged = (event: Event) => { - const detail = (event as CustomEvent<{ sessionId?: string | null }>) - .detail; - const next = - detail?.sessionId ?? readSessionIdFromUrl() ?? readActiveSessionId(); + const customEvent = event as CustomEvent<{ sessionId?: string | null }>; + const hasExplicitSession = + customEvent.detail && + Object.prototype.hasOwnProperty.call(customEvent.detail, "sessionId"); + const next = hasExplicitSession + ? (customEvent.detail.sessionId ?? null) + : (readSessionIdFromUrl() ?? readActiveSessionId()); console.log("[active-session] event-changed", { - detailSessionId: detail?.sessionId, + detailSessionId: customEvent.detail?.sessionId, next, }); setActiveSessionId(next); diff --git a/frontend/app/components/assistant-ui/thread-list.tsx b/frontend/app/components/assistant-ui/thread-list.tsx index b3efc14..73e63ea 100644 --- a/frontend/app/components/assistant-ui/thread-list.tsx +++ b/frontend/app/components/assistant-ui/thread-list.tsx @@ -131,8 +131,8 @@ const ThreadListNew: FC = () => { variant="outline" className="aui-thread-list-new h-9 w-full justify-start gap-2 rounded-lg px-3 text-sm hover:bg-muted data-active:bg-muted" onClick={() => { - clearActiveSessionId(); pushHomeUrl(); + clearActiveSessionId(); clearSession(); window.dispatchEvent(new Event("claw-active-session-cleared")); }} diff --git a/frontend/app/components/assistant-ui/threadlist-sidebar.tsx b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx index 37016ce..91998b0 100644 --- a/frontend/app/components/assistant-ui/threadlist-sidebar.tsx +++ b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx @@ -203,8 +203,8 @@ function CollapsedSidebarRail({ { - clearActiveSessionId(); pushHomeUrl(); + clearActiveSessionId(); clearSession(); window.dispatchEvent(new Event("claw-active-session-cleared")); }} diff --git a/tests/test_gui_server.py b/tests/test_gui_server.py index 11cc38c..91ede93 100644 --- a/tests/test_gui_server.py +++ b/tests/test_gui_server.py @@ -349,6 +349,29 @@ class GuiServerTests(unittest.TestCase): self.assertIsNotNone(row) self.assertEqual(row['status'], 'cancelled') + def test_cancel_run_abandons_held_session_lock(self) -> None: + with tempfile.TemporaryDirectory() as d: + client, state = _build_client(Path(d)) + record = state.run_manager.start('alice', 'thread-1') + old_lock = state.run_lock_for('alice', 'thread-1') + self.assertTrue(old_lock.acquire(blocking=False)) + old_agent = state.agent_for('alice', 'thread-1') + try: + response = client.post( + '/api/runs/cancel', + json={'account_id': 'alice', 'session_id': 'thread-1'}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()['cancelled']) + self.assertTrue(record.cancel_event.is_set()) + new_lock = state.run_lock_for('alice', 'thread-1') + self.assertIsNot(new_lock, old_lock) + self.assertTrue(new_lock.acquire(blocking=False)) + new_lock.release() + self.assertIsNot(state.agent_for('alice', 'thread-1'), old_agent) + finally: + old_lock.release() + def test_latest_run_prefers_running_over_queued_for_session(self) -> None: with tempfile.TemporaryDirectory() as d: client, state = _build_client(Path(d))