Files
MoviePilot/app/application/agenttask.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

576 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent 自主定时任务执行的异步应用边界。
"""
from __future__ import annotations
import asyncio
import threading
from collections.abc import Callable
from dataclasses import dataclass
from typing import List, Optional, 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")
class SyncTransaction(Protocol):
"""
在同步事务内执行回调,每次调用独立保留回调的结果类型。
"""
def __call__(self, operation: Callable[[object], T], /) -> T:
"""
由事务 owner 提供会话,并原样返回本次操作结果。
:param operation: 接收当前事务会话的同步业务操作;正常返回后提交,异常时回滚
:return: 操作返回的原始结果,仅在事务提交完成后返回
"""
...
@dataclass(frozen=True, slots=True)
class AgentTaskSnapshot:
"""
脱离数据库 Session 的自主任务完整快照。
:param id: 持久化任务主键
:param name: 任务展示名称
:param content: 触发执行时提交给 Agent 的任务指令
:param trigger_type: 触发类型:cron 为周期任务,date 为一次性任务
:param cron_expression: 周期任务的 Cron 表达式,非周期任务可为 None
:param run_at: 一次性任务的计划执行时间字符串,周期任务可为 None
:param enabled: 任务是否允许调度执行
:param user_id: 任务所属真实用户标识
:param username: 用户显示名称,未提供时为 None
:param session_id: 任务绑定的 Agent 会话标识
:param channel: 结果通知使用的渠道,未配置时为 None
:param source: 通知渠道实例标识,未配置时为 None
:param original_chat_id: 结果回复使用的渠道聊天标识,未配置时为 None
:param last_status: 任务最近一次运行的状态投影
:param last_run_at: 最近一次开始运行的时间字符串,未运行时为 None
:param last_result: 最近一次运行的结果摘要,未产生时为 None
:param last_run_id: 最近一次运行标识,未运行时为 None
:param run_count: 任务累计运行次数
:param created_at: 任务创建时间字符串
:param updated_at: 任务最近更新时间字符串
"""
id: int
name: str
content: str
trigger_type: str
cron_expression: str | None
run_at: str | None
enabled: bool
user_id: str
username: str | None
session_id: str
channel: str | None
source: str | None
original_chat_id: str | None
last_status: str
last_run_at: str | None
last_result: str | None
last_run_id: str | None
run_count: int
created_at: str
updated_at: str
@dataclass(frozen=True, slots=True)
class AgentTaskRunSnapshot:
"""
脱离数据库 Session 的自主任务运行快照。
:param run_id: 本次运行的稳定标识,用于收口与去重
:param task_id: 所属持久化任务主键
:param trigger_source: 运行触发来源,区分定时调度与用户手动触发
:param name: 任务展示名称
:param content: 触发执行时提交给 Agent 的任务指令
:param trigger_type: 触发类型:cron 为周期任务,date 为一次性任务
:param cron_expression: 周期任务的 Cron 表达式,非周期任务可为 None
:param run_at: 一次性任务的计划执行时间字符串,周期任务可为 None
:param user_id: 任务所属真实用户标识
:param username: 用户显示名称,未提供时为 None
:param session_id: 任务绑定的 Agent 会话标识
:param channel: 结果通知使用的渠道,未配置时为 None
:param message_source: 本次运行快照中的通知渠道实例标识
:param original_chat_id: 结果回复使用的渠道聊天标识,未配置时为 None
:param status: 本次运行的持久化执行状态
:param started_at: 本次运行开始时间字符串
:param finished_at: 本次运行结束时间字符串,尚未结束时为 None
:param result: 本次运行的结果摘要,尚未产生时为 None
"""
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
channel: str | None
message_source: str | None
original_chat_id: str | None
status: str
started_at: str
finished_at: str | None
result: str | None
class AgentTaskRepository(Protocol):
"""
调度、工具与执行用例共享的类型化自主任务仓储合同。
"""
def add(self, **values: object) -> AgentTaskSnapshot | None:
"""
新增任务并返回提交后的冻结快照。
:param values: 包含名称、任务指令、触发配置及用户会话归属的创建字段
:return: 提交成功后的任务快照,无法创建时为 None
"""
...
def get(
self,
task_id: int,
user_id: Optional[str] = None,
) -> AgentTaskSnapshot | None:
"""
读取任务当前投影。
:param task_id: 持久化任务主键
:param user_id: 限定任务归属的用户标识,None 表示不按用户过滤
:return: 匹配的任务快照,不存在或不属于指定用户时为 None
"""
...
def list(
self,
user_id: Optional[str] = None,
enabled: Optional[bool] = None,
) -> list[AgentTaskSnapshot]:
"""
按归属和启用状态读取任务快照。
:param user_id: 限定任务归属的用户标识,None 表示不按用户过滤
:param enabled: 启用状态筛选,None 表示不限制
:return: 符合归属及启用条件的任务快照列表
"""
...
def update(
self,
task_id: int,
payload: dict[str, object],
user_id: Optional[str] = None,
) -> bool:
"""
更新非运行中任务。
:param task_id: 待更新的任务主键
:param payload: 允许更新的任务定义与状态字段,其他字段由仓储忽略
:param user_id: 限定任务归属的用户标识,None 表示不按用户过滤
:return: 更新成功时为 True,无有效更新字段、记录不匹配或任务运行中时为 False
"""
...
def delete(self, task_id: int, user_id: Optional[str] = None) -> bool:
"""
删除非运行中任务及其运行记录。
:param task_id: 待删除的任务主键
:param user_id: 限定任务归属的用户标识,None 表示不按用户过滤
:return: 删除成功时为 True,不存在、不匹配或任务正在运行时为 False
"""
...
def mark_interrupted(self, task_id: int, result: str) -> bool:
"""
把遗留运行态收口为结果未知。
:param task_id: 需要收口遗留运行态的任务主键
:param result: 向用户说明中断且结果未知的摘要
:return: 遗留运行态已收口时为 True,没有可收口记录时为 False
"""
...
def begin_run(
self,
task_id: int,
trigger_source: str = "scheduled",
*,
run_id: str | None = None,
) -> AgentTaskRunSnapshot | None:
"""
原子认领任务并创建运行快照。
:param task_id: 尝试认领的任务主键
:param trigger_source: 触发来源,默认 scheduled 表示定时调度,manual 表示手动触发
:param run_id: 调用方指定的稳定运行标识,None 时由仓储生成
:return: 认领成功的运行快照;无法认领时为 None,不得据此启动执行
"""
...
def list_runs(
self,
task_id: int,
user_id: Optional[str] = None,
limit: int = 10,
) -> List[AgentTaskRunSnapshot]:
"""
读取指定任务最近的运行快照。
:param task_id: 所属任务主键
:param user_id: 限定任务归属的用户标识,None 表示不按用户过滤
:param limit: 最多返回的最近运行条数,默认 10
:return: 按最近运行优先排列的运行快照,任务不匹配时为空列表
"""
...
def finish_run_outcome(
self,
run_id: str,
success: bool,
result: str,
) -> AgentTaskFinishRecord:
"""
收口运行并返回事务确认的终态事实。
:param run_id: 需要收口的运行标识
:param success: 本次执行是否成功,False 将记录为失败
:param result: 本次执行结果摘要
:return: 已提交的运行收口、任务投影更新及一次性任务停用事实
"""
class AgentTaskFinishRecord(Protocol):
"""
同步仓储返回的结构化运行终态。
"""
@property
def run_finalized(self) -> bool:
"""
运行记录是否已进入终态。
:return: 本次运行记录已进入终态时为 True
"""
...
@property
def task_projection_updated(self) -> bool:
"""
任务最新运行投影是否由本次执行更新。
:return: 任务最近运行状态确由本次运行更新时为 True
"""
...
@property
def date_task_disabled(self) -> bool:
"""
一次性任务是否已随本次执行停用。
:return: 本次一次性运行完成后任务已停用时为 True
"""
...
AgentTaskRepositoryFactory = Callable[[object], AgentTaskRepository]
AgentTaskScheduleRemover = Callable[[int, int, str], bool]
@dataclass(frozen=True, slots=True)
class AgentTaskClaim:
"""
任务认领结果;拒绝原因保持现有 Agent 用户提示合同。
:param run: 认领成功后冻结的运行快照,拒绝认领时为 None
:param rejection: 可向用户展示的拒绝原因,认领成功时为 None
"""
run: AgentTaskRunSnapshot | None
rejection: str | None = None
@dataclass(frozen=True, slots=True)
class AgentTaskFinishOutcome:
"""
区分运行收口、任务投影更新与一次任务停用三个事实。
:param run_finalized: 本次运行记录是否已成功进入终态
:param task_projection_updated: 任务的最近运行投影是否由本次运行更新
:param date_task_disabled: 一次性任务是否因本次运行已完成而停用
"""
run_finalized: bool
task_projection_updated: bool
date_task_disabled: bool
def agent_task_to_dict(
task: AgentTaskSnapshot,
*,
next_run_at: str | None = None,
timezone: str | None = None,
) -> dict[str, object]:
"""
把自主任务快照转换为 Agent 工具稳定返回结构。
"""
return {
"id": task.id,
"name": task.name,
"content": task.content,
"trigger_type": task.trigger_type,
"cron_expression": task.cron_expression,
"run_at": task.run_at,
"timezone": timezone,
"enabled": task.enabled,
"last_status": task.last_status,
"last_run_at": task.last_run_at,
"last_result": task.last_result,
"last_run_id": task.last_run_id,
"run_count": task.run_count,
"next_run_at": next_run_at,
"created_at": task.created_at,
"updated_at": task.updated_at,
}
def agent_task_run_to_dict(run: AgentTaskRunSnapshot) -> dict[str, object]:
"""
把自主任务运行快照转换为 Agent 工具稳定返回结构。
"""
return {
"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,
"status": run.status,
"started_at": run.started_at,
"finished_at": run.finished_at,
"result": run.result,
}
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: AgentTaskRunSnapshot) -> AgentTaskRunSnapshot:
"""
在事务内复制运行字段,避免 ORM 对象越过会话边界。
"""
return run
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
run_created = threading.Event()
def transaction(session: object) -> AgentTaskClaim:
"""
在同一事务内认领任务或生成拒绝快照,避免泄漏 ORM 对象。
"""
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 定时任务当前不可执行"
),
)
run_created.set()
return AgentTaskClaim(run=self._snapshot(run))
async def claim_to_terminal() -> AgentTaskClaim:
"""
容量瞬时耗尽时保留本轮调度,直到认领取得 admission。
"""
while True:
try:
claim_result: AgentTaskClaim = await self._async_executor.run(
lambda: self._sync_transaction(transaction)
)
return claim_result
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:
# 纯容量拒绝尚未进入事务,不存在需要等待数据库容量的补偿对象。
if not run_created.is_set():
raise 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:
finish_result: AgentTaskFinishOutcome = await self._async_executor.run(
lambda: self._sync_transaction(transaction)
)
return finish_result
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 reset_agent_task_execution() -> None:
"""
清除当前 lifespan 的 AgentTask 执行服务。
"""
global _service
_service = None
def get_agent_task_execution_service() -> AgentTaskExecutionService:
"""
返回已登记的 AgentTask 执行服务。
"""
if _service is None:
raise RuntimeError("AgentTask 执行服务尚未配置")
return _service