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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user