mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -17,6 +17,7 @@ from unittest.mock import patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.runtime.orchestrator import (
|
||||
INTAKE_AGENT_ID,
|
||||
AgentInstance,
|
||||
@@ -265,7 +266,7 @@ class TestIntakeScopeSlugs:
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_history_digest_projects — the prompter-memory ambient's project scope
|
||||
# (covers all three intake scopes, unlike the conventions ambient resolver).
|
||||
# (covers all three intake scopes: project_slug, product_id, project_ids).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -375,6 +376,284 @@ class TestResolveHistoryDigestAmbient:
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_ambient_projects — the conventions ambient's project scope. Mirrors
|
||||
# _resolve_history_digest_projects's shape, including the MegaTask project_ids
|
||||
# scope (the pre-existing gap: this resolver used to stop at project_slug /
|
||||
# product_id / task_id and never saw a MegaTask's explicit project_ids).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveAmbientProjects:
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_slug_branch_resolves_single_project(self) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
|
||||
class _FakeProjectSvc:
|
||||
async def get_by_slug(self, slug: str) -> Any:
|
||||
return SimpleNamespace(slug=slug, id=uuid4())
|
||||
|
||||
with patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
):
|
||||
projects = await orch._resolve_ambient_projects(
|
||||
object(),
|
||||
project_slug="roboco",
|
||||
task_id=None,
|
||||
product_id=None,
|
||||
)
|
||||
assert [p.slug for p in projects] == ["roboco"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_slug_missing_returns_empty(self) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
|
||||
class _FakeProjectSvc:
|
||||
async def get_by_slug(self, _slug: str) -> Any:
|
||||
return None
|
||||
|
||||
with patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
):
|
||||
projects = await orch._resolve_ambient_projects(
|
||||
object(),
|
||||
project_slug="ghost",
|
||||
task_id=None,
|
||||
product_id=None,
|
||||
)
|
||||
assert projects == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_product_id_branch_delegates_to_ambient_product_projects(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
sentinel = [SimpleNamespace(slug="p1", id=uuid4())]
|
||||
|
||||
async def _fake_product_projects(_db: Any, product_id: str) -> list[Any]:
|
||||
assert product_id == "prod-1"
|
||||
return sentinel
|
||||
|
||||
# Patched on the instance (not the class): _ambient_product_projects is
|
||||
# a staticmethod, so accessing it via `self.` never binds `self` — but a
|
||||
# plain function patched onto the *class* would, since it's no longer
|
||||
# wrapped in `staticmethod`. Patching the instance attribute sidesteps
|
||||
# the descriptor lookup entirely.
|
||||
monkeypatch.setattr(orch, "_ambient_product_projects", _fake_product_projects)
|
||||
projects = await orch._resolve_ambient_projects(
|
||||
object(),
|
||||
project_slug=None,
|
||||
task_id=None,
|
||||
product_id="prod-1",
|
||||
)
|
||||
assert projects is sentinel
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_ids_branch_preserves_order_and_skips_missing(
|
||||
self,
|
||||
) -> None:
|
||||
"""The MegaTask scope: an explicit project_ids list must resolve into
|
||||
projects (order preserved, unresolvable ids skipped) — this is the gap
|
||||
fix, mirroring the history digest resolver's own project_ids branch."""
|
||||
orch = _make_minimal_orchestrator()
|
||||
good1 = "11111111-1111-1111-1111-111111111111"
|
||||
missing = "22222222-2222-2222-2222-222222222222"
|
||||
good2 = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
class _FakeProjectSvc:
|
||||
async def get(self, pid: Any) -> Any:
|
||||
if str(pid) == missing:
|
||||
return None
|
||||
return SimpleNamespace(slug=f"proj-{str(pid)[0]}", id=pid)
|
||||
|
||||
with patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
):
|
||||
projects = await orch._resolve_ambient_projects(
|
||||
object(),
|
||||
project_slug=None,
|
||||
task_id=None,
|
||||
product_id=None,
|
||||
project_ids=[good1, missing, good2],
|
||||
)
|
||||
assert [p.slug for p in projects] == ["proj-1", "proj-3"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_ids_takes_priority_over_other_scopes(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A MegaTask spawn passes project_slug/product_id as None in practice,
|
||||
but the resolver must still prefer the explicit project_ids set over a
|
||||
stray product_id/task_id if both were somehow present."""
|
||||
orch = _make_minimal_orchestrator()
|
||||
pid = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
class _FakeProjectSvc:
|
||||
async def get(self, _pid: Any) -> Any:
|
||||
return SimpleNamespace(slug="from-ids", id=_pid)
|
||||
|
||||
async def _fail_product_projects(*_a: Any, **_k: Any) -> list[Any]:
|
||||
raise AssertionError("product_id branch must not run")
|
||||
|
||||
monkeypatch.setattr(orch, "_ambient_product_projects", _fail_product_projects)
|
||||
with patch(
|
||||
"roboco.services.project.get_project_service",
|
||||
lambda _db: _FakeProjectSvc(),
|
||||
):
|
||||
projects = await orch._resolve_ambient_projects(
|
||||
object(),
|
||||
project_slug=None,
|
||||
task_id=None,
|
||||
product_id="prod-should-be-ignored",
|
||||
project_ids=[pid],
|
||||
)
|
||||
assert [p.slug for p in projects] == ["from-ids"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_scope_given_returns_empty(self) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
projects = await orch._resolve_ambient_projects(
|
||||
object(),
|
||||
project_slug=None,
|
||||
task_id=None,
|
||||
product_id=None,
|
||||
)
|
||||
assert projects == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_conventions_ambient — flag-gated + best-effort, now MegaTask-aware.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveConventionsAmbient:
|
||||
@pytest.mark.asyncio
|
||||
async def test_flag_off_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
monkeypatch.setattr(settings, "conventions_enabled", False)
|
||||
|
||||
result = await orch._resolve_conventions_ambient(
|
||||
"roboco", project_ids=["11111111-1111-1111-1111-111111111111"]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failure_returns_none_not_raises(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
|
||||
def _boom() -> Any:
|
||||
raise RuntimeError("db unavailable")
|
||||
|
||||
monkeypatch.setattr("roboco.db.base.get_session_factory", _boom)
|
||||
|
||||
result = await orch._resolve_conventions_ambient(
|
||||
None, project_ids=["11111111-1111-1111-1111-111111111111"]
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_ids_scope_reaches_conventions_layer(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The MegaTask project_ids scope must flow all the way through to
|
||||
conventions_ambient_layer — the actual gap this fix closes."""
|
||||
orch = _make_minimal_orchestrator()
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
|
||||
class _FakeFactory:
|
||||
def __call__(self) -> Any:
|
||||
return self
|
||||
|
||||
async def __aenter__(self) -> Any:
|
||||
return "fake-db"
|
||||
|
||||
async def __aexit__(self, *_a: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("roboco.db.base.get_session_factory", _FakeFactory)
|
||||
|
||||
sentinel_projects = [SimpleNamespace(slug="proj-1")]
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def _fake_resolve_projects(_self: Any, _db: Any, **kwargs: Any) -> Any:
|
||||
captured["kwargs"] = kwargs
|
||||
return sentinel_projects
|
||||
|
||||
async def _fake_layer(_db: Any, projects: Any) -> str:
|
||||
captured["projects"] = projects
|
||||
return "RENDERED BLOCK"
|
||||
|
||||
monkeypatch.setattr(
|
||||
AgentOrchestrator, "_resolve_ambient_projects", _fake_resolve_projects
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"roboco.agents.factories._base.conventions_ambient_layer", _fake_layer
|
||||
)
|
||||
|
||||
result = await orch._resolve_conventions_ambient(
|
||||
None, project_ids=["11111111-1111-1111-1111-111111111111"]
|
||||
)
|
||||
assert result == "RENDERED BLOCK"
|
||||
assert captured["kwargs"]["project_ids"] == [
|
||||
"11111111-1111-1111-1111-111111111111"
|
||||
]
|
||||
assert captured["projects"] is sentinel_projects
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _resolve_intake_ambient — must forward project_ids to BOTH sub-resolvers.
|
||||
# Regression test for the gap: it used to thread project_ids only to the
|
||||
# history-digest resolver, leaving a MegaTask intake with no conventions block.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveIntakeAmbientThreadsProjectIds:
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_ids_forwarded_to_conventions_and_history(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
orch = _make_minimal_orchestrator()
|
||||
conventions_calls: list[dict[str, Any]] = []
|
||||
history_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _conventions(_slug: Any, **kwargs: Any) -> str | None:
|
||||
conventions_calls.append(kwargs)
|
||||
return "CONVENTIONS"
|
||||
|
||||
async def _history(_slug: Any, **kwargs: Any) -> str | None:
|
||||
history_calls.append(kwargs)
|
||||
return "HISTORY"
|
||||
|
||||
monkeypatch.setattr(orch, "_resolve_conventions_ambient", _conventions)
|
||||
monkeypatch.setattr(orch, "_resolve_history_digest_ambient", _history)
|
||||
|
||||
result = await orch._resolve_intake_ambient(
|
||||
None,
|
||||
product_id=None,
|
||||
project_ids=["11111111-1111-1111-1111-111111111111"],
|
||||
)
|
||||
|
||||
assert result == "CONVENTIONS\n\n---\n\nHISTORY"
|
||||
assert conventions_calls == [
|
||||
{
|
||||
"product_id": None,
|
||||
"project_ids": ["11111111-1111-1111-1111-111111111111"],
|
||||
}
|
||||
]
|
||||
assert history_calls == [
|
||||
{
|
||||
"product_id": None,
|
||||
"project_ids": ["11111111-1111-1111-1111-111111111111"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# spawn_intake_session / reap_intake_session — orchestration (docker mocked).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user