diff --git a/backend/alembic/versions/d5e6a7b8c9d0_task_spawned_at.py b/backend/alembic/versions/d5e6a7b8c9d0_task_spawned_at.py new file mode 100644 index 0000000..10b7f89 --- /dev/null +++ b/backend/alembic/versions/d5e6a7b8c9d0_task_spawned_at.py @@ -0,0 +1,25 @@ +"""Метка спавна регулярной задачи (ТЗ 3.5): spawned_at против дубликатов. + +Revision ID: d5e6a7b8c9d0 +Revises: a5b6c7d8e9f0 +Create Date: 2026-09-21 12:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "d5e6a7b8c9d0" +down_revision: str | Sequence[str] | None = "a5b6c7d8e9f0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("tasks", sa.Column("spawned_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("tasks", "spawned_at") diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index caa9fe6..aff797f 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -17,16 +17,12 @@ TaskOut, TaskUpdate, ) +from app.services.closing import handle_task_closed from app.services.detailing import apply_proposal, detail_task -from app.services.garden import coins_for_xp, level_bonus_coins from app.services.options import pick_options -from app.services.recurrence import spawn_next from app.services.xp import ( coins_for_create, grant_create_xp, - grant_task_xp, - level_from_xp, - total_xp, ) router = APIRouter(prefix="/api/tasks", tags=["tasks"]) @@ -168,15 +164,11 @@ setattr(task, field, value) if data.get("status") == "done" and task.done_at is None: task.done_at = utcnow() - # Регулярная задача (ТЗ 3.5): после выполнения рождается следующий - # экземпляр — фиксированный календарь, следующий день строго после. - spawned = spawn_next(db, task) - if spawned is not None: - db.flush() - # Геймификация (ТЗ 3.13): XP за закрытие — один раз на задачу - event = grant_task_xp(db, task) + # Регулярная (ТЗ 3.5) + геймификация (ТЗ 3.13) — общий путь с MCP: + # закрытие агентом даёт те же спавн/XP/монеты, что и из UI + outcome = handle_task_closed(db, task, via_options=earned_via == "options") + event = outcome.event if event is not None: - event.via_options = earned_via == "options" response.headers["X-Earned-XP"] = str(event.amount) response.headers["X-Plant-Rarity"] = event.rarity if event.via_options: @@ -184,20 +176,13 @@ if task.mentally_hard: # «Ментально сложная» — фронт украшает праздник («сила воли») response.headers["X-Mentally-Hard"] = "1" - # Монеты — вторая награда за закрытие (валюта сада, рядом с XP) - old_level = level_from_xp(total_xp(db) - event.amount) - coins = coins_for_xp(event.amount) - db.add(CoinEvent(task_id=task.id, source="task", amount=coins)) - new_level = level_from_xp(total_xp(db)) - if new_level > old_level: - coins += level_bonus_coins(new_level) - db.add( - CoinEvent( - source="level", level=new_level, amount=level_bonus_coins(new_level) - ) - ) - response.headers["X-Earned-Coins"] = str(coins) + response.headers["X-Earned-Coins"] = str(outcome.coins) earned_event = event + elif data.get("status") not in (None, "done") and task.done_at is not None: + # Выход из done: отметка выполнения сбрасывается. Повторное закрытие + # снова пройдёт через ветку наград, но XP grant_task_xp повторно не + # даст, а спавн удержит spawned_at — дубликатов не будет + task.done_at = None db.flush() db.refresh(task) # перечитать связи (project/tags) после обновления diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index 6308364..ebac690 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -14,11 +14,12 @@ from sqlalchemy import and_, select from app.db import get_session_factory -from app.models import Attachment, Document, Project, Tag, Task, utcnow +from app.models import Attachment, CoinEvent, Document, Project, Tag, Task, utcnow from app.realtime import publish from app.schemas import DEADLINE_PERIODS, RECUR_KINDS +from app.services.closing import handle_task_closed from app.services.detailing import detail_task -from app.services.recurrence import spawn_next +from app.services.xp import coins_for_create, grant_create_xp VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"} @@ -109,9 +110,14 @@ raise ValueError("Unknown tag id in tag_ids") task.tags = tags session.add(task) + session.flush() + # Микронаграда за создание (ТЗ 3.13) — как в HTTP-API + grant_create_xp(session, "create_task") + session.add(CoinEvent(source="create_task", amount=coins_for_create("create_task"))) session.commit() # Агент меняет данные вне UI — уведомляем все открытые SSE-соединения publish(None, "task.changed", {"id": task.id, "source": "mcp"}) + publish(None, "xp.changed", {"celebrate": False}) # Автодетализация — в фоновом потоке (LLM работает десятки секунд) threading.Thread(target=detail_task, args=(task.id,), daemon=True).start() return {"id": task.id, "title": task.title, "detail_state": task.detail_state} @@ -161,8 +167,6 @@ if status not in VALID_STATUSES: raise ValueError(f"Unknown status: {status}") task.status = status - if status == "done" and task.done_at is None: - task.done_at = utcnow() if deadline_date is not None: if not deadline_date.strip(): task.deadline_date = None @@ -211,15 +215,31 @@ if len(tags) != len(set(tag_ids)): raise ValueError("Unknown tag id in tag_ids") task.tags = tags - spawned = None - if task.status == "done" and task.done_at is not None: - # Регулярная задача: следующий экземпляр по фиксированному календарю - spawned = spawn_next(session, task) + # Закрытие — переход в done (а не «уже закрытая»): тот же общий путь + # наград, что и в HTTP-API — спавн регулярной (один раз, spawned_at), + # XP/монеты/растение. Выход из done сбрасывает done_at. + outcome = None + if task.status == "done" and task.done_at is None: + task.done_at = utcnow() + outcome = handle_task_closed(session, task) + elif task.status != "done" and task.done_at is not None: + task.done_at = None session.commit() publish(None, "task.changed", {"id": task.id, "source": "mcp"}) + if outcome is not None and outcome.event is not None: + # Скромный тост в открытых вкладках — закрытие тоже праздник (ТЗ 3.14) + publish( + None, + "xp.changed", + { + "amount": outcome.event.amount, + "rarity": outcome.event.rarity, + "celebrate": True, + }, + ) result = _compact(task) - if spawned is not None: - result["spawned_next_id"] = spawned.id + if outcome is not None and outcome.spawned is not None: + result["spawned_next_id"] = outcome.spawned.id return result finally: session.close() diff --git a/backend/app/models.py b/backend/app/models.py index 986e322..2e6db37 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -104,6 +104,11 @@ done_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) + # Метка «следующий экземпляр регулярной уже создан» (ТЗ 3.5): спавн — один + # раз на цепочку, повторное закрытие/обновление закрытой не дублирует экземпляр + spawned_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) @property def description(self) -> str: @@ -132,8 +137,7 @@ document: Mapped["Document | None"] = relationship( "Document", primaryjoin=( - "and_(foreign(Document.owner_id) == Project.id," - " Document.owner_type == 'project')" + "and_(foreign(Document.owner_id) == Project.id, Document.owner_type == 'project')" ), uselist=False, cascade="all, delete-orphan", @@ -185,9 +189,7 @@ task_id: Mapped[int] = mapped_column( ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True ) - tag_id: Mapped[int] = mapped_column( - ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True - ) + tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True) class Document(Base): diff --git a/backend/app/services/closing.py b/backend/app/services/closing.py new file mode 100644 index 0000000..7ab5a47 --- /dev/null +++ b/backend/app/services/closing.py @@ -0,0 +1,53 @@ +"""Общий путь закрытия задачи (ТЗ 3.5, 3.13): спавн регулярной + награды. + +Один и тот же код используется HTTP-API и MCP-инструментами: закрытие задачи +агентом должно давать ровно те же XP/монеты/растение, что и закрытие из UI. +Идемпотентность: XP — один раз на задачу (grant_task_xp), спавн регулярной — +один раз на цепочку (метка spawned_at). Коммит делает вызывающий код. +""" + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.models import CoinEvent, Task, XpEvent, utcnow +from app.services import xp as xp_service +from app.services.garden import coins_for_xp, level_bonus_coins +from app.services.recurrence import spawn_next + + +@dataclass +class CloseOutcome: + """Результат закрытия: что начислено/создано (для заголовков и SSE).""" + + event: XpEvent | None + spawned: Task | None + coins: int # монеты за задачу, включая бонус за уровень + + +def handle_task_closed(db: Session, task: Task, via_options: bool = False) -> CloseOutcome: + """Задача перешла в done (done_at уже поставлен) — начислить всё положенное. + + Спавн следующего экземпляра регулярной — только если ещё не спавнили + (spawned_at is None); XP/монеты grant_task_xp не даст повторно и сам. + """ + outcome = CloseOutcome(event=None, spawned=None, coins=0) + if task.spawned_at is None: + spawned = spawn_next(db, task) + if spawned is not None: + task.spawned_at = utcnow() + outcome.spawned = spawned + event = xp_service.grant_task_xp(db, task) + if event is not None: + event.via_options = via_options + outcome.event = event + # Монеты — вторая награда за закрытие (валюта сада, рядом с XP) + old_level = xp_service.level_from_xp(xp_service.total_xp(db) - event.amount) + coins = coins_for_xp(event.amount) + db.add(CoinEvent(task_id=task.id, source="task", amount=coins)) + new_level = xp_service.level_from_xp(xp_service.total_xp(db)) + if new_level > old_level: + coins += level_bonus_coins(new_level) + db.add(CoinEvent(source="level", level=new_level, amount=level_bonus_coins(new_level))) + outcome.coins = coins + return outcome diff --git a/backend/tests/test_mcp.py b/backend/tests/test_mcp.py index 5500644..d2e0d4a 100644 --- a/backend/tests/test_mcp.py +++ b/backend/tests/test_mcp.py @@ -1,5 +1,7 @@ """Тесты M5: MCP-инструменты и авторизация /mcp.""" +from typing import Any + import pytest from fastapi.testclient import TestClient @@ -61,9 +63,7 @@ # без токена assert client.post("/mcp", json={}).status_code == 401 # неверный токен - resp = client.post( - "/mcp", json={}, headers={"Authorization": "Bearer wrong-token"} - ) + resp = client.post("/mcp", json={}, headers={"Authorization": "Bearer wrong-token"}) assert resp.status_code == 401 # верный токен — проходит аутентификацию (ошибки протокола — не 401) resp = client.post( @@ -72,3 +72,80 @@ headers={"Authorization": f"Bearer {MCP_TOKEN}"}, ) assert resp.status_code != 401 + + +# --- награды и повторения: общий путь закрытия с HTTP-API --- + + +def _db_events(task_id: int) -> tuple[list[Any], list[Any]]: + from sqlalchemy import select + + from app.models import CoinEvent, XpEvent + from tests.conftest import _test_session_factory + + session = _test_session_factory() + try: + xp = list(session.scalars(select(XpEvent).where(XpEvent.task_id == task_id)).all()) + coins = list(session.scalars(select(CoinEvent).where(CoinEvent.task_id == task_id)).all()) + return xp, coins + finally: + session.close() + + +def test_mcp_complete_grants_rewards(client: TestClient) -> None: + """Закрытие агентом даёт те же награды, что и закрытие из UI (XP + монеты).""" + from app.services.xp import XP_BASE + + tid = mcp_server.create_task("Награда от агента", priority=8)["id"] + mcp_server.complete_task(tid) + xp, coins = _db_events(tid) + assert len(xp) == 1 and xp[0].amount >= XP_BASE + assert len(coins) == 1 and coins[0].amount > 0 + + +def test_mcp_create_grants_micro_reward(client: TestClient) -> None: + from sqlalchemy import select + + from app.models import CoinEvent, XpEvent + from tests.conftest import _test_session_factory + + mcp_server.create_task("Микронаграда") + session = _test_session_factory() + try: + kinds = session.scalars(select(XpEvent.kind).where(XpEvent.kind == "create_task")).all() + assert kinds == ["create_task"] + coins = session.scalars(select(CoinEvent).where(CoinEvent.source == "create_task")).all() + assert len(coins) == 1 + finally: + session.close() + + +def test_mcp_recurring_spawns_once(client: TestClient) -> None: + """Регулярная от агента: спавн в момент закрытия, повторные обновления + закрытой задачи дубликат не создают (раньше спавнили каждый раз).""" + tid = mcp_server.create_task("Полить от агента")["id"] + mcp_server.update_task(tid, task_type="recurring", recur_kind="interval", recur_interval_days=3) + done = mcp_server.update_task(tid, status="done") + assert done.get("spawned_next_id") is not None + + # любое обновление уже закрытой — без нового экземпляра + for _ in range(2): + again = mcp_server.update_task(tid, priority=1) + assert "spawned_next_id" not in again + tasks = mcp_server.list_tasks(query="Полить от агента") + assert len(tasks) == 2 # закрытая + единственный следующий экземпляр + + +def test_mcp_undone_and_reclose_no_duplicates(client: TestClient) -> None: + """un-done сбрасывает done_at; повторное закрытие не даёт XP и не спавнит.""" + tid = mcp_server.create_task("Туда-обратно")["id"] + mcp_server.complete_task(tid) + assert mcp_server.get_task(tid)["done_at"] is not None + + mcp_server.update_task(tid, status="to_do") + assert mcp_server.get_task(tid)["done_at"] is None + + again = mcp_server.update_task(tid, status="done") + assert "spawned_next_id" not in again + xp, _coins = _db_events(tid) + assert len(xp) == 1 # XP — один раз на задачу diff --git a/backend/tests/test_recurrence.py b/backend/tests/test_recurrence.py index 5700895..bc32f30 100644 --- a/backend/tests/test_recurrence.py +++ b/backend/tests/test_recurrence.py @@ -139,3 +139,34 @@ session.add(task) session.flush() assert spawn_next(session, task) is None + + +# --- повторное закрытие: без дубликатов (spawned_at, сброс done_at) --- + + +def test_undone_resets_done_at(client: TestClient) -> None: + """Выход из done сбрасывает отметку выполнения — повторное закрытие + снова проходит через ветку закрытия (XP идемпотентен, спавн удержит + spawned_at).""" + tid = client.post("/api/tasks", json={"title": "x"}).json()["id"] + client.patch(f"/api/tasks/{tid}", json={"status": "done"}) + assert client.get(f"/api/tasks/{tid}").json()["done_at"] is not None + + client.patch(f"/api/tasks/{tid}", json={"status": "to_do"}) + assert client.get(f"/api/tasks/{tid}").json()["done_at"] is None + + +def test_recurring_reclose_no_second_spawn(client: TestClient) -> None: + tid = client.post("/api/tasks", json={"title": "Полить окно"}).json()["id"] + client.patch( + f"/api/tasks/{tid}", + json={"task_type": "recurring", "recur_kind": "interval", "recur_interval_days": 2}, + ) + client.post(f"/api/tasks/{tid}/approve") + client.patch(f"/api/tasks/{tid}", json={"status": "done"}) + client.patch(f"/api/tasks/{tid}", json={"status": "to_do"}) + client.patch(f"/api/tasks/{tid}", json={"status": "done"}) + + tasks = client.get("/api/tasks?query").json() + spawned = [t for t in tasks if t["title"] == "Полить окно" and t["status"] == "to_do"] + assert len(spawned) == 1 # один экземпляр, повторное закрытие не дублирует diff --git a/docs/TZ.md b/docs/TZ.md index f9b91b5..39bf785 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.27 | +| Версия ТЗ | 0.28 | | Дата | 2026-09-21 | | Статус | На обсуждении | @@ -174,6 +174,9 @@ - `get_task` — полное описание с вложениями. - Аутентификация агентов — через SSO-механизм (токен, выданный централизованной системой; детали после предоставления данных SSO). - Агент действует от имени пользователя; отдельной мультитенантности нет. +- Закрытие задачи агентом (`update_task`/`complete_task`) идёт по тому же общему + пути, что и в UI: спавн регулярной, XP, монеты, растение (3.5, 3.13); в открытых + вкладках — скромный тост о награде (3.14). ### 3.11. Локальные AI-функции @@ -499,7 +502,7 @@ 4. **Оффлайн-режим** в PWA: нужен ли, или всегда онлайн? 5. **Модель для Ollama**: какая конкретно модель и параметры (в конфиг, но нужно выбрать для проверки M2). 6. **Практическое различие `cancelled` / `deferred`**: определения зафиксированы (3.4), но поведение в интерфейсе почти одинаковое — скрыть из активных списков и оставить доступным для возврата. Уточнить при проектировании представлений, нужны ли оба статуса или их поведение сольётся. -7. **Анти-дюп награды за статус**: убедиться, что перевод задачи в «завершена» и обратно (и повторное закрытие) **не** начисляет XP/монеты/растение повторно — идемпотентность начисления при любых переключениях статуса (лазейка для дюпа награды). Проверить и покрыть тестом. +7. ~~**Анти-дюп награды за статус**~~ — **решено 2026-09-21**: закрытие (HTTP и MCP) идёт через общий путь; XP — один раз на задачу (grant_task_xp), спавн регулярной — один раз на цепочку (метка `spawned_at`), выход из done сбрасывает `done_at`. Повторные закрытия и обновления закрытой не дублируют награды и экземпляры — покрыто тестами. 8. ~~**Пиксель-арт сцена сада**~~ — **решено 2026-09-20**: сцена переведена на пиксель-арт (PixiJS 8, WebGL); спрайты генерируются из пиксель-матриц в коде (без внешних ассетов и лицензий), см. 3.13. 9. **Premium-виды в семенах**: premium-виды (сейчас кактус) рождаются только из rare/epic закрытий — «отпечаток крупной работы». Семена же продаются за монеты и дают common-растение; сейчас семя premium-вида купить можно. Оставить ли покупку семян premium-видов (простой доступ к виду) или запретить (эксклюзив rare/epic сохраняется) — решить. 10. **Самосоединяющийся забор**: секции изгороди автоматически соединяются с соседними секциями (маска соседей N/E/S/W → текстура); ручного поворота нет. Соединяются только соседние секции забора — диагонали не считаются. Подтвердить, что поведение устраивает. \ No newline at end of file