feat(agent): record task run history (#6305)

This commit is contained in:
InfinityPacer
2026-08-13 22:13:36 +08:00
committed by GitHub
parent 4de4fa4391
commit 57e7f86f38
12 changed files with 1016 additions and 128 deletions

View File

@@ -2941,11 +2941,16 @@ class AgentManager:
await agent.cleanup()
memory_manager.clear_memory(session_id, user_id)
async def execute_scheduled_task(self, task_id: int) -> tuple[bool, str]:
async def execute_scheduled_task(
self,
task_id: int,
trigger_source: str = "scheduled",
) -> tuple[bool, str]:
"""
按持久化上下文唤醒 Agent 执行自主定时任务并向用户回传结果。
:param task_id: Agent 定时任务 ID
:param trigger_source: 触发入口scheduled-自动调度manual-显式立即执行
:return: 执行是否成功及结果摘要
"""
if not settings.AI_AGENT_ENABLE:
@@ -2954,23 +2959,27 @@ class AgentManager:
task = oper.get(task_id)
if not task or not task.enabled:
return False, "Agent 定时任务不存在或已停用"
if not oper.mark_running(task_id):
run = oper.begin_run(task_id=task_id, trigger_source=trigger_source)
if not run:
return False, "Agent 定时任务当前不可执行"
trigger_description = (
"已手动触发" if run.trigger_source == "manual" else "已按计划触发"
)
task_message = (
f"定时任务已按计划触发。请立即完成下面的任务,不要只确认收到,"
f"定时任务{trigger_description}。请立即完成下面的任务,不要只确认收到,"
f"也不要重复创建同一个定时任务。\n\n"
f"任务名称:{task.name}\n"
f"任务内容:{task.content}\n\n"
f"任务名称:{run.name}\n"
f"任务内容:{run.content}\n\n"
"完成后请直接向用户发送消息报告本次执行结果;如果无法完成,也需发送消息说明原因。"
)
success = True
result = ""
notification_username = task.username or settings.SUPERUSER
notification_username = run.username or settings.SUPERUSER
try:
result = await self.process_message(
session_id=task.session_id,
user_id=task.user_id,
session_id=run.session_id,
user_id=run.user_id,
message=task_message,
channel=None,
source=None,
@@ -2996,23 +3005,17 @@ class AgentManager:
Notification(
mtype=NotificationType.Agent,
username=notification_username,
title=f"定时任务执行失败:{task.name}",
title=f"定时任务执行失败:{run.name}",
text=result,
save_history=False,
)
)
finally:
current_task = oper.get(task_id)
oper.finish(
task_id=task_id,
oper.finish_run(
run_id=run.run_id,
success=success,
result=str(result or ""),
disable=bool(
current_task
and task.trigger_type == "date"
and current_task.trigger_type == task.trigger_type
and current_task.run_at == task.run_at
),
disable_date_task=run.trigger_type == "date",
)
return success, str(result or "任务执行完成")

View File

@@ -64,6 +64,15 @@ class QueryAgentTasksTool(MoviePilotTool):
next_run_at=scheduler.get_agent_task_next_run(task.id),
timezone=settings.TZ,
)
if task_id:
data["recent_runs"] = [
oper.run_to_dict(run)
for run in oper.list_runs(
task_id=task.id,
user_id=str(self._user_id),
limit=10,
)
]
result.append(data)
return result

View File

@@ -164,11 +164,16 @@ class UpdateAgentTaskTool(MoviePilotTool):
if payload.enabled and task.last_status != "interrupted":
update_payload["last_status"] = "waiting"
oper.update(
updated = oper.update(
task_id=payload.task_id,
payload=update_payload,
user_id=str(self._user_id),
)
if not updated:
current = oper.get(task_id=payload.task_id, user_id=str(self._user_id))
if current and current.last_status == "running":
return {"error": f"Agent 定时任务 {payload.task_id} 正在执行,请稍后再修改"}
return None
scheduler = Scheduler()
next_run_at = scheduler.update_agent_task_job(payload.task_id)
updated_task = oper.get(task_id=payload.task_id, user_id=str(self._user_id))

View File

@@ -1,8 +1,12 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from uuid import uuid4
from app.db import DbOper
from app.db.models.agenttask import AgentTask
from app.db.models.agenttaskrun import AgentTaskRun
class AgentTaskOper(DbOper):
@@ -86,32 +90,80 @@ class AgentTaskOper(DbOper):
def delete(self, task_id: int, user_id: Optional[str] = None) -> bool:
"""
删除 Agent 定时任务。
删除非运行中的 Agent 定时任务及其运行历史
"""
return AgentTask.delete_task(
return AgentTaskRun.delete_task_and_runs(
self._db,
task_id=task_id,
user_id=user_id,
)
def mark_running(self, task_id: int) -> bool:
def begin_run(
self,
task_id: int,
trigger_source: str = "scheduled",
) -> Optional[AgentTaskRun]:
"""
将 Agent 定时任务标记为运行中
原子创建一次运行并返回其任务快照
"""
return AgentTask.mark_running(
run_id = uuid4().hex
created_run_id = AgentTaskRun.begin_run(
self._db,
task_id=task_id,
run_at=self._now(),
run_id=run_id,
trigger_source=trigger_source,
started_at=self._now(),
)
return self.get_run(created_run_id) if created_run_id else None
def mark_running(self, task_id: int) -> bool:
"""兼容既有调用并为该次执行创建运行记录。"""
return self.begin_run(task_id=task_id) is not None
def mark_interrupted(self, task_id: int, result: str) -> bool:
"""
将遗留的运行中任务标记为中断且结果未知。
"""
return AgentTask.mark_interrupted(
return AgentTaskRun.interrupt_task(
self._db,
task_id=task_id,
result=(result or "")[:20000],
finished_at=self._now(),
)
def get_run(self, run_id: str) -> Optional[AgentTaskRun]:
"""查询一次 Agent 任务运行。"""
return AgentTaskRun.get_by_run_id(self._db, run_id=run_id)
def list_runs(
self,
task_id: int,
user_id: Optional[str] = None,
limit: int = 10,
) -> list[AgentTaskRun]:
"""查询任务最近的有界运行历史。"""
return AgentTaskRun.list_for_task(
self._db,
task_id=task_id,
user_id=user_id,
limit=limit,
)
def finish_run(
self,
run_id: str,
success: bool,
result: str,
disable_date_task: bool = False,
) -> bool:
"""收口精确运行并更新仍匹配的任务投影。"""
return AgentTaskRun.finish_run(
self._db,
run_id=run_id,
success=success,
result=(result or "")[:20000],
finished_at=self._now(),
disable_date_task=disable_date_task,
)
def finish(
@@ -124,12 +176,14 @@ class AgentTaskOper(DbOper):
"""
记录 Agent 定时任务执行结果。
"""
return AgentTask.finish_task(
self._db,
task_id=task_id,
task = self.get(task_id)
if not task or not task.last_run_id:
return False
return self.finish_run(
run_id=task.last_run_id,
success=success,
result=(result or "")[:20000],
disable=disable,
result=result,
disable_date_task=disable,
)
@staticmethod
@@ -153,8 +207,27 @@ class AgentTaskOper(DbOper):
"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 or 0,
"next_run_at": next_run_at,
"created_at": task.created_at,
"updated_at": task.updated_at,
}
@staticmethod
def run_to_dict(run: AgentTaskRun) -> dict:
"""将一次 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,
}

View File

@@ -1,5 +1,6 @@
from .agentchat import AgentChat
from .agenttask import AgentTask
from .agenttaskrun import AgentTaskRun
from .downloadfailure import DownloadFailure
from .downloadhistory import DownloadHistory, DownloadFiles
from .mediaserver import MediaServerItem

View File

@@ -1,4 +1,3 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, Column, Index, Integer, String, Text
@@ -36,6 +35,8 @@ class AgentTask(Base):
last_status = Column(String, nullable=False, default="waiting")
last_run_at = Column(String)
last_result = Column(Text)
# 最新一次真实执行的公开 ID用于保护 last_* 投影不被旧运行覆盖
last_run_id = Column(String)
# 已收口执行次数;进程中断的未完成尝试不计入
run_count = Column(Integer, nullable=False, default=0)
created_at = Column(String, nullable=False)
@@ -101,95 +102,15 @@ class AgentTask(Base):
user_id: Optional[str] = None,
) -> bool:
"""
按任务 ID 和可选用户 ID 更新 Agent 定时任务
仅在任务未运行时按任务 ID 和可选用户 ID 更新配置
运行状态与配置必须在同一条条件更新中判定,避免执行认领后被并发配置写入
覆盖回可再次执行的状态。
"""
query = db.query(cls).filter(cls.id == task_id)
query = db.query(cls).filter(
cls.id == task_id,
cls.last_status != "running",
)
if user_id is not None:
query = query.filter(cls.user_id == user_id)
return bool(query.update(payload))
@classmethod
@db_update
def delete_task(
cls,
db: Session,
task_id: int,
user_id: Optional[str] = None,
) -> bool:
"""
按任务 ID 和可选用户 ID 删除 Agent 定时任务。
"""
query = db.query(cls).filter(cls.id == task_id)
if user_id is not None:
query = query.filter(cls.user_id == user_id)
return bool(query.delete())
@classmethod
@db_update
def mark_running(cls, db: Session, task_id: int, run_at: str) -> bool:
"""
将可执行任务标记为运行中。
"""
updated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return bool(
db.query(cls)
.filter(
cls.id == task_id,
cls.enabled.is_(True),
cls.last_status != "running",
)
.update(
{
"last_status": "running",
"last_run_at": run_at,
"updated_at": updated_at,
}
)
)
@classmethod
@db_update
def mark_interrupted(cls, db: Session, task_id: int, result: str) -> bool:
"""
将服务重启时遗留的运行中任务标记为结果未知。
该状态保留原执行时间和计数,避免把可能已经产生副作用的执行误记为
从未开始或完整失败。
"""
return bool(
db.query(cls)
.filter(
cls.id == task_id,
cls.last_status == "running",
)
.update(
{
"last_status": "interrupted",
"last_result": result,
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
)
)
@classmethod
@db_update
def finish_task(
cls,
db: Session,
task_id: int,
success: bool,
result: str,
disable: bool = False,
) -> bool:
"""
记录 Agent 定时任务执行结果,并按需关闭单次任务。
"""
payload = {
"last_status": "success" if success else "failed",
"last_result": result,
"run_count": cls.run_count + 1,
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
if disable:
payload["enabled"] = False
return bool(db.query(cls).filter(cls.id == task_id).update(payload))

View File

@@ -0,0 +1,254 @@
from typing import Optional
from sqlalchemy import Column, Index, Integer, String, Text, update
from sqlalchemy.orm import Session
from app.db import Base, db_query, db_update, get_id_column
from app.db.models.agenttask import AgentTask
class AgentTaskRun(Base):
"""Agent 自主定时任务的一次真实执行记录。"""
id = get_id_column()
# 对外稳定的运行身份;内部自增主键不进入 Agent 合同
run_id = Column(String, nullable=False)
# 所属计划及触发入口
task_id = Column(Integer, nullable=False)
trigger_source = Column(String, nullable=False)
# 执行开始时的任务与用户上下文快照
name = Column(String, nullable=False)
content = Column(Text, nullable=False)
trigger_type = Column(String, nullable=False)
cron_expression = Column(String)
run_at = Column(String)
user_id = Column(String, nullable=False)
username = Column(String)
session_id = Column(String, nullable=False)
channel = Column(String)
message_source = Column(String)
original_chat_id = Column(String)
# running-success/failed/interrupted取消沿用 failed 和明确结果文本
status = Column(String, nullable=False)
started_at = Column(String, nullable=False)
finished_at = Column(String)
result = Column(Text)
__table_args__ = (
Index("ix_agenttaskrun_run_id", "run_id", unique=True),
Index("ix_agenttaskrun_task_started", "task_id", "started_at", "id"),
)
@classmethod
@db_update
def begin_run(
cls,
db: Session,
task_id: int,
run_id: str,
trigger_source: str,
started_at: str,
) -> Optional[str]:
"""原子认领可执行任务并创建对应的运行记录。"""
if trigger_source not in {"scheduled", "manual"}:
raise ValueError(f"不支持的 Agent 任务触发来源:{trigger_source}")
# 认领和快照读取必须是同一条语句,配置更新与执行开始才能共享同一行级顺序。
claimed = db.execute(
update(AgentTask)
.where(
AgentTask.id == task_id,
AgentTask.enabled.is_(True),
AgentTask.last_status != "running",
)
.values({
"last_status": "running",
"last_run_at": started_at,
"last_run_id": run_id,
"updated_at": started_at,
})
.returning(
AgentTask.id,
AgentTask.name,
AgentTask.content,
AgentTask.trigger_type,
AgentTask.cron_expression,
AgentTask.run_at,
AgentTask.user_id,
AgentTask.username,
AgentTask.session_id,
AgentTask.channel,
AgentTask.source,
AgentTask.original_chat_id,
)
.execution_options(synchronize_session=False)
).mappings().first()
if not claimed:
return None
db.add(cls(
run_id=run_id,
task_id=claimed["id"],
trigger_source=trigger_source,
name=claimed["name"],
content=claimed["content"],
trigger_type=claimed["trigger_type"],
cron_expression=claimed["cron_expression"],
run_at=claimed["run_at"],
user_id=claimed["user_id"],
username=claimed["username"],
session_id=claimed["session_id"],
channel=claimed["channel"],
message_source=claimed["source"],
original_chat_id=claimed["original_chat_id"],
status="running",
started_at=started_at,
))
db.flush()
return run_id
@classmethod
@db_update
def finish_run(
cls,
db: Session,
run_id: str,
success: bool,
result: str,
finished_at: str,
disable_date_task: bool = False,
) -> bool:
"""原子收口精确运行,并仅在仍为最新运行时更新任务投影。"""
run = db.query(cls).filter(
cls.run_id == run_id,
).first()
if not run:
return False
status = "success" if success else "failed"
finalized = db.query(cls).filter(
cls.run_id == run_id,
cls.status == "running",
).update(
{
"status": status,
"result": result,
"finished_at": finished_at,
},
synchronize_session=False,
)
if not finalized:
return False
task = db.query(AgentTask).filter(
AgentTask.id == run.task_id,
AgentTask.last_run_id == run_id,
).first()
if task:
payload = {
"last_status": status,
"last_result": result,
"run_count": AgentTask.run_count + 1,
"updated_at": finished_at,
}
if (
disable_date_task
and run.trigger_type == "date"
and task.trigger_type == run.trigger_type
and task.run_at == run.run_at
):
payload["enabled"] = False
db.query(AgentTask).filter(
AgentTask.id == run.task_id,
AgentTask.last_run_id == run_id,
).update(payload, synchronize_session=False)
return True
@classmethod
@db_update
def interrupt_task(
cls,
db: Session,
task_id: int,
result: str,
finished_at: str,
) -> bool:
"""原子标记冷启动时遗留的最新运行及任务投影为结果未知。"""
task = db.query(AgentTask).filter(
AgentTask.id == task_id,
AgentTask.last_status == "running",
).first()
if not task:
return False
if task.last_run_id:
interrupted = db.query(cls).filter(
cls.run_id == task.last_run_id,
cls.task_id == task.id,
cls.status == "running",
).update(
{
"status": "interrupted",
"result": result,
"finished_at": finished_at,
},
synchronize_session=False,
)
if not interrupted:
return False
return bool(db.query(AgentTask).filter(
AgentTask.id == task.id,
AgentTask.last_status == "running",
AgentTask.last_run_id == task.last_run_id,
).update(
{
"last_status": "interrupted",
"last_result": result,
"updated_at": finished_at,
},
synchronize_session=False,
))
@classmethod
@db_update
def delete_task_and_runs(
cls,
db: Session,
task_id: int,
user_id: Optional[str] = None,
) -> bool:
"""原子删除非运行中任务及其执行历史。"""
query = db.query(AgentTask).filter(
AgentTask.id == task_id,
AgentTask.last_status != "running",
)
if user_id is not None:
query = query.filter(AgentTask.user_id == user_id)
deleted = query.delete(synchronize_session=False)
if not deleted:
return False
db.query(cls).filter(cls.task_id == task_id).delete(synchronize_session=False)
return True
@classmethod
@db_query
def get_by_run_id(
cls,
db: Session,
run_id: str,
) -> Optional["AgentTaskRun"]:
"""按公开运行 ID 查询一次执行。"""
return db.query(cls).filter(cls.run_id == run_id).first()
@classmethod
@db_query
def list_for_task(
cls,
db: Session,
task_id: int,
user_id: Optional[str] = None,
limit: int = 10,
) -> list["AgentTaskRun"]:
"""按父任务 owner 校验后返回最近的有界运行历史。"""
query = db.query(cls).join(AgentTask, AgentTask.id == cls.task_id).filter(
cls.task_id == task_id,
)
if user_id is not None:
query = query.filter(AgentTask.user_id == user_id)
return query.order_by(cls.started_at.desc(), cls.id.desc()).limit(limit).all()

View File

@@ -1065,7 +1065,7 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
job = self._jobs.get(job_id)
if not job or job.get("running"):
return False
self.start(job_id)
self.start(job_id, task_id=task_id, trigger_source="manual")
return True
def init_agent_task_jobs(self) -> None:
@@ -1208,17 +1208,25 @@ class Scheduler(ConfigReloadMixin, metaclass=SingletonClass):
else None
)
async def execute_agent_task(self, task_id: int) -> tuple[bool, str]:
async def execute_agent_task(
self,
task_id: int,
trigger_source: str = "scheduled",
) -> tuple[bool, str]:
"""
唤醒 Agent 执行指定自主定时任务。
:param task_id: Agent 定时任务 ID
:param trigger_source: 触发入口scheduled-自动调度manual-显式立即执行
:return: 执行是否成功及结果摘要
"""
from app.agent import agent_manager
try:
return await agent_manager.execute_scheduled_task(task_id)
return await agent_manager.execute_scheduled_task(
task_id,
trigger_source=trigger_source,
)
finally:
task = AgentTaskOper().get(task_id)
if task and task.trigger_type == "date" and not task.enabled: