[feature] delegate carries dev-task collision surface (sequencing S1)

The cell/main PM's delegate verb now carries the dev-task collision
surface (intends_to_touch / adds_migration / touches_shared) and an
explicit depends_on override through DelegateRequest -> DelegateInputs
-> _create_subtask_from_inputs -> create_subtask, and create_subtask
forwards sequence / dependency_ids / batch_id / surfaces into the
prepared TaskCreateRequest instead of dropping them (the base create
already persists them at task.py:878-884).

This is the plumbing for the multi-level sequencing model edge kind 3
(dev-task collision DAG). Previously a dev task delegated with a
collision surface or an explicit dependency lost it before persistence
— dependency_ids was always [], so the only dev-task ordering was the
weak assignee-keyed spawn barrier (the live 2026-06-27 out-of-order
break: 40842957 started before 9b3682b8's PR merged). Phase S2 runs
SequencingService over the surfaced siblings and wires the DAG via
add_dependency.
This commit is contained in:
Renn F
2026-06-28 04:05:30 +02:00
parent a2bc2f1b97
commit c9fd735a32
8 changed files with 182 additions and 2 deletions
+4
View File
@@ -85,6 +85,10 @@ async def delegate(
estimated_complexity=body.estimated_complexity, estimated_complexity=body.estimated_complexity,
project_id=body.project_id, project_id=body.project_id,
covers_parent_criteria=body.covers_parent_criteria, covers_parent_criteria=body.covers_parent_criteria,
intends_to_touch=body.intends_to_touch,
adds_migration=body.adds_migration,
touches_shared=body.touches_shared,
depends_on=body.depends_on,
) )
env = await choreographer.delegate(x_agent_id, body.parent_task_id, inputs) env = await choreographer.delegate(x_agent_id, body.parent_task_id, inputs)
return envelope_to_response(env, request) return envelope_to_response(env, request)
+4
View File
@@ -85,6 +85,10 @@ async def delegate(
estimated_complexity=body.estimated_complexity, estimated_complexity=body.estimated_complexity,
project_id=body.project_id, project_id=body.project_id,
covers_parent_criteria=body.covers_parent_criteria, covers_parent_criteria=body.covers_parent_criteria,
intends_to_touch=body.intends_to_touch,
adds_migration=body.adds_migration,
touches_shared=body.touches_shared,
depends_on=body.depends_on,
) )
env = await choreographer.delegate(x_agent_id, body.parent_task_id, inputs) env = await choreographer.delegate(x_agent_id, body.parent_task_id, inputs)
return envelope_to_response(env, request) return envelope_to_response(env, request)
+12
View File
@@ -275,6 +275,18 @@ class DelegateRequest(BaseModel):
# Parent acceptance-criterion ids this subtask is responsible for. Lets the # Parent acceptance-criterion ids this subtask is responsible for. Lets the
# coverage + roll-up AC gates verify every parent AC is claimed and satisfied. # coverage + roll-up AC gates verify every parent AC is claimed and satisfied.
covers_parent_criteria: StrList | None = None covers_parent_criteria: StrList | None = None
# Dev-task collision surface (the multi-level sequencing model — edge kind
# 3). The cell PM states what each dev task touches so the choreographer can
# run SequencingService and wire the dev-task collision DAG (file-overlap
# serializes, migration-adders chain, shared-surface edits run last).
# Optional: a delegate without surfaces joins no collision edges (parallel).
intends_to_touch: StrList | None = None
adds_migration: bool = False
touches_shared: bool = False
# Explicit dependency override (edge the surface rules would miss, e.g. a
# non-collision ordering the PM knows). Optional; wired verbatim as
# dependency_ids on the created dev task.
depends_on: list[UUID] | None = None
# Pre-gateway parity: cross-field validators that catch the most common # Pre-gateway parity: cross-field validators that catch the most common
# LLM-vs-schema confusions. Pre-gateway lived in # LLM-vs-schema confusions. Pre-gateway lived in
@@ -323,6 +323,16 @@ class DelegateInputs:
# Parent AC ids this subtask is responsible for — the decomposition coverage # Parent AC ids this subtask is responsible for — the decomposition coverage
# link. Empty/None means the child covers no specific parent criteria yet. # link. Empty/None means the child covers no specific parent criteria yet.
covers_parent_criteria: list[str] | None = None covers_parent_criteria: list[str] | None = None
# Dev-task collision surface (multi-level sequencing — edge kind 3). The
# cell PM states what each dev task touches so the choreographer can run
# SequencingService and wire the dev-task collision DAG. Optional: a
# delegate without surfaces joins no collision edges (parallel).
intends_to_touch: list[str] | None = None
adds_migration: bool = False
touches_shared: bool = False
# Explicit dependency override — wired verbatim as dependency_ids on the
# created dev task (an edge the surface rules would miss).
depends_on: list[UUID] | None = None
class Choreographer: class Choreographer:
@@ -4846,6 +4856,14 @@ class Choreographer:
task_type=type_enum, task_type=type_enum,
nature=nature_enum, nature=nature_enum,
estimated_complexity=complexity_enum, estimated_complexity=complexity_enum,
# Dev-task collision surface (multi-level sequencing — edge kind 3)
# + explicit dependency override. Forwarded so create_subtask can
# persist them (Phase S2 runs SequencingService over the surfaced
# siblings and wires the collision DAG via add_dependency).
intends_to_touch=inputs.intends_to_touch,
adds_migration=inputs.adds_migration,
touches_shared=inputs.touches_shared,
dependency_ids=list(inputs.depends_on) if inputs.depends_on else [],
) )
new_task = await self.task.create_subtask(req) new_task = await self.task.create_subtask(req)
# Assign a distinct ordinal within the parent's siblings so the merge # Assign a distinct ordinal within the parent's siblings so the merge
+11
View File
@@ -8148,6 +8148,17 @@ class TaskService(BaseService):
task_type=req.task_type, task_type=req.task_type,
nature=req.nature, nature=req.nature,
status=req.status or inferred_status, status=req.status or inferred_status,
# Forward ordering + collision surface so a dev task delegated with
# surfaces/dependencies keeps them (multi-level sequencing — edge
# kinds 3 & 4). Previously dropped here, which is why dev-task
# dependency_ids was always [] and the only ordering was the weak
# assignee-keyed spawn barrier (live 2026-06-27 out-of-order break).
sequence=req.sequence,
dependency_ids=list(req.dependency_ids) if req.dependency_ids else [],
batch_id=req.batch_id,
intends_to_touch=req.intends_to_touch,
adds_migration=req.adds_migration,
touches_shared=req.touches_shared,
) )
return await self.create(prepared) return await self.create(prepared)
@@ -676,6 +676,50 @@ async def test_fail_qa_routes_to_dev_via_work_session_when_marker_missing(
assert markers.get_original_developer(failed) == str(dev_id) assert markers.get_original_developer(failed) == str(dev_id)
@pytest.mark.asyncio
async def test_create_subtask_round_trips_collision_surfaces_and_deps(
task_setup: dict, db_session: AsyncSession
) -> None:
"""create_subtask forwards intends_to_touch / adds_migration /
touches_shared / sequence / dependency_ids onto the created subtask.
Previously these were dropped inside create_subtask's `prepared`
TaskCreateRequest, so a dev task delegated with a collision surface or an
explicit dependency lost it before persistence the root cause of
dev-task dependency_ids always being [] (the live 2026-06-27 out-of-order
break). The base ``create`` persists them (task.py:878-884); this test
locks the forwarding through create_subtask.
"""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
await db_session.flush()
dep_id = uuid4()
sub = await svc.create_subtask(
TaskCreateRequest(
title="child",
description="child description with enough length",
acceptance_criteria=["ac1"],
team=Team.BACKEND,
created_by=task_setup["agent_id"],
project_id=task_setup["project_id"],
parent_task_id=parent.id,
task_type=TaskType.CODE,
nature=TaskNature.TECHNICAL,
sequence=3,
dependency_ids=[dep_id],
intends_to_touch=["roboco/services/foo.py"],
adds_migration=True,
touches_shared=True,
)
)
assert sub.intends_to_touch == ["roboco/services/foo.py"]
assert sub.adds_migration is True
assert sub.touches_shared is True
expected_sequence = 3
assert sub.sequence == expected_sequence
assert sub.dependency_ids == [dep_id]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fail_qa_work_session_fallback_excludes_qa_session( async def test_fail_qa_work_session_fallback_excludes_qa_session(
task_setup: dict, db_session: AsyncSession task_setup: dict, db_session: AsyncSession
+45 -1
View File
@@ -7,7 +7,7 @@ No DB required — Choreographer is mocked.
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4 from uuid import UUID, uuid4
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
@@ -296,6 +296,50 @@ async def test_delegate_dispatches_inputs_bundle() -> None:
assert inputs.task_type == "code" assert inputs.task_type == "code"
@pytest.mark.asyncio
async def test_delegate_forwards_collision_surfaces_to_inputs() -> None:
"""The dev-task collision surface (intends_to_touch / adds_migration /
touches_shared) and an explicit depends_on override must round-trip from
the HTTP body through DelegateInputs so the cell PM can express the
dev-task collision DAG (the missing edge kind 3 see the multi-level
sequencing design). Today these fields do not exist on DelegateRequest /
DelegateInputs, so the test would 422 / AttributeError the red signal.
"""
mock_chore = MagicMock()
mock_chore.delegate = AsyncMock(
return_value=_make_envelope(status="created", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
dep_id = str(uuid4())
resp = client.post(
"/api/v1/flow/cell_pm/delegate",
json={
"parent_task_id": _TASK_ID,
"title": "Implement /v1/foo",
"description": "Add the foo endpoint with passing tests.",
"assigned_to": "be-dev-1",
"team": "backend",
"task_type": "code",
"nature": "technical",
"estimated_complexity": "medium",
"acceptance_criteria": ["GET /v1/foo returns 200 with body"],
"intends_to_touch": ["roboco/api/routes/v1/foo.py"],
"adds_migration": True,
"touches_shared": False,
"depends_on": [dep_id],
},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200, resp.text
inputs = mock_chore.delegate.await_args.args[2]
assert inputs.intends_to_touch == ["roboco/api/routes/v1/foo.py"]
assert inputs.adds_migration is True
assert inputs.touches_shared is False
assert inputs.depends_on == [UUID(dep_id)]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_submit_up_dispatches_notes() -> None: async def test_submit_up_dispatches_notes() -> None:
"""POST /api/v1/flow/cell_pm/submit_up forwards task_id and notes.""" """POST /api/v1/flow/cell_pm/submit_up forwards task_id and notes."""
+44 -1
View File
@@ -7,7 +7,7 @@ No DB required — Choreographer is mocked.
from __future__ import annotations from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4 from uuid import UUID, uuid4
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
@@ -273,6 +273,49 @@ async def test_delegate_to_cell_pm_dispatches_inputs_bundle() -> None:
assert inputs.task_type == "planning" assert inputs.task_type == "planning"
@pytest.mark.asyncio
async def test_delegate_forwards_collision_surfaces_to_inputs() -> None:
"""Main-PM delegate forwards the dev-task collision surface + depends_on
override through DelegateInputs (parity with the cell-PM route the
main PM delegates cell-tasks/dev-tasks the same way)."""
mock_chore = MagicMock()
mock_chore.delegate = AsyncMock(
return_value=_make_envelope(status="created", task_id=_TASK_ID)
)
client = TestClient(_build_app(mock_chore))
dep_id = str(uuid4())
resp = client.post(
"/api/v1/flow/main_pm/delegate",
json={
"parent_task_id": _TASK_ID,
"title": "Backend slice",
"description": "Plan + drive backend work for feature X end to end.",
"assigned_to": "be-pm",
"team": "backend",
"task_type": "planning",
"nature": "technical",
"estimated_complexity": "high",
"acceptance_criteria": [
"all subtasks created with acceptance criteria",
"branch + PR opened against the slice",
],
"intends_to_touch": ["roboco/services/foo.py"],
"adds_migration": True,
"touches_shared": True,
"depends_on": [dep_id],
},
headers=_HEADERS,
)
assert resp.status_code == _HTTP_200, resp.text
inputs = mock_chore.delegate.await_args.args[2]
assert inputs.intends_to_touch == ["roboco/services/foo.py"]
assert inputs.adds_migration is True
assert inputs.touches_shared is True
assert inputs.depends_on == [UUID(dep_id)]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_escalate_to_ceo_dispatches() -> None: async def test_escalate_to_ceo_dispatches() -> None:
mock_chore = MagicMock() mock_chore = MagicMock()