from typing import Any, Dict, List, Optional from sqlalchemy import Index, Integer, String, Text, delete, select, update from sqlalchemy.orm import Mapped, Session, mapped_column from app.db.base import Base, execute_dml, get_id_column from app.db.models.agenttask import AgentTask class AgentTaskRun(Base): """Agent 自主定时任务的一次真实执行记录。""" id = get_id_column() # 对外稳定的运行身份;内部自增主键不进入 Agent 合同 run_id: Mapped[str] = mapped_column(String, nullable=False) # 所属计划及触发入口 task_id: Mapped[int] = mapped_column(Integer, nullable=False) trigger_source: Mapped[str] = mapped_column(String, nullable=False) # 执行开始时的任务与用户上下文快照 name: Mapped[str] = mapped_column(String, nullable=False) content: Mapped[str] = mapped_column(Text, nullable=False) trigger_type: Mapped[str] = mapped_column(String, nullable=False) cron_expression: Mapped[Optional[str]] = mapped_column(String) run_at: Mapped[Optional[str]] = mapped_column(String) user_id: Mapped[str] = mapped_column(String, nullable=False) username: Mapped[Optional[str]] = mapped_column(String) session_id: Mapped[str] = mapped_column(String, nullable=False) channel: Mapped[Optional[str]] = mapped_column(String) message_source: Mapped[Optional[str]] = mapped_column(String) original_chat_id: Mapped[Optional[str]] = mapped_column(String) # running-success/failed/interrupted;取消沿用 failed 和明确结果文本 status: Mapped[str] = mapped_column(String, nullable=False) started_at: Mapped[str] = mapped_column(String, nullable=False) finished_at: Mapped[Optional[str]] = mapped_column(String) result: Mapped[Optional[str]] = mapped_column(Text) __table_args__ = ( Index("ix_agenttaskrun_run_id", "run_id", unique=True), Index("ix_agenttaskrun_task_started", "task_id", "started_at", "id"), ) @classmethod 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 def finish_run( cls, db: Session, run_id: str, success: bool, result: str, finished_at: str, disable_date_task: bool = False, ) -> bool: """原子收口精确运行,并仅在仍为最新运行时更新任务投影。""" run = db.execute( select(cls).where(cls.run_id == run_id) ).scalars().first() if not run: return False status = "success" if success else "failed" finalized = execute_dml( db, update(cls) .where( cls.run_id == run_id, cls.status == "running", ) .values( status=status, result=result, finished_at=finished_at, ), execution_options={"synchronize_session": False}, ) if not finalized: return False task = db.execute( select(AgentTask).where( AgentTask.id == run.task_id, AgentTask.last_run_id == run_id, ) ).scalars().first() if task: payload: Dict[str, Any] = { "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 execute_dml( db, update(AgentTask) .where( AgentTask.id == run.task_id, AgentTask.last_run_id == run_id, ) .values(**payload), execution_options={"synchronize_session": False}, ) return True @classmethod def interrupt_task( cls, db: Session, task_id: int, result: str, finished_at: str, ) -> bool: """原子标记冷启动时遗留的最新运行及任务投影为结果未知。""" task = db.execute( select(AgentTask).where( AgentTask.id == task_id, AgentTask.last_status == "running", ) ).scalars().first() if not task: return False if task.last_run_id: interrupted = execute_dml( db, update(cls) .where( cls.run_id == task.last_run_id, cls.task_id == task.id, cls.status == "running", ) .values( status="interrupted", result=result, finished_at=finished_at, ), execution_options={"synchronize_session": False}, ) if not interrupted: return False return bool(execute_dml( db, update(AgentTask) .where( AgentTask.id == task.id, AgentTask.last_status == "running", AgentTask.last_run_id == task.last_run_id, ) .values( last_status="interrupted", last_result=result, updated_at=finished_at, ), execution_options={"synchronize_session": False}, )) @classmethod def delete_task_and_runs( cls, db: Session, task_id: int, user_id: Optional[str] = None, ) -> bool: """原子删除非运行中任务及其执行历史。""" statement = delete(AgentTask).where( AgentTask.id == task_id, AgentTask.last_status != "running", ) if user_id is not None: statement = statement.where(AgentTask.user_id == user_id) deleted = execute_dml( db, statement, execution_options={"synchronize_session": False} ) if not deleted: return False execute_dml( db, delete(cls).where(cls.task_id == task_id), execution_options={"synchronize_session": False}, ) return True @classmethod def get_by_run_id( cls, db: Session, run_id: str, ) -> Optional["AgentTaskRun"]: """按公开运行 ID 查询一次执行。""" return db.execute( select(cls).where(cls.run_id == run_id) ).scalars().first() @classmethod def list_for_task( cls, db: Session, task_id: int, user_id: Optional[str] = None, limit: int = 10, ) -> List["AgentTaskRun"]: """按父任务 owner 校验后返回最近的有界运行历史。""" statement = select(cls).join(AgentTask, AgentTask.id == cls.task_id).where( cls.task_id == task_id, ) if user_id is not None: statement = statement.where(AgentTask.user_id == user_id) return list(db.execute( statement.order_by(cls.started_at.desc(), cls.id.desc()).limit(limit) ).scalars().all())