From 99081bbd8752a3ee104da57ff5b4b164b103484b Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sun, 15 Feb 2026 03:31:55 +0530 Subject: [PATCH] feat(api): add previous_in_progress_at tracking and update task logic for review status --- backend/app/api/tasks.py | 15 +- backend/tests/test_task_agent_permissions.py | 224 +++++++++++++++++++ 2 files changed, 237 insertions(+), 2 deletions(-) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index cf37ea3b..70cd8c2a 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -1405,6 +1405,7 @@ async def update_task( board_id=board_id, previous_status=previous_status, previous_assigned=previous_assigned, + previous_in_progress_at=task.in_progress_at, status_requested=(requested_status is not None and requested_status != previous_status), updates=updates, comment=comment, @@ -1669,6 +1670,7 @@ class _TaskUpdateInput: tag_ids: list[UUID] | None custom_field_values: TaskCustomFieldValues custom_field_values_set: bool + previous_in_progress_at: datetime | None = None normalized_tag_ids: list[UUID] | None = None @@ -2136,6 +2138,9 @@ async def _apply_non_lead_agent_task_rules( if status_value == "inbox": update.task.assigned_agent_id = None update.task.in_progress_at = None + elif status_value == "review": + update.task.assigned_agent_id = None + update.task.in_progress_at = None else: update.task.assigned_agent_id = update.actor.agent.id if update.actor.agent else None if status_value == "in_progress": @@ -2346,11 +2351,17 @@ async def _finalize_updated_task( # ensure reviewers get context on readiness. if status_raw == "review": comment_text = (update.comment or "").strip() + review_comment_author = update.task.assigned_agent_id or update.previous_assigned + review_comment_since = ( + update.task.in_progress_at + if update.task.in_progress_at is not None + else update.previous_in_progress_at + ) if not comment_text and not await has_valid_recent_comment( session, update.task, - update.task.assigned_agent_id, - update.task.in_progress_at, + review_comment_author, + review_comment_since, ): raise _comment_validation_error() diff --git a/backend/tests/test_task_agent_permissions.py b/backend/tests/test_task_agent_permissions.py index 4a568de5..dd0e02ab 100644 --- a/backend/tests/test_task_agent_permissions.py +++ b/backend/tests/test_task_agent_permissions.py @@ -10,6 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession from app.api import tasks as tasks_api from app.api.deps import ActorContext +from app.core.time import utcnow from app.models.agents import Agent from app.models.boards import Board from app.models.gateways import Gateway @@ -327,3 +328,226 @@ async def test_non_lead_agent_forbidden_for_lead_only_patch_fields() -> None: assert exc.value.detail["code"] == "task_update_field_forbidden" finally: await engine.dispose() + + +@pytest.mark.asyncio +async def test_non_lead_agent_moves_task_to_review_and_task_unassigns() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + org_id = uuid4() + board_id = uuid4() + gateway_id = uuid4() + worker_id = uuid4() + task_id = uuid4() + + session.add(Organization(id=org_id, name="org")) + session.add( + Gateway( + id=gateway_id, + organization_id=org_id, + name="gateway", + url="https://gateway.local", + workspace_root="/tmp/workspace", + ), + ) + session.add( + Board( + id=board_id, + organization_id=org_id, + name="board", + slug="board", + gateway_id=gateway_id, + ), + ) + session.add( + Agent( + id=worker_id, + name="worker", + board_id=board_id, + gateway_id=gateway_id, + status="online", + ), + ) + session.add( + Task( + id=task_id, + board_id=board_id, + title="assigned task", + description="", + status="in_progress", + assigned_agent_id=worker_id, + in_progress_at=utcnow(), + ), + ) + await session.commit() + + task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first() + assert task is not None + actor = (await session.exec(select(Agent).where(col(Agent.id) == worker_id))).first() + assert actor is not None + + updated = await tasks_api.update_task( + payload=TaskUpdate(status="review", comment="Moving to review."), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=actor), + ) + + assert updated.status == "review" + assert updated.assigned_agent_id is None + assert updated.in_progress_at is None + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_non_lead_agent_comment_in_review_without_status_does_not_reassign() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + org_id = uuid4() + board_id = uuid4() + gateway_id = uuid4() + assignee_id = uuid4() + commentator_id = uuid4() + task_id = uuid4() + + session.add(Organization(id=org_id, name="org")) + session.add( + Gateway( + id=gateway_id, + organization_id=org_id, + name="gateway", + url="https://gateway.local", + workspace_root="/tmp/workspace", + ), + ) + session.add( + Board( + id=board_id, + organization_id=org_id, + name="board", + slug="board", + gateway_id=gateway_id, + ), + ) + session.add( + Agent( + id=assignee_id, + name="assignee", + board_id=board_id, + gateway_id=gateway_id, + status="online", + ), + ) + session.add( + Agent( + id=commentator_id, + name="commentator", + board_id=board_id, + gateway_id=gateway_id, + status="online", + ), + ) + session.add( + Task( + id=task_id, + board_id=board_id, + title="review task", + description="", + status="review", + assigned_agent_id=None, + ), + ) + await session.commit() + + task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first() + assert task is not None + commentator = ( + await session.exec(select(Agent).where(col(Agent.id) == commentator_id)) + ).first() + assert commentator is not None + + updated = await tasks_api.update_task( + payload=TaskUpdate(comment="I can help with this."), + task=task, + session=session, + actor=ActorContext(actor_type="agent", actor=commentator), + ) + + assert updated.status == "review" + assert updated.assigned_agent_id is None + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_non_lead_agent_moves_to_review_without_comment_or_recent_comment_fails() -> None: + engine = await _make_engine() + try: + async with await _make_session(engine) as session: + org_id = uuid4() + board_id = uuid4() + gateway_id = uuid4() + worker_id = uuid4() + task_id = uuid4() + + session.add(Organization(id=org_id, name="org")) + session.add( + Gateway( + id=gateway_id, + organization_id=org_id, + name="gateway", + url="https://gateway.local", + workspace_root="/tmp/workspace", + ), + ) + session.add( + Board( + id=board_id, + organization_id=org_id, + name="board", + slug="board", + gateway_id=gateway_id, + ), + ) + session.add( + Agent( + id=worker_id, + name="worker", + board_id=board_id, + gateway_id=gateway_id, + status="online", + ), + ) + session.add( + Task( + id=task_id, + board_id=board_id, + title="assigned task", + description="", + status="in_progress", + assigned_agent_id=worker_id, + in_progress_at=utcnow(), + ), + ) + await session.commit() + + task = (await session.exec(select(Task).where(col(Task.id) == task_id))).first() + assert task is not None + actor = (await session.exec(select(Agent).where(col(Agent.id) == worker_id))).first() + assert actor is not None + + with pytest.raises(HTTPException) as exc: + await tasks_api.update_task( + payload=TaskUpdate(status="review"), + task=task, + session=session, + actor=ActorContext(actor_type="agent", agent=actor), + ) + + assert exc.value.status_code == 422 + assert exc.value.detail == "Comment is required." + finally: + await engine.dispose()