fix(agent): close cancellation lifecycle races (#6440)

This commit is contained in:
InfinityPacer
2026-08-24 21:06:10 +08:00
committed by GitHub
parent f65e16ff4e
commit b2a817f6d1
4 changed files with 294 additions and 19 deletions
+41 -17
View File
@@ -3034,6 +3034,10 @@ class AgentManager:
# 等待消息,超时后自动退出worker # 等待消息,超时后自动退出worker
task = await asyncio.wait_for(queue.get(), timeout=60.0) task = await asyncio.wait_for(queue.get(), timeout=60.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
# 超时回调与入队可能在同一轮事件循环就绪;已有消息时继续消费,
# 避免旧 worker 退出后留下没有消费者的非空队列。
if not queue.empty():
continue
# 队列空闲超时,退出worker # 队列空闲超时,退出worker
logger.debug(f"会话 {session_id} 的消息队列空闲,worker退出") logger.debug(f"会话 {session_id} 的消息队列空闲,worker退出")
break break
@@ -3306,22 +3310,17 @@ class AgentManager:
self._session_cleanup_pending.add(session_id) self._session_cleanup_pending.add(session_id)
self._session_cancel_requested.add(session_id) self._session_cancel_requested.add(session_id)
worker.cancel() worker.cancel()
stopped_cleanly = await self._wait_for_worker_shutdown( try:
session_id, stopped_cleanly = await self._wait_for_worker_shutdown(
worker, session_id,
reason="clear_session", worker,
) reason="clear_session",
if not stopped_cleanly:
queue = self._session_queues.pop(session_id, None)
if queue:
self._discard_queued_messages(queue)
worker.add_done_callback(
lambda done: self._schedule_deferred_session_cleanup(
session_id,
user_id,
worker,
)
) )
except asyncio.CancelledError:
self._defer_session_cleanup(session_id, user_id, worker)
raise
if not stopped_cleanly:
self._defer_session_cleanup(session_id, user_id, worker)
return return
if self._session_workers.get(session_id) is worker: if self._session_workers.get(session_id) is worker:
self._session_workers.pop(session_id, None) # noqa self._session_workers.pop(session_id, None) # noqa
@@ -3346,14 +3345,36 @@ class AgentManager:
memory_manager.clear_memory(session_id, user_id) memory_manager.clear_memory(session_id, user_id)
logger.info(f"会话 {session_id} 的记忆已清空") logger.info(f"会话 {session_id} 的记忆已清空")
def _defer_session_cleanup(
self,
session_id: str,
user_id: str,
worker: asyncio.Task,
) -> None:
"""把中断或超时的清理转交给 worker 终态回调。"""
queue = self._session_queues.pop(session_id, None)
if queue:
self._discard_queued_messages(queue)
self._session_shutdown_pending[session_id] = worker
worker.add_done_callback(
lambda done: self._schedule_deferred_session_cleanup(
session_id,
user_id,
done,
)
)
def _schedule_deferred_session_cleanup( def _schedule_deferred_session_cleanup(
self, self,
session_id: str, session_id: str,
user_id: str, user_id: str,
worker: asyncio.Task, worker: asyncio.Task,
) -> None: ) -> None:
"""worker 超时后延迟释放会话资源,避免与仍在运行的 Agent 竞态。""" """worker 取得终态后释放会话资源,避免与仍在运行的 Agent 竞态。"""
if session_id in self._session_shutdown_pending: if self._session_shutdown_pending.get(session_id) is worker:
existing = self._session_deferred_cleanup_tasks.get(session_id)
if existing is not None and not existing.done():
return
cleanup_task = asyncio.create_task( cleanup_task = asyncio.create_task(
self._finish_deferred_session_cleanup( self._finish_deferred_session_cleanup(
session_id=session_id, session_id=session_id,
@@ -3438,6 +3459,9 @@ class AgentManager:
) )
return True return True
except asyncio.CancelledError: except asyncio.CancelledError:
current = asyncio.current_task()
if worker.cancelled() and (current is None or not current.cancelling()):
return True
raise raise
except asyncio.TimeoutError: except asyncio.TimeoutError:
self._session_shutdown_pending[session_id] = worker self._session_shutdown_pending[session_id] = worker
+6
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import threading
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol, TypeVar from typing import Protocol, TypeVar
@@ -155,6 +156,7 @@ class AgentTaskExecutionService:
"""认领一次执行;取消发生在提交后时先补偿收口再传播取消。""" """认领一次执行;取消发生在提交后时先补偿收口再传播取消。"""
run_id = uuid4().hex run_id = uuid4().hex
run_created = threading.Event()
def transaction(session: object) -> AgentTaskClaim: def transaction(session: object) -> AgentTaskClaim:
repository = self._repository(session) repository = self._repository(session)
@@ -173,6 +175,7 @@ class AgentTaskExecutionService:
else "Agent 定时任务当前不可执行" else "Agent 定时任务当前不可执行"
), ),
) )
run_created.set()
return AgentTaskClaim(run=self._snapshot(run)) return AgentTaskClaim(run=self._snapshot(run))
async def claim_to_terminal() -> AgentTaskClaim: async def claim_to_terminal() -> AgentTaskClaim:
@@ -189,6 +192,9 @@ class AgentTaskExecutionService:
try: try:
return await claim_task return await claim_task
except asyncio.CancelledError as cancellation: except asyncio.CancelledError as cancellation:
# 纯容量拒绝尚未进入事务,不存在需要等待数据库容量的补偿对象。
if not run_created.is_set():
raise cancellation
finalize_task = asyncio.create_task(self._finalize( finalize_task = asyncio.create_task(self._finalize(
run_id=run_id, run_id=run_id,
task_id=task_id, task_id=task_id,
+206
View File
@@ -697,3 +697,209 @@ async def test_background_prompt_is_owned_and_cancelled_by_manager_close(
assert manager._session_queues == {} assert manager._session_queues == {}
assert manager._session_workers == {} assert manager._session_workers == {}
assert manager.active_agents == {} assert manager.active_agents == {}
@pytest.mark.anyio
async def test_stop_current_task_handles_worker_cancelled_before_first_run() -> None:
"""worker 尚未首次运行时停止也必须完成收口。"""
manager = AgentManager()
session_id = "stop-before-worker-start"
worker = None
await manager.initialize()
try:
await manager.process_message(session_id, "1", "message")
worker = manager._session_workers[session_id]
assert worker.done() is False
assert await manager.stop_current_task(session_id) is True
assert worker.done() is True
assert session_id not in manager._session_workers
assert session_id not in manager._session_queues
finally:
if worker is not None:
if not worker.done():
worker.cancel()
try:
await worker
except BaseException:
pass
if manager._session_workers.get(session_id) is worker:
manager._session_workers.pop(session_id, None)
if manager._accepting_tasks:
await manager.close()
@pytest.mark.anyio
async def test_clear_session_cancellation_does_not_stick_cleanup_pending(
monkeypatch,
) -> None:
"""clear_session 被调用方取消后必须能重试或已转交延迟清理。"""
manager = AgentManager()
memory_manager = MemoryManager()
memory_manager.clear_memory = MagicMock()
monkeypatch.setattr(agent_module, "memory_manager", memory_manager)
session_id = "clear-caller-cancelled"
started = asyncio.Event()
cancellation_seen = asyncio.Event()
release = asyncio.Event()
execution = None
clear_request = None
class BlockingAgent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
async def process(self, _message, **_kwargs):
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancellation_seen.set()
await release.wait()
raise
async def cleanup(self):
return True
def get_session_status(self):
return {}
await manager.initialize()
try:
execution = asyncio.create_task(
manager.process_message(
session_id,
"1",
"message",
agent_factory=BlockingAgent,
wait_for_completion=True,
)
)
await asyncio.wait_for(started.wait(), timeout=1)
clear_request = asyncio.create_task(
manager.clear_session(session_id, "1")
)
await asyncio.wait_for(cancellation_seen.wait(), timeout=1)
assert clear_request.done() is False
clear_request.cancel()
with pytest.raises(asyncio.CancelledError):
await clear_request
release.set()
with pytest.raises(asyncio.CancelledError):
await execution
# 取消安全的延迟清理可能异步完成;若未发生转交,重试仍必须完成清理。
await asyncio.wait_for(
manager.clear_session(session_id, "1"),
timeout=1,
)
for _ in range(100):
if (
session_id not in manager._session_cleanup_pending
and session_id not in manager.active_agents
):
break
await asyncio.sleep(0)
assert session_id not in manager._session_cleanup_pending
assert session_id not in manager._session_shutdown_pending
assert session_id not in manager._session_deferred_cleanup_tasks
assert session_id not in manager._session_workers
assert session_id not in manager._session_queues
assert session_id not in manager.active_agents
assert memory_manager.clear_memory.call_count == 1
finally:
release.set()
if clear_request is not None and not clear_request.done():
clear_request.cancel()
try:
await clear_request
except BaseException:
pass
if execution is not None and not execution.done():
execution.cancel()
try:
await execution
except BaseException:
pass
for deferred in list(manager._session_deferred_cleanup_tasks.values()):
if not deferred.done():
deferred.cancel()
try:
await deferred
except BaseException:
pass
if manager._accepting_tasks:
await manager.close()
@pytest.mark.anyio
async def test_session_worker_restarts_after_idle_timeout_races_with_full_enqueue(
monkeypatch,
) -> None:
"""空闲退出与满队列入队交错时必须保留会话消费者。"""
manager = AgentManager()
memory_manager = MemoryManager()
monkeypatch.setattr(agent_module, "memory_manager", memory_manager)
session_id = "idle-timeout-full-queue"
processed = []
first_processed = asyncio.Event()
idle_waiting = asyncio.Event()
release_timeout = asyncio.Event()
timeout_intercepted = False
real_wait_for = asyncio.wait_for
async def process(task):
processed.append(task.message)
if task.message == "initial":
first_processed.set()
return task.message
async def controlled_wait_for(awaitable, timeout):
nonlocal timeout_intercepted
if timeout == 60.0 and processed and not timeout_intercepted:
timeout_intercepted = True
idle_waiting.set()
await release_timeout.wait()
awaitable.close()
raise asyncio.TimeoutError
return await real_wait_for(awaitable, timeout)
monkeypatch.setattr(agent_module.asyncio, "wait_for", controlled_wait_for)
await manager.initialize()
try:
manager._process_message_internal = process
await manager.process_message(session_id, "1", "initial")
await real_wait_for(first_processed.wait(), timeout=1)
await real_wait_for(idle_waiting.wait(), timeout=1)
for index in range(AGENT_SESSION_QUEUE_MAX_SIZE):
await manager.process_message(
session_id,
"1",
f"queued-{index}",
)
assert manager._session_queues[session_id].full()
release_timeout.set()
async def all_messages_processed() -> None:
while len(processed) < AGENT_SESSION_QUEUE_MAX_SIZE + 1:
await asyncio.sleep(0)
await real_wait_for(all_messages_processed(), timeout=1)
assert processed == [
"initial",
*[f"queued-{index}" for index in range(AGENT_SESSION_QUEUE_MAX_SIZE)],
]
worker = manager._session_workers.get(session_id)
assert worker is not None
assert worker.done() is False
finally:
release_timeout.set()
if manager._accepting_tasks:
await manager.clear_session(session_id, "1")
await manager.close()
+41 -2
View File
@@ -8,11 +8,15 @@ from uuid import uuid4
import pytest import pytest
from app.application.agenttask import AgentTaskExecutionService from app.application.agenttask import AgentTaskExecutionService
from app.application.database import AsyncDatabaseExecutor
from app.db.adapters.transaction import TransactionalWriteRunner from app.db.adapters.transaction import TransactionalWriteRunner
from app.db.oper.agenttask import AgentTaskOper from app.db.oper.agenttask import AgentTaskOper
from app.db.session import SessionFactory, async_session_scope from app.db.session import SessionFactory, async_session_scope
from app.db.worker import DatabaseWorker from app.db.worker import DatabaseWorker
from app.schemas.exception import DatabaseWorkerClosedError from app.schemas.exception import (
DatabaseWorkerClosedError,
DatabaseWorkerOverloadedError,
)
def _add_task(prefix: str, *, trigger_type: str = "cron"): def _add_task(prefix: str, *, trigger_type: str = "cron"):
@@ -34,7 +38,7 @@ def _add_task(prefix: str, *, trigger_type: str = "cron"):
def _build_service( def _build_service(
worker: DatabaseWorker, worker: AsyncDatabaseExecutor,
repository: Callable[[object], object] | None = None, repository: Callable[[object], object] | None = None,
) -> AgentTaskExecutionService: ) -> AgentTaskExecutionService:
"""按生产事务和 worker 边界构造独立服务。""" """按生产事务和 worker 边界构造独立服务。"""
@@ -91,6 +95,41 @@ async def test_cancelled_queued_claim_does_not_create_run() -> None:
await worker.shutdown() await worker.shutdown()
@pytest.mark.anyio
async def test_cancelled_overloaded_claim_does_not_start_compensation() -> None:
"""认领尚未获得 admission 时取消,不得启动不存在运行的补偿收口。"""
class OverloadedExecutor:
"""在认领取得 worker admission 前稳定制造取消窗口。"""
def __init__(self) -> None:
self.calls = 0
self.first_call = asyncio.Event()
async def run(self, _operation):
self.calls += 1
self.first_call.set()
if self.calls > 1:
raise AssertionError("未获 admission 的认领不应启动终态补偿")
raise DatabaseWorkerOverloadedError("worker full")
executor = OverloadedExecutor()
task = _add_task("overload-cancel")
service = _build_service(executor)
claim = asyncio.create_task(service.claim(task.id))
await executor.first_call.wait()
claim.cancel()
with pytest.raises(asyncio.CancelledError):
await claim
assert executor.calls == 1
current = AgentTaskOper().get(task.id)
assert current.last_status == "waiting"
assert current.last_run_id is None
assert AgentTaskOper().list_runs(task.id) == []
@pytest.mark.anyio @pytest.mark.anyio
async def test_cancelled_started_claim_is_compensated_before_return() -> None: async def test_cancelled_started_claim_is_compensated_before_return() -> None:
"""认领事务已开始时取消,返回前必须把已提交运行收口为失败。""" """认领事务已开始时取消,返回前必须把已提交运行收口为失败。"""