Newer
Older
gnexus-tasks / backend / app / services / claims.py
"""Мандат ИИ-агента: что агенту можно и как он берёт задачу в работу (ТЗ 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