A2A switchboard (pair cards), Secretary/PM task access + closed over-permission hole, MegaTask conventions fix (#298)

* feat(tasks): Secretary full task access; PM lighter editing — and a closed over-permission hole

Secretary: the CEO-gated edit directive covers the full content surface
(title/description/AC/priority/team/complexity/nature + claim-aware
reassignment through the real reassign paths, enum coercion, slug or
UUID assignees), and read_task returns full detail (notes, plan,
bounded progress, PR refs). The submit_directive tool docs never
mentioned edit at all — fixed, it was undiscoverable.

PMs: scouted the PATCH route and found has_higher_perms gave PM
identities UNRESTRICTED admin (ASSIGN is not team-scoped) — wider than
'not that much'. Now: cell PMs hard-403 outside their team, and both PM
roles are capped to the content allowlist (title/description/AC/
priority) with zero status changes via this surface. CEO/Board/Auditor
keep full admin. Built subagent-driven (Sonnet 5), reviewed.

* feat(a2a): the switchboard — org-chart pair cards with live activity

70 permission-matrix-derived pair cards (cells/pm-chain/board/cross),
lighting on either direction's a2a.message frames with a 45s fade —
A2A only, never verbs, per CEO ruling. Click-through reuses the v1
transcript + chime-in drawer; v1 list stays as the mobile fallback.
One CEO-gated /a2a/chat/admin/pairs route joins the static matrix
against conversations in a single bulk query. Built subagent-driven
(Sonnet 5), reviewed; pre-existing agent-utils slug-map gap flagged.

* fix(runtime): conventions ambient covers the MegaTask project_ids scope

_resolve_intake_ambient forwarded project_ids only to the history-digest
resolver — a MegaTask intake got no architectural-conventions block even
with the flag on. The conventions resolver now takes project_ids first
(mirroring the history resolver), both share one order-preserving
_projects_by_ids helper, and a regression test pins the threading to
both sub-resolvers. Built subagent-driven (Sonnet 5), reviewed.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 01:58:38 +02:00
committed by GitHub
co-authored by Renn F
parent da563487b8
commit 876e19b389
30 changed files with 2734 additions and 67 deletions
+68 -1
View File
@@ -19,7 +19,7 @@ from roboco.api.routes.a2a import wellknown_router
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.enforcement import A2AAccessDeniedError
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.a2a import A2ATask, A2ATaskState, A2ATaskStatus
from roboco.models.a2a import A2AAdminPairSummary, A2ATask, A2ATaskState, A2ATaskStatus
from roboco.models.base import (
TaskNature,
TaskStatus,
@@ -35,6 +35,8 @@ if TYPE_CHECKING:
_PAGE_TOKEN_OFFSET = 20
_MIN_STREAM_CHUNKS = 2
_EXPECTED_PAIR_LIST_TOTAL = 2
_EXPECTED_PAIR_MESSAGE_COUNT = 4
@pytest_asyncio.fixture
@@ -1067,6 +1069,71 @@ async def test_admin_list_conversations_as_ceo(a2a_route_client: dict) -> None:
assert body["items"][0]["agent_b"] == "fe-dev-1"
@pytest.mark.asyncio
async def test_admin_list_pairs_forbidden_for_non_ceo(a2a_route_client: dict) -> None:
client = a2a_route_client["client"]
response = await client.get("/api/a2a/chat/admin/pairs", headers=_HDR)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_admin_list_pairs_as_ceo(a2a_route_client: dict) -> None:
"""CEO gets the switchboard's pair cards — the static matrix joined with
each pair's representative conversation stats."""
app = a2a_route_client["app"]
dev = a2a_route_client["dev"]
client = a2a_route_client["client"]
_set_ceo_context(app, dev)
conv_id = uuid4()
pair_with_history = A2AAdminPairSummary(
agent_a="be-dev-1",
role_a="developer",
team_a="backend",
agent_b="be-qa",
role_b="qa",
team_b="backend",
group_key="cell-backend",
conversation_id=str(conv_id),
last_message_at=datetime.now(UTC),
message_count=4,
)
pair_never_talked = A2AAdminPairSummary(
agent_a="auditor",
role_a="auditor",
team_a="board",
agent_b="product-owner",
role_b="product_owner",
team_b="board",
group_key="board",
conversation_id=None,
last_message_at=None,
message_count=0,
)
with patch("roboco.api.routes.a2a.A2AService") as mock_service_cls:
instance = AsyncMock()
instance.list_admin_pairs = AsyncMock(
return_value=[pair_with_history, pair_never_talked]
)
mock_service_cls.return_value = instance
response = await client.get("/api/a2a/chat/admin/pairs", headers=_HDR)
assert response.status_code == HTTPStatus.OK
instance.list_admin_pairs.assert_awaited_once_with()
body = response.json()
assert body["total"] == _EXPECTED_PAIR_LIST_TOTAL
first = body["items"][0]
assert first["agent_a"] == "be-dev-1"
assert first["agent_b"] == "be-qa"
assert first["group_key"] == "cell-backend"
assert first["conversation_id"] == str(conv_id)
assert first["message_count"] == _EXPECTED_PAIR_MESSAGE_COUNT
second = body["items"][1]
assert second["group_key"] == "board"
assert second["conversation_id"] is None
assert second["message_count"] == 0
@pytest.mark.asyncio
async def test_admin_get_messages_as_ceo_returns_full_transcript(
a2a_route_client: dict,