Files
roboco/tests/unit/gateway/test_reassign_verb.py
T
a036c97985 fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking (#666)
* fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking

Three root causes behind the Mini App/bot showing wrong numbers:

Pricing: glm-5.2 gets a grounded per-token rate (z.ai published pricing,
$1.40/$4.40/$0.26 per 1M, source+date in the table comment) so a GLM
fleet day stops reporting $0.00 for half a million tokens; ungrounded
Ollama-Cloud models render "subscription (untracked)" instead of a bare
zero (is_ollama_cloud_model, consumed directly by the cockpit). Side
effect, intended and documented: honestly-priced GLM now trips the
downgrade-only comparator for new qa/documenter complexity pins.

Display timezone: the cockpit bucketed days in UTC for a GMT+2 operator.
New pure foundation module display_time (resolve_zone/local_date/
trailing_dates/day_bounds_utc, DST-correct with tests for the 23h/25h
days) + ROBOCO_DISPLAY_TIMEZONE (IANA-validated, default UTC); the
cockpit's spend/velocity series bucket raw session/completion rows by
the display zone. The UTC-keyed rollup table and the main dashboard are
deliberately untouched.

Agent activity: AgentTable.status was never set to ACTIVE and
current_task_id was never written anywhere — "active: 0, working: []"
was structurally permanent. Every claim path now marks the claimant
ACTIVE with rollback symmetry (_finalize_claim for dev/PM claims,
_qa_or_doc_claim for QA/doc/PR-gate claims, pr_review_claim for external
review) and every release path clears it (pass/fail QA, pr_pass/pr_fail,
complete_review, advance-to-PM-review, reaper unclaim, voluntary
unclaim, reassign retarget, pool divert, admin transitions, unblock
restore-to-in-progress). The bot's /status shares the cockpit's fleet
derivation so the two surfaces can't disagree. Known ceiling, commented:
one current_task_id column shows a multi-root coordinator PM's most
recent claim only.

Drill: sonnet develop -> sonnet adversarial (refuted the original
chokepoint coverage claim; QA/doc/reviewer paths were unwired) ->
correction round (wired them all + restored a dropped assertion, deleted
a dead helper and the dead subscription_billed field) -> review.

* fix(db): post_update on AgentTable.current_task breaks the flush cycle

agents.current_task_id and tasks.assigned_to reference each other, so a
flush touching both rows — every claim now marks its agent ACTIVE — is
an instance-level circular dependency SQLAlchemy cannot topologically
sort. The e2e smoke's full verb paths (12 tests) hit it; the unit and
integration suites never flush both dirty rows with relationships
loaded. post_update emits the FK as a second UPDATE, the canonical fix
for mutually-referencing rows.

* fix(budgets): enforce only explicitly-set budgets — no per-TaskType defaults

The per-TaskType default cap table blocked an unbudgeted coordination
root one opus planning turn in ($1.50 PLANNING default vs. real
coordination spend) — a false positive by design the moment the fleet
runs a priced model. Budgets are now explicit-input only:
effective_task_budget_usd returns None for an unset budget_usd, the
budget sweep skips enforcement (and never prices spend) on None, and
the unblock re-check passes on None so clearing the budget field is
itself a valid resolution. The project monthly cap stays as the
explicit-input fleet-wide backstop. Panel copy tells the truth
("No cap" placeholder; empty = uncapped), and the TaskType default
table plus its resolver are deleted.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 21:09:50 +02:00

163 lines
5.6 KiB
Python

"""The cell_pm `reassign` verb — hand a claimed/in_progress task to another
developer in the caller's OWN cell, preserving the branch.
Covers the intra-cell guard (`Choreographer._validate_reassign`, using real
agents_config data) and the reaper-safe service write
(`TaskService.reassign_active_claim`).
"""
from __future__ import annotations
from typing import cast
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from roboco.models.base import AgentStatus, TaskStatus
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.gateway.choreographer._impl import Choreographer
from roboco.services.task import TaskService
_BE_PM = UUID(AGENT_UUIDS["be-pm"])
def _task(team: str = "backend", status: str = "in_progress") -> MagicMock:
return MagicMock(team=MagicMock(value=team), status=MagicMock(value=status))
# ---------------------------------------------------------------------------
# _validate_reassign — intra-cell guard
# ---------------------------------------------------------------------------
def test_allows_same_cell_developer() -> None:
assert Choreographer._validate_reassign(_task(), _BE_PM, "be-dev-2") is None
def test_rejects_cross_cell_developer() -> None:
# fe-dev-1 is a frontend dev; a backend PM may not reassign to it.
env = Choreographer._validate_reassign(_task("backend"), _BE_PM, "fe-dev-1")
assert env is not None
assert env.error == "not_authorized"
def test_rejects_non_developer_target() -> None:
# be-qa is in the cell but is not a developer.
env = Choreographer._validate_reassign(_task("backend"), _BE_PM, "be-qa")
assert env is not None
assert env.error == "not_authorized"
def test_rejects_task_outside_callers_cell() -> None:
env = Choreographer._validate_reassign(_task("frontend"), _BE_PM, "be-dev-2")
assert env is not None
assert env.error == "not_authorized"
def test_rejects_non_active_status() -> None:
env = Choreographer._validate_reassign(
_task("backend", "awaiting_qa"), _BE_PM, "be-dev-2"
)
assert env is not None
assert env.error == "invalid_state"
def test_rejects_unknown_slug() -> None:
env = Choreographer._validate_reassign(_task("backend"), _BE_PM, "be-dev-99")
assert env is not None
assert env.error == "invalid_state"
def test_allows_claimed_status() -> None:
assert (
Choreographer._validate_reassign(
_task("backend", "claimed"), _BE_PM, "be-dev-1"
)
is None
)
# ---------------------------------------------------------------------------
# reassign_active_claim — reaper-safe service write
# ---------------------------------------------------------------------------
def _build_task(**over: object) -> MagicMock:
base: dict[str, object] = {
"id": uuid4(),
"status": TaskStatus.IN_PROGRESS,
"assigned_to": None,
"claimed_by": None,
"claimed_at": None,
"last_heartbeat_at": None,
"active_claimant_id": None,
}
base.update(over)
return MagicMock(**base)
def _service() -> TaskService:
session = MagicMock()
session.flush = AsyncMock()
# reassign_active_claim now retargets the agent-side claim marker
# (_retarget_agent_claim), which reads old/new agent rows via
# session.get — default to "no matching row" so tests that don't care
# about the agent side effect stay a no-op there, same as before this
# write existed.
session.get = AsyncMock(return_value=None)
return TaskService(session)
@pytest.mark.asyncio
async def test_reassign_active_claim_seeds_a_fresh_claim() -> None:
task = _build_task(status=TaskStatus.IN_PROGRESS)
svc = _service()
object.__setattr__(svc, "get", AsyncMock(return_value=task))
new_id = uuid4()
result = await svc.reassign_active_claim(task.id, new_id)
assert result is task
assert task.assigned_to == new_id
assert task.claimed_by == new_id
assert task.active_claimant_id == new_id
# Fresh claim window so the reaper doesn't treat the new dev as stale.
assert task.claimed_at is not None
assert task.last_heartbeat_at is not None
@pytest.mark.asyncio
async def test_reassign_active_claim_refuses_non_active_status() -> None:
task = _build_task(status=TaskStatus.AWAITING_QA)
svc = _service()
object.__setattr__(svc, "get", AsyncMock(return_value=task))
assert await svc.reassign_active_claim(task.id, uuid4()) is None
@pytest.mark.asyncio
async def test_reassign_active_claim_retargets_agent_active_marker() -> None:
"""The old claimant's ACTIVE/current_task_id marker must move to the new
claimant — otherwise the fleet keeps showing the SUPERSEDED agent as
working on this task, and never shows the real new claimant as active."""
old_id, new_id = uuid4(), uuid4()
task = _build_task(status=TaskStatus.IN_PROGRESS, claimed_by=old_id)
old_agent = MagicMock(status=AgentStatus.ACTIVE, current_task_id=task.id)
new_agent = MagicMock(status=AgentStatus.IDLE, current_task_id=None)
svc = _service()
object.__setattr__(svc, "get", AsyncMock(return_value=task))
async def _fake_get(_model: object, agent_id: object) -> object:
if agent_id == old_id:
return old_agent
if agent_id == new_id:
return new_agent
return None
cast("MagicMock", svc.session).get = AsyncMock(side_effect=_fake_get)
result = await svc.reassign_active_claim(task.id, new_id)
assert result is task
assert old_agent.status == AgentStatus.IDLE
assert old_agent.current_task_id is None
assert new_agent.status == AgentStatus.ACTIVE
assert new_agent.current_task_id == task.id