Fix session cancel and new task routing
This commit is contained in:
+25
-1
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}}
|
||||
|
||||
@@ -203,8 +203,8 @@ function CollapsedSidebarRail({
|
||||
<CollapsedIconButton
|
||||
label="新任务"
|
||||
onClick={() => {
|
||||
clearActiveSessionId();
|
||||
pushHomeUrl();
|
||||
clearActiveSessionId();
|
||||
clearSession();
|
||||
window.dispatchEvent(new Event("claw-active-session-cleared"));
|
||||
}}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user