feat(gateway): decomposition coverage gate + AC visibility (guardrails spec 2)

The decomposition floor that pairs with the roll-up gate (spec 4): a PM
cannot finish decomposing a parent while one of its acceptance criteria has
no subtask responsible for it — the "two leaves, half the ACs silently
dropped" pattern. Three parts:

- Gate: i_am_idle is rejected for a cell_pm/main_pm whose owned parent still
  has criteria in unclaimed_parent_acceptance_criteria (claimed = referenced
  by any live, non-cancelled child). Distinct from the roll-up gate, which
  fires at submit_up/complete and demands a *completed* child; this fires
  earlier and asks only that every criterion be *claimed*. Safe-by-
  construction: inert until a PM declares coverage, so legacy / not-yet-
  adopted decompositions are never blocked.

- Visibility: PM-facing briefings (give_me_work, i_will_plan, submit_up) and
  every delegate response now carry parent_ac_coverage ({id,text,claimed,
  verified} per criterion) + unclaimed_parent_acs, so a PM can map subtasks
  to criterion ids via covers_parent_criteria and see what is still
  uncovered after each delegate. Off for leaf roles, so a developer's own
  criteria never surface as bogus "unclaimed" noise.

- Prompts: cell_pm / main_pm role prompts document covers_parent_criteria and
  the new idle enforcement in the existing Coverage discipline.

TaskService.{parent_ac_coverage,unclaimed_parent_acceptance_criteria} added
beside uncovered_parent_acceptance_criteria; all three refactored onto a
shared _parent_ac_ref_sets helper (keeps each under the xenon B ceiling,
preserves the committed roll-up behavior). Verb tables regenerated for the
new delegate param — the regen also syncs pre-existing table drift that was
never regenerated after earlier merges (read_messages, pass_review
ac_verdicts, board pitch). Two brand-new generated tables (prompter,
secretary) are left untracked pending a separate decision.
This commit is contained in:
Renn F
2026-06-16 03:49:00 +02:00
parent 5ce4570c85
commit 1fb723174a
14 changed files with 380 additions and 29 deletions
@@ -203,3 +203,63 @@ async def test_i_am_idle_allows_dev_owning_awaiting_pm_review() -> None:
env = await c.i_am_idle(agent_id)
assert env.status == "idle"
task_svc.mark_agent_idle.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_idle_refuses_pm_with_uncovered_decomposition() -> None:
"""A PM that declared coverage but left a parent criterion unclaimed cannot
idle — the decomposition floor (Spec 2)."""
agent_id = uuid4()
parent_id = uuid4()
parent = MagicMock(id=parent_id, status="in_progress")
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [parent]
task_svc.list_in_progress_for_agent.return_value = [parent]
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
task_svc.unclaimed_parent_acceptance_criteria.return_value = ["crit b", "crit c"]
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
body = env.as_dict()
assert body["error"] == "invalid_state"
assert str(parent_id) in body["message"]
assert "covers_parent_criteria" in body["remediate"]
assert "crit b" in body["remediate"] and "crit c" in body["remediate"]
task_svc.mark_agent_idle.assert_not_awaited()
@pytest.mark.asyncio
async def test_i_am_idle_allows_pm_with_full_coverage() -> None:
"""Coverage primitive returns [] (covered or undeclared) -> PM idles through."""
agent_id = uuid4()
parent = MagicMock(id=uuid4(), status="in_progress")
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [parent]
task_svc.list_in_progress_for_agent.return_value = []
task_svc.agent_for.return_value = MagicMock(role="cell_pm")
task_svc.unclaimed_parent_acceptance_criteria.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.mark_agent_idle.assert_awaited_once()
@pytest.mark.asyncio
async def test_i_am_idle_decomposition_guard_is_pm_only() -> None:
"""The decomposition floor is PM-only: a developer never hits it even with a
(hypothetical) unclaimed list, and the coverage primitive is not consulted."""
agent_id = uuid4()
parent = MagicMock(id=uuid4(), status="in_progress")
task_svc = AsyncMock()
task_svc.list_assigned_for_agent.return_value = [parent]
task_svc.list_in_progress_for_agent.return_value = []
task_svc.agent_for.return_value = MagicMock(role="developer")
deps = _make_deps(task=task_svc)
c = Choreographer(deps)
env = await c.i_am_idle(agent_id)
assert env.status == "idle"
task_svc.unclaimed_parent_acceptance_criteria.assert_not_awaited()