feat(api): add previous_in_progress_at tracking and update task logic for review status

This commit is contained in:
Abhimanyu Saharan
2026-02-15 03:31:55 +05:30
parent 3c1f89d91d
commit 99081bbd87
2 changed files with 237 additions and 2 deletions

View File

@@ -1405,6 +1405,7 @@ async def update_task(
board_id=board_id, board_id=board_id,
previous_status=previous_status, previous_status=previous_status,
previous_assigned=previous_assigned, previous_assigned=previous_assigned,
previous_in_progress_at=task.in_progress_at,
status_requested=(requested_status is not None and requested_status != previous_status), status_requested=(requested_status is not None and requested_status != previous_status),
updates=updates, updates=updates,
comment=comment, comment=comment,
@@ -1669,6 +1670,7 @@ class _TaskUpdateInput:
tag_ids: list[UUID] | None tag_ids: list[UUID] | None
custom_field_values: TaskCustomFieldValues custom_field_values: TaskCustomFieldValues
custom_field_values_set: bool custom_field_values_set: bool
previous_in_progress_at: datetime | None = None
normalized_tag_ids: list[UUID] | 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": if status_value == "inbox":
update.task.assigned_agent_id = None update.task.assigned_agent_id = None
update.task.in_progress_at = None update.task.in_progress_at = None
elif status_value == "review":
update.task.assigned_agent_id = None
update.task.in_progress_at = None
else: else:
update.task.assigned_agent_id = update.actor.agent.id if update.actor.agent else None update.task.assigned_agent_id = update.actor.agent.id if update.actor.agent else None
if status_value == "in_progress": if status_value == "in_progress":
@@ -2346,11 +2351,17 @@ async def _finalize_updated_task(
# ensure reviewers get context on readiness. # ensure reviewers get context on readiness.
if status_raw == "review": if status_raw == "review":
comment_text = (update.comment or "").strip() 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( if not comment_text and not await has_valid_recent_comment(
session, session,
update.task, update.task,
update.task.assigned_agent_id, review_comment_author,
update.task.in_progress_at, review_comment_since,
): ):
raise _comment_validation_error() raise _comment_validation_error()

View File

@@ -10,6 +10,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from app.api import tasks as tasks_api from app.api import tasks as tasks_api
from app.api.deps import ActorContext from app.api.deps import ActorContext
from app.core.time import utcnow
from app.models.agents import Agent from app.models.agents import Agent
from app.models.boards import Board from app.models.boards import Board
from app.models.gateways import Gateway 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" assert exc.value.detail["code"] == "task_update_field_forbidden"
finally: finally:
await engine.dispose() 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()