Files
roboco/tests/unit/gateway/test_choreographer_pm.py
T
93739a9dca fix(notifications): stop tick-wide row locks + poisoned-session swallows behind the PendingRollbackError 500s (#743)
* fix(notifications): release re-escalation row locks per row; stop swallowing DB errors in notify_get

The re-escalation sweep ran one tick-wide transaction, so each CAS
claim's row lock was held across every remaining delivery until the
single commit — a concurrent mark-read UPDATE on a claimed row starved
into the 60s lock_timeout. The sweep now commits per row (claim commit
releases the lock before delivery and makes the burned slot durable),
re-fetches each row by snapshotted id so one row's rollback can't
expire the rest of the tick, and savepoints each recipient's delivery.

notify_get's bare except swallowed the resulting LockNotAvailableError
into a false "notification not found" and returned a poisoned session
to the commit-at-send middleware, which blew up with
PendingRollbackError; it now catches only the two domain outcomes.

defer_after_commit's listeners fire on SAVEPOINT release too, which
would have drained deferred telegram/bus work before real durability —
they now skip savepoint boundaries via get_nested_transaction() (the
root get_transaction() is non-None inside the listener even at a real
commit). acknowledge_for_recipient's Redis dedup-clear moved before the
flush so the row lock never spans a Redis round-trip. The five
best-effort CEO-notify swallows that persist notification rows are
savepointed.

* fix(services): contain swallowed best-effort DB write failures instead of poisoning the session

Sweep of the same class as the notify_get incident: broad
except-Exception handlers that swallow a failure whose try-body writes
through the shared session leave the session rollback-pending, and the
verb/request then dies later with PendingRollbackError at
commit-at-send. Confirmed-dangerous sites now run the write inside a
savepoint (safe since defer_after_commit skips savepoint boundaries):
ceo_approve's verified-stamp, completion/pitch/postmortem-style CEO
notifies, _inherit_upstream_base, _link_commit_to_task (covers every
commit route), board-program LEARN records, the QA/PR-gate/PM-merge
verified-stamps, and the documenter->PM handoff.
_ack_pending_wake_notifications gets the same treatment so a wake-ack
failure can't fail the A2A read. telegram_inbound's per-update loop and
intake confirm roll back explicitly instead (their success paths commit
mid-flow, so a savepoint doesn't fit).

A swallowed savepoint rollback fully expires any ORM object mutated
inside the block, and the next attribute read raises MissingGreenlet —
strictly worse than the original bug. The two paths that keep using the
object after the swallow (doc handoff's envelope build, base
inheritance's claim continuation) refresh it in the except path;
regression tests run against a real session and were verified to fail
with the refresh reverted.

* test: shape mocked session.execute results so sync accessors stop leaking unawaited coroutines

An AsyncMock's auto-created children are themselves AsyncMock, so
production code that correctly awaits session.execute() and then calls
sync accessors (.scalars().all(), .scalar_one_or_none()) on the result
was silently collecting unawaited coroutines in 22 test files — 80
RuntimeWarnings per unit run, and in test_flow_soup_guard one mock
raised a real TypeError that a coincidentally-matching invalid_state
envelope masked. Each affected fixture now returns a plain MagicMock
shaped like a real Result. Zero AsyncMock warnings remain.

* docs: document per-row sweep commits and the savepoint/refresh containment pattern

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-30 16:35:42 +02:00

1407 lines
52 KiB
Python

"""Tests for PM Choreographer methods.
Covers: triage, triage_all, unblock, complete, escalate_up.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
def _make_deps(**overrides: Any) -> ChoreographerDeps:
base = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base.update(overrides)
repo = base["evidence_repo"]
for method in (
"list_unread_a2a",
"list_unread_mentions",
"list_pending_notifications",
"task_metadata_gaps",
"recent_team_activity",
"blockers_in_lane",
"journal_highlights_for_task",
):
getattr(repo, method).return_value = []
# C8: default-fresh journal:decision so PM-decision gate passes.
# Tests that exercise the gate boundary stub their own value.
# The check matches MagicMock and AsyncMock (the two default sentinel
# types pytest's unittest.mock leaves on un-stubbed return_values).
_ldef = base["journal"].latest_decision_at.return_value
if type(_ldef).__name__ in ("MagicMock", "AsyncMock"):
base["journal"].latest_decision_at.return_value = datetime.now(UTC)
# cell_pm_complete / main_pm_complete's pm-origin verified-stamp reads via
# session.execute (ReviewFindingsRepository.list_for_task) before merging
# — an empty scalars result (no findings) so the stamp is a no-op here.
# Additive (not a session replacement) so a test's own session.begin_nested
# setup (e.g. submit_root's savepoint) is untouched.
base["task"].session.execute = AsyncMock(
return_value=MagicMock(
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
)
)
# That same verified-stamp now runs inside its own savepoint (a mid-flush
# failure must not poison the shared session) — an unconfigured
# AsyncMock's begin_nested() call returns a raw unawaited coroutine,
# which `async with` cannot use. Same shape as the execute default above;
# a test's own explicit begin_nested config (e.g. submit_root's) is the
# identical shape, so overwriting it here is a no-op for those tests.
base["task"].session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
return ChoreographerDeps(**base)
@pytest.mark.asyncio
async def test_cell_pm_triage_returns_blocked_first() -> None:
pm_id = uuid4()
blocked_task = MagicMock(id=uuid4(), status="blocked", title="b", team="backend")
pending_task = MagicMock(
id=uuid4(), status="awaiting_pm_review", title="p", team="backend"
)
task_svc = AsyncMock()
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.list_blocked_for_team.return_value = [blocked_task]
task_svc.list_awaiting_pm_review_for_team.return_value = [pending_task]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.triage(pm_id)
body = env.as_dict()
assert body["task_id"] == str(blocked_task.id)
assert "unblock" in body["next"].lower()
@pytest.mark.asyncio
async def test_cell_pm_triage_returns_awaiting_review_when_no_blocked() -> None:
pm_id = uuid4()
pending_task = MagicMock(id=uuid4(), status="awaiting_pm_review", team="backend")
task_svc = AsyncMock()
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.list_blocked_for_team.return_value = []
task_svc.list_awaiting_pm_review_for_team.return_value = [pending_task]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.triage(pm_id)
body = env.as_dict()
assert body["task_id"] == str(pending_task.id)
assert "complete" in body["next"]
@pytest.mark.asyncio
async def test_cell_pm_triage_returns_idle_when_no_work() -> None:
pm_id = uuid4()
task_svc = AsyncMock()
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.list_blocked_for_team.return_value = []
task_svc.list_awaiting_pm_review_for_team.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.triage(pm_id)
body = env.as_dict()
assert body["status"] == "idle"
@pytest.mark.asyncio
async def test_main_pm_triage_all_includes_cross_team() -> None:
pm_id = uuid4()
blocked = MagicMock(id=uuid4(), status="blocked", team="backend", title="x")
task_svc = AsyncMock()
task_svc.list_blocked_all_teams.return_value = [blocked]
task_svc.list_awaiting_main_pm_all.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.triage_all(pm_id)
body = env.as_dict()
assert body["error"] is None
assert body["task_id"] == str(blocked.id)
@pytest.mark.asyncio
async def test_main_pm_triage_all_returns_idle() -> None:
pm_id = uuid4()
task_svc = AsyncMock()
task_svc.list_blocked_all_teams.return_value = []
task_svc.list_awaiting_main_pm_all.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.triage_all(pm_id)
assert env.status == "idle"
@pytest.mark.asyncio
async def test_unblock_restores_pre_block_state() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="blocked",
pre_block_state="awaiting_documentation",
pre_block_assignee=uuid4(),
pre_block_metadata={"some_field": "x"},
)
after = MagicMock(
**{
**t.__dict__,
"status": "awaiting_documentation",
"assigned_to": t.pre_block_assignee,
},
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.unblock_with_restore.return_value = after
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.unblock(
pm_id, task_id, "block resolved upstream; restoring", restore=True
)
assert env.error is None
assert env.status == "awaiting_documentation"
task_svc.unblock_with_restore.assert_awaited_once_with(pm_id, task_id, restore=True)
@pytest.mark.asyncio
async def test_unblock_default_restores() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="blocked",
pre_block_state="awaiting_qa",
pre_block_assignee=uuid4(),
pre_block_metadata={},
)
after = MagicMock(**{**t.__dict__, "status": "awaiting_qa"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.unblock_with_restore.return_value = after
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
# restore omitted -> defaults to True
env = await c.unblock(pm_id, task_id, "block resolved upstream; restoring")
assert env.status == "awaiting_qa"
@pytest.mark.asyncio
async def test_unblock_blocks_without_journal_decision() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="blocked")
task_svc = AsyncMock()
task_svc.get.return_value = t
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = False
journal_svc.latest_decision_at.return_value = None
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.unblock(pm_id, task_id, "block resolved upstream; restoring")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision" in body["missing"]
@pytest.mark.asyncio
async def test_unblock_wrong_state_returns_invalid_state() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="in_progress")
task_svc = AsyncMock()
task_svc.get.return_value = t
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.unblock(pm_id, task_id, "block resolved upstream; restoring")
body = env.as_dict()
assert body["error"] == "invalid_state"
@pytest.mark.asyncio
async def test_unblock_restore_false_returns_legacy_message() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="blocked",
pre_block_state="awaiting_qa",
pre_block_assignee=uuid4(),
pre_block_metadata={},
)
after = MagicMock(**{**t.__dict__, "status": "in_progress"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.unblock_with_restore.return_value = after
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.unblock(
pm_id, task_id, "block resolved upstream; restoring", restore=False
)
body = env.as_dict()
assert body["status"] == "in_progress"
assert "re-engage" in body["next"].lower()
@pytest.mark.asyncio
async def test_unblock_refused_while_a_dependency_is_unfinished() -> None:
"""A dependency block can't be force-cleared by a PM.
It auto-clears via _unblock_dependents once the upstream completes; manual
unblock would let the dependent proceed without the upstream's work.
"""
pm_id = uuid4()
task_id = uuid4()
dep_id = uuid4()
t = MagicMock(id=task_id, status="blocked", dependency_ids=[dep_id])
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.unmet_dependency_ids.return_value = [dep_id]
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.unblock(pm_id, task_id, "block resolved upstream; restoring")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "depends on" in body["message"]
# The task must not have been advanced out of blocked.
task_svc.unblock_with_restore.assert_not_awaited()
@pytest.mark.asyncio
async def test_cell_pm_complete_merges_then_completes() -> None:
pm_id = uuid4()
task_id = uuid4()
parent_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=pm_id,
pr_number=8,
branch_name="feature/backend/abc--def",
parent_task_id=parent_id,
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "completed"})
# #181/#182: the merge target is the PARENT task's real branch_name —
# here under a DIFFERENT team prefix, which the old parent_branch_for
# would have mis-derived as feature/backend/abc.
parent = MagicMock(
id=parent_id, branch_name="feature/main_pm/abc", parent_task_id=None
)
task_svc = AsyncMock()
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else t
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = after
git_svc = AsyncMock()
git_svc.is_pr_merged_for_task.return_value = False
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "merge-abc"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(pm_id, task_id, notes="reviewed and approved")
assert env.error is None
assert env.status == "completed"
git_svc.pr_merge.assert_awaited_once_with(
8,
target="feature/main_pm/abc",
project_id=t.project_id,
actor_agent_id=pm_id,
)
@pytest.mark.asyncio
async def test_cell_pm_complete_does_not_500_when_complete_returns_none() -> None:
"""`complete()` returns None when its PR-merged guard fails — which is
exactly what happened live when the merge recorded against the WRONG
task's work session (the cross-repo pr_number collision) left this
task's `pr_status="open"`. `_finalize_cell_complete` used to deref
`t.status` on the None and 500, leaving the cell PM thrashing
escalate<->blocked until the CEO intervened. It must fail closed into a
clean invalid_state envelope, never a 500.
"""
pm_id = uuid4()
task_id = uuid4()
parent_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=pm_id,
pr_number=8,
branch_name="feature/backend/abc--def",
parent_task_id=parent_id,
team="backend",
)
parent = MagicMock(
id=parent_id, branch_name="feature/main_pm/abc", parent_task_id=None
)
task_svc = AsyncMock()
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else t
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = None # complete() rejected
git_svc = AsyncMock()
git_svc.is_pr_merged_for_task.return_value = False
git_svc.pr_merge.return_value = {"merge_commit_sha": "merge-abc"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(pm_id, task_id, notes="reviewed and approved")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert (
"merged" in body.get("message", "").lower()
or "complete" in body.get("message", "").lower()
)
# No AttributeError 500 — the verb completed cleanly with a remediation.
assert body.get("remediate")
@pytest.mark.asyncio
async def test_cell_pm_complete_blocks_if_subtasks_unfinished() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=pm_id,
parent_task_id=uuid4(),
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.all_subtasks_terminal.return_value = False
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(
pm_id, task_id, notes="reviewed cell scope and approved merge"
)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "subtasks" in str(body["missing"]).lower()
@pytest.mark.asyncio
async def test_cell_pm_complete_requires_journal_decision() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=pm_id,
parent_task_id=uuid4(),
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.all_subtasks_terminal.return_value = True
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = False
journal_svc.latest_decision_at.return_value = None
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(pm_id, task_id, notes="x")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision" in body["missing"]
@pytest.mark.asyncio
async def test_cell_pm_complete_no_pr_returns_invalid_state() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=pm_id,
pr_number=None,
branch_name="feature/backend/abc--def",
parent_task_id=uuid4(),
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.all_subtasks_terminal.return_value = True
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(
pm_id, task_id, notes="reviewed cell scope and approved merge"
)
assert env.as_dict()["error"] == "invalid_state"
@pytest.mark.asyncio
async def test_cell_pm_complete_not_assigned_returns_not_authorized() -> None:
pm_id = uuid4()
other = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="awaiting_pm_review", assigned_to=other)
task_svc = AsyncMock()
task_svc.get.return_value = t
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(pm_id, task_id, notes="x")
assert env.as_dict()["error"] == "not_authorized"
@pytest.mark.asyncio
async def test_cell_pm_complete_in_progress_steers_to_submit_up() -> None:
"""Mirror of the main-PM submit_root steer: a cell task still in_progress
must enter the gate via submit_up first. The rejection must NAME submit_up
so the cell PM isn't left guessing the verb (the gap that deadlocked the
main PM on submit_root)."""
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=pm_id,
parent_task_id=uuid4(),
branch_name="feature/backend/parent123",
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.all_subtasks_terminal.return_value = True
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(
pm_id, task_id, notes="cell scope assembled; ready to bubble up"
)
assert env.error is not None
assert "submit_up" in (env.remediate or "")
@pytest.mark.asyncio
async def test_main_pm_complete_escalates_code_root_without_reopening_pr() -> None:
"""A code root reaches main_pm_complete already in awaiting_pm_review —
submit_root opened the root→master PR and the main reviewer pr_passed it —
so complete just escalates to the CEO and does NOT reopen the PR."""
main_pm_id = uuid4()
root_task_id = uuid4()
t = MagicMock(
id=root_task_id,
status="awaiting_pm_review",
assigned_to=main_pm_id,
pr_number=99,
branch_name="feature/backend/root123",
parent_task_id=None,
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.escalate_to_ceo.return_value = after
task_svc.all_subtasks_terminal.return_value = True
git_svc = AsyncMock()
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(
main_pm_id, root_task_id, notes="root scope reviewed and ready for production"
)
assert env.error is None
assert env.status == "awaiting_ceo_approval"
git_svc.create_pr.assert_not_awaited()
task_svc.escalate_to_ceo.assert_awaited_once()
@pytest.mark.asyncio
async def test_main_pm_complete_rejects_in_progress_code_root_toward_submit_root() -> (
None
):
"""A code root must pass the in-path gate first. main_pm_complete rejects it
while still in_progress and points the Main PM at submit_root."""
main_pm_id = uuid4()
root_task_id = uuid4()
t = MagicMock(
id=root_task_id,
status="in_progress",
assigned_to=main_pm_id,
pr_number=None,
branch_name="feature/main_pm/root123",
parent_task_id=None,
team="main_pm",
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.all_subtasks_terminal.return_value = True
git_svc = AsyncMock()
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(
main_pm_id, root_task_id, notes="root scope reviewed; ready for CEO sign-off"
)
assert env.error is not None
assert "submit_root" in (env.remediate or "")
task_svc.escalate_to_ceo.assert_not_awaited()
@pytest.mark.asyncio
async def test_submit_root_accepts_main_pm_and_enters_the_gate() -> None:
"""submit_root is the Main PM's entry to the in-path gate. It reuses the
cell PM's _submit_up_guard for ownership/state, so the ownership guard must
NOT hardcode-reject main_pm — otherwise submit_root and complete point at
each other and a code root can never close (the circular-rejection bug)."""
main_pm_id = uuid4()
root_task_id = uuid4()
in_prog = MagicMock(
id=root_task_id,
status="in_progress",
assigned_to=main_pm_id,
pr_number=None,
branch_name="feature/main_pm/root123",
parent_task_id=None,
batch_id=None, # a normal root carries no batch_id (not a MegaTask umbrella)
team="main_pm",
)
gated = MagicMock(**{**in_prog.__dict__, "status": "awaiting_pr_review"})
task_svc = AsyncMock()
task_svc.get.return_value = in_prog
task_svc.submit_for_review.return_value = gated
task_svc.all_subtasks_terminal.return_value = True
task_svc.uncovered_parent_acceptance_criteria.return_value = []
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
task_svc.session.begin_nested = MagicMock(
return_value=MagicMock(__aenter__=AsyncMock(), __aexit__=AsyncMock())
)
git_svc = AsyncMock()
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.submit_root(
main_pm_id, root_task_id, notes="root scope assembled; opening root→master PR"
)
assert env.error is None, env.as_dict()
assert env.status == "awaiting_pr_review"
task_svc.submit_for_review.assert_awaited_once()
@pytest.mark.asyncio
async def test_main_pm_complete_walks_branchless_coordination_root_to_ceo() -> None:
"""A branchless coordination root (product fan-out, no repo/PR) skips the
in-path gate: main_pm_complete walks it in_progress→awaiting_pm_review and
escalates to the CEO. No root→master PR is created (it has no branch)."""
main_pm_id = uuid4()
root_task_id = uuid4()
in_prog = MagicMock(
id=root_task_id,
status="in_progress",
assigned_to=main_pm_id,
pr_number=None,
branch_name=None,
parent_task_id=None,
team="main_pm",
)
awaiting = MagicMock(**{**in_prog.__dict__, "status": "awaiting_pm_review"})
after = MagicMock(**{**in_prog.__dict__, "status": "awaiting_ceo_approval"})
task_svc = AsyncMock()
task_svc.get.return_value = in_prog
task_svc.submit_pm_review.return_value = awaiting
task_svc.escalate_to_ceo.return_value = after
task_svc.all_subtasks_terminal.return_value = True
git_svc = AsyncMock()
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(
main_pm_id, root_task_id, notes="coordination root reviewed; ready for CEO"
)
assert env.error is None
assert env.status == "awaiting_ceo_approval"
git_svc.create_pr.assert_not_awaited()
task_svc.submit_pm_review.assert_awaited_once()
task_svc.escalate_to_ceo.assert_awaited_once()
@pytest.mark.asyncio
async def test_main_pm_complete_skips_pr_creation_if_already_master_targeted() -> None:
main_pm_id = uuid4()
root_task_id = uuid4()
t = MagicMock(
id=root_task_id,
status="awaiting_pm_review",
assigned_to=main_pm_id,
pr_number=42,
branch_name="feature/backend/root123",
parent_task_id=None,
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.escalate_to_ceo.return_value = after
task_svc.all_subtasks_terminal.return_value = True
git_svc = AsyncMock()
git_svc.pr_target.return_value = "master"
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
await c.main_pm_complete(
main_pm_id, root_task_id, notes="root scope reviewed and ready"
)
git_svc.create_pr.assert_not_awaited()
task_svc.escalate_to_ceo.assert_awaited_once()
@pytest.mark.asyncio
async def test_main_pm_complete_rejects_non_root_task() -> None:
main_pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=main_pm_id,
parent_task_id=uuid4(), # has parent -> not a root task
batch_id=None, # a plain subtask, NOT a MegaTask root-subtask
)
task_svc = AsyncMock()
task_svc.get.return_value = t
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(main_pm_id, task_id, notes="x")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "root tasks" in body["message"]
@pytest.mark.asyncio
async def test_main_pm_complete_allows_batch_root_subtask() -> None:
"""A MegaTask root-subtask IS parented (the umbrella) yet carries its own
project/branch/PR and behaves as a root for git/CEO purposes — the
parent_task_id refusal above must NOT fire for it (is_batch_root_subtask)."""
main_pm_id = uuid4()
root_task_id = uuid4()
t = MagicMock(
id=root_task_id,
status="awaiting_pm_review",
assigned_to=main_pm_id,
pr_number=42,
branch_name="feature/main_pm/rootsub1",
parent_task_id=uuid4(), # the umbrella
batch_id=uuid4(), # batch_id + parent_task_id -> is_batch_root_subtask
team="main_pm",
)
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.escalate_to_ceo.return_value = after
task_svc.all_subtasks_terminal.return_value = True
task_svc.uncovered_parent_acceptance_criteria.return_value = []
git_svc = AsyncMock()
git_svc.pr_target.return_value = "master"
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(
main_pm_id, root_task_id, notes="root-subtask reviewed and ready"
)
assert env.error is None, env.as_dict()
assert env.status == "awaiting_ceo_approval"
task_svc.escalate_to_ceo.assert_awaited_once()
@pytest.mark.asyncio
async def test_main_pm_complete_blocks_unfinished_subtasks() -> None:
main_pm_id = uuid4()
root_task_id = uuid4()
t = MagicMock(
id=root_task_id,
status="awaiting_pm_review",
assigned_to=main_pm_id,
parent_task_id=None,
team="backend",
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.all_subtasks_terminal.return_value = False
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.main_pm_complete(main_pm_id, root_task_id, notes="x")
body = env.as_dict()
assert body["error"] == "tracing_gap"
@pytest.mark.asyncio
async def test_complete_dispatches_cell_pm() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=pm_id,
parent_task_id=uuid4(),
pr_number=8,
branch_name="feature/backend/abc--def",
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "completed"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = after
git_svc = AsyncMock()
git_svc.pr_merge.return_value = {"merged": True, "merge_commit_sha": "x"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.complete(
pm_id, task_id, notes="cell scope reviewed and approved for merge"
)
assert env.status == "completed"
task_svc.cell_pm_complete.assert_awaited_once()
@pytest.mark.asyncio
async def test_complete_dispatches_main_pm() -> None:
main_pm_id = uuid4()
root_task_id = uuid4()
t = MagicMock(
id=root_task_id,
status="awaiting_pm_review",
assigned_to=main_pm_id,
pr_number=None,
branch_name="feature/backend/root123",
parent_task_id=None,
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "awaiting_ceo_approval"})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="main_pm")
task_svc.all_subtasks_terminal.return_value = True
task_svc.escalate_to_ceo.return_value = after
git_svc = AsyncMock()
git_svc.create_pr.return_value = {"pr_number": 99, "pr_url": "x"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.complete(
main_pm_id, root_task_id, notes="root scope reviewed and ready"
)
assert env.status == "awaiting_ceo_approval"
task_svc.escalate_to_ceo.assert_awaited_once()
@pytest.mark.asyncio
async def test_complete_rejects_non_pm_role() -> None:
dev_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.agent_for.return_value = MagicMock(role="developer")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.complete(dev_id, task_id, notes="reviewed and approved")
body = env.as_dict()
assert body["error"] == "not_authorized"
assert "cell_pm" in body["remediate"] and "main_pm" in body["remediate"]
@pytest.mark.asyncio
async def test_escalate_up_routes_by_escalation_target() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="blocked", assigned_to=pm_id, team="backend")
after = MagicMock(**{**t.__dict__, "assigned_to": uuid4()})
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
role="cell_pm",
escalation_target="main-pm",
)
task_svc.escalate.return_value = after
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="cross-cell coordination needed")
assert env.error is None
task_svc.escalate.assert_awaited_once_with(
pm_id,
task_id,
"cross-cell coordination needed",
)
@pytest.mark.asyncio
async def test_escalate_up_returns_invalid_state_when_target_lookup_fails() -> None:
"""Regression: escalate_up_to_role returning None used to crash on t.status."""
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="blocked", assigned_to=pm_id, team="backend")
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
role="cell_pm",
escalation_target="main-pm",
)
task_svc.escalate.return_value = None # target slug not found in DB
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="needs cross-cell coordination")
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "main-pm" in body["message"]
@pytest.mark.asyncio
async def test_escalate_up_blocks_without_journal_decision() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="blocked")
task_svc = AsyncMock()
task_svc.get.return_value = t
# Spec gate runs before the journal:decision preflight; provide a
# valid PM role so the gate passes and the preflight is the
# load-bearing rejector.
task_svc.agent_for.return_value = MagicMock(
role="cell_pm", escalation_target="main-pm"
)
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = False
journal_svc.latest_decision_at.return_value = None
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="needs cross-cell coordination")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision" in body["missing"]
@pytest.mark.asyncio
async def test_escalate_up_no_target_returns_invalid_state() -> None:
"""Verb-specific preflight: PM whose escalation_target is unconfigured.
The spec allows cell_pm/main_pm to call escalate_up regardless of
target slug presence (target metadata lives on the agent record, not
the lifecycle). The verb body's preflight is what surfaces the
invalid_state when no target is configured.
"""
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="blocked")
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(
role="cell_pm",
escalation_target=None,
)
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
deps = _make_deps(task=task_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="needs cross-cell coordination")
body = env.as_dict()
assert body["error"] == "invalid_state"
@pytest.mark.asyncio
async def test_escalate_up_task_not_found() -> None:
pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.escalate_up(pm_id, task_id, reason="needs cross-cell coordination")
assert env.as_dict()["error"] == "not_found"
# ---------------------------------------------------------------------------
# H7: cell_pm_complete idempotent when PR already merged to target
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cell_pm_complete_idempotent_when_pr_already_merged_to_target() -> None:
"""Re-issuing cell_pm_complete after the PR is already merged to the
target must not call git.pr_merge again (which would 405) and must not
500. It should complete the task or surface invalid_state cleanly."""
pm_id = uuid4()
task_id = uuid4()
parent_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=pm_id,
pr_number=8,
branch_name="feature/backend/abc--def",
parent_task_id=parent_id,
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "completed"})
parent = MagicMock(
id=parent_id, branch_name="feature/main_pm/abc", parent_task_id=None
)
task_svc = AsyncMock()
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else t
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = after
git_svc = AsyncMock()
git_svc.is_pr_merged_for_task.return_value = True # already merged
git_svc.pr_merge.return_value = {"merge_commit_sha": "merge-abc"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.cell_pm_complete(pm_id, task_id, notes="re-issue after None complete")
body = env.as_dict()
assert body.get("error") is None or body.get("error") == "invalid_state", body
assert not git_svc.pr_merge.called, (
"cell_pm_complete re-issued git.pr_merge on an already-merged PR"
)
# ---------------------------------------------------------------------------
# H6: cell_pm_complete survives parent-advance failure
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cell_pm_complete_survives_parent_advance_failure() -> None:
"""_maybe_advance_parent_to_pm_review throws after the leaf is completed.
The verb must NOT 500 — the completion is committed; the side-effect
failure is logged and the envelope carries a warning."""
pm_id = uuid4()
task_id = uuid4()
parent_id = uuid4()
t = MagicMock(
id=task_id,
status="awaiting_pm_review",
assigned_to=pm_id,
pr_number=8,
branch_name="feature/backend/abc--def",
parent_task_id=parent_id,
team="backend",
)
after = MagicMock(**{**t.__dict__, "status": "completed"})
parent = MagicMock(
id=parent_id, branch_name="feature/main_pm/abc", parent_task_id=None
)
task_svc = AsyncMock()
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else t
task_svc.all_subtasks_terminal.return_value = True
task_svc.cell_pm_complete.return_value = after
git_svc = AsyncMock()
git_svc.is_pr_merged_for_task.return_value = False
git_svc.pr_merge.return_value = {"merge_commit_sha": "merge-abc"}
journal_svc = AsyncMock()
journal_svc.has_decision_for_task.return_value = True
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
journal_svc.has_reflect_for_task.return_value = True
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
with patch.object(
c,
"_maybe_advance_parent_to_pm_review",
new=AsyncMock(side_effect=RuntimeError("advance down")),
):
env = await c.cell_pm_complete(pm_id, task_id, notes="reviewed and approved")
body = env.as_dict()
assert body.get("error") is None, body
assert body.get("warning") is not None
assert "advance" in body["warning"].lower()
# ---------------------------------------------------------------------------
# declare_coverage
# ---------------------------------------------------------------------------
def _declare_coverage_deps(
*,
parent_id: Any,
child_id: Any,
parent_kwargs: dict[str, Any],
child_kwargs: dict[str, Any],
agent_kwargs: dict[str, Any],
) -> tuple[AsyncMock, MagicMock, MagicMock]:
"""Wire a task_svc AsyncMock whose .get resolves parent_id/child_id, plus
the parent + child MagicMocks (declare_coverage loads both by id)."""
parent = MagicMock(id=parent_id, **parent_kwargs)
child = MagicMock(id=child_id, parent_task_id=parent_id, **child_kwargs)
task_svc = AsyncMock()
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else child
task_svc.agent_for.return_value = MagicMock(**agent_kwargs)
return task_svc, parent, child
@pytest.mark.asyncio
async def test_declare_coverage_task_not_found() -> None:
task_svc = AsyncMock()
task_svc.get.return_value = None
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(uuid4(), uuid4(), ["id-a"])
assert env.error == "not_found"
@pytest.mark.asyncio
async def test_declare_coverage_no_parent_returns_invalid_state() -> None:
pm_id = uuid4()
child_id = uuid4()
child = MagicMock(id=child_id, parent_task_id=None, team="backend")
task_svc = AsyncMock()
task_svc.get.return_value = child
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error == "invalid_state"
task_svc.add_parent_ac_refs.assert_not_awaited()
@pytest.mark.asyncio
async def test_declare_coverage_non_pm_rejected() -> None:
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, _child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={"assigned_to": pm_id},
child_kwargs={"team": "backend"},
agent_kwargs={"role": "developer", "team": "backend"},
)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error == "not_authorized"
task_svc.add_parent_ac_refs.assert_not_awaited()
@pytest.mark.asyncio
async def test_declare_coverage_rejects_pm_off_team_without_parent_ownership() -> None:
pm_id, other_pm_id = uuid4(), uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, _child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={"assigned_to": other_pm_id},
child_kwargs={"team": "frontend"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_declare_coverage_allows_pm_on_child_team_without_ownership() -> None:
"""The minimum authorization bar: a PM on the child's own team may
declare coverage even without owning the parent coordination task."""
pm_id, other_pm_id = uuid4(), uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": other_pm_id,
"acceptance_criteria": ["crit a"],
"acceptance_criteria_ids": ["id-a"],
},
child_kwargs={"team": "backend", "status": "completed"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=[])
task_svc.add_parent_ac_refs.return_value = child
task_svc.uncovered_parent_acceptance_criteria.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error is None, env.as_dict()
@pytest.mark.asyncio
async def test_declare_coverage_unknown_criterion_rejected_lists_parent_acs() -> None:
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, _child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": pm_id,
"acceptance_criteria": ["crit a", "crit b"],
"acceptance_criteria_ids": ["id-a", "id-b"],
},
child_kwargs={"team": "backend"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=["bogus"])
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["bogus"])
assert env.error == "invalid_state"
assert env.remediate is not None
assert "crit a" in env.remediate and "crit b" in env.remediate
task_svc.add_parent_ac_refs.assert_not_awaited()
@pytest.mark.asyncio
async def test_declare_coverage_happy_path_stamps_refs_and_returns_remaining() -> None:
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": pm_id,
"acceptance_criteria": ["crit a", "crit b"],
"acceptance_criteria_ids": ["id-a", "id-b"],
},
child_kwargs={"team": "backend", "status": "completed"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=[])
task_svc.add_parent_ac_refs.return_value = child
task_svc.uncovered_parent_acceptance_criteria.return_value = ["crit b"]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error is None, env.as_dict()
task_svc.add_parent_ac_refs.assert_awaited_once_with(
child_id, ["id-a"], declared_by=pm_id
)
assert env.evidence == {"remaining_uncovered_parent_acs": ["crit b"]}
@pytest.mark.asyncio
async def test_declare_coverage_idempotent_redeclare() -> None:
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": pm_id,
"acceptance_criteria": ["crit a"],
"acceptance_criteria_ids": ["id-a"],
},
child_kwargs={"team": "backend", "status": "completed"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=[])
task_svc.add_parent_ac_refs.return_value = child
task_svc.uncovered_parent_acceptance_criteria.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
first = await c.declare_coverage(pm_id, child_id, ["id-a"])
count_after_first = task_svc.add_parent_ac_refs.await_count
second = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert first.error is None, first.as_dict()
assert second.error is None, second.as_dict()
assert task_svc.add_parent_ac_refs.await_count == count_after_first + 1
@pytest.mark.asyncio
async def test_declare_coverage_then_submit_up_gate_passes() -> None:
"""declare_coverage followed by the roll-up gate — the production
deadlock's end-to-end fix: once uncovered_parent_acceptance_criteria
empties, _parent_acs_covered_envelope no longer blocks submit_up."""
pm_id = uuid4()
parent_id, child_id = uuid4(), uuid4()
task_svc, _parent, child = _declare_coverage_deps(
parent_id=parent_id,
child_id=child_id,
parent_kwargs={
"assigned_to": pm_id,
"acceptance_criteria": ["crit a"],
"acceptance_criteria_ids": ["id-a"],
},
child_kwargs={"team": "backend", "status": "completed"},
agent_kwargs={"role": "cell_pm", "team": "backend"},
)
task_svc.unknown_ac_refs = MagicMock(return_value=[])
task_svc.add_parent_ac_refs.return_value = child
task_svc.uncovered_parent_acceptance_criteria.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, child_id, ["id-a"])
assert env.error is None, env.as_dict()
assert env.evidence == {"remaining_uncovered_parent_acs": []}
gate_env = await c._parent_acs_covered_envelope(
pm_id, parent_id, context_phrase="bubbling up"
)
assert gate_env is None
# ---------------------------------------------------------------------------
# declare_coverage — root-owned self-declare
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_declare_coverage_self_declare_accepted_for_owning_pm() -> None:
"""Root-owned mode: task_id is the PM's OWN root (assigned_to ==
pm_agent_id) — parent is child by identity (the self-declare signal), no
separate parent fetch, criteria validate against the root's own ACs."""
pm_id = uuid4()
root_id = uuid4()
root = MagicMock(
id=root_id,
assigned_to=pm_id,
parent_task_id=None,
acceptance_criteria=["crit a", "crit b"],
acceptance_criteria_ids=["id-a", "id-b"],
)
task_svc = AsyncMock()
task_svc.get.return_value = root
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="board")
task_svc.unknown_ac_refs = MagicMock(return_value=[])
task_svc.add_parent_ac_refs.return_value = root
task_svc.uncovered_parent_acceptance_criteria.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, root_id, ["id-a"])
assert env.error is None, env.as_dict()
task_svc.add_parent_ac_refs.assert_awaited_once_with(
root_id, ["id-a"], declared_by=pm_id
)
# Self-declare targets its own uncovered-set, never a parent_task_id.
task_svc.uncovered_parent_acceptance_criteria.assert_awaited_once_with(root_id)
@pytest.mark.asyncio
async def test_declare_coverage_self_declare_rejected_for_non_owner() -> None:
"""A PM who is not the assignee of the target root cannot self-declare on
it — falls through to the ordinary child-declare path, which requires a
parent_task_id; a root has none, so it is rejected."""
pm_id, owner_id = uuid4(), uuid4()
root_id = uuid4()
root = MagicMock(
id=root_id,
assigned_to=owner_id,
parent_task_id=None,
acceptance_criteria=["crit a"],
acceptance_criteria_ids=["id-a"],
)
task_svc = AsyncMock()
task_svc.get.return_value = root
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="board")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.declare_coverage(pm_id, root_id, ["id-a"])
assert env.error == "invalid_state"
task_svc.add_parent_ac_refs.assert_not_awaited()