mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* Cleanup + Missing greenlet error * fix(messaging): persist a group's active-session pointer so posts reuse it create_session and create_session_with_access_check set group.active_session_id from session.id BEFORE the flush that materializes it — the id is a flush-time uuid4 default, so the pointer was written as NULL and every post opened a fresh session, fragmenting one conversation across many. Flush first, then link, the same ordering the seed path already uses. Two tests fabricated "two distinct sessions" by calling create_session twice on one group, which only differed because of this bug; switch them to two groups so they keep testing their real intent. Add a regression guard that the pointer is actually persisted and a second create reuses the live session. * fix(orchestrator): gate spawns on dependencies and keep cell tasks in their cell The cross-task dependency check ran only on the dev dispatch path, so cell-PM, Main-PM and board agents were spawned onto dependency-blocked tasks and flailed unblock / escalate / notify against an unfinished upstream — climbing ownership of cell work up to the board, which cannot drive it, and deadlocking the task. - Move the dependency gate into the shared spawn readiness check so it covers every role, and auto-block the task so it leaves the pending pool until the upstream reaches a terminal state (then the existing auto-unblock revives it). - Cell-ownership invariant: a backend/frontend/ux_ui task may only be worked or owned by its own cell. The readiness gate refuses a board or Main-PM spawn onto a cell task; reassign refuses and clears such an owner; and on dependency-clear a mis-owned cell task is re-homed to its cell's pending pool instead of reviving under an owner that cannot progress it. - A dependency block is never a CEO signal: notify(target=ceo) is refused while the task is waiting on an unfinished upstream, with a remediate to idle and wait — the block clears on its own. * Uploading images + Fixing pyproject.toml * ++ * revert(orchestrator): drop the cell-ownership block pending a tooling audit The cell-ownership invariant added earlier — a board / Main-PM role may never be spawned onto or reassigned to a cell task, plus re-homing a mis-owned cell task on dependency-clear — was too absolute. It forbids a higher role from stepping in when something genuinely deeper is going on, and contradicts the existing rule that main_pm may hold a task at awaiting_pm_review. The dependency spawn gate already prevents the cascade that handed the board cell tasks; the deadlock it guarded against will be addressed with a return-path approach after auditing what tools the cell PMs actually need. Keeps the dependency gate and the CEO dependency-block notify guard. * docs(prompts): a dependency wait is wait-and-idle, not escalate The cell-PM and Main-PM prompts told agents to escalate_up / retry unblock on a blocked task without distinguishing a dependency wait (which auto-clears the moment the upstream completes) from a real wedge — the source of the escalate/unblock flail and the CEO-notification spam. Split the blocked-state guidance: a cross-cell dependency wait = note + i_am_idle (do not escalate, unblock, or notify the CEO); escalate only a genuinely broken upstream. Fix two stale references to i_am_blocked, a developer-only verb the PMs do not have, to escalate_up. Correct the CLAUDE.md verb-surface table, which understated every role: it listed 4 cell_pm verbs while the flow manifest derives the full set (11, including unclaim and i_am_idle) from lifecycle.spec.intents_for_role. * feat(gateway): cell_pm reassign verb — intra-cell developer hand-off A cell PM can now hand a claimed/in_progress task to another developer in its own cell without unclaim (which drops the work back to the pool and loses the assignee). The branch is keyed to the task, so the work-in-progress is preserved; the new dev is respawned to continue. Intra-cell only: the task must be in the caller's cell and new_assignee must be a developer of that same cell. Wired through every layer: the reassign IntentSpec (composes=(), cell_pm-only), the choreographer verb + intra-cell guard, a reaper-safe TaskService.reassign_active_claim (reseeds the claim heartbeat so the new dev is not immediately reaped), the ReassignRequest schema, the cell_pm flow route, and the MCP flow-server tool. Tracing-waived like unclaim (mechanical hand-off). Regenerated lifecycle/verb artifacts; prompt + CLAUDE.md updated. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
381 lines
12 KiB
Python
381 lines
12 KiB
Python
"""Tests for ContentActions.notify — formal ack-required notifications.
|
|
|
|
Pre-gateway, PMs and Board could issue formal notifications requiring
|
|
acknowledgment via NotificationService. Gateway only had say/dm
|
|
(informal). This verb fills the gap by composing NotificationService
|
|
into the standard envelope path, role-gated to PMs and Board only
|
|
(content tools share one router, so the role check lives in the verb).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
|
|
|
|
|
def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
|
|
if "task" in overrides:
|
|
task = overrides["task"]
|
|
else:
|
|
task = AsyncMock()
|
|
task.get_active_task_for_agent.return_value = None
|
|
task.get_journal_context_task_for_agent.return_value = None
|
|
task.agent_for.return_value = MagicMock(role="cell_pm")
|
|
|
|
git = overrides.get("git", AsyncMock())
|
|
messaging = overrides.get("messaging", AsyncMock())
|
|
a2a = overrides.get("a2a", AsyncMock())
|
|
journal = overrides.get("journal", AsyncMock())
|
|
workspace = overrides.get("workspace", AsyncMock())
|
|
notifications = overrides.get("notifications", AsyncMock())
|
|
return ContentActionsDeps(
|
|
task=task,
|
|
git=git,
|
|
messaging=messaging,
|
|
a2a=a2a,
|
|
journal=journal,
|
|
workspace=workspace,
|
|
notifications=notifications,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_pm_creates_ack_required_notification() -> None:
|
|
"""Cell PM calls notify(); NotificationService.send_ack_notification fired."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="be-dev-1",
|
|
text="Please review the new acceptance criteria before resuming.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
assert body["status"] == "sent"
|
|
notif_svc.send_ack_notification.assert_awaited_once()
|
|
call_kwargs = notif_svc.send_ack_notification.call_args.kwargs
|
|
assert call_kwargs["from_agent"] == agent_id
|
|
assert call_kwargs["to_agent"] == "be-dev-1"
|
|
assert "acceptance criteria" in call_kwargs["body"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_main_pm_succeeds() -> None:
|
|
"""Main PM is also allowed."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = MagicMock(role="main_pm")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="fe-pm",
|
|
text="Please align frontend cell with new release timeline.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
notif_svc.send_ack_notification.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_board_product_owner_succeeds() -> None:
|
|
"""Product Owner (Board) is allowed."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = MagicMock(role="product_owner")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="main-pm",
|
|
text="Roadmap priorities updated; please reflect in Q2 plan.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
notif_svc.send_ack_notification.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_board_head_marketing_succeeds() -> None:
|
|
"""Head of Marketing (Board) is allowed."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = MagicMock(role="head_marketing")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="main-pm",
|
|
text="Marketing launch dates confirmed; coordinate engineering deliverables.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
notif_svc.send_ack_notification.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_developer_rejected_with_not_authorized() -> None:
|
|
"""Developer cannot send formal notifications; envelope is not_authorized."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = MagicMock(role="developer")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="be-pm",
|
|
text="Heads up — I think the staging deploy is broken.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized"
|
|
assert "developer" in body["message"]
|
|
notif_svc.send_ack_notification.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_qa_rejected_with_not_authorized() -> None:
|
|
"""QA cannot send formal notifications."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = MagicMock(role="qa")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="be-pm",
|
|
text="QA cannot proceed without environment access.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized"
|
|
notif_svc.send_ack_notification.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_documenter_rejected_with_not_authorized() -> None:
|
|
"""Documenter cannot send formal notifications."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = MagicMock(role="documenter")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="be-pm",
|
|
text="Documentation review requested.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized"
|
|
notif_svc.send_ack_notification.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_auditor_rejected_with_not_authorized() -> None:
|
|
"""Auditor is read-only — cannot communicate outwardly via notifications."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = MagicMock(role="auditor")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="ceo",
|
|
text="Quality concern detected.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized"
|
|
notif_svc.send_ack_notification.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_auto_fills_task_id_from_active_task() -> None:
|
|
"""When the PM has an active task, notify auto-attaches it."""
|
|
agent_id = uuid4()
|
|
task_id = uuid4()
|
|
task_obj = MagicMock(id=task_id, status="awaiting_pm_review")
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = task_obj
|
|
task_svc.get_journal_context_task_for_agent.return_value = task_obj
|
|
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="be-dev-1",
|
|
text="Heads up: this task has been escalated for CEO approval.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
assert body["task_id"] == str(task_id)
|
|
call_kwargs = notif_svc.send_ack_notification.call_args.kwargs
|
|
assert call_kwargs["task_id"] == task_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_unknown_role_rejected() -> None:
|
|
"""If task.agent_for returns None, treat as unknown role and reject."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = None
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="be-dev-1",
|
|
text="Test message.",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized"
|
|
notif_svc.send_ack_notification.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_priority_high_passed_through() -> None:
|
|
"""Optional priority='high' is forwarded to NotificationService."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get_active_task_for_agent.return_value = None
|
|
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
|
|
notif_svc = AsyncMock()
|
|
|
|
deps = _make_deps(task=task_svc, notifications=notif_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.notify(
|
|
agent_id=agent_id,
|
|
target="be-dev-1",
|
|
text="Critical: production deployment failed; please join war room.",
|
|
priority="high",
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
call_kwargs = notif_svc.send_ack_notification.call_args.kwargs
|
|
assert call_kwargs["priority"] == "high"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# A dependency block is never a CEO signal — notify(target="ceo") is refused
|
|
# while the related task is waiting on an unfinished upstream.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ca_for_notify(
|
|
role: str, task: object, unmet: list[object]
|
|
) -> tuple[ContentActions, AsyncMock]:
|
|
task_svc = AsyncMock()
|
|
task_svc.agent_for.return_value = MagicMock(role=role)
|
|
task_svc.get.return_value = task
|
|
task_svc.unmet_dependency_ids.return_value = unmet
|
|
notif_svc = AsyncMock()
|
|
ca = ContentActions(_make_deps(task=task_svc, notifications=notif_svc))
|
|
# Ownership is exercised elsewhere; isolate the dependency-block gate.
|
|
object.__setattr__(
|
|
ca, "_verify_explicit_task_ownership", AsyncMock(return_value=None)
|
|
)
|
|
return ca, notif_svc
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_ceo_about_dependency_block_refused() -> None:
|
|
"""PO cannot page the CEO about a task that is just waiting on an upstream."""
|
|
dep_id = uuid4()
|
|
task = MagicMock(id=uuid4(), dependency_ids=[dep_id])
|
|
ca, notif_svc = _ca_for_notify("product_owner", task, unmet=[dep_id])
|
|
env = await ca.notify(
|
|
agent_id=uuid4(),
|
|
target="ceo",
|
|
text="URGENT: relax the backend dependency so the cell can resume.",
|
|
priority="urgent",
|
|
task_id=task.id,
|
|
)
|
|
body = env.as_dict()
|
|
assert body["error"] == "invalid_state"
|
|
assert "dependency block" in body["message"]
|
|
notif_svc.send_ack_notification.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_ceo_about_unblocked_task_allowed() -> None:
|
|
"""A CEO notification about a task with no open dependency still goes through."""
|
|
task = MagicMock(id=uuid4(), dependency_ids=[])
|
|
ca, notif_svc = _ca_for_notify("product_owner", task, unmet=[])
|
|
env = await ca.notify(
|
|
agent_id=uuid4(),
|
|
target="ceo",
|
|
text="Product review complete — ready for your go/no-go.",
|
|
task_id=task.id,
|
|
)
|
|
assert env.as_dict()["error"] is None
|
|
notif_svc.send_ack_notification.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_noncel_target_about_blocked_task_allowed() -> None:
|
|
"""The gate is CEO-scoped: notifying another PM about a block is unaffected."""
|
|
dep_id = uuid4()
|
|
task = MagicMock(id=uuid4(), dependency_ids=[dep_id])
|
|
ca, notif_svc = _ca_for_notify("main_pm", task, unmet=[dep_id])
|
|
env = await ca.notify(
|
|
agent_id=uuid4(),
|
|
target="be-pm",
|
|
text="Heads up: this task is waiting on the UX design.",
|
|
task_id=task.id,
|
|
)
|
|
assert env.as_dict()["error"] is None
|
|
notif_svc.send_ack_notification.assert_awaited_once()
|