refactor: isolate agent task execution data boundary (#6433)

This commit is contained in:
InfinityPacer
2026-08-24 12:14:46 +08:00
committed by GitHub
parent 8440793edb
commit feff545b16
11 changed files with 907 additions and 64 deletions
+9
View File
@@ -153,6 +153,10 @@ def configure_plugin_system_services():
from app.workflow import WorkFlowManager
configure_workflow_runtime(lambda: WorkFlowManager())
from app.application.agentdata import configure_agent_data_ports
from app.application.agenttask import (
AgentTaskExecutionService,
configure_agent_task_execution,
)
from app.db.oper.agentchat import AgentChatOper
from app.db.oper.downloadfailure import DownloadFailureOper
from app.db.oper.downloadhistory import DownloadHistoryOper
@@ -272,6 +276,11 @@ def configure_plugin_system_services():
workflow=lambda: WorkflowOper(),
plugin_data=lambda: PluginDataOper(),
)
configure_agent_task_execution(AgentTaskExecutionService(
repository=lambda session: AgentTaskOper(session),
async_executor=database_executor,
sync_transaction=transaction_runner.sync,
))
configure_agent_chat_persistence(
AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
+13 -6
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6554,
"edge_sha256": "1c72744be67d95f98eac1c7b1a72bdbf7b6d1d4f337556b8e22293c3a9525c67",
"edge_count": 6560,
"edge_sha256": "bdd34affb7c42a4cbcdc85e713d9b4ac591c3343559dd5032b4518e77eba721e",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -356,6 +356,7 @@
"app.agent.orchestrator -> app.agent.tools.impl.query_system_settings",
"app.agent.orchestrator -> app.application",
"app.agent.orchestrator -> app.application.agentdata",
"app.agent.orchestrator -> app.application.agenttask",
"app.agent.orchestrator -> app.application.messaging",
"app.agent.orchestrator -> app.application.messaging.chat",
"app.agent.orchestrator -> app.application.plugin",
@@ -2483,6 +2484,12 @@
"app.api.servcookie -> app.runtime.log",
"app.api.servcookie -> app.schemas",
"app.api.servcookie -> app.schemas.servcookie",
"app.application.agenttask -> app.application",
"app.application.agenttask -> app.application.database",
"app.application.agenttask -> app.runtime",
"app.application.agenttask -> app.runtime.execution",
"app.application.agenttask -> app.schemas",
"app.application.agenttask -> app.schemas.exception",
"app.application.audio -> app.domain",
"app.application.audio -> app.domain.context",
"app.application.audio -> app.domain.meta",
@@ -5809,6 +5816,7 @@
"app.scheduler -> app.adapters.external.server",
"app.scheduler -> app.application",
"app.scheduler -> app.application.agent",
"app.scheduler -> app.application.agentdata",
"app.scheduler -> app.application.configuration",
"app.scheduler -> app.application.database",
"app.scheduler -> app.application.image",
@@ -5827,9 +5835,6 @@
"app.scheduler -> app.chain.subscribe",
"app.scheduler -> app.chain.transfer",
"app.scheduler -> app.chain.workflow",
"app.scheduler -> app.db",
"app.scheduler -> app.db.oper",
"app.scheduler -> app.db.oper.agenttask",
"app.scheduler -> app.foundation",
"app.scheduler -> app.foundation.singleton",
"app.scheduler -> app.runtime",
@@ -6187,6 +6192,7 @@
"app.startup.initializers.modules -> app.api.data",
"app.startup.initializers.modules -> app.application",
"app.startup.initializers.modules -> app.application.agentdata",
"app.startup.initializers.modules -> app.application.agenttask",
"app.startup.initializers.modules -> app.application.chain",
"app.startup.initializers.modules -> app.application.chain.context",
"app.startup.initializers.modules -> app.application.chain.data",
@@ -6571,7 +6577,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 809,
"module_count": 810,
"modules": [
"app",
"app.adapters",
@@ -6820,6 +6826,7 @@
"app.application",
"app.application.agent",
"app.application.agentdata",
"app.application.agenttask",
"app.application.audio",
"app.application.backup",
"app.application.chain",
+104
View File
@@ -352,6 +352,108 @@ def test_scheduler_registers_and_removes_agent_task_job() -> None:
scheduler._scheduler.shutdown(wait=False)
def test_stale_agent_task_generation_cannot_remove_replacement_job() -> None:
"""旧执行收尾不得删除配置刷新后注册的新 generation。"""
task = _add_agent_task("date", _future_time(), "generation-replace")
scheduler = _build_agent_task_scheduler()
scheduler.update_agent_task_job(task.id)
job_id = scheduler._get_agent_task_job_id(task.id)
old_generation = scheduler._jobs[job_id]["_generation"]
scheduler.update_agent_task_job(task.id)
new_generation = scheduler._jobs[job_id]["_generation"]
assert new_generation > old_generation
assert scheduler._remove_agent_task_job_generation(
task.id,
old_generation,
"old-run",
) is False
assert scheduler._jobs[job_id]["_generation"] == new_generation
assert scheduler._scheduler.get_job(job_id) is not None
@pytest.mark.anyio
async def test_date_task_reload_job_is_removed_after_run_finishes(
monkeypatch,
) -> None:
"""运行中重载生成的同一次任务副本必须随 date 终态一起移除。"""
task = _add_agent_task("date", _future_time(), "date-reload-active")
scheduler = _build_agent_task_scheduler()
scheduler.update_agent_task_job(task.id)
job_id = scheduler._get_agent_task_job_id(task.id)
original_generation = scheduler._jobs[job_id]["_generation"]
started = asyncio.Event()
release = asyncio.Event()
async def process_message(**_kwargs) -> str:
started.set()
await release.wait()
return "执行完成"
manager = SimpleNamespace(
execute_scheduled_task=AgentManager.execute_scheduled_task,
process_message=process_message,
_accepting_tasks=True,
)
manager.execute_scheduled_task = AgentManager.execute_scheduled_task.__get__(manager)
monkeypatch.setattr(
"app.application.agent.get_running_agent_manager",
lambda: manager,
)
assert scheduler.start(job_id) is True
await asyncio.wait_for(started.wait(), timeout=1)
scheduler.update_agent_task_job(task.id)
replacement_generation = scheduler._jobs[job_id]["_generation"]
assert replacement_generation > original_generation
assert scheduler._jobs[job_id]["_agent_task_status"] == "running"
release.set()
async def wait_until_released() -> None:
while scheduler._handles:
await asyncio.sleep(0)
await asyncio.wait_for(wait_until_released(), timeout=1)
completed = AgentTaskOper().get(task.id)
assert completed.enabled is False
assert completed.last_status == "success"
assert job_id not in scheduler._jobs
assert scheduler._scheduler.get_job(job_id) is None
def test_finished_date_task_cannot_remove_reenabled_job() -> None:
"""date 收口后重新启用的任务不再属于旧执行的运行时清理范围。"""
task = _add_agent_task("date", _future_time(), "date-reenabled")
scheduler = _build_agent_task_scheduler()
scheduler.update_agent_task_job(task.id)
job_id = scheduler._get_agent_task_job_id(task.id)
original_generation = scheduler._jobs[job_id]["_generation"]
oper = AgentTaskOper()
run = oper.begin_run(task.id)
assert run is not None
outcome = oper.finish_run_outcome(run.run_id, success=True, result="完成")
assert outcome.date_task_disabled is True
assert oper.update(
task.id,
{"enabled": True, "last_status": "waiting"},
) is True
scheduler.update_agent_task_job(task.id)
replacement_generation = scheduler._jobs[job_id]["_generation"]
assert replacement_generation > original_generation
assert scheduler._jobs[job_id]["_agent_task_run_id"] == run.run_id
assert scheduler._jobs[job_id]["_agent_task_status"] == "waiting"
assert scheduler._remove_agent_task_job_generation(
task.id,
original_generation,
run.run_id,
) is False
assert scheduler._jobs[job_id]["_generation"] == replacement_generation
assert scheduler._scheduler.get_job(job_id) is not None
@pytest.mark.parametrize(
"run_time_factory",
[_past_time, _future_time, _invalid_time],
@@ -401,6 +503,7 @@ async def test_interrupted_date_task_manual_run_disables_and_removes_job(
manager = SimpleNamespace(
execute_scheduled_task=AgentManager.execute_scheduled_task,
process_message=process_message,
_accepting_tasks=True,
)
manager.execute_scheduled_task = AgentManager.execute_scheduled_task.__get__(manager)
monkeypatch.setattr(
@@ -674,6 +777,7 @@ async def test_scheduler_config_reload_preserves_active_agent_task(
manager = SimpleNamespace(
execute_scheduled_task=AgentManager.execute_scheduled_task,
process_message=process_message,
_accepting_tasks=True,
)
manager.execute_scheduled_task = AgentManager.execute_scheduled_task.__get__(manager)
monkeypatch.setattr(
+310
View File
@@ -0,0 +1,310 @@
"""AgentTask 执行服务的取消、终态和调度清理合同。"""
import asyncio
import threading
from collections.abc import Callable
from uuid import uuid4
import pytest
from app.application.agenttask import AgentTaskExecutionService
from app.db.adapters.transaction import TransactionalWriteRunner
from app.db.oper.agenttask import AgentTaskOper
from app.db.session import SessionFactory, async_session_scope
from app.db.worker import DatabaseWorker
from app.schemas.exception import DatabaseWorkerClosedError
def _add_task(prefix: str, *, trigger_type: str = "cron"):
"""创建一条与其他用例隔离的可执行任务。"""
user_id = f"{prefix}-{uuid4().hex}"
return AgentTaskOper().add(
name=f"{prefix} 检查",
content="检查资源并报告",
trigger_type=trigger_type,
cron_expression="0 * * * *" if trigger_type == "cron" else None,
run_at="2099-01-01T00:00:00+08:00" if trigger_type == "date" else None,
user_id=user_id,
username="admin",
session_id=f"session-{user_id}",
channel=None,
source="api",
original_chat_id=None,
)
def _build_service(
worker: DatabaseWorker,
repository: Callable[[object], object] | None = None,
) -> AgentTaskExecutionService:
"""按生产事务和 worker 边界构造独立服务。"""
transaction = TransactionalWriteRunner(
sync_session=SessionFactory,
async_session=async_session_scope,
)
return AgentTaskExecutionService(
repository=repository or (lambda session: AgentTaskOper(session)),
async_executor=worker,
sync_transaction=transaction.sync,
)
async def _wait_for_worker(
worker: DatabaseWorker,
predicate: Callable[[], bool],
) -> None:
"""等待 worker 进入目标状态,超时由测试框架明确失败。"""
for _ in range(200):
if predicate():
return
await asyncio.sleep(0.005)
raise AssertionError(f"数据库 worker 未进入目标状态: {worker.snapshot()}")
@pytest.mark.anyio
async def test_cancelled_queued_claim_does_not_create_run() -> None:
"""认领尚未开始时取消,应撤销排队工作且不得产生运行记录。"""
worker = DatabaseWorker(max_workers=1, capacity=2)
await worker.start()
occupied = threading.Event()
release = threading.Event()
blocker = asyncio.create_task(worker.run(
lambda: (occupied.set(), release.wait())
))
await asyncio.to_thread(occupied.wait)
task = _add_task("queued-cancel")
service = _build_service(worker)
claim = asyncio.create_task(service.claim(task.id))
await _wait_for_worker(worker, lambda: worker.snapshot().queued == 1)
claim.cancel()
await asyncio.sleep(0)
release.set()
with pytest.raises(asyncio.CancelledError):
await claim
await blocker
current = AgentTaskOper().get(task.id)
assert current.last_status == "waiting"
assert current.last_run_id is None
assert AgentTaskOper().list_runs(task.id) == []
await worker.shutdown()
@pytest.mark.anyio
async def test_cancelled_started_claim_is_compensated_before_return() -> None:
"""认领事务已开始时取消,返回前必须把已提交运行收口为失败。"""
worker = DatabaseWorker(max_workers=1, capacity=2)
await worker.start()
started = threading.Event()
release = threading.Event()
class BlockingRepository:
"""在认领已写入但事务尚未提交的位置制造取消窗口。"""
def __init__(self, session: object) -> None:
self._repository = AgentTaskOper(session)
def __getattr__(self, name: str):
return getattr(self._repository, name)
def begin_run(self, *args, **kwargs):
run = self._repository.begin_run(*args, **kwargs)
started.set()
release.wait()
return run
task = _add_task("started-cancel")
service = _build_service(worker, BlockingRepository)
claim = asyncio.create_task(service.claim(task.id))
await asyncio.to_thread(started.wait)
claim.cancel()
release.set()
with pytest.raises(asyncio.CancelledError):
await claim
current = AgentTaskOper().get(task.id)
runs = AgentTaskOper().list_runs(task.id)
assert current.last_status == "failed"
assert current.last_result == "Agent 定时任务已取消"
assert current.run_count == 1
assert len(runs) == 1
assert runs[0].status == "failed"
await worker.shutdown()
@pytest.mark.anyio
async def test_claim_retries_transient_worker_overload() -> None:
"""一次性任务不得因触发瞬间容量已满而永久丢失。"""
worker = DatabaseWorker(max_workers=1, capacity=1)
await worker.start()
task = _add_task("claim-overload", trigger_type="date")
service = _build_service(worker)
occupied = threading.Event()
release = threading.Event()
blocker = asyncio.create_task(worker.run(
lambda: (occupied.set(), release.wait())
))
await asyncio.to_thread(occupied.wait)
claim = asyncio.create_task(service.claim(task.id))
await _wait_for_worker(worker, lambda: worker.snapshot().rejected > 0)
assert claim.done() is False
release.set()
claimed = await claim
await blocker
assert claimed.run is not None
current = AgentTaskOper().get(task.id)
assert current.last_status == "running"
assert current.last_run_id == claimed.run.run_id
await service.finalize(claimed.run, success=True, result="完成")
await worker.shutdown()
@pytest.mark.anyio
async def test_repeated_finalize_cancellation_waits_for_single_terminal_write() -> None:
"""重复取消不得打断已开始的终态事务或重复累计执行次数。"""
worker = DatabaseWorker(max_workers=1, capacity=2)
await worker.start()
started = threading.Event()
release = threading.Event()
class BlockingRepository:
"""在运行终态已写入但事务尚未提交的位置制造重复取消窗口。"""
def __init__(self, session: object) -> None:
self._repository = AgentTaskOper(session)
def __getattr__(self, name: str):
return getattr(self._repository, name)
def finish_run_outcome(self, *args, **kwargs):
outcome = self._repository.finish_run_outcome(*args, **kwargs)
started.set()
release.wait()
return outcome
task = _add_task("finish-cancel")
claim_service = _build_service(worker)
claimed = await claim_service.claim(task.id)
assert claimed.run is not None
service = _build_service(worker, BlockingRepository)
finalize = asyncio.create_task(service.finalize(
claimed.run,
success=True,
result="完成",
))
await asyncio.to_thread(started.wait)
finalize.cancel()
await asyncio.sleep(0)
finalize.cancel()
release.set()
with pytest.raises(asyncio.CancelledError):
await finalize
current = AgentTaskOper().get(task.id)
runs = AgentTaskOper().list_runs(task.id)
assert current.last_status == "success"
assert current.run_count == 1
assert len(runs) == 1
assert runs[0].status == "success"
await worker.shutdown()
@pytest.mark.anyio
async def test_cancelled_queued_finalize_waits_for_terminal_write() -> None:
"""终态事务仍在队列时取消,返回前也必须完成唯一一次收口。"""
worker = DatabaseWorker(max_workers=1, capacity=2)
await worker.start()
task = _add_task("queued-finish-cancel")
service = _build_service(worker)
claimed = await service.claim(task.id)
assert claimed.run is not None
occupied = threading.Event()
release = threading.Event()
blocker = asyncio.create_task(worker.run(
lambda: (occupied.set(), release.wait())
))
await asyncio.to_thread(occupied.wait)
finalize = asyncio.create_task(service.finalize(
claimed.run,
success=True,
result="完成",
))
await _wait_for_worker(worker, lambda: worker.snapshot().queued == 1)
finalize.cancel()
release.set()
with pytest.raises(asyncio.CancelledError):
await finalize
await blocker
current = AgentTaskOper().get(task.id)
runs = AgentTaskOper().list_runs(task.id)
assert current.last_status == "success"
assert current.run_count == 1
assert len(runs) == 1
assert runs[0].status == "success"
await worker.shutdown()
@pytest.mark.anyio
async def test_finalize_retries_transient_worker_overload() -> None:
"""容量暂满时保留终态 owner,取得 admission 后再提交结果。"""
worker = DatabaseWorker(max_workers=1, capacity=1)
await worker.start()
task = _add_task("finish-overload")
service = _build_service(worker)
claimed = await service.claim(task.id)
assert claimed.run is not None
occupied = threading.Event()
release = threading.Event()
blocker = asyncio.create_task(worker.run(
lambda: (occupied.set(), release.wait())
))
await asyncio.to_thread(occupied.wait)
finalize = asyncio.create_task(service.finalize(
claimed.run,
success=True,
result="完成",
))
await _wait_for_worker(worker, lambda: worker.snapshot().rejected > 0)
assert finalize.done() is False
release.set()
outcome = await finalize
await blocker
assert outcome.run_finalized is True
assert AgentTaskOper().get(task.id).last_status == "success"
assert worker.snapshot().queued == 0
assert worker.snapshot().running == 0
await worker.shutdown()
@pytest.mark.anyio
async def test_closed_worker_does_not_finalize_or_remove_schedule() -> None:
"""持久化不可用时不得伪造终态或清理运行时调度。"""
worker = DatabaseWorker(max_workers=1, capacity=2)
await worker.start()
task = _add_task("closed-finalize", trigger_type="date")
service = _build_service(worker)
claimed = await service.claim(task.id)
assert claimed.run is not None
await worker.shutdown()
removed: list[tuple[int, int]] = []
with pytest.raises(DatabaseWorkerClosedError):
await service.finalize(
claimed.run,
success=True,
result="完成",
scheduler_generation=3,
remove_schedule=lambda task_id, generation, _run_id: (
removed.append((task_id, generation)) or True
),
)
assert removed == []
assert AgentTaskOper().get(task.id).last_status == "running"
+4 -29
View File
@@ -1,8 +1,6 @@
import json
from concurrent.futures import ThreadPoolExecutor
from threading import Event, Thread, current_thread
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from uuid import uuid4
import pytest
@@ -17,7 +15,6 @@ from app.db.oper.agenttask import AgentTaskOper
from app.db.models.agenttask import AgentTask
from app.db.models.agenttaskrun import AgentTaskRun
from app.db.session import SessionFactory
from app.scheduler import Scheduler
Engine = get_engine()
@@ -165,31 +162,6 @@ async def test_agenttask_oper_async_get_uses_async_query_boundary() -> None:
assert await AgentTaskOper().async_get(task.id, user_id="another-user") is None
@pytest.mark.anyio
async def test_scheduler_agent_task_cleanup_uses_async_query(monkeypatch) -> None:
"""async 调度收尾必须等待异步任务查询,不得退回同步 Oper 调用。"""
execute = AsyncMock(return_value=(True, "执行完成"))
async_get = AsyncMock(
return_value=SimpleNamespace(trigger_type="cron", enabled=True)
)
sync_get = Mock(side_effect=AssertionError("不应调用同步 AgentTaskOper.get"))
scheduler = SimpleNamespace(remove_agent_task_job=Mock())
monkeypatch.setattr(
"app.application.agent.get_running_agent_manager",
lambda: SimpleNamespace(execute_scheduled_task=execute),
)
monkeypatch.setattr(AgentTaskOper, "async_get", async_get)
monkeypatch.setattr(AgentTaskOper, "get", sync_get)
result = await Scheduler.execute_agent_task(scheduler, task_id=42)
assert result == (True, "执行完成")
execute.assert_awaited_once_with(42, trigger_source="scheduled")
async_get.assert_awaited_once_with(42)
sync_get.assert_not_called()
scheduler.remove_agent_task_job.assert_not_called()
def test_begin_run_rolls_back_task_claim_when_run_insert_fails() -> None:
"""运行记录插入失败时,任务的 running 投影必须随事务回滚。"""
first_task = _add_task("run-rollback-first")
@@ -258,7 +230,10 @@ def test_stale_finish_cannot_overwrite_latest_run_projection() -> None:
second = oper.begin_run(task.id, "manual")
assert second
assert oper.finish_run(first.run_id, success=True, result="旧结果")
outcome = oper.finish_run_outcome(first.run_id, success=True, result="旧结果")
assert outcome.run_finalized is True
assert outcome.task_projection_updated is False
assert outcome.date_task_disabled is False
current = oper.get(task.id)
assert current.last_run_id == second.run_id
assert current.last_status == "running"
+24
View File
@@ -599,6 +599,30 @@ def test_agent_consumers_use_explicit_data_port_getters():
assert violations == []
def test_scheduler_does_not_depend_on_database_implementation():
"""Scheduler 只能消费应用端口,不得重新直连 app.db 实现。"""
dependencies = _build_module_graph().get("app.scheduler", set())
assert {
dependency
for dependency in dependencies
if dependency == "app.db" or dependency.startswith("app.db.")
} == set()
def test_agent_task_async_execution_uses_application_service():
"""AgentTask async 执行不得经动态数据端口隐藏同步 Oper 调用。"""
path = APP_ROOT / "agent" / "orchestrator.py"
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
violations = [
node.lineno
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom)
and node.module == "app.application.agentdata"
and any(alias.name == "get_agent_task_port" for alias in node.names)
]
assert violations == []
def test_monitor_dispatcher_uses_explicit_history_port_getter():
"""监控分发器不得把兼容 TransferHistoryPort 伪装成数据库 Oper。"""
path = APP_ROOT / "monitor" / "dispatcher.py"