fix(tasks): reconcile acceptance_criteria_ids at the update chokepoint (#682)

Every post-create rewrite of acceptance_criteria (task PATCH route,
prompter update_live_draft / _patch_batch_child / update_live_batch)
routes through TaskService.update()'s generic field loop, which
overwrote the criteria without touching acceptance_criteria_ids —
leaving ids mismatched or empty, and an empty id list silently
disabled the parent-coverage gate entirely.

- New pure _reconcile_ac_ids: one id per new criterion; text-unchanged
  criteria keep their id (children and findings reference criteria by
  id or exact text — a blanket re-mint would orphan every live
  reference), new/reworded text mints fresh, dropped criteria drop
  theirs. create() now stamps through the same helper (explicitly
  supplied ids still win).
- update() derives acceptance_criteria_ids whenever acceptance_criteria
  is rewritten without an explicit id list.
- The parent-coverage gate self-heals a criteria-bearing row whose ids
  are empty/out-of-length (re-stamp in place) instead of returning
  early and silently waiving coverage for the whole subtree.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-24 14:18:58 +02:00
committed by GitHub
co-authored by Renn F
parent c4ba351ae0
commit 3516d925fe
5 changed files with 250 additions and 18 deletions
@@ -1694,6 +1694,46 @@ async def test_production_replay_root_with_child_and_root_owned_coverage(
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == []
@pytest.mark.asyncio
async def test_parent_ac_edit_preserves_ids_children_still_resolve(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A board-redraft-style parent AC edit that drops one criterion and adds
another must keep the id of every criterion whose TEXT is unchanged, so a
child's existing parent_ac_refs -- by id OR by exact text -- still
resolves after the rewrite instead of orphaning."""
svc = task_setup["svc"]
parent = await svc.create(
_req(task_setup, acceptance_criteria=["crit a", "crit b", "crit x"])
)
id_a, _id_b, id_x = parent.acceptance_criteria_ids
child_by_id = await svc.create(
_req(task_setup, parent_task_id=parent.id, parent_ac_refs=[id_a])
)
child_by_id.status = TaskStatus.COMPLETED
child_by_text = await svc.create(
_req(task_setup, parent_task_id=parent.id, parent_ac_refs=["crit x"])
)
child_by_text.status = TaskStatus.COMPLETED
await db_session.flush()
# "crit b" dropped, "crit new" added, "crit a"/"crit x" kept (reordered).
await svc.update(
parent.id,
acceptance_criteria=["crit x", "crit a", "crit new"],
)
await db_session.flush()
refreshed = await svc.get(parent.id)
assert refreshed is not None
assert set(refreshed.acceptance_criteria_ids[:2]) == {id_a, id_x}
assert refreshed.acceptance_criteria_ids[2] not in {id_a, id_x}
# Both the id-based and text-based refs still resolve -- only the
# brand-new criterion is uncovered.
assert await svc.uncovered_parent_acceptance_criteria(parent.id) == ["crit new"]
# ---------------------------------------------------------------------------
# _unblock_dependents
# ---------------------------------------------------------------------------