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
@@ -0,0 +1,73 @@
"""Add per-criterion AC ids + child->parent AC linkage to tasks.
Acceptance criteria were a flat ``list[str]`` with no per-criterion identity, so
nothing could relate a child task's criteria back to the parent's. That let a
PM decompose a 16-criterion parent into two children covering half of them, with
the rest silently dropped and never re-checked at roll-up.
This adds two additive array columns:
- ``acceptance_criteria_ids``: a stable id per element of ``acceptance_criteria``
(1:1, same order). Generated at task creation going forward; backfilled here
for existing rows as ``md5(task_id || ':' || index)`` so ids are deterministic
and stable.
- ``parent_ac_refs``: on a child task, the parent AC ids this child is
responsible for (empty on tasks that are not decomposition children).
Both are nullable-with-default-empty so existing readers are unaffected; the
coverage + roll-up gates that consume them ship in later changes.
Revision ID: 036_ac_ids_and_parent_refs
Revises: 035_secretary_directives
Create Date: 2026-06-16
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "036_ac_ids_and_parent_refs"
down_revision = "035_secretary_directives"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column(
"acceptance_criteria_ids",
sa.ARRAY(sa.String()),
nullable=False,
server_default=sa.text("'{}'::varchar[]"),
),
)
op.add_column(
"tasks",
sa.Column(
"parent_ac_refs",
sa.ARRAY(sa.String()),
nullable=False,
server_default=sa.text("'{}'::varchar[]"),
),
)
# Backfill stable per-criterion ids for existing rows: md5(task_id:index),
# one per element of acceptance_criteria, preserving order. Rows with no
# criteria stay empty.
op.execute(
"""
UPDATE tasks
SET acceptance_criteria_ids = (
SELECT array_agg(md5(tasks.id::text || ':' || s::text) ORDER BY s)
FROM generate_subscripts(tasks.acceptance_criteria, 1) AS s
)
WHERE acceptance_criteria IS NOT NULL
AND array_length(acceptance_criteria, 1) > 0
"""
)
def downgrade() -> None:
op.drop_column("tasks", "parent_ac_refs")
op.drop_column("tasks", "acceptance_criteria_ids")
+10
View File
@@ -167,6 +167,16 @@ class TaskTable(Base):
acceptance_criteria: Mapped[list[str]] = mapped_column(
ARRAY(String), nullable=False
)
# Stable id per acceptance_criteria element (1:1, same order). Lets a child
# task's parent_ac_refs point at specific parent criteria (migration 036).
acceptance_criteria_ids: Mapped[list[str]] = mapped_column(
ARRAY(String), nullable=False, default=list
)
# On a decomposition child: the parent AC ids this child is responsible for
# (empty on non-children). Powers the coverage + roll-up AC gates.
parent_ac_refs: Mapped[list[str]] = mapped_column(
ARRAY(String), nullable=False, default=list
)
# Status
status: Mapped[TaskStatus] = mapped_column(
+14
View File
@@ -135,6 +135,14 @@ class Task(TimestampMixin):
acceptance_criteria: list[str] = Field(
..., min_length=1, description="How do we know it's done?"
)
acceptance_criteria_ids: list[str] = Field(
default_factory=list,
description="Stable id per acceptance_criteria element (1:1, same order).",
)
parent_ac_refs: list[str] = Field(
default_factory=list,
description="On a decomposition child: parent AC ids this child covers.",
)
# Status
status: TaskStatus = Field(default=TaskStatus.PENDING)
@@ -419,6 +427,12 @@ class TaskCreateRequest:
sequence: int = 0 # Order within siblings (lower = first)
dependency_ids: list[UUID] = field(default_factory=list)
# AC identity + linkage (migration 036). acceptance_criteria_ids is generated
# in TaskService.create when empty; parent_ac_refs is the parent AC ids a
# decomposition child is responsible for (the coverage/roll-up linkage).
acceptance_criteria_ids: list[str] = field(default_factory=list)
parent_ac_refs: list[str] = field(default_factory=list)
# Prompter origin tracking
source: str = "manual"
confirmed_by_human: bool = False
@@ -313,6 +313,9 @@ class DelegateInputs:
acceptance_criteria: list[str] | None = None
estimated_complexity: str = "medium"
project_id: UUID | None = None
# Parent AC ids this subtask is responsible for — the decomposition coverage
# link. Empty/None means the child covers no specific parent criteria yet.
covers_parent_criteria: list[str] | None = None
class Choreographer:
@@ -4093,6 +4096,7 @@ class Choreographer:
title=inputs.title,
description=inputs.description,
acceptance_criteria=inputs.acceptance_criteria,
parent_ac_refs=inputs.covers_parent_criteria or [],
team=team_enum,
created_by=pm_agent_id,
project_id=resolved_project_id,
+9 -1
View File
@@ -9,7 +9,7 @@ import asyncio
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, ClassVar, cast
from uuid import UUID
from uuid import UUID, uuid4
from sqlalchemy import and_, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -553,10 +553,17 @@ class TaskService(BaseService):
if req.parent_task_id:
await self._validate_parent_depth(req.parent_task_id)
# Stable per-criterion ids (1:1 with acceptance_criteria) so children can
# reference specific parent criteria; generated here when not supplied.
ac_ids = req.acceptance_criteria_ids or [
uuid4().hex for _ in (req.acceptance_criteria or [])
]
task = TaskTable(
title=req.title,
description=req.description,
acceptance_criteria=req.acceptance_criteria,
acceptance_criteria_ids=ac_ids,
parent_ac_refs=req.parent_ac_refs,
team=req.team,
created_by=req.created_by,
assigned_to=req.assigned_to,
@@ -6314,6 +6321,7 @@ class TaskService(BaseService):
title=req.title,
description=req.description,
acceptance_criteria=req.acceptance_criteria,
parent_ac_refs=req.parent_ac_refs,
team=req.team,
created_by=req.created_by,
project_id=req.project_id,
+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.