fix(agent): bound chat shutdown cancellation

This commit is contained in:
InfinityPacer
2026-08-23 11:44:18 +08:00
parent 0ba4a7e5e3
commit 1e2d0d3b07
6 changed files with 96 additions and 5 deletions
+3 -1
View File
@@ -197,7 +197,9 @@ async def shutdown_web_agent_background_tasks() -> None:
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
# asyncio.wait 不会因关闭阶段自身被取消而再次取消这些任务;仍在收尾的
# Agent 任务会保留在注册表中,直到自己的数据库操作取得确定终态。
await asyncio.wait(tasks)
def register_channel_admin_resolver(
+8 -2
View File
@@ -389,6 +389,10 @@ class AgentChatPersistenceService:
self._session_locks[session_id] = lock
return lock
def begin_shutdown(self) -> None:
"""停止接受新的 AgentChat 持久化任务。"""
self._closing = True
async def _run_write(
self,
session_id: str,
@@ -436,11 +440,13 @@ class AgentChatPersistenceService:
async def shutdown(self) -> None:
"""拒绝新写入并等待当前会话锁和 worker 操作取得终态。"""
self._closing = True
self.begin_shutdown()
current = asyncio.current_task()
tasks = tuple(task for task in self._active_tasks if task is not current)
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
# wait 不会在生命周期超时时取消实际写入;外层可及时返回并保留
# 数据库 worker owner,已开始的事务继续由 worker 收口。
await asyncio.wait(tasks)
async def async_append_display_messages(
self,
+5
View File
@@ -578,6 +578,11 @@ async def stop_modules():
await run_step("消息服务", stop_message)
await run_step("Redis缓存连接", lambda: RedisHelper().close())
await run_step("异步Redis缓存连接", lambda: AsyncRedisHelper().close())
# 先关闭持久化准入,取消中的 Web Agent finally 才会快速拒绝晚到的快照写入。
await run_step(
"Agent会话持久化准入",
lambda: get_configured_agent_chat_persistence().begin_shutdown(),
)
await run_step("Web Agent后台任务", shutdown_web_agent_background_tasks)
await run_step(
"Agent会话持久化",
+42
View File
@@ -355,6 +355,48 @@ async def test_agent_chat_persistence_shutdown_drains_active_writes() -> None:
await shutdown
@pytest.mark.asyncio
async def test_agent_chat_shutdown_timeout_keeps_worker_owner_until_write_finishes() -> None:
"""持久化关闭超时时保留运行中的写入和数据库 worker owner。"""
started = threading.Event()
release = threading.Event()
class BlockingRepository(_Repository):
def save_agent_messages(self, **kwargs):
started.set()
release.wait(1)
super().save_agent_messages(**kwargs)
worker = DatabaseWorker(max_workers=1, capacity=1)
await worker.start()
service = AgentChatPersistenceService(
repository=lambda _session: BlockingRepository(),
async_executor=worker,
sync_transaction=lambda operation: operation(object()),
)
write = asyncio.create_task(
service.async_save_agent_messages(
session_id="shutdown-timeout-session",
user_id="1",
messages=[],
)
)
assert await asyncio.to_thread(started.wait, 1)
shutdown = asyncio.create_task(service.shutdown())
try:
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(shutdown, timeout=0.01)
assert service._closing is True
assert write.done() is False
assert worker._executor is not None
finally:
release.set()
await write
await worker.shutdown()
assert worker._executor is None
@pytest.mark.asyncio
async def test_agent_chat_persistence_uses_real_worker_and_sqlite_transaction() -> None:
"""真实 AgentChat Oper 经 worker 写入后可被 native async 查询恢复。"""
+28
View File
@@ -40,6 +40,34 @@ async def test_web_agent_background_tasks_are_cancelled_and_drained() -> None:
assert finished.is_set()
@pytest.mark.anyio
async def test_web_agent_shutdown_timeout_does_not_cancel_task_cleanup() -> None:
"""关闭超时时保留仍在执行取消收尾的 Web Agent 任务。"""
started = asyncio.Event()
release = asyncio.Event()
async def task_with_slow_cleanup() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
await release.wait()
raise
task = create_web_agent_background_task(task_with_slow_cleanup())
await started.wait()
shutdown = asyncio.create_task(shutdown_web_agent_background_tasks())
await asyncio.sleep(0)
shutdown.cancel()
with pytest.raises(asyncio.CancelledError):
await shutdown
assert task.done() is False
release.set()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.anyio
async def test_agent_entrypoint_initializes_on_calling_loop(monkeypatch) -> None:
"""Agent 启动入口必须在应用主循环完成初始化。"""
+10 -2
View File
@@ -571,7 +571,7 @@ def test_stop_modules_continues_after_internal_owner_failures(monkeypatch):
def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
"""关闭时必须先收口 Web Agent 后台任务,再关闭会话持久化端口"""
"""关闭时先关闭持久化准入,再收口 Web Agent 和已有写入"""
order = []
monkeypatch.setattr(modules_initializer, "stop_agent", AsyncMock())
dependencies = _patch_module_shutdown_dependencies(monkeypatch)
@@ -581,6 +581,9 @@ def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
AsyncMock(side_effect=lambda: order.append("web-agent")),
)
persistence = MagicMock()
persistence.begin_shutdown = MagicMock(
side_effect=lambda: order.append("persistence-admission")
)
persistence.shutdown = AsyncMock(side_effect=lambda: order.append("persistence"))
monkeypatch.setattr(
modules_initializer,
@@ -596,7 +599,12 @@ def test_stop_modules_drains_web_agent_tasks_before_persistence(monkeypatch):
asyncio.run(modules_initializer.stop_modules())
assert order == ["web-agent", "persistence", "database"]
assert order == [
"persistence-admission",
"web-agent",
"persistence",
"database",
]
def _patch_module_shutdown_dependencies(monkeypatch) -> dict: