feat(tasks): AC identity + child->parent AC linkage (guardrails spec 1/4)

Foundation for the decomposition-coverage and roll-up AC-verification gates.
Acceptance criteria were a flat list[str] with no per-criterion identity, so
nothing could relate a child task's criteria to the parent's — letting a PM drop
half a parent's ACs unnoticed (PR #175).

- migration 036: additive acceptance_criteria_ids + parent_ac_refs array columns;
  backfills stable md5(task_id:index) ids for existing rows.
- Task model + TaskCreateRequest + db table: the two fields.
- TaskService.create generates one stable id per criterion (1:1) when absent.
- DelegateInputs.covers_parent_criteria -> child.parent_ac_refs (the linkage),
  propagated through create_subtask.
- regression-safe (53 task tests green) + 1 new test.

Coverage gate (spec 2), roll-up AC gate (spec 4), per-dev sequenced queues
(spec 3) build on this. Design: docs/SPEC_AC_GUARDRAILS_2026-06-16.md.
This commit is contained in:
Renn F
2026-06-16 03:02:54 +02:00
parent 55ff05e6ec
commit 87ca142f4e
6 changed files with 141 additions and 1 deletions
+31
View File
@@ -18,9 +18,13 @@ from roboco.models.base import (
AgentRole,
AgentStatus,
BlockerResolverType,
Complexity,
TaskNature,
TaskStatus,
TaskType,
Team,
)
from roboco.models.task import TaskCreateRequest
from roboco.services.task import GatewayAgentView, TaskService
@@ -590,6 +594,33 @@ async def test_admin_set_status_non_blocked_is_bare_status_set() -> None:
assert task.assigned_to == owner
@pytest.mark.asyncio
async def test_create_generates_ac_ids_and_carries_parent_ac_refs() -> None:
# Every task gets one stable id per acceptance criterion (1:1), and a
# decomposition child carries the parent AC ids it covers — the linkage the
# coverage + roll-up gates rely on.
svc = TaskService(
MagicMock(add=MagicMock(), flush=AsyncMock(), execute=AsyncMock())
)
req = TaskCreateRequest(
title="t",
description="d",
acceptance_criteria=["crit a", "crit b", "crit c"],
team=Team.BACKEND,
created_by=uuid4(),
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
estimated_complexity=Complexity.MEDIUM,
project_id=uuid4(),
parent_ac_refs=["parent-ac-1", "parent-ac-2"],
)
task = await svc.create(req)
n = len(req.acceptance_criteria)
assert len(task.acceptance_criteria_ids) == n
assert len(set(task.acceptance_criteria_ids)) == n
assert list(task.parent_ac_refs) == ["parent-ac-1", "parent-ac-2"]
@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.