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()
+74
View File
@@ -672,6 +672,80 @@ async def test_uncovered_parent_acs_empty_when_all_covered() -> None:
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == []
@pytest.mark.asyncio
async def test_parent_ac_coverage_maps_claimed_and_verified() -> None:
# Per-criterion visibility: a COMPLETED child both claims and verifies its
# criterion; an in-flight child only claims; an untouched criterion is
# neither. This is the digest a decomposing PM reads from the briefing.
parent = _build_task(
acceptance_criteria=["crit a", "crit b", "crit c"],
acceptance_criteria_ids=["id-a", "id-b", "id-c"],
)
svc = _svc_with_children(
parent,
[
(TaskStatus.COMPLETED, ["id-a"]),
(TaskStatus.IN_PROGRESS, ["id-b"]),
],
)
assert await svc.parent_ac_coverage(parent.id) == [
{"id": "id-a", "text": "crit a", "claimed": True, "verified": True},
{"id": "id-b", "text": "crit b", "claimed": True, "verified": False},
{"id": "id-c", "text": "crit c", "claimed": False, "verified": False},
]
@pytest.mark.asyncio
async def test_parent_ac_coverage_empty_without_ac_ids() -> None:
# No stable ids on the parent (e.g. created before the linkage) -> nothing to
# report; the digest stays absent rather than emitting bogus rows.
parent = _build_task(acceptance_criteria=["a"], acceptance_criteria_ids=[])
svc = _svc_with_children(parent, [(TaskStatus.IN_PROGRESS, ["id-a"])])
assert await svc.parent_ac_coverage(parent.id) == []
@pytest.mark.asyncio
async def test_unclaimed_parent_acs_inert_without_declared_coverage() -> None:
# The decomposition floor is opt-in: with no child declaring parent_ac_refs
# it returns [] so a PM who never adopts coverage is never blocked at idle.
parent = _build_task(
acceptance_criteria=["a", "b"], acceptance_criteria_ids=["id-a", "id-b"]
)
svc = _svc_with_children(
parent, [(TaskStatus.IN_PROGRESS, []), (TaskStatus.IN_PROGRESS, [])]
)
assert await svc.unclaimed_parent_acceptance_criteria(parent.id) == []
@pytest.mark.asyncio
async def test_unclaimed_parent_acs_counts_live_children_not_just_completed() -> None:
# The distinction from the roll-up gate: an in-flight child *claims* its
# criterion (so the decomposition floor is satisfied) even though it has not
# yet *verified* it (so the roll-up gate still flags it). A cancelled child's
# claim does not count -- its work died with it.
parent = _build_task(
acceptance_criteria=["crit a", "crit b", "crit c"],
acceptance_criteria_ids=["id-a", "id-b", "id-c"],
)
rows = [
(TaskStatus.IN_PROGRESS, ["id-a"]), # live -> claims crit a
(TaskStatus.CANCELLED, ["id-b"]), # cancelled -> claim void
]
# unclaimed: crit a is claimed by the live child; crit b (only the cancelled
# child) and crit c (nobody) remain.
assert await _svc_with_children(parent, rows).unclaimed_parent_acceptance_criteria(
parent.id
) == ["crit b", "crit c"]
# roll-up still flags crit a too: the live child has not COMPLETED it.
assert await _svc_with_children(parent, rows).uncovered_parent_acceptance_criteria(
parent.id
) == [
"crit a",
"crit b",
"crit c",
]
@pytest.mark.asyncio
async def test_unblock_with_branch_resumes_in_progress() -> None:
# A task claimed (has a branch) before it blocked resumes in_progress.