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
|
self._run_locks[key] = lock
|
||||||
return 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(
|
def update(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -2322,6 +2344,7 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
if payload.run_id and state.run_manager.cancel_run(payload.run_id):
|
if payload.run_id and state.run_manager.cancel_run(payload.run_id):
|
||||||
cancelled_ids.add(payload.run_id)
|
cancelled_ids.add(payload.run_id)
|
||||||
cancelled_ids.update(state.run_manager.cancel_session(account_key, safe_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(
|
cancelled_bg_task_ids = await _bash_bg_manager.cancel_session(
|
||||||
state,
|
state,
|
||||||
account_key,
|
account_key,
|
||||||
@@ -2334,7 +2357,7 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
stage='用户已取消',
|
stage='用户已取消',
|
||||||
)
|
)
|
||||||
cancelled_ids.update(stored_cancelled)
|
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:
|
if cancelled:
|
||||||
_mark_session_interrupted(
|
_mark_session_interrupted(
|
||||||
state.account_paths(payload.account_id)['sessions'],
|
state.account_paths(payload.account_id)['sessions'],
|
||||||
@@ -2346,6 +2369,7 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
'cancelled': cancelled,
|
'cancelled': cancelled,
|
||||||
'cancelled_run_ids': sorted(cancelled_ids),
|
'cancelled_run_ids': sorted(cancelled_ids),
|
||||||
'cancelled_bg_task_ids': sorted(cancelled_bg_task_ids),
|
'cancelled_bg_task_ids': sorted(cancelled_bg_task_ids),
|
||||||
|
'abandoned_runtime': abandoned_runtime,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _run_chat_payload(
|
def _run_chat_payload(
|
||||||
|
|||||||
@@ -255,12 +255,15 @@ function AssistantWorkspace() {
|
|||||||
setActiveSessionId(next);
|
setActiveSessionId(next);
|
||||||
};
|
};
|
||||||
const handleChanged = (event: Event) => {
|
const handleChanged = (event: Event) => {
|
||||||
const detail = (event as CustomEvent<{ sessionId?: string | null }>)
|
const customEvent = event as CustomEvent<{ sessionId?: string | null }>;
|
||||||
.detail;
|
const hasExplicitSession =
|
||||||
const next =
|
customEvent.detail &&
|
||||||
detail?.sessionId ?? readSessionIdFromUrl() ?? readActiveSessionId();
|
Object.prototype.hasOwnProperty.call(customEvent.detail, "sessionId");
|
||||||
|
const next = hasExplicitSession
|
||||||
|
? (customEvent.detail.sessionId ?? null)
|
||||||
|
: (readSessionIdFromUrl() ?? readActiveSessionId());
|
||||||
console.log("[active-session] event-changed", {
|
console.log("[active-session] event-changed", {
|
||||||
detailSessionId: detail?.sessionId,
|
detailSessionId: customEvent.detail?.sessionId,
|
||||||
next,
|
next,
|
||||||
});
|
});
|
||||||
setActiveSessionId(next);
|
setActiveSessionId(next);
|
||||||
|
|||||||
@@ -131,8 +131,8 @@ const ThreadListNew: FC = () => {
|
|||||||
variant="outline"
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
clearActiveSessionId();
|
|
||||||
pushHomeUrl();
|
pushHomeUrl();
|
||||||
|
clearActiveSessionId();
|
||||||
clearSession();
|
clearSession();
|
||||||
window.dispatchEvent(new Event("claw-active-session-cleared"));
|
window.dispatchEvent(new Event("claw-active-session-cleared"));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -203,8 +203,8 @@ function CollapsedSidebarRail({
|
|||||||
<CollapsedIconButton
|
<CollapsedIconButton
|
||||||
label="新任务"
|
label="新任务"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clearActiveSessionId();
|
|
||||||
pushHomeUrl();
|
pushHomeUrl();
|
||||||
|
clearActiveSessionId();
|
||||||
clearSession();
|
clearSession();
|
||||||
window.dispatchEvent(new Event("claw-active-session-cleared"));
|
window.dispatchEvent(new Event("claw-active-session-cleared"));
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -349,6 +349,29 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(row)
|
self.assertIsNotNone(row)
|
||||||
self.assertEqual(row['status'], 'cancelled')
|
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:
|
def test_latest_run_prefers_running_over_queued_for_session(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
client, state = _build_client(Path(d))
|
client, state = _build_client(Path(d))
|
||||||
|
|||||||
Reference in New Issue
Block a user