[feature] wire dev-task collision DAG at cell-PM delegation (sequencing S2)

Pure dev_task_collision_edges in sequencing.py turns a parent's surfaced
siblings into (depends_on_id, task_id) pairs via SequencingService. TaskService.
wire_sibling_collision_dag wires them through add_dependency (idempotent). The
choreographer calls it after each dev-task delegate so the sibling collision DAG
is built incrementally as the cell PM decomposes — file-overlap serializes,
migration chains, shared-last; stable (priority, sequence) ordering keeps edges
from flipping into reverse cycles on re-runs.
This commit is contained in:
Renn F
2026-06-28 04:11:38 +02:00
parent c9fd735a32
commit 12621a3608
5 changed files with 259 additions and 1 deletions
@@ -4874,6 +4874,16 @@ class Choreographer:
siblings = await self.task.get_subtasks(parent_task_id)
next_seq = len([s for s in siblings if s.id != new_task.id])
await self.task.set_sequence(new_task.id, next_seq)
# Wire the dev-task collision DAG (multi-level sequencing edge kind 3):
# run the deterministic analyzer over the parent's surfaced siblings
# and add_dependency each collision edge so a later dev task stays
# PENDING until the sibling it collides with completes (PR merged) —
# the cross-dev ordering the assignee-keyed spawn barrier could not
# guarantee (live 2026-06-27 out-of-order break). Incremental +
# idempotent: re-run after every delegate; add_dependency dedupes, and
# dev_task_collision_edges orders by (priority, sequence) so re-runs
# only add edges (never flip an existing pair's order into a cycle).
await self.task.wire_sibling_collision_dag(parent_task_id)
# Thread the parent's existing session links onto the
# new subtask so the assigned agent (dev/qa/doc) lands in the
# group chat the PM has already been talking in. Pre-gateway
+65
View File
@@ -205,3 +205,68 @@ class SequencingService:
seen.add(edge)
out.append(edge)
return out
# ---------------------------------------------------------------------------
# dev_task_collision_edges — the dev-task collision DAG (edge kind 3).
# ---------------------------------------------------------------------------
# A collision needs at least two surfaced siblings to produce an edge.
_MIN_COLLISION_PAIR = 2
def dev_task_collision_edges(siblings: list) -> list[tuple[object, object]]:
"""Wire the dev-task collision DAG for a parent's surfaced siblings.
Pure glue over :class:`SequencingService`: turns each surfaced sibling
(a task row with ``id`` / ``priority`` / ``sequence`` / ``intends_to_touch``
/ ``adds_migration`` / ``touches_shared`` / ``project_id``) into a
:class:`DraftSurface`, runs the analyzer, and returns
``(depends_on_id, task_id)`` pairs — *task depends-on depends_on* — ready
for ``TaskService.add_dependency``.
Incremental + idempotent by construction: a sibling with no collision
surface (empty ``intends_to_touch`` and not ``adds_migration`` /
``touches_shared``) is parallel to everything and contributes no edges; a
sibling without a ``project_id`` cannot collide (collisions are scoped to
a repo) and is skipped. Siblings are ordered by ``(priority, sequence)``
before indexing so the analyzer's edge order is stable across re-runs
(a newly-delegated sibling takes a fresh ``sequence``; existing siblings
keep theirs), so re-running after each delegate only ADDS edges for the
new sibling's collisions — never flips an existing pair's order into a
reverse edge (which would cycle). ``add_dependency`` dedupes, so repeated
wiring is a no-op on already-wired pairs.
"""
surfaced = [
s
for s in siblings
if getattr(s, "project_id", None)
and (
getattr(s, "intends_to_touch", None)
or getattr(s, "adds_migration", False)
or getattr(s, "touches_shared", False)
)
]
if len(surfaced) < _MIN_COLLISION_PAIR:
return []
# Stable order across incremental re-runs: priority is set at creation,
# sequence is append-only (existing siblings keep theirs).
surfaced.sort(
key=lambda s: (int(getattr(s, "priority", 2)), int(getattr(s, "sequence", 0)))
)
surfaces = [
DraftSurface(
idx=i,
priority=int(getattr(s, "priority", 2)),
intends_to_touch=list(getattr(s, "intends_to_touch", None) or []),
adds_migration=bool(getattr(s, "adds_migration", False)),
touches_shared=bool(getattr(s, "touches_shared", False)),
project_id=str(s.project_id) if s.project_id is not None else None,
)
for i, s in enumerate(surfaced)
]
# cell_of / cell_capacity are advisory (contention warnings only); dev
# tasks under one cell-task share the parent's cell, so a constant keeps
# any warning attributable. Empty capacity -> no warnings emitted.
plan = SequencingService().analyze(surfaces, lambda _idx: "", {})
return [(surfaced[a].id, surfaced[b].id) for a, b in plan.edges]
+27
View File
@@ -5934,6 +5934,33 @@ class TaskService(BaseService):
task.dependency_ids = [*task.dependency_ids, depends_on_id]
await self.session.flush()
async def wire_sibling_collision_dag(self, parent_task_id: UUID) -> None:
"""Wire the dev-task collision DAG (multi-level sequencing edge kind 3).
Runs the deterministic collision-sequencing analyzer over a parent's
surfaced siblings and wires each returned edge as a real
``dependency_ids`` entry via :meth:`add_dependency`, so a dev task whose
surface collides with an earlier sibling's stays PENDING (held by the
``list_pending(filter_by_dependencies=True)`` gate) until that sibling
completes and :meth:`_unblock_dependents` releases it cross-dev and
cross-reroute, the ordering the assignee-keyed spawn barrier could not
guarantee (live 2026-06-27 out-of-order break).
Incremental + idempotent: re-run after each delegate. A surfaced
sibling (``intends_to_touch`` / ``adds_migration`` / ``touches_shared``)
joins the DAG scoped to its ``project_id`` (two repos never collide);
a no-surface / no-project sibling is parallel to everything and
contributes no edge. ``dev_task_collision_edges`` orders siblings by
``(priority, sequence)`` so re-runs only ADD edges (never flip an
existing pair's order), and ``add_dependency`` dedupes.
"""
from roboco.services.sequencing import dev_task_collision_edges
siblings = await self.get_subtasks(parent_task_id)
edges = dev_task_collision_edges(siblings)
for depends_on_id, task_id in edges:
await self.add_dependency(UUID(str(task_id)), UUID(str(depends_on_id)))
async def set_sequence(self, task_id: UUID, sequence: int) -> None:
"""Set a task's sibling-ordering sequence (lower = first).
@@ -720,6 +720,60 @@ async def test_create_subtask_round_trips_collision_surfaces_and_deps(
assert sub.dependency_ids == [dep_id]
@pytest.mark.asyncio
async def test_wire_sibling_collision_dag_serializes_overlapping_dev_tasks(
task_setup: dict, db_session: AsyncSession
) -> None:
"""wire_sibling_collision_dag runs the collision analyzer over a parent's
surfaced dev-task siblings and wires dependency_ids so a later dev task
whose surface overlaps an earlier one stays PENDING until it completes.
T1 (a.py) and T2 (b.py) are disjoint -> parallel (no edge). T3 (a.py)
overlaps T1 -> T3 depends-on T1. The explicit `depends_on` override on T3
(forwarded through create_subtask in S1) is also present."""
svc = task_setup["svc"]
parent = await svc.create(_req(task_setup))
await db_session.flush()
async def _dev(seq: int, surface: list[str]) -> Any:
t = await svc.create_subtask(
TaskCreateRequest(
title=f"dev-{seq}",
description=f"dev task {seq} description long enough",
acceptance_criteria=["ac"],
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=seq,
intends_to_touch=surface,
)
)
await svc.set_sequence(t.id, seq)
return t
t1 = await _dev(0, ["roboco/api/a.py"])
t2 = await _dev(1, ["roboco/api/b.py"])
t3 = await _dev(2, ["roboco/api/a.py"])
await svc.wire_sibling_collision_dag(parent.id)
# Reload to read the wired dependency_ids.
r1 = await svc.get(t1.id)
r2 = await svc.get(t2.id)
r3 = await svc.get(t3.id)
assert r1 is not None and r2 is not None and r3 is not None
# T1 and T2 are disjoint -> parallel (no collision edge between them).
assert r1.dependency_ids == []
assert r2.dependency_ids == []
# T3 overlaps T1 (same file, same repo) -> T3 depends-on T1.
assert t1.id in r3.dependency_ids
# T2 does NOT collide with T3 (disjoint file) -> no T2->T3 edge.
assert t2.id not in r3.dependency_ids
@pytest.mark.asyncio
async def test_fail_qa_work_session_fallback_excludes_qa_session(
task_setup: dict, db_session: AsyncSession
+103 -1
View File
@@ -9,12 +9,15 @@ the shared threat service, and S1/S2/S7 in one parallel wave.
from __future__ import annotations
from dataclasses import dataclass, field
from uuid import uuid4
import pytest
from roboco.foundation.policy.sequencing.models import (
DraftSurface,
SequencingError,
)
from roboco.services.sequencing import SequencingService
from roboco.services.sequencing import SequencingService, dev_task_collision_edges
def _backend(_i: int) -> str:
@@ -181,3 +184,102 @@ def test_golden_reproduces_ceo_waves() -> None:
# The page-isolated frontend work (S1/S2/S7) lands in one parallel wave.
assert _wave_of(plan.waves, S1) == _wave_of(plan.waves, S2)
assert _wave_of(plan.waves, S2) == _wave_of(plan.waves, S7)
# ---------------------------------------------------------------------------
# dev_task_collision_edges — the dev-task collision DAG (edge kind 3).
# Pure glue: a parent's surfaced siblings -> (depends_on_id, task_id) pairs.
# Wraps SequencingService so the choreographer can wire the DAG via add_dependency
# at cell-PM dev-delegation time (incremental, idempotent). See the multi-level
# sequencing design doc.
# ---------------------------------------------------------------------------
@dataclass
class _Sib:
"""Minimal sibling shape — the attributes dev_task_collision_edges reads."""
id: object
priority: int = 2
sequence: int = 0
intends_to_touch: list[str] = field(default_factory=list)
adds_migration: bool = False
touches_shared: bool = False
project_id: str | None = "proj-backend"
def _edge_set(pairs: list[tuple[object, object]]) -> set[tuple[object, object]]:
return set(pairs)
def test_dev_collision_disjoint_surfaces_are_parallel() -> None:
# Same project, disjoint files → no edge (the two dev tasks run together).
a, b = (
_Sib(uuid4(), sequence=0, intends_to_touch=["a.py"]),
_Sib(uuid4(), sequence=1, intends_to_touch=["b.py"]),
)
assert dev_task_collision_edges([a, b]) == []
def test_dev_collision_overlap_serializes_more_important_first() -> None:
# Both touch a.py → serialized; lower priority NUMBER runs first.
first = _Sib(uuid4(), priority=1, sequence=0, intends_to_touch=["a.py"])
second = _Sib(uuid4(), priority=2, sequence=1, intends_to_touch=["a.py"])
edges = dev_task_collision_edges([second, first]) # passed out of order
assert edges == [
(first.id, second.id)
] # first depends-on nothing; second depends-on first
def test_dev_collision_overlap_equal_priority_uses_sequence() -> None:
# Equal priority → lower sequence runs first (stable across incremental re-runs).
t1 = _Sib(uuid4(), sequence=0, intends_to_touch=["a.py"])
t3 = _Sib(uuid4(), sequence=1, intends_to_touch=["a.py"])
assert dev_task_collision_edges([t1, t3]) == [(t1.id, t3.id)]
def test_dev_collision_skips_unsurfaced_siblings() -> None:
# A sibling with no surface is parallel to everything (no edges to/from it).
surfaced = _Sib(uuid4(), sequence=0, intends_to_touch=["a.py"])
bare = _Sib(uuid4(), sequence=1) # no intends_to_touch / migration / shared
other = _Sib(uuid4(), sequence=2, intends_to_touch=["a.py"])
edges = _edge_set(dev_task_collision_edges([surfaced, bare, other]))
assert edges == {(surfaced.id, other.id)}
assert bare.id not in {e[0] for e in edges} and bare.id not in {e[1] for e in edges}
def test_dev_collision_skips_different_project() -> None:
# Same path, different repo → no collision (different codebase).
a = _Sib(uuid4(), sequence=0, intends_to_touch=["a.py"], project_id="proj-be")
b = _Sib(uuid4(), sequence=1, intends_to_touch=["a.py"], project_id="proj-fe")
assert dev_task_collision_edges([a, b]) == []
def test_dev_collision_migration_chain_serializes() -> None:
# Two migration-adders in the same repo chain serially (alembic single-head).
m1 = _Sib(uuid4(), sequence=0, adds_migration=True, intends_to_touch=["m1.py"])
m2 = _Sib(uuid4(), sequence=1, adds_migration=True, intends_to_touch=["m2.py"])
assert dev_task_collision_edges([m1, m2]) == [(m1.id, m2.id)]
def test_dev_collision_shared_last_after_non_shared_overlap() -> None:
# A touches_shared edit runs after a non-shared task that overlaps it.
base = _Sib(uuid4(), sequence=0, intends_to_touch=["svc/shared.py"])
shared = _Sib(
uuid4(), sequence=1, touches_shared=True, intends_to_touch=["svc/shared.py"]
)
assert dev_task_collision_edges([base, shared]) == [(base.id, shared.id)]
def test_dev_collision_single_surfaced_sibling_no_edge() -> None:
solo = _Sib(uuid4(), sequence=0, intends_to_touch=["a.py"])
assert dev_task_collision_edges([solo]) == []
def test_dev_collision_returns_depends_on_first_pairs() -> None:
# Contract: each pair is (depends_on_id, task_id) — task depends-on depends_on.
first = _Sib(uuid4(), sequence=0, intends_to_touch=["a.py"])
second = _Sib(uuid4(), sequence=1, intends_to_touch=["a.py"])
[(dep, task)] = dev_task_collision_edges([first, second])
assert dep == first.id
assert task == second.id