mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user