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
+26 -12
View File
@@ -79,7 +79,7 @@ from app.application.plugin.runtime import get_plugin_manager
def _get_plugin_tools_revision() -> int:
"""读取插件工具目录修订号,避免 Agent 编排依赖具体管理器类型。"""
return get_plugin_manager().get_plugin_agent_tools_revision()
from app.application.agentdata import get_agent_task_port
from app.application.agenttask import get_agent_task_execution_service
from app.application.agentdata import get_agent_user_port
from app.application.messaging.chat import (
get_configured_agent_chat_service,
@@ -3504,6 +3504,8 @@ class AgentManager:
self,
task_id: int,
trigger_source: str = "scheduled",
scheduler_generation: int | None = None,
remove_schedule: Callable[[int, int, str], bool] | None = None,
) -> tuple[bool, str]:
"""
按持久化上下文唤醒 Agent 执行自主定时任务并向用户回传结果。
@@ -3514,13 +3516,17 @@ class AgentManager:
"""
if not settings.AI_AGENT_ENABLE:
return False, "AI Agent 未启用"
oper = get_agent_task_port()
task = oper.get(task_id)
if not task or not task.enabled:
return False, "Agent 定时任务不存在或已停用"
run = oper.begin_run(task_id=task_id, trigger_source=trigger_source)
if not run:
return False, "Agent 定时任务当前不可执行"
accepting_before_claim = self._accepting_tasks
task_service = get_agent_task_execution_service()
claim = await task_service.claim(
task_id=task_id,
trigger_source=trigger_source,
scheduler_generation=scheduler_generation,
remove_schedule=remove_schedule,
)
run = claim.run
if run is None:
return False, claim.rejection or "Agent 定时任务当前不可执行"
trigger_description = (
"已手动触发" if run.trigger_source == "manual" else "已按计划触发"
@@ -3558,7 +3564,14 @@ class AgentManager:
raise
except Exception as err:
success = False
result = f"Agent 定时任务执行失败:{str(err)}"
error_message = str(err)
if (
accepting_before_claim
and not self._accepting_tasks
and error_message == "AgentManager 未运行或已关闭"
):
error_message = "AgentManager 已关闭"
result = f"Agent 定时任务执行失败:{error_message}"
logger.error(f"Agent 定时任务 {task_id} 执行失败: {str(err)}")
await AgentChain().async_post_message(
Message(
@@ -3570,11 +3583,12 @@ class AgentManager:
)
)
finally:
oper.finish_run(
run_id=run.run_id,
await task_service.finalize(
run,
success=success,
result=str(result or ""),
disable_date_task=run.trigger_type == "date",
scheduler_generation=scheduler_generation,
remove_schedule=remove_schedule,
)
return success, str(result or "任务执行完成")
+290
View File
@@ -0,0 +1,290 @@
"""Agent 自主定时任务执行的异步应用边界。"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from typing import Protocol, TypeVar
from uuid import uuid4
from app.application.database import AsyncDatabaseExecutor
from app.runtime.execution import await_task_to_terminal
from app.schemas.exception import DatabaseWorkerOverloadedError
T = TypeVar("T")
SyncTransaction = Callable[[Callable[[object], T]], T]
class AgentTaskRecord(Protocol):
"""执行认领与终态判定所需的任务投影。"""
id: int
enabled: bool
last_run_id: str | None
last_status: str
class AgentTaskRunRecord(Protocol):
"""执行期间需要脱离数据库会话持有的运行记录字段。"""
run_id: str
task_id: int
trigger_source: str
name: str
content: str
trigger_type: str
cron_expression: str | None
run_at: str | None
user_id: str
username: str | None
session_id: str
class AgentTaskRepository(Protocol):
"""AgentTask 执行用例使用的同步短事务仓储合同。"""
def get(self, task_id: int) -> AgentTaskRecord | None:
"""读取任务当前投影。"""
def begin_run(
self,
task_id: int,
trigger_source: str = "scheduled",
*,
run_id: str | None = None,
) -> AgentTaskRunRecord | None:
"""原子认领任务并创建运行快照。"""
def finish_run_outcome(
self,
run_id: str,
success: bool,
result: str,
) -> AgentTaskFinishRecord:
"""收口运行并返回事务确认的终态事实。"""
class AgentTaskFinishRecord(Protocol):
"""同步仓储返回的结构化运行终态。"""
run_finalized: bool
task_projection_updated: bool
date_task_disabled: bool
AgentTaskRepositoryFactory = Callable[[object], AgentTaskRepository]
AgentTaskScheduleRemover = Callable[[int, int, str], bool]
@dataclass(frozen=True, slots=True)
class AgentTaskRunSnapshot:
"""任务认领成功后可安全跨越数据库会话的执行快照。"""
run_id: str
task_id: int
trigger_source: str
name: str
content: str
trigger_type: str
cron_expression: str | None
run_at: str | None
user_id: str
username: str | None
session_id: str
@dataclass(frozen=True, slots=True)
class AgentTaskClaim:
"""任务认领结果;拒绝原因保持现有 Agent 用户提示合同。"""
run: AgentTaskRunSnapshot | None
rejection: str | None = None
@dataclass(frozen=True, slots=True)
class AgentTaskFinishOutcome:
"""区分运行收口、任务投影更新与一次任务停用三个事实。"""
run_finalized: bool
task_projection_updated: bool
date_task_disabled: bool
class AgentTaskExecutionService:
"""通过有界数据库 worker 认领并收口一次 AgentTask 执行。"""
def __init__(
self,
*,
repository: AgentTaskRepositoryFactory,
async_executor: AsyncDatabaseExecutor,
sync_transaction: SyncTransaction,
) -> None:
"""保存同步仓储、事务和异步执行器。"""
self._repository = repository
self._async_executor = async_executor
self._sync_transaction = sync_transaction
@staticmethod
def _snapshot(run: AgentTaskRunRecord) -> AgentTaskRunSnapshot:
"""在事务内复制运行字段,避免 ORM 对象越过会话边界。"""
return AgentTaskRunSnapshot(
run_id=run.run_id,
task_id=run.task_id,
trigger_source=run.trigger_source,
name=run.name,
content=run.content,
trigger_type=run.trigger_type,
cron_expression=run.cron_expression,
run_at=run.run_at,
user_id=run.user_id,
username=run.username,
session_id=run.session_id,
)
async def claim(
self,
task_id: int,
trigger_source: str = "scheduled",
*,
scheduler_generation: int | None = None,
remove_schedule: AgentTaskScheduleRemover | None = None,
) -> AgentTaskClaim:
"""认领一次执行;取消发生在提交后时先补偿收口再传播取消。"""
run_id = uuid4().hex
def transaction(session: object) -> AgentTaskClaim:
repository = self._repository(session)
run = repository.begin_run(
task_id=task_id,
trigger_source=trigger_source,
run_id=run_id,
)
if not run:
task = repository.get(task_id)
return AgentTaskClaim(
run=None,
rejection=(
"Agent 定时任务不存在或已停用"
if not task or not task.enabled
else "Agent 定时任务当前不可执行"
),
)
return AgentTaskClaim(run=self._snapshot(run))
async def claim_to_terminal() -> AgentTaskClaim:
"""容量瞬时耗尽时保留本轮调度,直到认领取得 admission。"""
while True:
try:
return await self._async_executor.run(
lambda: self._sync_transaction(transaction)
)
except DatabaseWorkerOverloadedError:
await asyncio.sleep(0.01)
claim_task = asyncio.create_task(claim_to_terminal())
try:
return await claim_task
except asyncio.CancelledError as cancellation:
finalize_task = asyncio.create_task(self._finalize(
run_id=run_id,
task_id=task_id,
success=False,
result="Agent 定时任务已取消",
scheduler_generation=scheduler_generation,
remove_schedule=remove_schedule,
))
await await_task_to_terminal(finalize_task)
raise cancellation
async def finalize(
self,
run: AgentTaskRunSnapshot,
*,
success: bool,
result: str,
scheduler_generation: int | None = None,
remove_schedule: AgentTaskScheduleRemover | None = None,
) -> AgentTaskFinishOutcome:
"""等待终态事务完成,并仅清理仍属于该 generation 的一次任务。"""
return await self._finalize(
run_id=run.run_id,
task_id=run.task_id,
success=success,
result=result,
scheduler_generation=scheduler_generation,
remove_schedule=remove_schedule,
)
async def _finalize(
self,
*,
run_id: str,
task_id: int,
success: bool,
result: str,
scheduler_generation: int | None,
remove_schedule: AgentTaskScheduleRemover | None,
) -> AgentTaskFinishOutcome:
"""按稳定运行 ID 收口,供正常路径和取消补偿共享。"""
def transaction(session: object) -> AgentTaskFinishOutcome:
repository = self._repository(session)
outcome = repository.finish_run_outcome(
run_id=run_id,
success=success,
result=result,
)
return AgentTaskFinishOutcome(
run_finalized=outcome.run_finalized,
task_projection_updated=outcome.task_projection_updated,
date_task_disabled=outcome.date_task_disabled,
)
async def finish_to_terminal() -> AgentTaskFinishOutcome:
"""容量瞬时耗尽时保留 owner,直到收口取得 admission。"""
while True:
try:
return await self._async_executor.run(
lambda: self._sync_transaction(transaction)
)
except DatabaseWorkerOverloadedError:
await asyncio.sleep(0.01)
finish_task = asyncio.create_task(finish_to_terminal())
cancellation: asyncio.CancelledError | None = None
try:
outcome = await asyncio.shield(finish_task)
except asyncio.CancelledError as error:
cancellation = error
outcome = await await_task_to_terminal(finish_task)
if (
outcome.date_task_disabled
and scheduler_generation is not None
and remove_schedule is not None
):
remove_schedule(task_id, scheduler_generation, run_id)
if cancellation is not None:
raise cancellation
return outcome
_service: AgentTaskExecutionService | None = None
def configure_agent_task_execution(service: AgentTaskExecutionService) -> None:
"""由启动组合根登记 AgentTask 执行服务。"""
global _service
_service = service
def get_agent_task_execution_service() -> AgentTaskExecutionService:
"""返回已登记的 AgentTask 执行服务。"""
if _service is None:
raise RuntimeError("AgentTask 执行服务尚未配置")
return _service
+67
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
@@ -16,6 +18,15 @@ from app.db.models.agenttask import (
from app.db.models.agenttaskrun import AgentTaskRun
@dataclass(frozen=True, slots=True)
class AgentTaskFinishRecord:
"""一次运行收口后由数据库事务确认的三个独立事实。"""
run_finalized: bool
task_projection_updated: bool
date_task_disabled: bool
class AgentTaskOper(DbOper):
"""
Agent 自主定时任务管理。
@@ -238,6 +249,62 @@ class AgentTaskOper(DbOper):
)
)
def finish_run_outcome(
self,
run_id: str,
success: bool,
result: str,
) -> AgentTaskFinishRecord:
"""收口运行,并返回当前事务实际更新的任务投影和停用事实。"""
finished_at = self._now()
normalized_result = (result or "")[:20000]
expected_status = "success" if success else "failed"
def finalize(session: Session) -> AgentTaskFinishRecord:
"""使用列查询绕过 ORM identity-map,读取刚写入的真实投影。"""
finalized = AgentTaskRun.finish_run(
session,
run_id=run_id,
success=success,
result=normalized_result,
finished_at=finished_at,
disable_date_task=True,
)
run = session.execute(
select(
AgentTaskRun.task_id,
AgentTaskRun.trigger_type,
).where(AgentTaskRun.run_id == run_id)
).mappings().first()
task = None
if run:
task = session.execute(
select(
AgentTask.last_run_id,
AgentTask.last_status,
AgentTask.enabled,
).where(AgentTask.id == run["task_id"])
).mappings().first()
projection_updated = bool(
finalized
and task
and task["last_run_id"] == run_id
and task["last_status"] == expected_status
)
return AgentTaskFinishRecord(
run_finalized=finalized,
task_projection_updated=projection_updated,
date_task_disabled=bool(
projection_updated
and run
and run["trigger_type"] == "date"
and task
and not task["enabled"]
),
)
return self._execute_sync_write(finalize)
def finish(
self,
task_id: int,
+51 -17
View File
@@ -28,7 +28,7 @@ from app.chain.transfer import TransferChain
from app.chain.workflow import WorkflowChain
from app.runtime.config import global_vars
from app.runtime.events import Event, eventmanager
from app.db.oper.agenttask import AgentTaskOper
from app.application.agentdata import get_agent_task_port
from app.application.database import get_database_governance
from app.application.outbox import dispatch_pending_outbox
from app.application.plugin.runtime import get_plugin_manager
@@ -1274,6 +1274,8 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
func = job.get("func")
if not func:
return
if func == self.execute_agent_task:
kwargs.setdefault("scheduler_generation", generation)
if self.__supports_progress_callback(func) and "progress_callback" not in kwargs:
kwargs["progress_callback"] = self.__build_progress_callback(
job_id=job_id, job=job
@@ -1426,7 +1428,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"""
按数据库当前状态注册所有启用的 Agent 自主定时任务。
"""
for task in AgentTaskOper().list(enabled=True):
for task in get_agent_task_port().list(enabled=True):
self.update_agent_task_job(task.id)
def _reconcile_agent_task_interruptions(self) -> None:
@@ -1439,7 +1441,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
with self._lock:
if self._agent_task_interruptions_reconciled:
return
oper = AgentTaskOper()
oper = get_agent_task_port()
for task in oper.list():
if task.last_status == "running":
oper.mark_interrupted(
@@ -1460,7 +1462,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"""
config = get_scheduler_runtime_config()
self.remove_agent_task_job(task_id)
task = AgentTaskOper().get(task_id)
task = get_agent_task_port().get(task_id)
if (
not config.ai_agent_enable
or not task
@@ -1496,6 +1498,8 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
kwargs={"task_id": task_id},
).to_runtime_state()
self._assign_job_generation(job_id, job)
job["_agent_task_run_id"] = task.last_run_id
job["_agent_task_status"] = task.last_status
self._jobs[job_id] = job
self._jobs[job_id]["provider_name"] = "[Agent]"
# 已开始的一次任务在重启后结果未知,只保留显式执行入口,不能按
@@ -1531,6 +1535,31 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
except JobLookupError:
pass
def _remove_agent_task_job_generation(
self,
task_id: int,
generation: int,
run_id: str,
) -> bool:
"""移除本次执行或其运行中重载产生的 AgentTask 调度注册。"""
job_id = self._get_agent_task_job_id(task_id)
with self._lock:
job = self._jobs.get(job_id)
if job is None:
return False
if job.get("_generation", 0) != generation and not (
job.get("_agent_task_run_id") == run_id
and job.get("_agent_task_status") == "running"
):
return False
self._jobs.pop(job_id, None)
if self._scheduler:
try:
self._scheduler.remove_job(job_id)
except JobLookupError:
pass
return True
def get_agent_task_next_run(self, task_id: int) -> Optional[str]:
"""
查询 Agent 自主定时任务的下一次执行时间。
@@ -1546,7 +1575,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
if next_run_time:
return next_run_time.isoformat(timespec="seconds")
task = AgentTaskOper().get(task_id)
task = get_agent_task_port().get(task_id)
if not task or not task.enabled:
return None
if task.trigger_type == "date" and task.last_status == "interrupted":
@@ -1572,6 +1601,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
self,
task_id: int,
trigger_source: str = "scheduled",
scheduler_generation: int | None = None,
) -> tuple[bool, str]:
"""
唤醒 Agent 执行指定自主定时任务。
@@ -1582,19 +1612,23 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
"""
from app.application.agent import get_running_agent_manager
try:
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过 Agent 定时任务")
return False, "智能助手服务未运行"
return await manager.execute_scheduled_task(
task_id,
trigger_source=trigger_source,
manager = get_running_agent_manager()
if manager is None:
logger.warning("智能助手服务未运行,跳过 Agent 定时任务")
return False, "智能助手服务未运行"
if scheduler_generation is None:
job_id = self._get_agent_task_job_id(task_id)
with self._lock:
job = self._jobs.get(job_id)
if job is not None:
scheduler_generation = job.get("_generation", 0)
kwargs: dict[str, Any] = {"trigger_source": trigger_source}
if scheduler_generation is not None:
kwargs.update(
scheduler_generation=scheduler_generation,
remove_schedule=self._remove_agent_task_job_generation,
)
finally:
task = await AgentTaskOper().async_get(task_id)
if task and task.trigger_type == "date" and not task.enabled:
self.remove_agent_task_job(task_id)
return await manager.execute_scheduled_task(task_id, **kwargs)
def init_plugin_jobs(self):
"""
+9
View File
@@ -95,6 +95,10 @@ from app.application.site.query import SiteQueryService, configure_site_query_se
from app.application.site.health import SiteHealthService, configure_site_health_service
from app.application.workflow import WorkflowQueryService, configure_workflow_query
from app.application.agentdata import configure_agent_data_ports
from app.application.agenttask import (
AgentTaskExecutionService,
configure_agent_task_execution,
)
from app.api.data import ApiDataPorts, configure_api_data_runtime
from app.application.subscription.write import configure_subscribe_writer
from app.adapters.external.server import (
@@ -831,6 +835,11 @@ async def init_modules() -> HostRuntime:
workflow=lambda: WorkflowOper(),
plugin_data=lambda: PluginDataOper(),
)
configure_agent_task_execution(AgentTaskExecutionService(
repository=lambda session: AgentTaskOper(session),
async_executor=database_worker,
sync_transaction=transaction_runner.sync,
))
configure_subscribe_writer(
lambda: TransactionalSubscribeWriter(
sync_session=SessionFactory,