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,
+81
View File
@@ -12,6 +12,7 @@ from uuid import uuid4 as _u
import pytest
import pytest_asyncio
from roboco.agents_config import A2A_ALLOWED_PAIRS
from roboco.db.tables import (
A2AConversationTable,
A2AMessageTable,
@@ -637,6 +638,86 @@ async def test_get_conversation_admin_returns_none_for_unknown(
assert await svc.get_conversation_admin(uuid4()) is None
# ---------------------------------------------------------------------------
# list_admin_pairs — the A2A switchboard's static-matrix + DB join
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_admin_pairs_bounded_by_static_matrix(a2a_setup: dict) -> None:
"""With no conversations at all, every pair from the static matrix is
still returned (conversation-less), sized exactly to the matrix."""
svc = a2a_setup["svc"]
pairs = await svc.list_admin_pairs()
assert len(pairs) == len(A2A_ALLOWED_PAIRS)
assert all(p.conversation_id is None for p in pairs)
assert all(p.message_count == 0 for p in pairs)
assert all(p.last_message_at is None for p in pairs)
@pytest.mark.asyncio
async def test_list_admin_pairs_joins_representative_conversation(
a2a_setup: dict,
) -> None:
svc = a2a_setup["svc"]
conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
await svc.send_chat_message(UUID(conv.id), "be-dev-1", "hello")
pairs = await svc.list_admin_pairs()
match = next(p for p in pairs if {p.agent_a, p.agent_b} == {"be-dev-1", "be-qa"})
assert match.conversation_id == conv.id
assert match.message_count == 1
assert match.last_message_at is not None
assert match.group_key == "cell-backend"
assert match.role_a == "developer"
assert match.role_b == "qa"
@pytest.mark.asyncio
async def test_list_admin_pairs_picks_most_recently_updated_conversation(
a2a_setup: dict,
) -> None:
"""A pair with two conversations (distinct topics) surfaces the more
recently active one as its representative conversation."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
conv_old = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="t1")
conv_new = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="t2")
now = datetime.now(UTC)
row_old = await db.get(A2AConversationTable, UUID(conv_old.id))
row_new = await db.get(A2AConversationTable, UUID(conv_new.id))
assert row_old is not None
assert row_new is not None
row_old.updated_at = now - timedelta(minutes=10)
row_new.updated_at = now
await db.flush()
pairs = await svc.list_admin_pairs()
match = next(p for p in pairs if {p.agent_a, p.agent_b} == {"be-dev-1", "be-qa"})
assert match.conversation_id == conv_new.id
@pytest.mark.asyncio
async def test_list_admin_pairs_excludes_disallowed_pairs(a2a_setup: dict) -> None:
"""A conversation row between two agents the matrix does NOT allow (dev
A2A is same-cell only this should never legitimately exist, but the
join must be robust against it) never surfaces as a pair card: the
service iterates the static matrix, not "any conversation row"."""
svc = a2a_setup["svc"]
db = a2a_setup["db"]
stray = A2AConversationTable(agent_a="be-dev-1", agent_b="fe-dev-1")
db.add(stray)
await db.flush()
pairs = await svc.list_admin_pairs()
assert not any({p.agent_a, p.agent_b} == {"be-dev-1", "fe-dev-1"} for p in pairs)
# ---------------------------------------------------------------------------
# CEO reply-only budget — an agent may only reply to the CEO inside a
# conversation the CEO itself opened, and only up to the CEO's own message
@@ -0,0 +1,241 @@
"""PM-lighter PATCH surface on PATCH /api/tasks/{id}.
The CEO's spec: PMs get a *lighter* content-only slice of the same REST PATCH
path the Secretary's edit directive uses — title/description/
acceptance_criteria/priority scoped to tasks in the PM's remit (cell_pm:
own team only; main_pm: any team). No status changes, no structural/
ownership fields, no git fields those stay on the lifecycle-verb surface.
cell_pm/main_pm already hold TaskAction.ASSIGN (see TASK_PERMISSIONS), which
is *not* team-scoped in ``can_perform_task_action`` so absent this gate a
PM would already ride the CEO/Board/Auditor "full admin" bypass on this
route (any field, any team). These tests pin the narrower behavior down.
"""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.tasks import router as tasks_router
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.base import TaskNature, TaskStatus, TaskType
from roboco.models.permissions import AgentContext
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
async def _make_client(
db_session: AsyncSession, *, role: AgentRole, team: Team | None
) -> dict[str, Any]:
pm = AgentTable(
id=uuid4(),
name="PM",
slug=f"pm-{uuid4().hex[:8]}",
role=role,
team=team,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="pm",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(pm)
await db_session.flush()
project = ProjectTable(
id=uuid4(),
name="PL-Proj",
slug=f"pl-proj-{uuid4().hex[:6]}",
git_url="https://example.com/pl.git",
assigned_cell=Team.BACKEND,
created_by=pm.id,
)
db_session.add(project)
await db_session.flush()
app = FastAPI()
app.include_router(tasks_router, prefix="/api/tasks")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=cast("UUID", pm.id), role=role, team=team)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
client = AsyncClient(transport=transport, base_url="http://test")
return {
"client": client,
"app": app,
"agent": pm,
"project": project,
"db": db_session,
}
@pytest_asyncio.fixture
async def cell_pm_client(db_session: AsyncSession) -> AsyncIterator[dict]:
"""A backend cell PM (ASSIGN, no UPDATE_OWN)."""
setup = await _make_client(db_session, role=AgentRole.CELL_PM, team=Team.BACKEND)
async with setup["client"]:
yield setup
setup["app"].dependency_overrides.clear()
@pytest_asyncio.fixture
async def main_pm_client(db_session: AsyncSession) -> AsyncIterator[dict]:
"""The Main PM (ASSIGN, no team restriction)."""
setup = await _make_client(db_session, role=AgentRole.MAIN_PM, team=None)
async with setup["client"]:
yield setup
setup["app"].dependency_overrides.clear()
def _seed_task(setup: dict, **kw: Any) -> TaskTable:
team = kw.pop("team", Team.BACKEND)
task = TaskTable(
id=uuid4(),
title=kw.pop("title", "t"),
description=kw.pop("description", "d"),
acceptance_criteria=["ac"],
status=kw.pop("status", TaskStatus.IN_PROGRESS),
priority=2,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
project_id=setup["project"].id,
created_by=setup["agent"].id,
assigned_to=None,
team=team,
)
setup["db"].add(task)
return task
def _hdr(agent: AgentTable, role: AgentRole) -> dict[str, str]:
return {"X-Agent-ID": str(agent.id), "X-Agent-Role": role.value}
@pytest.mark.asyncio
async def test_cell_pm_can_patch_content_field_on_own_team_task(
cell_pm_client: dict,
) -> None:
setup = cell_pm_client
task = _seed_task(setup, team=Team.BACKEND)
await setup["db"].flush()
response = await setup["client"].patch(
f"/api/tasks/{task.id}",
json={"title": "Sharper title from the cell PM"},
headers=_hdr(setup["agent"], AgentRole.CELL_PM),
)
assert response.status_code == HTTPStatus.OK
assert response.json()["title"] == "Sharper title from the cell PM"
@pytest.mark.asyncio
async def test_cell_pm_cannot_patch_task_outside_own_team(
cell_pm_client: dict,
) -> None:
"""ASSIGN is not team-scoped — without an explicit check a cell PM would
ride the same admin bypass CEO/Board get on ANY team's task."""
setup = cell_pm_client
task = _seed_task(setup, team=Team.FRONTEND)
await setup["db"].flush()
response = await setup["client"].patch(
f"/api/tasks/{task.id}",
json={"title": "Should not land"},
headers=_hdr(setup["agent"], AgentRole.CELL_PM),
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.parametrize(
"field,value",
[
("dev_notes", "trying to sneak a note in"),
("team", "frontend"),
("assigned_to", str(uuid4())),
],
)
@pytest.mark.asyncio
async def test_cell_pm_cannot_patch_fields_outside_lighter_allowlist(
cell_pm_client: dict, field: str, value: object
) -> None:
setup = cell_pm_client
task = _seed_task(setup, team=Team.BACKEND)
await setup["db"].flush()
response = await setup["client"].patch(
f"/api/tasks/{task.id}",
json={field: value},
headers=_hdr(setup["agent"], AgentRole.CELL_PM),
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_cell_pm_cannot_change_status_via_patch(cell_pm_client: dict) -> None:
"""No status changes beyond what the lifecycle verbs already grant — the
PM-lighter PATCH surface must not become a side-door status override."""
setup = cell_pm_client
task = _seed_task(setup, team=Team.BACKEND, status=TaskStatus.IN_PROGRESS)
await setup["db"].flush()
response = await setup["client"].patch(
f"/api/tasks/{task.id}",
json={"status": "completed", "force": True},
headers=_hdr(setup["agent"], AgentRole.CELL_PM),
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_main_pm_can_patch_content_field_on_any_team_task(
main_pm_client: dict,
) -> None:
setup = main_pm_client
task = _seed_task(setup, team=Team.UX_UI)
await setup["db"].flush()
response = await setup["client"].patch(
f"/api/tasks/{task.id}",
json={"description": "A clarified description of at least twenty chars."},
headers=_hdr(setup["agent"], AgentRole.MAIN_PM),
)
assert response.status_code == HTTPStatus.OK
@pytest.mark.asyncio
async def test_main_pm_cannot_patch_privileged_field(main_pm_client: dict) -> None:
setup = main_pm_client
task = _seed_task(setup, team=Team.BACKEND)
await setup["db"].flush()
response = await setup["client"].patch(
f"/api/tasks/{task.id}",
json={"parent_task_id": str(uuid4())},
headers=_hdr(setup["agent"], AgentRole.MAIN_PM),
)
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_main_pm_cannot_change_status_via_patch(main_pm_client: dict) -> None:
setup = main_pm_client
task = _seed_task(setup, status=TaskStatus.AWAITING_PM_REVIEW)
await setup["db"].flush()
response = await setup["client"].patch(
f"/api/tasks/{task.id}",
json={"status": "completed"},
headers=_hdr(setup["agent"], AgentRole.MAIN_PM),
)
assert response.status_code == HTTPStatus.FORBIDDEN
+33
View File
@@ -102,6 +102,7 @@ async def task_client(
"agent": main_pm,
"project": project,
"db": db_session,
"app": app,
}
app.dependency_overrides.clear()
@@ -109,6 +110,24 @@ async def task_client(
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
def _as_ceo(setup: dict) -> None:
"""Re-override this client's agent identity to CEO for the rest of the
test the general PATCH admin surface (status overrides, structural
fields, force hatches, ...) is CEO/Board/Auditor-only now that
cell_pm/main_pm get the narrower "PM lighter" content-only slice (see
test_tasks_route_pm_lighter_patch.py). ``task_client``'s default agent
stays main_pm so the many other tests that specifically exercise
PM-role behavior (or "not CEO" refusals) are unaffected.
"""
async def _override_ceo() -> AgentContext:
return AgentContext(
agent_id=cast("UUID", setup["agent"].id), role=AgentRole.CEO, team=None
)
setup["app"].dependency_overrides[get_agent_context] = _override_ceo
def _seed_task(
setup: dict, *, status: TaskStatus = TaskStatus.PENDING, **kw: Any
) -> TaskTable:
@@ -278,6 +297,7 @@ async def test_update_task_status_override_recovers_blocked(task_client: dict) -
override, so an operator can recover a task wedged in ``blocked`` (which
``/complete`` refuses) instead of the status being silently dropped. The
``force`` flag acknowledges the bypass past the lifecycle gate (#13)."""
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.BLOCKED)
await task_client["db"].flush()
@@ -297,6 +317,7 @@ async def test_update_task_status_override_refused_without_force(
"""#13: pasting over the lifecycle gate into a terminal/final hatch state
(completed / awaiting_qa / awaiting_pm_review) without ``force`` is refused
with 400 the bypass must be an explicit, acknowledged forced override."""
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.IN_PROGRESS)
await task_client["db"].flush()
@@ -316,6 +337,7 @@ async def test_update_task_status_override_non_hatch_needs_no_force(
) -> None:
"""#13: a non-terminal recovery override (blocked -> pending) does NOT require
``force`` only the terminal/final hatch states do."""
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.BLOCKED)
await task_client["db"].flush()
@@ -336,6 +358,7 @@ async def test_update_task_override_gate_states_require_force(
"""The hatch set covers the CEO gate and the terminal cancel too (not just
completed/awaiting_qa/awaiting_pm_review): a privileged PATCH into either
without ``force`` is refused 400."""
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.IN_PROGRESS)
await task_client["db"].flush()
@@ -353,6 +376,7 @@ async def test_update_task_override_gate_states_require_force(
async def test_update_task_override_gate_states_with_force_succeeds(
task_client: dict, hatch: str
) -> None:
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.IN_PROGRESS)
await task_client["db"].flush()
@@ -370,6 +394,7 @@ async def test_update_task_resurrect_terminal_requires_force(task_client: dict)
"""Resurrecting a COMPLETED task back to in_progress is a bypass of the merge
decision; the target (in_progress) is not itself a hatch state, so the
target-only gate would miss it the source-terminal check requires force."""
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.COMPLETED)
await task_client["db"].flush()
@@ -416,6 +441,7 @@ async def test_admin_complete_with_open_pr_names_the_pr(task_client: dict) -> No
"""Admin status→completed on a task whose PR is still OPEN strands its
commits (bit the CEO twice live, 2026-07-02). The refusal must name the
PR and the stranding not just the generic lifecycle-gate text."""
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.AWAITING_CEO_APPROVAL)
await task_client["db"].flush()
@@ -438,6 +464,7 @@ async def test_admin_complete_with_open_pr_force_still_escapes(
) -> None:
"""``force`` remains the deliberate, audited escape — an operator who
KNOWS the PR should be stranded can still complete."""
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.AWAITING_CEO_APPROVAL)
await task_client["db"].flush()
@@ -457,6 +484,7 @@ async def test_admin_complete_with_merged_pr_gets_generic_gate_only(
) -> None:
"""A merged PR strands nothing — the refusal stays the generic hatch
text (no PR callout), and force completes as before."""
_as_ceo(task_client)
client = task_client["client"]
task = _seed_task(task_client, status=TaskStatus.AWAITING_CEO_APPROVAL)
await task_client["db"].flush()
@@ -3163,6 +3191,7 @@ async def test_get_sessions_for_task_not_found(task_client: dict) -> None:
@pytest.mark.asyncio
async def test_patch_nature_persists(task_client: dict) -> None:
"""PATCH with nature=non_technical persists; GET returns updated value."""
_as_ceo(task_client)
task = _seed_task(task_client, nature=TaskNature.TECHNICAL)
await task_client["db"].flush()
response = await task_client["client"].patch(
@@ -3178,6 +3207,7 @@ async def test_patch_nature_persists(task_client: dict) -> None:
@pytest.mark.asyncio
async def test_patch_task_type_persists(task_client: dict) -> None:
"""PATCH with task_type=research persists; GET returns updated value."""
_as_ceo(task_client)
task = _seed_task(task_client, task_type=TaskType.CODE)
await task_client["db"].flush()
response = await task_client["client"].patch(
@@ -3193,6 +3223,7 @@ async def test_patch_task_type_persists(task_client: dict) -> None:
@pytest.mark.asyncio
async def test_patch_project_id_persists(task_client: dict) -> None:
"""PATCH with project_id=<valid-uuid> persists; GET returns updated value."""
_as_ceo(task_client)
task = _seed_task(task_client)
# Create a second project to switch to
second_project = ProjectTable(
@@ -3250,6 +3281,7 @@ async def test_patch_title_only_changes_title(task_client: dict) -> None:
@pytest.mark.asyncio
async def test_patch_assigned_to_slug_resolves_to_uuid(task_client: dict) -> None:
"""PATCH assigned_to with agent slug resolves to agent UUID."""
_as_ceo(task_client)
dev = await _seed_agent(task_client)
task = _seed_task(task_client)
await task_client["db"].flush()
@@ -3267,6 +3299,7 @@ async def test_patch_assigned_to_slug_resolves_to_uuid(task_client: dict) -> Non
@pytest.mark.asyncio
async def test_patch_assigned_to_null_unassigns(task_client: dict) -> None:
"""PATCH assigned_to: null sets assigned_to to null (unassign)."""
_as_ceo(task_client)
dev = await _seed_agent(task_client)
task = _seed_task(task_client, assigned_to=dev.id)
await task_client["db"].flush()