fix(coordination): stop phantom re-delegation from text-vs-id AC-ref mismatch (#259)

A parent's acceptance-criteria coverage is matched by stable criterion id, but
a PM may declare covers_parent_criteria on a child by EITHER the criterion's id
OR its full text (both happen in practice). _parent_ac_ref_sets unioned the raw
refs and matched by id only, so a COMPLETED child that declared coverage by text
was invisible to the matcher: the criterion read "uncovered", the roll-up gate
refused, and the PM re-delegated the already-finished work as a brand-new empty
subtask (0 commits, no PR) that can never close — looping for hours and burning
tokens (observed live: a parent's xenon work completed + merged via one child,
then re-delegated 2h later as an empty phantom).

Normalize every child ref to the criterion id (text -> id via the parent's own
criteria) in a small _normalize_ac_refs helper, so coverage counts regardless of
how it was declared. Fixes existing mismatched data and future declarations; all
three consumers (uncovered/unclaimed/parent_ac_coverage) share the builder. An
unknown ref (neither id nor a current criterion text) passes through and matches
nothing, exactly as before. Adds two regression tests.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-25 05:03:24 +02:00
committed by GitHub
co-authored by Renn F
parent cfef0f3019
commit dfbb8649d0
2 changed files with 63 additions and 1 deletions
+40
View File
@@ -672,6 +672,46 @@ 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_uncovered_parent_acs_recognizes_text_declared_coverage() -> None:
# Regression (phantom re-delegation): a PM may declare covers_parent_criteria
# by the criterion's full TEXT instead of its id. Matching is by id, so a
# COMPLETED child that declared coverage by text used to read "uncovered" —
# and the PM re-delegated the already-finished work as an empty phantom
# subtask (0 commits, no PR) that could never close. _parent_ac_ref_sets now
# normalizes text -> id so coverage counts regardless of how it was declared.
parent = _build_task(
acceptance_criteria=["crit a", "crit b"],
acceptance_criteria_ids=["id-a", "id-b"],
)
svc = _svc_with_children(
parent,
[
(TaskStatus.COMPLETED, ["crit a"]), # declared by TEXT, not "id-a"
(TaskStatus.COMPLETED, ["id-b"]), # declared by id
],
)
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == []
@pytest.mark.asyncio
async def test_parent_ac_coverage_normalizes_text_refs() -> None:
# A text-declared coverage ref from a COMPLETED child surfaces as
# claimed+verified, same as an id-declared one.
parent = _build_task(
acceptance_criteria=["crit a", "crit b"],
acceptance_criteria_ids=["id-a", "id-b"],
)
svc = _svc_with_children(parent, [(TaskStatus.COMPLETED, ["crit a"])])
cov = await svc.parent_ac_coverage(parent.id)
assert cov[0] == {
"id": "id-a",
"text": "crit a",
"claimed": True,
"verified": True,
}
@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