diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 4042795..cb831c9 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -62,17 +62,23 @@ return task +def _mandate_error(exc: Exception) -> HTTPException: + """Мандат нарушен → 403; занятая другим агентом задача → 409 (конфликт).""" + status = 409 if isinstance(exc, claims.LeaseBusyError) else 403 + return HTTPException(status_code=status, detail=str(exc)) + + def _mandate(actor: Actor, task: Task, *, sets_eligible: bool = False) -> None: - """Рамка мандата (ТЗ 3.20): агент — 403, если вышел за неё. + """Рамка мандата (ТЗ 3.20): агент — 403/409, если вышел за неё. sets_eligible — запрос меняет флаг доступности: его ставит только владелец. """ try: if sets_eligible: claims.ensure_owner_sets_eligible(actor) - claims.ensure_agent_can_mutate(task, actor) + claims.ensure_agent_can_touch(task, actor) except claims.MandateError as exc: - raise HTTPException(status_code=403, detail=str(exc)) from None + raise _mandate_error(exc) from None def _archived_project_ids(db: Any, user_id: str) -> Select[tuple[int]]: @@ -325,6 +331,11 @@ tasklog.log_event( db, task, tasklog.KIND_ELIGIBLE, actor, "on" if task.ai_eligible else "off" ) + # Аренда (ТЗ 3.20): действие владельца её снимает (задача снова свободна для + # агентов), а выход из работы — тем более: держать аренду на закрытой задаче + # значило бы «оживить» её следующим heartbeat'ом агента + if not actor.is_agent or task.status not in ("to_do", "in_progress"): + claims.clear_claim(task) db.flush() db.refresh(task) # перечитать связи (project/tags) после обновления @@ -341,6 +352,46 @@ return task +@router.post("/{task_id}/claim", response_model=TaskOut) +async def claim_task(task_id: int, db: DbDep, user_id: UserIdDep, actor: ActorDep) -> Task: + """Взять задачу в работу (ТЗ 3.20): аренда на 2 часа, повторный вызов — продление.""" + task = _get_task_for_update(db, task_id, user_id) + try: + fresh = claims.claim_task(task, actor) + except claims.MandateError as exc: + raise _mandate_error(exc) from None + if fresh: + tasklog.log_event(db, task, tasklog.KIND_CLAIMED, actor) + db.flush() + db.refresh(task) + db.commit() + # Продление (heartbeat) молчит: иначе агент, работающий часами, засыпал бы + # владельца событиями — а в задаче ничего не изменилось + if fresh: + publish(user_id, "task.changed", {"id": task.id}) + return task + + +@router.post("/{task_id}/release", response_model=TaskOut) +async def release_task( + task_id: int, db: DbDep, user_id: UserIdDep, actor: ActorDep, reason: str = Query("") +) -> Task: + """Вернуть задачу в свободные: агент понял, что она ему не по силам (ТЗ 3.20).""" + task = _get_task_for_update(db, task_id, user_id) + try: + had_lease = claims.release_claim(task, actor) + except claims.MandateError as exc: + raise _mandate_error(exc) from None + if had_lease: + tasklog.log_event(db, task, tasklog.KIND_RELEASED, actor, reason or None) + db.flush() + db.refresh(task) + db.commit() + if had_lease: + publish(user_id, "task.changed", {"id": task.id}) + return task + + @router.post("/{task_id}/approve", response_model=TaskOut) async def approve_task( task_id: int, db: DbDep, user_id: UserIdDep, schema: ApproveIn | None = None diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index c3b715e..733d018 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -44,6 +44,12 @@ " — ask the owner to mark the task as available for AI agents " "(ai_eligible), or pass is_user=true if you act for the owner" ) +# Подсказка к отказу во взятии в работу: брать задачу агент может только из +# списка свободных, флаг ставит владелец (см. _mandate_failure) +_CLAIM_HINT = ( + " — take a task from list_available_tasks; only the owner marks a task " + "as available for AI agents (ai_eligible)" +) def _mcp_user(ctx: Context[Any, Any, Any] | None) -> str: @@ -91,6 +97,17 @@ закрытие дублей не даёт. У регулярной задачи следующий экземпляр создаётся сам (поле spawned_next_id в ответе) — вручную создавать его не нужно. +ЧТО МОЖНО БРАТЬ В РАБОТУ +Владелец помечает задачи, которые доверяет ИИ-агенту. Такую задачу можно взять +в работу: list_available_tasks() покажет доступные, claim_task(task_id) возьмёт +её (аренда на 2 часа — пока она жива, задачу не тронет другой агент), дальше +работа и complete_task с комментарием; владелец примет работу или вернёт её. +Работа затягивается — повторяйте claim_task: это продление аренды. Задача не по +силам — release_task(task_id, reason=...), и она снова свободна. +Задачу без пометки владельца и задачу, взятую другим агентом, не трогайте: +ошибка скажет, что делать. Действовать от имени владельца (его поручение, не +своё) можно, передав is_user=true. + ПОИСК list_tasks(query="подстрока", project_id=..., status=...) ищет по заголовку и описанию; get_task(task_id) даёт полные данные. Найдя задачу, работайте с ней @@ -493,13 +510,14 @@ old_status = task.status old_eligible = task.ai_eligible # Мандат (ТЗ 3.20): без флага доступности агент задачу не меняет, а сам флаг - # ставит только владелец; владельцу рамка не мешает + # ставит только владелец; занятую чужим агентом задачу агент не трогает — + # иначе он закрыл бы работу того, кто её уже делает. Владельцу рамка не мешает try: if ai_eligible is not None: claims.ensure_owner_sets_eligible(actor) - claims.ensure_agent_can_mutate(task, actor) + claims.ensure_agent_can_touch(task, actor) except claims.MandateError as exc: - raise ValueError(f"{exc}{_MANDATE_HINT}") from None + raise _mandate_failure(exc, _MANDATE_HINT) from None if title is not None: if not title.strip(): raise ValueError("title cannot be empty") @@ -589,6 +607,9 @@ tasklog.log_event( session, task, tasklog.KIND_ELIGIBLE, actor, "on" if task.ai_eligible else "off" ) + # Аренда (ТЗ 3.20): действие владельца и выход из работы её снимают + if not actor.is_agent or task.status not in ("to_do", "in_progress"): + claims.clear_claim(task) session.commit() publish(user_id, "task.changed", {"id": task.id, "source": "mcp"}) if outcome is not None and outcome.event is not None: @@ -733,6 +754,134 @@ session.close() +def _available(task: Task, actor: Actor) -> dict[str, Any]: + """Свободная задача глазами агента: сжатая карточка плюс срок аренды.""" + data = _compact(task) + data["estimated_minutes"] = task.estimated_minutes + deadline = task.claim_deadline + data["claim_expires_at"] = deadline.isoformat() if deadline else None + data["claimed_by_me"] = task.claim_is_alive and task.claimed_by_token_id == actor.token_id + return data + + +def _get_task_locked(db: Any, task_id: int, user_id: str) -> Task: + """Задача с блокировкой строки: взятие в работу неделимо (ТЗ 3.20). + + В SQLite (тесты) FOR UPDATE игнорируется — гонку двух агентов ловит только + Postgres; правило «аренда одна» проверяет claims.claim_task. + """ + task = cast( + Task | None, + db.scalar( + select(Task) + .where(Task.id == task_id, Task.user_id == user_id) + .with_for_update(of=Task) + ), + ) + if task is None: + raise ValueError( + f"Task {task_id} not found — find the right id with list_available_tasks." + ) + return task + + +def _mandate_failure(exc: claims.MandateError, hint: str) -> ValueError: + """Отказ по мандату с подсказкой следующего шага (ТЗ 3.10). + + Чужая живая аренда — не «нельзя», а «занято»: подсказка у неё своя, иначе + агент пошёл бы выпрашивать флаг у владельца вместо другой задачи. + """ + busy = ( + " — another agent is working on it; take another task from list_available_tasks, " + "or repeat claim_task after the lease expires (2 hours)" + ) + return ValueError(f"{exc}{busy if isinstance(exc, claims.LeaseBusyError) else hint}") + + +def list_available_tasks(ctx: Context[Any, Any, Any] | None = None) -> list[dict[str, Any]]: + """Задачи, которые вы вправе взять в работу прямо сейчас (ТЗ 3.20). + + Только помеченные владельцем (ai_eligible), не начатые и не занятые другим + агентом. Здесь же остаются задачи, уже взятые вами: у них виден срок аренды + (claim_expires_at) — продлевайте её claim_task, если работа затягивается. + """ + user_id = _mcp_user(ctx) + actor = _mcp_actor(ctx) + session = get_session_factory()() + try: + rows = session.scalars( + select(Task) + .where(Task.user_id == user_id, claims.available_to_agent(actor)) + .order_by(Task.priority.desc().nullslast(), Task.created_at) + ).all() + return [_available(t, actor) for t in rows] + finally: + session.close() + + +def claim_task( + task_id: Annotated[int, Field(description="id задачи из list_available_tasks")], + ctx: Context[Any, Any, Any] | None = None, +) -> dict[str, Any]: + """Взять задачу в работу: аренда на два часа (ТЗ 3.20). + + Пока аренда жива, задачу не тронет другой агент. Повторный вызов — продление + аренды (heartbeat): вызывайте его, пока работаете, если она занимает больше + двух часов (в журнал продление не пишется). Закончив, закройте задачу через + complete_task с комментарием — владелец примет работу. Если задача оказалась + не по силам, верните её через release_task: она снова станет свободной. + """ + user_id = _mcp_user(ctx) + actor = _mcp_actor(ctx) + session = get_session_factory()() + try: + task = _get_task_locked(session, task_id, user_id) + try: + fresh = claims.claim_task(task, actor) + except claims.MandateError as exc: + raise _mandate_failure(exc, _CLAIM_HINT) from None + if fresh: + tasklog.log_event(session, task, tasklog.KIND_CLAIMED, actor) + session.commit() + result = _available(task, actor) + result["renewed"] = not fresh + return result + finally: + session.close() + + +def release_task( + task_id: Annotated[int, Field(description="id задачи, взятой через claim_task")], + reason: Annotated[ + str, Field(description="Почему возвращаете задачу — уйдёт в журнал владельцу") + ] = "", + ctx: Context[Any, Any, Any] | None = None, +) -> dict[str, Any]: + """Вернуть задачу в свободные: она оказалась вам не по силам (ТЗ 3.20). + + Возврат — не отказ навсегда: задача снова появится в list_available_tasks и + её сможет взять другой агент. Причину пишите в reason — владелец увидит её в + журнале. + """ + user_id = _mcp_user(ctx) + actor = _mcp_actor(ctx) + session = get_session_factory()() + try: + task = _get_task_locked(session, task_id, user_id) + try: + had_lease = claims.release_claim(task, actor) + except claims.MandateError as exc: + raise _mandate_failure(exc, _CLAIM_HINT) from None + if had_lease: + tasklog.log_event(session, task, tasklog.KIND_RELEASED, actor, reason or None) + session.commit() + result = _available(task, actor) + result["released"] = had_lease + return result + finally: + session.close() + + def list_projects( include_archived: Annotated[bool, Field(description="Включая проекты в архиве")] = False, ctx: Context[Any, Any, Any] | None = None, @@ -1037,4 +1186,7 @@ mcp.tool()(complete_task) mcp.tool()(list_tasks) mcp.tool()(get_task) +mcp.tool()(list_available_tasks) +mcp.tool()(claim_task) +mcp.tool()(release_task) mcp.tool()(list_tags) diff --git a/backend/app/models.py b/backend/app/models.py index e5abd1c..d3c373c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -207,6 +207,11 @@ return self.claimed_by_name if self.claim_is_alive else None @property + def claim_deadline(self) -> datetime | None: + """Срок живой аренды наружу: «взято до …»; протухшая — как будто её нет.""" + return self.claim_expires_at if self.claim_is_alive else None + + @property def description(self) -> str: return self.document.body if self.document else "" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ca90c24..9d7af54 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -157,6 +157,7 @@ done_by_kind: str | None accept_state: str | None claimed_by: str | None + claim_expires_at: datetime | None created_at: datetime approved_at: datetime | None done_at: datetime | None diff --git a/backend/app/services/claims.py b/backend/app/services/claims.py index 2e4397d..a1b0237 100644 --- a/backend/app/services/claims.py +++ b/backend/app/services/claims.py @@ -4,17 +4,25 @@ держатель токена и так действует как владелец (3.20), а рамка нужна, чтобы агент не трогал то, что владелец ему не поручал. -Ошибки — доменным исключением: REST отдаёт по нему 403, MCP — текст с подсказкой +Взятие в работу — аренда (lease): задача помечается арендатором на два часа, и живая +чужая аренда блокирует не только взятие, но и изменение задачи агентом — иначе второй +агент закроет работу первого. Протухшая аренда считается свободной сама, по времени: +фоновая задача для этого не нужна, а сервер может быть выключен месяцами. + +Ошибки — доменным исключением: REST отдаёт по нему 403/409, MCP — текст с подсказкой следующего шага (стиль 3.10), а правила остаются одними для обоих путей. """ from datetime import timedelta -from app.actor import Actor -from app.models import Task +from sqlalchemy import and_, or_ +from sqlalchemy.sql.elements import ColumnElement -# Аренда (взятие в работу) живёт два часа: агент продлевает её повторным claim, -# пропал — задача сама вернулась в свободные (ленивое протухание, без крона) +from app.actor import Actor +from app.models import Task, utcnow + +# Аренда живёт два часа: агент продлевает её повторным вызовом (heartbeat), +# пропал — задача сама вернулась в свободные CLAIM_TTL = timedelta(hours=2) @@ -22,6 +30,10 @@ """Действие агента вне мандата: владелец задачу ему не поручал.""" +class LeaseBusyError(MandateError): + """Задачу держит другой агент, и аренда ещё жива (конфликт, не «нельзя»).""" + + def ensure_agent_can_mutate(task: Task, actor: Actor) -> None: """Агент меняет только задачи с флагом доступности; владелец — любые.""" if actor.is_agent and not task.ai_eligible: @@ -35,3 +47,98 @@ """ 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 + diff --git a/backend/tests/test_agent_mandate.py b/backend/tests/test_agent_mandate.py index c33ad42..59d24b9 100644 --- a/backend/tests/test_agent_mandate.py +++ b/backend/tests/test_agent_mandate.py @@ -1,15 +1,18 @@ -"""Тесты мандата ИИ-агента: доступность задачи, гейт агента (ТЗ 3.20).""" +"""Тесты мандата ИИ-агента: доступность задачи, гейт агента, аренда (ТЗ 3.20).""" +from datetime import timedelta from types import SimpleNamespace from typing import Any import pytest from fastapi.testclient import TestClient +from sqlalchemy import update from app import mcp_server from app.actor import VIA_API, agent_actor from app.dependencies import actor_of, require_actor, require_user -from tests.conftest import agent_ctx, mcp_ctx # type: ignore[attr-defined] +from app.models import Task, utcnow +from tests.conftest import _test_session_factory, agent_ctx, mcp_ctx # type: ignore[attr-defined] def _create(client: TestClient, **payload: Any) -> dict[str, Any]: @@ -20,15 +23,16 @@ return got.json() -def _as_agent(client: TestClient, name: str = "Нави") -> Any: +def _as_agent(client: TestClient, name: str = "Нави", token_id: int = 7) -> Any: """Действие REST — агентское: подменяем актора, авторизация остаётся. Заголовок `X-Actor` разбирает actor_of() по пути; здесь подменён именно актор, - поэтому правило заголовка проверяется отдельным тестом. + поэтому правило заголовка проверяется отдельным тестом. token_id разводит + агентов: аренда — на токен, а не на имя (label не уникален). """ overrides = client.app.dependency_overrides # type: ignore[attr-defined] saved = overrides.get(require_actor) - overrides[require_actor] = lambda: agent_actor(name, 7, "Владелец", VIA_API) + overrides[require_actor] = lambda: agent_actor(name, token_id, "Владелец", VIA_API) return saved @@ -40,6 +44,21 @@ overrides[require_actor] = saved +def _shift_lease(task_id: int, minutes: int) -> None: + """Сдвинуть срок аренды от «сейчас»: −1 мин — протухла, +1 мин — вот-вот. + + Ждать два часа в тесте нечем, а протухание в проекте ленивое — по времени + (см. Task.claim_is_alive), так что ход времени подделываем здесь. + """ + with _test_session_factory() as session: + session.execute( + update(Task) + .where(Task.id == task_id) + .values(claim_expires_at=utcnow() + timedelta(minutes=minutes)) + ) + session.commit() + + def test_flag_defaults_off(client: TestClient) -> None: task = _create(client) assert task["ai_eligible"] is False @@ -172,3 +191,167 @@ assert full["created_by_kind"] == "user" assert full["accept_state"] is None assert full["claimed_by"] is None + + +def _events(client: TestClient, **params: Any) -> list[dict[str, Any]]: + resp = client.get("/api/task-events", params=params) + assert resp.status_code == 200, resp.text + return list(resp.json()["items"]) + + +def test_lease_flow_over_mcp(client: TestClient) -> None: + """Полный круг агента: список свободных → взял → вернул (ТЗ 3.20).""" + ctx = agent_ctx(label="Нави", token_id=7) + task = mcp_server.create_task("своя", ai_eligible=True, ctx=ctx) + assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == [task["id"]] + + taken = mcp_server.claim_task(task["id"], ctx=ctx) + assert taken["claimed_by"] == "Нави" + assert taken["claimed_by_me"] is True + assert taken["claim_expires_at"] is not None + assert taken["renewed"] is False + # Своя живая аренда не прячет задачу из списка: агенту виден срок, до которого он занят + assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == [task["id"]] + + back = mcp_server.release_task(task["id"], reason="нужен доступ к банку", ctx=ctx) + assert back["released"] is True + assert back["claimed_by"] is None + assert back["claim_expires_at"] is None + rows = _events(client, task_id=task["id"]) + assert [row["kind"] for row in rows] == ["released", "claimed", "created"] + assert rows[0]["comment"] == "нужен доступ к банку" + # Повторный возврат — не ошибка и не событие: аренды уже нет + assert mcp_server.release_task(task["id"], ctx=ctx)["released"] is False + assert [r["kind"] for r in _events(client, kind="released")] == ["released"] + + +def test_claim_refuses_unflagged_and_started(client: TestClient) -> None: + ctx = agent_ctx(label="Нави") + plain = mcp_server.create_task("без флага", ctx=ctx) + with pytest.raises(ValueError, match="not available to an AI agent"): + mcp_server.claim_task(plain["id"], ctx=ctx) + + started = mcp_server.create_task("начатая", ai_eligible=True, ctx=ctx) + mcp_server.update_task(started["id"], status="in_progress", is_user=True, ctx=ctx) + with pytest.raises(ValueError, match="is not in to_do"): + mcp_server.claim_task(started["id"], ctx=ctx) + assert [t["id"] for t in mcp_server.list_available_tasks(ctx=ctx)] == [] + + +def test_claim_is_for_agents_only(client: TestClient) -> None: + """Владелец не «берёт» задачу: аренда — способ агента заявить, что он занят.""" + ctx = mcp_ctx() + task = mcp_server.create_task("моя", ai_eligible=True, ctx=ctx) + with pytest.raises(ValueError, match="Task claim is for AI agents"): + mcp_server.claim_task(task["id"], ctx=ctx) + + +def test_heartbeat_renews_lease_without_second_event(client: TestClient) -> None: + ctx = agent_ctx(label="Нави") + task = mcp_server.create_task("долгая", ai_eligible=True, ctx=ctx) + taken = mcp_server.claim_task(task["id"], ctx=ctx) + _shift_lease(task["id"], 1) + + renewed = mcp_server.claim_task(task["id"], ctx=ctx) + assert renewed["renewed"] is True + assert renewed["claim_expires_at"] > taken["claim_expires_at"] + # Продление — не событие: журнал не забивается пингами + assert [r["kind"] for r in _events(client, kind="claimed")] == ["claimed"] + + +def test_foreign_agent_cannot_claim_or_touch(client: TestClient) -> None: + """Живая аренда чужая — и взять нельзя, и изменить нельзя (ТЗ 3.20).""" + first = agent_ctx(label="Нави", token_id=7) + second = agent_ctx(label="Другой", token_id=9) + task = mcp_server.create_task("общая", ai_eligible=True, ctx=first) + mcp_server.claim_task(task["id"], ctx=first) + + with pytest.raises(ValueError, match="claimed by Нави"): + mcp_server.claim_task(task["id"], ctx=second) + with pytest.raises(ValueError, match="another agent is working on it"): + mcp_server.update_task(task["id"], priority=5, ctx=second) + + +def test_expired_lease_is_free(client: TestClient) -> None: + """Протухшая аренда считается свободной — задачу не «заклинивает» (ТЗ 3.20).""" + first = agent_ctx(label="Нави", token_id=7) + second = agent_ctx(label="Другой", token_id=9) + task = mcp_server.create_task("брошенная", ai_eligible=True, ctx=first) + mcp_server.claim_task(task["id"], ctx=first) + _shift_lease(task["id"], -1) + + assert mcp_server.get_task(task["id"], ctx=second)["claimed_by"] is None + taken = mcp_server.claim_task(task["id"], ctx=second) + assert taken["claimed_by"] == "Другой" + assert taken["renewed"] is False + + +def test_agent_keeps_lease_while_working_and_loses_it_on_close(client: TestClient) -> None: + ctx = agent_ctx(label="Нави") + task = mcp_server.create_task("в работе", ai_eligible=True, ctx=ctx) + mcp_server.claim_task(task["id"], ctx=ctx) + + working = mcp_server.update_task(task["id"], status="in_progress", ctx=ctx) + assert working["claimed_by"] == "Нави" + # Закрытая задача аренду не держит: иначе heartbeat «оживил» бы закрытую + closed = mcp_server.update_task(task["id"], status="done", comment="готово", ctx=ctx) + assert closed["claimed_by"] is None + + +def test_owner_action_releases_foreign_lease(client: TestClient) -> None: + """Вмешательство владельца снимает аренду: задача снова свободна для агентов.""" + ctx = agent_ctx(label="Нави") + task = mcp_server.create_task("чужая работа", ai_eligible=True, ctx=ctx) + mcp_server.claim_task(task["id"], ctx=ctx) + + assert mcp_server.update_task(task["id"], priority=7, is_user=True, ctx=ctx)["priority"] == 7 + second = agent_ctx(label="Другой", token_id=9) + assert mcp_server.claim_task(task["id"], ctx=second)["claimed_by"] == "Другой" + + +def test_rest_claim_and_release(client: TestClient) -> None: + """REST тех же правил: агент берёт и возвращает задачу, аренда видна в TaskOut.""" + task = _create(client, ai_eligible=True) + saved = _as_agent(client) + try: + taken = client.post(f"/api/tasks/{task['id']}/claim") + assert taken.status_code == 200, taken.text + assert taken.json()["claimed_by"] == "Нави" + assert taken.json()["claim_expires_at"] is not None + + released = client.post(f"/api/tasks/{task['id']}/release", params={"reason": "не по силам"}) + assert released.status_code == 200, released.text + assert released.json()["claimed_by"] is None + finally: + _restore_agent(client, saved) + # Владелец (сессия) взять задачу не может — это не его механика + assert client.post(f"/api/tasks/{task['id']}/claim").status_code == 403 + + +def test_rest_foreign_lease_conflicts(client: TestClient) -> None: + """Занятая чужим агентом задача — 409 (конфликт), а не «нельзя» (403).""" + task = _create(client, ai_eligible=True) + mine = _as_agent(client, "Нави") + try: + assert client.post(f"/api/tasks/{task['id']}/claim").status_code == 200 + finally: + _restore_agent(client, mine) + other = _as_agent(client, "Другой", token_id=9) + try: + taken = client.post(f"/api/tasks/{task['id']}/claim") + assert taken.status_code == 409, taken.text + touched = client.patch(f"/api/tasks/{task['id']}", json={"priority": 5}) + assert touched.status_code == 409, touched.text + assert client.post(f"/api/tasks/{task['id']}/release").status_code == 409 + finally: + _restore_agent(client, other) + + +def test_rest_agent_claim_requires_flag(client: TestClient) -> None: + task = _create(client) + saved = _as_agent(client) + try: + denied = client.post(f"/api/tasks/{task['id']}/claim") + assert denied.status_code == 403, denied.text + finally: + _restore_agent(client, saved) diff --git a/docs/TZ.md b/docs/TZ.md index c29cf5f..35d1139 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -524,9 +524,10 @@ в MCP нет намеренно; - `list_tags` — справочник тегов (id для параметра `tag_ids`); - `list_available_tasks` — задачи, доступные ИИ-агенту (3.20): помеченные владельцем, - в статусе `to_do`, со свободной арендой (0.85); - - `claim_task` / `release_task` — взять задачу в работу и вернуть её, если она не по - силам (0.85, 3.20); + в статусе `to_do`, со свободной арендой; уже взятая этим агентом задача остаётся в + списке — у неё видно время аренды (0.85); + - `claim_task` / `release_task` — взять задачу в работу (повторный вызов — продление + аренды, а не второе взятие) и вернуть её, если она не по силам (0.85, 3.20); - `complete_task` — закрытие задачи; для действия от имени агента **обязателен комментарий** о том, что сделано (0.85). Тот же параметр `comment` у `update_task(status="done")` — закрытие идёт и через него.