mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
105 lines
3.3 KiB
Python
105 lines
3.3 KiB
Python
"""Team-match must actually fire: the spec gate rejects cross-team actors.
|
|||
|
|
|
||
|
|
Live 2026-07-02: a frontend cell PM was dispatched onto a BACKEND task's
|
||
|
|
review, then blocked it, escalated it, and briefly held it — a 40-minute
|
||
|
|
ownership tug-of-war. Seventeen ActionSpecs carry needs_team_match=True and
|
||
|
|
_check_team_match enforces it — but only when the caller supplies
|
||
|
|
Context.agent_team, which no choreographer site did, so the gate sat in its
|
||
|
|
permissive fallback forever. These tests pin the policy behavior the
|
||
|
|
choreographer sweep wires up.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from typing import Any, cast
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
from roboco.foundation.policy.lifecycle import (
|
||
|
|
Context,
|
||
|
|
Role,
|
||
|
|
can_invoke_intent,
|
||
|
|
)
|
||
|
|
from roboco.models.base import TaskStatus
|
||
|
|
|
||
|
|
|
||
|
|
def _task(**overrides: Any) -> Any:
|
||
|
|
base: dict[str, Any] = {
|
||
|
|
"id": uuid4(),
|
||
|
|
"status": TaskStatus.IN_PROGRESS,
|
||
|
|
"team": "backend",
|
||
|
|
"assigned_to": None,
|
||
|
|
"task_type": "code",
|
||
|
|
}
|
||
|
|
base.update(overrides)
|
||
|
|
return cast("Any", SimpleNamespace(**base))
|
||
|
|
|
||
|
|
|
||
|
|
def test_cross_team_developer_is_rejected_when_team_supplied() -> None:
|
||
|
|
decision = can_invoke_intent(
|
||
|
|
Role.DEVELOPER,
|
||
|
|
"i_am_blocked",
|
||
|
|
_task(team="backend", status=TaskStatus.IN_PROGRESS, task_type="code"),
|
||
|
|
Context(actor_id=uuid4(), agent_team="frontend"),
|
||
|
|
)
|
||
|
|
assert not decision.allowed
|
||
|
|
assert "team" in (decision.message or "").lower()
|
||
|
|
|
||
|
|
|
||
|
|
def test_cross_team_cell_pm_resume_is_rejected() -> None:
|
||
|
|
decision = can_invoke_intent(
|
||
|
|
Role.CELL_PM,
|
||
|
|
"resume",
|
||
|
|
_task(team="backend", status=TaskStatus.PAUSED),
|
||
|
|
Context(actor_id=uuid4(), agent_team="frontend"),
|
||
|
|
)
|
||
|
|
assert not decision.allowed
|
||
|
|
assert "team" in (decision.message or "").lower()
|
||
|
|
|
||
|
|
|
||
|
|
def test_same_team_developer_is_allowed() -> None:
|
||
|
|
decision = can_invoke_intent(
|
||
|
|
Role.DEVELOPER,
|
||
|
|
"i_am_blocked",
|
||
|
|
_task(team="backend", status=TaskStatus.IN_PROGRESS, task_type="code"),
|
||
|
|
Context(actor_id=uuid4(), agent_team="backend"),
|
||
|
|
)
|
||
|
|
assert decision.allowed
|
||
|
|
|
||
|
|
|
||
|
|
def test_missing_team_keeps_permissive_fallback() -> None:
|
||
|
|
"""Absent agent_team defers to the service layer (backward compatible)."""
|
||
|
|
decision = can_invoke_intent(
|
||
|
|
Role.DEVELOPER,
|
||
|
|
"i_am_blocked",
|
||
|
|
_task(team="backend", status=TaskStatus.IN_PROGRESS, task_type="code"),
|
||
|
|
Context(actor_id=uuid4()),
|
||
|
|
)
|
||
|
|
assert decision.allowed
|
||
|
|
|
||
|
|
|
||
|
|
def test_org_wide_roles_are_exempt_cross_team() -> None:
|
||
|
|
"""Main PM handles every cell's escalations; the exemption keeps that."""
|
||
|
|
for role, verb, status in (
|
||
|
|
(Role.MAIN_PM, "resume", TaskStatus.PAUSED),
|
||
|
|
(Role.MAIN_PM, "unblock", TaskStatus.BLOCKED),
|
||
|
|
):
|
||
|
|
decision = can_invoke_intent(
|
||
|
|
role,
|
||
|
|
verb,
|
||
|
|
_task(team="backend", status=status),
|
||
|
|
Context(actor_id=uuid4(), agent_team="main_pm"),
|
||
|
|
)
|
||
|
|
assert decision.allowed, f"{role} {verb} must stay org-wide"
|
||
|
|
|
||
|
|
|
||
|
|
def test_cross_team_cell_pm_unblock_is_rejected() -> None:
|
||
|
|
decision = can_invoke_intent(
|
||
|
|
Role.CELL_PM,
|
||
|
|
"unblock",
|
||
|
|
_task(team="backend", status=TaskStatus.BLOCKED),
|
||
|
|
Context(actor_id=uuid4(), agent_team="frontend"),
|
||
|
|
)
|
||
|
|
assert not decision.allowed
|
||
|
|
assert "team" in (decision.message or "").lower()
|