Files
roboco/tests/unit/gateway/test_choreographer_pm_extras.py
T
5fe1e6df58 feat: in-path PR-review gate — per-cell + main reviewers (#229)
* feat(lifecycle): add the in-path PR-review gate status + reviewer verbs

Insert awaiting_pr_review between the assembled-PR submit and the PM merge,
giving the merge level the rejection capability it structurally lacks — today
only qa_fail and ceo_reject ever reach needs_revision, so a PM review is a
merge button with no teeth.

- New Status awaiting_pr_review + submit_for_review / pr_pass / pr_fail actions
  (pr_pass -> awaiting_pm_review, pr_fail -> needs_revision, mirroring the QA gate).
- Reviewer verbs claim_gate_review / pr_pass / pr_fail, and a main-PM submit_root
  verb (the root analogue of the cell PM's submit_up; opens the root->master PR).
- Extend the self-review-symmetry validator to the new sign-off actions.
- Mirror the value into the ORM TaskStatus enum + the A2A state map, and add the
  postgres taskstatus enum value (migration 040, forward-only like 037).
- Regenerate the per-role verb tables; add gate spec tests.

Spec surface only; the gateway methods + dispatch are wired in follow-ups, so the
verbs are advertised but dormant (flow_server tolerates unregistered verbs).

* feat(identity): add the three cell PR-review-gate reviewers

The in-path gate needs a reviewer per cell so each cell's assembled cell->root
PR is reviewed by a stack-specialized agent, while pr-reviewer-1 serves the
root->master gate (and keeps doing inbound external PRs).

- be/fe/ux-pr-reviewer: PR_REVIEWER role, team-scoped (so dispatch routes each
  cell's gate to its own reviewer); seeded identities + ROLE_TEAM_RULES + names.
  AI agent count 22 -> 25.
- They reuse the existing roboco-agent-pr-reviewer image (AGENT_IMAGES maps the
  three slugs to it, as be-dev-1/-2 share one image) — no new image.
- Tracing table: pr_pass/pr_fail require a learning entry (parity with
  post_pr_review), submit_root mirrors submit_up, claim_gate_review is waived
  (its tracing applies on pr_pass/pr_fail) — completes the verb surface added
  in the prior commit.
- Update the roster-pinning identity tests.

* feat(gateway): wire the in-path PR-review gate end to end

Make the assembled-PR review gate operational across the choreographer, the
TaskService transitions, and the v1 flow surface.

- TaskService: submit_for_review (in_progress→awaiting_pr_review), pr_gate_claim
  (no-transition reviewer claim), pr_pass (→awaiting_pm_review), pr_fail
  (→needs_revision); mirror qa_pass/qa_fail (clear claim, actor-mismatch warn,
  issues appended for the PM's revision). VerbRunner gains the matching atomic
  handlers + a create_root_pr side effect.
- Repoint submit_up to compose submit_for_review (cell→root PR enters the gate),
  and add a main-PM submit_root verb (opens the root→master PR, enters the gate).
- Split main_pm_complete: a code root must pass the gate first (requires
  awaiting_pm_review; rejects an in_progress code root toward submit_root and no
  longer reopens the PR), while a branchless coordination root still walks
  straight through, ungated.
- PRGateMixin (claim_gate_review / pr_pass / pr_fail) composed onto the
  Choreographer; flow_server forwarders + v1 routes (pr_reviewer + main_pm) +
  request schemas.
- Tests: gate spec + the updated submit_up / main_pm_complete expectations + new
  real-DB integration tests driving submit_for_review→pr_gate_claim→pr_pass and
  pr_fail through the real enforcement layer.

* feat(orchestrator): dispatch the in-path PR-review gate

Make the gate live in the dispatch loop.

- _dispatch_pr_gate_work: route awaiting_pr_review tasks to reviewers by level —
  a cell→root task to its cell reviewer (be/fe/ux-pr-reviewer), the root→master
  task to pr-reviewer-1. The reviewer self-claims via claim_gate_review (no
  pre-claim, mirroring the external-PR dispatcher); registered in
  _dispatch_all_work. _select_agent_for_cell learns the pr_reviewer role.
- _build_pr_gate_prompt: anchors the reviewer to the parent objective + full
  acceptance criteria + the FE<->BE contract, then pr_pass / pr_fail.
- _readiness_check_role_for_status: awaiting_pr_review -> pr_reviewer.
- Fail routing: pr_fail reassigns the failed assembled task to its PM
  (_revision_pm_for_task: cell PM for a cell team, Main PM for the root), and the
  revision dispatcher is generalized from coordination-roots-only to any
  PM-owned needs_revision task so the gate-failed task is re-coordinated instead
  of deadlocking.

* docs: document the in-path PR-review gate + the cell reviewers (22→25)

Reflect the shipped gate across the canonical + RAG docs.

- CLAUDE.md: agent count 22→25, the cell reviewers in the org chart, an
  awaiting_pr_review state + the gate transitions + a gate note in the lifecycle
  section, and submit_root / claim_gate_review / pr_pass / pr_fail in the verb
  surface table.
- docs/rag/architecture: org-structure (count, cell-reviewer roster, cells
  table), agent-uuids (be/fe/ux-pr-reviewer rows), agent-model (role + team
  rows).
- docs/rag/roles/pr-reviewer: the in-path gate section + the gate verbs.
- Wrap reviewer.id with UUID(str(...)) in the gate DB tests for mypy.

* docs: finish the gate doc sweep across README + RAG + generated artifacts

Catch the remaining surfaces beyond the canonical docs.

- README + how-to: agent count 22→25, the 6-agent cells (+ PR Reviewer), the
  main reviewer's root→master gate role.
- RAG: permissions + tool-permissions + task-tools list the gate verbs
  (claim_gate_review / pr_pass / pr_fail) for pr_reviewer; regenerate the
  lifecycle artifacts (intent-verbs, status-transitions, the per-role
  lifecycle-*.md prompts, panel lifecycle.json) from the spec via
  build_lifecycle_artifacts.py so they carry the new status + verbs.

* fix(migration): shorten the 040 revision id to fit alembic_version VARCHAR(32)

The revision id '040_taskstatus_awaiting_pr_review' is 33 chars; alembic's
alembic_version.version_num column is VARCHAR(32), so recording the migration on
a real 'alembic upgrade head' failed with 'value too long for type character
varying(32)' (surfaced on the NAS deploy). The test suite missed it: the test DB
is built via Base.metadata.create_all and the parity test only renders SQL
offline, so nothing actually applied the migration chain.

- Rename to '040_awaiting_pr_review' (22 chars).
- Add a guard test asserting every revision id fits the VARCHAR(32) column.
- Verified by applying the full chain 001->040 against real Postgres: it now
  reaches head and records '040_awaiting_pr_review' without truncation.

* fix(migration): land the actual 040 revision-id shortening + guard test

The prior commit captured only the file rename (git add aborted on the deleted
old path), leaving the long revision id and missing the guard test. This commit
carries the real content: revision id '040_awaiting_pr_review' (22 chars) and the
revision-id length guard. Re-verified against real Postgres — the full chain
reaches head and records the short id without truncation.

* fix(product): flush cell deletes before inserts when re-mapping projects

Editing a product's cell->project map (PATCH /api/products/{id}) 409'd with
'duplicate key value violates unique constraint uq_product_projects_product_team'
whenever a team already had a mapping. _replace_cells clears the old rows and
appends the new ones, but within a single flush SQLAlchemy orders INSERTs before
DELETEs for the same table, so the new (product_id, team) rows collided with the
not-yet-deleted old ones. Flush the deletes first.

Pre-existing bug (unrelated to the PR-review gate); surfaced on the NAS. New
real-Postgres regression test re-maps all three cells to different projects —
it fails with the unique violation without the fix and passes with it. The
existing update test only changed WHICH team was mapped, so it never collided.

* fix(gateway): let main_pm submit_root past the shared submit-up guard

submit_root reused the cell PM's _submit_up_ownership_guard, which
hardcoded agent.role != cell_pm and rejected the Main PM with
"submit_up is reserved for cell_pm". A branch-bearing code root could
then never close: submit_root bounced to complete, while complete
required awaiting_pm_review (reachable only via submit_root) and bounced
back — a circular rejection.

Both callers already run the spec gate (can_invoke_intent), which
enforces submit_up→cell_pm and submit_root→main_pm, so the guard's role
re-check was redundant for submit_up and wrong for submit_root. Broaden
it to accept either PM role as a defense-in-depth non-PM reject.

Adds the first choreographer-level submit_root test (the gap that let
this ship).

* fix(gateway): proactively steer both PMs to their bubble-up verb

The submit_root deadlock had a sibling steering gap: nothing told a PM
which verb opens the gate. The delegate next-hint said only 'i_am_idle
when done', and complete's in_progress rejection named submit_root for
the Main PM but left the Cell PM with a bare 'not ready for completion'
— no submit_up pointer, the same guess-the-verb trap.

- delegate hint now names the role-correct verb (root → submit_root,
  cell parent → submit_up) proactively, before any rejection.
- cell_pm_complete's in_progress rejection now steers to submit_up,
  mirroring the Main PM's submit_root gate hint.

Tests cover both the cell-PM steer and the role-aware delegate hint.

* docs: correct who-merges-which-PR across the gate docs + complete description

Audit of the gate docs found the merge actors mis-stated in several
places — the exact ambiguity that risks 'the reviewer/PM merges the root
PR' confusion:

- complete IntentSpec description said 'Main PM merges root PR' — false;
  main_pm_complete escalates and the CEO merges root→master. Corrected
  (propagated to intent-verbs.md, lifecycle.json, generated role prompts
  via build_lifecycle_artifacts.py).
- task-tools.md: submit_up target was awaiting_pm_review (should be
  awaiting_pr_review); Main PM flow had no submit_root — added it.
- README.md: lifecycle diagram now shows the awaiting_pr_review gate.
- cell-pm.md / main-pm.md: dropped the stale 'submit_up hands work to the
  Main PM who merges your cell branch' model — the cell PM merges its own
  gated cell→root PR; the Main PM owns the root + submit_root; the CEO
  merges master. Added submit_root to the main-pm manifest.
- git-commits.md, pr-creation.md, tool-permissions.md, git-tools.md:
  stopped attributing root→master PR opening to complete (it's submit_root).

No behavior change; verb wiring + state machine verified gap-free this
session (the pr_fail→needs_revision→PM respawn loop closes correctly).

* fix(orchestrator): stop closure respawn waiting the reaper window

A PM that finished its subtasks and idled left its parent 'paused' with a
fresh last_heartbeat_at. _is_recently_paused gated closure respawn on
_claim_heartbeat_ttl — the REAPER window (stale_claim_reap_seconds: 600s
default, 1800s on the NAS) — so the parent sat untouched for up to 10-30
minutes before its PM was respawned to close it. The whole chain stalled
behind it.

The race that guard actually protects against (i_am_idle auto-pauses, then
the agent is marked IDLE + its container tears down) is seconds, and the
live-session case is already covered by _is_agent_active. Introduce a
dedicated short debounce (pm_closure_recently_paused_seconds, default 45s)
and gate closure on that instead.

The existing test fixture masked this by setting _claim_heartbeat_ttl to
claim_stale_seconds (180s), not the production reaper value. Fixture now
mirrors production; adds a regression test that a parent paused past the
debounce but within the reaper window respawns immediately.

* feat(gate): post the in-path review verdict on the assembled PR

The in-path gate previously left no trace on the PR it gated — pr_pass /
pr_fail were pure status transitions. Now each verdict is posted as a
GitHub review on the assembled PR itself (server-side, bot account), so
the decision is visible on the very PR the PM merges.

- pr_pass → APPROVE, pr_fail → REQUEST_CHANGES on a cell→root PR.
- The root→master PR ALWAYS gets a plain COMMENT, never APPROVE/REQUEST_
  CHANGES: only the CEO acts on master, so the gate must never leave an
  approval that could satisfy branch protection (letting someone else
  merge) nor a blocking review that could impede the CEO's merge.
- Best-effort and AFTER the DB transition — a GitHub failure is logged,
  never rolls back the gate decision. Reuses git.post_pr_review's existing
  self-review→COMMENT downgrade for the org's own PRs.

Adds _project_slug_for to the ChoreographerHelpers protocol (mypy) and a
unit suite covering event selection, the master-bound COMMENT rule, the
no-PR skip, and failure-swallowing. Docs updated (pr-reviewer, task-tools).

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-06-20 09:27:29 +02:00

1193 lines
39 KiB
Python

"""Tests for the restored PM lifecycle verbs.
Covers: i_will_plan, delegate, submit_up, pm_give_me_work, and the
auto-pause behavior of i_am_idle for PMs.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.gateway.choreographer import (
Choreographer,
ChoreographerDeps,
DelegateInputs,
)
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)
# VerbRunner wraps composed atomic actions in
# ``task.session.begin_nested()``. AsyncMock auto-attribute access
# would return an unawaitable coroutine, breaking the
# ``async with`` protocol. Overwrite session with a MagicMock that
# implements the async-context-manager protocol explicitly.
task = base["task"]
task.session = MagicMock()
task.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
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)
return ChoreographerDeps(**base)
# ---------------------------------------------------------------------------
# i_will_plan
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_will_plan_claims_starts_and_sets_plan() -> None:
pm_id = uuid4()
task_id = uuid4()
pending = MagicMock(
id=task_id,
status="pending",
plan=None,
assigned_to=None,
task_type="planning",
parent_task_id=None,
sequence=0,
)
claimed = MagicMock(
id=task_id,
status="claimed",
plan=None,
assigned_to=pm_id,
task_type="planning",
)
started = MagicMock(
id=task_id,
status="in_progress",
plan={"text": "x"},
assigned_to=pm_id,
task_type="planning",
)
task_svc = AsyncMock()
task_svc.get.return_value = pending
task_svc.agent_for.return_value = MagicMock(
id=pm_id, role="cell_pm", team="backend", slug=None
)
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.get_subtasks.return_value = []
task_svc.claim.return_value = claimed
task_svc.set_plan.return_value = claimed
task_svc.start.return_value = started
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="break the work into 3 subtasks",
rich_plan={
"approach": (
"Three-cell decomposition: backend, frontend, and ux each "
"own a vertical slice of the work. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{
"title": "Backend slice",
"description": (
"be-dev-1 implements the API + DB migration with "
"tests and opens the leaf PR for QA review."
),
},
{
"title": "Frontend slice",
"description": (
"fe-dev-1 wires the UI integration with loading and "
"error states and opens the leaf PR for QA."
),
},
],
},
)
assert env.error is None
assert env.status == "in_progress"
task_svc.claim.assert_awaited_once_with(task_id, pm_id)
task_svc.set_plan.assert_awaited_once()
task_svc.start.assert_awaited_once_with(task_id, pm_id)
@pytest.mark.asyncio
async def test_i_will_plan_blocks_when_journal_decision_at_claim_missing() -> None:
"""Pre-gateway parity P3: i_will_plan requires a journal:decision at claim.
The composed (claim, set_plan, start) sequence runs first — the
claim sticks. Then the post-claim tracing gate fires because no
journal:decision exists for (PM, task), and the agent gets a
tracing_gap pointing at note(scope='decision', ...).
"""
pm_id = uuid4()
task_id = uuid4()
pending = MagicMock(
id=task_id,
status="pending",
plan=None,
assigned_to=None,
task_type="planning",
parent_task_id=None,
sequence=0,
)
started = MagicMock(
id=task_id,
status="in_progress",
plan={"text": "x"},
assigned_to=pm_id,
task_type="planning",
)
task_svc = AsyncMock()
# `get` is called twice: at verb entry and inside _post_claim_journal_gate.
task_svc.get.side_effect = [pending, started]
task_svc.agent_for.return_value = MagicMock(
id=pm_id, role="cell_pm", team="backend", slug=None
)
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.get_subtasks.return_value = []
task_svc.claim.return_value = MagicMock(
id=task_id, status="claimed", plan=None, assigned_to=pm_id
)
task_svc.set_plan.return_value = MagicMock(
id=task_id, status="claimed", plan={"text": "x"}, assigned_to=pm_id
)
task_svc.start.return_value = started
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.i_will_plan(
pm_id,
task_id,
plan="break the work into 3 subtasks",
rich_plan={
"approach": (
"Three-cell decomposition: backend, frontend, and ux each "
"own a vertical slice of the work. Backend lands first, QA "
"reviews each PR after it opens, documentation follows, then "
"complete and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{
"title": "Backend slice",
"description": (
"be-dev-1 implements the API + DB migration with "
"tests and opens the leaf PR for QA review."
),
},
{
"title": "Frontend slice",
"description": (
"fe-dev-1 wires the UI integration with loading and "
"error states and opens the leaf PR for QA."
),
},
],
},
)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision_at_claim" in body["missing"]
assert "note(scope='decision'" in body["remediate"]
# The composed action ran — claim+set_plan+start fired even though
# the post-claim gate failed.
task_svc.claim.assert_awaited_once_with(task_id, pm_id)
task_svc.start.assert_awaited_once_with(task_id, pm_id)
@pytest.mark.asyncio
async def test_i_will_plan_rejects_non_pm_role() -> None:
pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = MagicMock(id=task_id, status="pending")
task_svc.agent_for.return_value = MagicMock(role="developer", team="backend")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(pm_id, task_id, plan="x")
body = env.as_dict()
assert body["error"] == "not_authorized"
@pytest.mark.asyncio
async def test_i_will_plan_rejects_non_pending_state() -> None:
pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = MagicMock(
id=task_id, status="in_progress", assigned_to=uuid4()
)
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="x",
rich_plan={
"approach": (
"Single-cell decomposition: backend handles all scope. "
"be-dev-1 owns the change end to end — branch, implement, "
"test, open PR; QA reviews after the PR opens, documentation "
"follows, then be-pm completes and submits up. No cross-cell "
"dependencies for this planning task."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
assert body["error"] == "invalid_state"
@pytest.mark.asyncio
async def test_i_will_plan_calls_claim_when_pre_assigned_and_pending() -> None:
"""Regression: CEO pre-assigns root task to main-pm with status=pending.
Old code skipped claim when task.assigned_to already matched the
caller, leaving status=pending and start() silently returning None
while the choreographer fabricated an OK envelope. Smoke 2026-05-03.
"""
pm_id = uuid4()
task_id = uuid4()
pending_pre_assigned = MagicMock(
id=task_id,
status="pending",
plan=None,
assigned_to=pm_id, # CEO pre-assigned to this PM
task_type="planning",
parent_task_id=None,
sequence=0,
)
claimed = MagicMock(
id=task_id,
status="claimed",
plan=None,
assigned_to=pm_id,
task_type="planning",
)
started = MagicMock(
id=task_id,
status="in_progress",
plan={"text": "x"},
assigned_to=pm_id,
task_type="planning",
)
task_svc = AsyncMock()
task_svc.get.return_value = pending_pre_assigned
task_svc.agent_for.return_value = MagicMock(
id=pm_id, role="main_pm", team="main_pm", slug=None
)
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.get_subtasks.return_value = []
task_svc.claim.return_value = claimed
task_svc.set_plan.return_value = claimed
task_svc.start.return_value = started
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="distribute to be-pm and fe-pm",
rich_plan={
"approach": (
"Two-cell dispatch: be-pm owns the backend vertical, fe-pm "
"owns the frontend vertical. Backend lands first, QA reviews "
"each PR after it opens, documentation follows, then complete "
"and submit up. Strict sequencing with no cross-cell "
"dependencies beyond the stated ordering."
),
"sub_tasks": [
{
"title": "Backend cell",
"description": (
"be-pm decomposes and delegates the backend vertical "
"to be-dev-1, who implements and opens the leaf PR."
),
},
{
"title": "Frontend cell",
"description": (
"fe-pm decomposes and delegates the frontend vertical "
"to fe-dev-1, who implements and opens the leaf PR."
),
},
],
},
)
assert env.error is None
assert env.status == "in_progress"
# The bug: claim was skipped when assigned_to == pm_agent_id.
# The fix: claim is driven by status == pending, not assigned_to mismatch.
task_svc.claim.assert_awaited_once_with(task_id, pm_id)
task_svc.start.assert_awaited_once_with(task_id, pm_id)
@pytest.mark.asyncio
async def test_i_will_plan_surfaces_start_failure_instead_of_faking_ok() -> None:
"""Regression: when start() returns None, the verb must reject — not lie.
Old code returned Envelope.ok with status='in_progress' even when
start failed silently. Agents then called delegate against a still-
pending task and got PARENT_NOT_CLAIMED in a loop. Smoke 2026-05-03.
"""
pm_id = uuid4()
task_id = uuid4()
pending = MagicMock(
id=task_id,
status="pending",
plan=None,
assigned_to=None,
task_type="planning",
parent_task_id=None,
sequence=0,
)
claimed = MagicMock(
id=task_id,
status="claimed",
plan=None,
assigned_to=pm_id,
task_type="planning",
)
task_svc = AsyncMock()
task_svc.get.return_value = pending
task_svc.agent_for.return_value = MagicMock(
id=pm_id, role="main_pm", team="main_pm", slug=None
)
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.get_subtasks.return_value = []
task_svc.claim.return_value = claimed
task_svc.set_plan.return_value = claimed
task_svc.start.return_value = None # the bug: start fails silently
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="x",
rich_plan={
"approach": (
"Single-cell decomposition: backend handles all scope. "
"be-dev-1 owns the change end to end — branch, implement, "
"test, open PR; QA reviews after the PR opens, documentation "
"follows, then be-pm completes and submits up. No cross-cell "
"dependencies for this planning task."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "start failed" in body["message"]
@pytest.mark.asyncio
async def test_i_will_plan_idempotent_when_already_in_progress_for_caller() -> None:
"""Regression: respawned PM re-calling i_will_plan on a task they
already moved to in_progress must NOT be rejected. Returns OK with
current state. Smoke 2026-05-04 captured the cycle the old reject
caused: respawn → reject → reaper drops claim → respawn → loop.
"""
pm_id = uuid4()
task_id = uuid4()
in_progress = MagicMock(
id=task_id,
status="in_progress",
plan={"text": "x"},
assigned_to=pm_id,
task_type="planning",
parent_task_id=None,
sequence=0,
)
task_svc = AsyncMock()
task_svc.get.return_value = in_progress
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
task_svc.list_in_progress_for_agent.return_value = [in_progress]
task_svc.list_paused_for_agent.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="re-entry plan",
rich_plan={
"approach": (
"Idempotent re-entry: task already in progress, refresh heartbeat."
),
"sub_tasks": [{"title": "Re-entry subtask", "description": "Resume work"}],
},
)
assert env.error is None
assert env.status == "in_progress"
# Heartbeat refreshed so the reaper sees activity.
task_svc.heartbeat.assert_awaited()
# Did NOT re-call claim or start — already past those.
task_svc.claim.assert_not_awaited()
task_svc.start.assert_not_awaited()
@pytest.mark.asyncio
async def test_i_will_plan_recovery_when_already_claimed_for_caller() -> None:
"""Same regression but task is in claimed (post-claim, pre-start).
Recovery semantics (Task 12 spec migration): when the caller already
owns the task in `claimed`, the verb runs only set_plan + start
(skipping re-claim, which the spec gate would reject because CLAIMED
is not a source state for `claim`). End state is `in_progress`.
"""
pm_id = uuid4()
task_id = uuid4()
claimed = MagicMock(
id=task_id,
status="claimed",
plan=None,
assigned_to=pm_id,
task_type="planning",
parent_task_id=None,
sequence=0,
)
started = MagicMock(
id=task_id,
status="in_progress",
plan="re-entry plan",
assigned_to=pm_id,
task_type="planning",
)
task_svc = AsyncMock()
task_svc.get.return_value = claimed
task_svc.agent_for.return_value = MagicMock(
id=pm_id, role="main_pm", team="main_pm", slug=None
)
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.get_subtasks.return_value = []
task_svc.set_plan.return_value = claimed
task_svc.start.return_value = started
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="re-entry plan",
rich_plan={
"approach": (
"Recovery re-entry: task claimed but not started; run set_plan + start."
),
"sub_tasks": [
{"title": "Recovery subtask", "description": "Resume from claimed"}
],
},
)
assert env.error is None
assert env.status == "in_progress"
task_svc.heartbeat.assert_awaited()
# Recovery does NOT re-call claim — CLAIMED is not a claim source state.
task_svc.claim.assert_not_awaited()
# Recovery DOES call start to push claimed → in_progress.
task_svc.start.assert_awaited_once_with(task_id, pm_id)
@pytest.mark.asyncio
async def test_i_will_plan_still_rejects_in_progress_for_other_agent() -> None:
"""Idempotency only applies to the caller. Different PM still rejected."""
pm_id = uuid4()
other_pm_id = uuid4()
task_id = uuid4()
in_progress = MagicMock(
id=task_id,
status="in_progress",
plan={"text": "x"},
assigned_to=other_pm_id, # different PM owns it
task_type="planning",
parent_task_id=None,
sequence=0,
)
task_svc = AsyncMock()
task_svc.get.return_value = in_progress
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="x",
rich_plan={
"approach": (
"Single-cell decomposition: backend handles all scope. "
"be-dev-1 owns the change end to end — branch, implement, "
"test, open PR; QA reviews after the PR opens, documentation "
"follows, then be-pm completes and submits up. No cross-cell "
"dependencies for this planning task."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
assert body["error"] == "invalid_state"
@pytest.mark.asyncio
async def test_i_will_plan_returns_tracing_gap_without_plan() -> None:
pm_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = MagicMock(
id=task_id, status="pending", plan=None, assigned_to=None
)
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_will_plan(
pm_id,
task_id,
plan="",
rich_plan={
"approach": (
"Single-cell decomposition: backend handles all scope. "
"be-dev-1 owns the change end to end — branch, implement, "
"test, open PR; QA reviews after the PR opens, documentation "
"follows, then be-pm completes and submits up. No cross-cell "
"dependencies for this planning task."
),
"sub_tasks": [
{
"title": "Slice A",
"description": (
"be-dev-1 implements the backend API change with "
"tests and opens the leaf PR for QA review."
),
}
],
},
)
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "plan" in body["missing"]
@pytest.mark.asyncio
async def test_i_will_plan_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.i_will_plan(pm_id, task_id, plan="x")
assert env.as_dict()["error"] == "not_found"
# ---------------------------------------------------------------------------
# delegate
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delegate_main_pm_to_cell_pm_creates_subtask() -> None:
main_pm_id = uuid4()
parent_id = uuid4()
project_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=project_id,
status="in_progress",
assigned_to=main_pm_id,
)
new_task = MagicMock(id=uuid4())
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
task_svc.get_subtasks.return_value = []
task_svc.create_subtask.return_value = new_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
main_pm_id,
parent_id,
DelegateInputs(
title="Backend planning",
description="Plan backend work for feature X",
assigned_to="be-pm",
team="backend",
task_type="planning",
nature="technical",
acceptance_criteria=["all backend subtasks defined with criteria"],
),
)
assert env.error is None
assert env.status == "created"
task_svc.create_subtask.assert_awaited_once()
req = task_svc.create_subtask.call_args.args[0]
assert req.parent_task_id == parent_id
assert req.assigned_to == UUID(AGENT_UUIDS["be-pm"])
@pytest.mark.asyncio
async def test_delegate_cell_pm_to_team_dev_creates_subtask() -> None:
cell_pm_id = uuid4()
parent_id = uuid4()
project_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=project_id,
status="in_progress",
assigned_to=cell_pm_id,
)
new_task = MagicMock(id=uuid4())
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.get_subtasks.return_value = []
task_svc.create_subtask.return_value = new_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
cell_pm_id,
parent_id,
DelegateInputs(
title="Implement endpoint",
description="Add /v1/foo endpoint with tests",
assigned_to="be-dev-1",
team="backend",
task_type="code",
nature="technical",
acceptance_criteria=["GET /v1/foo returns 200 with body"],
),
)
assert env.error is None
assert env.status == "created"
@pytest.mark.asyncio
async def test_delegate_main_pm_to_dev_is_rejected() -> None:
main_pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=uuid4(),
status="in_progress",
assigned_to=main_pm_id,
)
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
task_svc.get_subtasks.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
main_pm_id,
parent_id,
DelegateInputs(
title="Implement endpoint",
description="Add /v1/foo endpoint with passing tests please",
assigned_to="be-dev-1",
team="backend",
task_type="code",
nature="technical",
acceptance_criteria=["GET /v1/foo returns 200 with body"],
),
)
body = env.as_dict()
assert body["error"] == "not_authorized"
assert "be-pm" in body["remediate"]
@pytest.mark.asyncio
async def test_delegate_cell_pm_to_other_pm_rejected() -> None:
cell_pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=uuid4(),
status="in_progress",
assigned_to=cell_pm_id,
)
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.get_subtasks.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
cell_pm_id,
parent_id,
DelegateInputs(
title="Implement endpoint",
description="Add /v1/foo endpoint with passing tests please",
assigned_to="be-pm",
team="backend",
task_type="code",
nature="technical",
acceptance_criteria=["GET /v1/foo returns 200 with body"],
),
)
body = env.as_dict()
assert body["error"] == "not_authorized"
@pytest.mark.asyncio
async def test_delegate_unknown_assignee_returns_invalid_state() -> None:
pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=uuid4(),
status="in_progress",
assigned_to=pm_id,
)
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
task_svc.get_subtasks.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
pm_id,
parent_id,
DelegateInputs(
title="Implement endpoint",
description="Add /v1/foo endpoint with passing tests please",
assigned_to="nope-pm",
team="backend",
task_type="code",
nature="technical",
acceptance_criteria=["GET /v1/foo returns 200 with body"],
),
)
body = env.as_dict()
assert body["error"] == "not_authorized"
@pytest.mark.asyncio
async def test_delegate_invalid_team_enum_rejected() -> None:
pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=uuid4(),
status="in_progress",
assigned_to=pm_id,
)
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
task_svc.get_subtasks.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
pm_id,
parent_id,
DelegateInputs(
title="Implement endpoint",
description="Add /v1/foo endpoint with passing tests please",
assigned_to="be-dev-1",
team="not-a-team",
task_type="code",
nature="technical",
acceptance_criteria=["GET /v1/foo returns 200 with body"],
),
)
assert env.as_dict()["error"] == "invalid_state"
# ---------------------------------------------------------------------------
# submit_up
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_submit_up_opens_pr_and_keeps_cell_pm_assignment() -> None:
"""submit_up opens the cell→root PR and moves the cell task into the
in-path PR-review gate (awaiting_pr_review), but does NOT hand it off to
Main PM. The cell PM owns cell completion (it is respawned to `complete`
after the cell reviewer pr_passes), so the assignment stays put — no
reassign."""
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=pm_id,
parent_task_id=None,
branch_name="feature/backend/abc123",
team="backend",
)
after = MagicMock(
id=task_id,
status="awaiting_pr_review",
assigned_to=pm_id,
parent_task_id=None,
branch_name="feature/backend/abc123",
team="backend",
)
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.submit_for_review.return_value = after
git_svc = AsyncMock()
git_svc.create_pr.return_value = {"pr_number": 12, "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)
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
c = Choreographer(deps)
env = await c.submit_up(
pm_id, task_id, notes="cell completed all subtasks; ready for review"
)
assert env.error is None
assert env.status == "awaiting_pr_review"
git_svc.create_pr.assert_awaited_once()
task_svc.reassign.assert_not_called()
@pytest.mark.asyncio
async def test_submit_up_blocks_when_subtasks_not_terminal() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=pm_id,
branch_name="feature/backend/abc123",
team="backend",
)
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 = 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.submit_up(pm_id, task_id, notes="ready for main pm please review")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "subtasks" in str(body["missing"]).lower()
@pytest.mark.asyncio
async def test_submit_up_rejects_main_pm_role() -> None:
main_pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="in_progress", assigned_to=main_pm_id)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.submit_up(main_pm_id, task_id, notes="enough words to pass min len")
body = env.as_dict()
assert body["error"] == "not_authorized"
@pytest.mark.asyncio
async def test_submit_up_blocks_without_journal_decision() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(
id=task_id,
status="in_progress",
assigned_to=pm_id,
branch_name="feature/backend/abc",
team="backend",
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
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.submit_up(pm_id, task_id, notes="enough words to pass min len")
body = env.as_dict()
assert body["error"] == "tracing_gap"
assert "journal:decision" in body["missing"]
@pytest.mark.asyncio
async def test_submit_up_short_notes_rejected() -> None:
pm_id = uuid4()
task_id = uuid4()
t = MagicMock(id=task_id, status="in_progress", assigned_to=pm_id)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = MagicMock(role="cell_pm", team="backend")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.submit_up(pm_id, task_id, notes="short")
body = env.as_dict()
assert body["error"] == "tracing_gap"
# ---------------------------------------------------------------------------
# pm_give_me_work
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pm_give_me_work_returns_first_assigned() -> None:
pm_id = uuid4()
t = MagicMock(id=uuid4(), status="pending", title="x", team="backend")
task_svc = AsyncMock()
task_svc.list_pending_for_agent.return_value = []
task_svc.list_assigned_for_agent.return_value = [t]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.pm_give_me_work(pm_id)
assert env.error is None
assert env.task_id == str(t.id)
assert env.next is not None
assert "i_will_plan" in env.next
@pytest.mark.asyncio
async def test_pm_give_me_work_returns_idle_when_empty() -> None:
pm_id = uuid4()
task_svc = AsyncMock()
task_svc.list_pending_for_agent.return_value = []
task_svc.list_assigned_for_agent.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.pm_give_me_work(pm_id)
assert env.status == "idle"
@pytest.mark.asyncio
async def test_pm_give_me_work_paused_hint_mentions_subtasks() -> None:
pm_id = uuid4()
t = MagicMock(id=uuid4(), status="paused", title="x", team="backend")
task_svc = AsyncMock()
task_svc.list_pending_for_agent.return_value = []
task_svc.list_assigned_for_agent.return_value = [t]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.pm_give_me_work(pm_id)
assert env.next is not None
assert "subtasks" in env.next or "complete" in env.next
# ---------------------------------------------------------------------------
# i_am_idle auto-pause
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_i_am_idle_auto_pauses_in_progress_tasks() -> None:
agent_id = uuid4()
t = MagicMock(id=uuid4(), status="in_progress")
task_svc = AsyncMock()
task_svc.list_in_progress_for_agent.return_value = [t]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
assert env.status == "idle"
task_svc.pause_for_agent.assert_awaited_once_with(agent_id, t.id)
task_svc.mark_agent_idle.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_idle_no_in_progress_skips_pause() -> None:
agent_id = uuid4()
task_svc = AsyncMock()
task_svc.list_in_progress_for_agent.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
assert env.status == "idle"
task_svc.pause_for_agent.assert_not_awaited()
@pytest.mark.asyncio
async def test_i_am_idle_with_unread_skips_pause_and_idle() -> None:
agent_id = uuid4()
task_svc = AsyncMock()
deps = _make_deps(task=task_svc)
# Override the default empty list AFTER _make_deps has zeroed it out.
deps.evidence_repo.list_unread_a2a.return_value = ["something"]
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
assert env.status == "idle_with_unread"
task_svc.list_in_progress_for_agent.assert_not_awaited()
task_svc.mark_agent_idle.assert_not_awaited()
# ---------------------------------------------------------------------------
# Bug B from 2026-05-09 smoke: main-pm sent task_type="code" when delegating
# to be-pm. A Cell PM PLANS the slice; it does not execute code. The
# delegate verb must reject this misclassification at the gateway so future
# spawns can't recreate the wrong-type-task condition.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_delegate_main_pm_to_cell_pm_rejects_code_typed_subtask() -> None:
main_pm_id = uuid4()
parent_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=uuid4(),
status="in_progress",
assigned_to=main_pm_id,
)
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
task_svc.get_subtasks.return_value = []
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
main_pm_id,
parent_id,
DelegateInputs(
title="Backend slice",
description="Plan + drive backend work end to end please",
assigned_to="be-pm",
team="backend",
task_type="code", # WRONG — Cell PM should get planning
nature="technical",
acceptance_criteria=["all subtasks created with criteria"],
),
)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "planning" in body["message"].lower()
assert "be-pm" in body["message"] or "cell_pm" in body["message"].lower()
@pytest.mark.asyncio
async def test_delegate_main_pm_to_cell_pm_accepts_planning_subtask() -> None:
"""The contract from the test above: planning IS the right type."""
main_pm_id = uuid4()
parent_id = uuid4()
project_id = uuid4()
parent = MagicMock(
id=parent_id,
project_id=project_id,
status="in_progress",
assigned_to=main_pm_id,
)
new_task = MagicMock(id=uuid4())
task_svc = AsyncMock()
task_svc.get.return_value = parent
task_svc.agent_for.return_value = MagicMock(role="main_pm", team="main_pm")
task_svc.get_subtasks.return_value = []
task_svc.create_subtask.return_value = new_task
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.delegate(
main_pm_id,
parent_id,
DelegateInputs(
title="Backend slice",
description="Plan + drive backend work end to end please",
assigned_to="be-pm",
team="backend",
task_type="planning",
nature="technical",
acceptance_criteria=["all subtasks created with criteria"],
),
)
assert env.error is None