"""Мандат ИИ-агента: что агенту можно и как он берёт задачу в работу (ТЗ 3.20).
Без флага `ai_eligible` агент задачу не меняет — только читает. Это не охрана:
держатель токена и так действует как владелец (3.20), а рамка нужна, чтобы агент не
трогал то, что владелец ему не поручал.
Взятие в работу — аренда (lease): задача помечается арендатором на два часа, и живая
чужая аренда блокирует не только взятие, но и изменение задачи агентом — иначе второй
агент закроет работу первого. Протухшая аренда считается свободной сама, по времени:
фоновая задача для этого не нужна, а сервер может быть выключен месяцами.
Ошибки — доменным исключением: REST отдаёт по нему 403/409, MCP — текст с подсказкой
следующего шага (стиль 3.10), а правила остаются одними для обоих путей.
"""
from datetime import timedelta
from sqlalchemy import and_, or_
from sqlalchemy.sql.elements import ColumnElement
from app.actor import Actor
from app.models import Task, utcnow
# Аренда живёт два часа: агент продлевает её повторным вызовом (heartbeat),
# пропал — задача сама вернулась в свободные
CLAIM_TTL = timedelta(hours=2)
class MandateError(Exception):
"""Действие агента вне мандата: владелец задачу ему не поручал."""
class LeaseBusyError(MandateError):
"""Задачу держит другой агент, и аренда ещё жива (конфликт, не «нельзя»)."""
def ensure_agent_can_mutate(task: Task, actor: Actor) -> None:
"""Агент меняет только задачи с флагом доступности; владелец — любые."""
if actor.is_agent and not task.ai_eligible:
raise MandateError(f"Task {task.id} is not available to an AI agent")
def ensure_owner_sets_eligible(actor: Actor) -> None:
"""Флаг доступности ставит владелец: агент не выдаёт себе мандат задним числом.
Своя задача — другое дело: при создании агент вправе попросить мандат (3.20).
"""
if actor.is_agent:
raise MandateError("ai_eligible is set by the owner")
def holder_token_id(task: Task) -> int | None:
"""Живая аренда: id токена арендатора (None — свободна; протухшая — тоже свободна).
Токена может уже не быть (отозвали) — тогда аренда бесхозна и задачу можно взять.
"""
if not task.claim_is_alive:
return None
return task.claimed_by_token_id
def foreign_lease(task: Task, actor: Actor) -> bool:
"""Задачу держит другой агент, и аренда жива (ТЗ 3.20)."""
holder = holder_token_id(task)
return holder is not None and holder != actor.token_id
def ensure_agent_can_touch(task: Task, actor: Actor) -> None:
"""Полная рамка агента: флаг доступности и чужая живая аренда."""
ensure_agent_can_mutate(task, actor)
if foreign_lease(task, actor):
raise LeaseBusyError(f"Task {task.id} is claimed by {task.claimed_by or 'another agent'}")
def available_condition() -> ColumnElement[bool]:
"""Условие «задачу можно взять»: тот же предикат, что и в claim_task.
Общий на список свободных и на само взятие: разойдись они — задача числилась бы
взятой, не показываясь в списке (или наоборот).
"""
return and_(
Task.ai_eligible.is_(True),
Task.status == "to_do",
or_(Task.claim_expires_at.is_(None), Task.claim_expires_at < utcnow()),
)
def available_to_agent(actor: Actor) -> ColumnElement[bool]:
"""Задачи, которые этот агент может взять — или уже держит (для heartbeat).
Единственное расхождение со «свободными»: своя живая аренда. Задача, взятая
агентом, остаётся в его списке — иначе он терял бы её из виду, не доделав.
"""
free = available_condition()
if actor.token_id is None:
return free
return or_(free, Task.claimed_by_token_id == actor.token_id)
def claim_task(task: Task, actor: Actor) -> bool:
"""Взять задачу в работу. True — аренда выдана впервые, False — продление.
Продление (heartbeat) даёт агенту спокойно работать дольше двух часов: аренда
отодвигается, но событие журнала не пишется — иначе журнал забился бы пингами.
"""
if not actor.is_agent:
raise MandateError("Task claim is for AI agents")
if actor.token_id is None:
raise MandateError("MCP token is required to claim a task")
ensure_agent_can_mutate(task, actor)
if task.status != "to_do":
# Взять можно только не начатую задачу: in_progress уже чья-то работа
raise MandateError(f"Task {task.id} is not in to_do")
holder = holder_token_id(task)
if holder is not None and holder != actor.token_id:
raise LeaseBusyError(f"Task {task.id} is claimed by {task.claimed_by or 'another agent'}")
fresh = holder is None
if fresh:
task.claimed_at = utcnow()
task.claimed_by_token_id = actor.token_id
task.claimed_by_name = actor.name or None
task.claim_expires_at = utcnow() + CLAIM_TTL
return fresh
def release_claim(task: Task, actor: Actor) -> bool:
"""Вернуть задачу в свободные. False — аренды не было (повторный вызов не ошибка).
Чужую аренду агент не отбирает; владелец своим действием снимает любую.
"""
holder = holder_token_id(task)
if holder is not None and actor.is_agent and holder != actor.token_id:
raise LeaseBusyError(f"Task {task.id} is claimed by {task.claimed_by or 'another agent'}")
clear_claim(task)
return holder is not None
def clear_claim(task: Task) -> None:
"""Снять аренду: выход из to_do/in_progress, закрытие, удаление, действие владельца."""
task.claimed_by_token_id = None
task.claimed_by_name = None
task.claimed_at = None
task.claim_expires_at = None