mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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.
273 lines
12 KiB
Python
273 lines
12 KiB
Python
"""Deterministic collision-sequencing analyzer for sequenced batch intake.
|
|
|
|
Turns a batch's per-task collision surfaces into a dependency DAG + execution
|
|
waves. Correctness lives in CODE, not agent judgment: the analyzer *guarantees*
|
|
the safety serializations (file overlap, migration chain, shared-last) so a weak
|
|
Prompter can only ever over-serialize, never miss a declared collision. Pure —
|
|
no DB, no services; consumed by the batch-create path.
|
|
|
|
Rules (evaluated in order; an edge ``(a, b)`` means *b depends on a*, a first):
|
|
1. File overlap — overlapping surfaces serialize, more-important (lower
|
|
``(priority, idx)``) first. Mixed shared/non-shared pairs
|
|
are left to rule 3 so a high-priority shared task can't
|
|
invert shared-last into a cycle.
|
|
2. Migration chain — all ``adds_migration`` drafts run serially, ordered by
|
|
``(priority, idx)`` (Alembic cannot have concurrent heads).
|
|
3. Shared-last — every non-shared draft that overlaps a ``touches_shared``
|
|
draft runs before it.
|
|
4. Cell contention — when a wave puts more drafts on a cell than its capacity,
|
|
warn (never serialize — that is the orchestrator's job).
|
|
|
|
Then: dedupe edges, existence + cycle check, Kahn topological layering.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from fnmatch import fnmatch
|
|
from itertools import pairwise
|
|
from typing import TYPE_CHECKING
|
|
|
|
from roboco.foundation.policy.sequencing.models import (
|
|
DraftSurface,
|
|
SequencePlan,
|
|
SequencingError,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
|
|
class SequencingService:
|
|
"""Pure analyzer: ``analyze`` is the only public entry point."""
|
|
|
|
def analyze(
|
|
self,
|
|
surfaces: list[DraftSurface],
|
|
cell_of: Callable[[int], str],
|
|
cell_capacity: dict[str, int],
|
|
) -> SequencePlan:
|
|
"""Compute the dependency edges + execution waves for a batch."""
|
|
edges = self._dedupe(
|
|
self._file_overlap_edges(surfaces)
|
|
+ self._migration_chain_edges(surfaces)
|
|
+ self._shared_last_edges(surfaces)
|
|
)
|
|
waves = self._toposort(edges, len(surfaces))
|
|
warnings = self._contention_warnings(waves, cell_of, cell_capacity)
|
|
return SequencePlan(edges=edges, waves=waves, warnings=warnings)
|
|
|
|
# --- rule 1: file overlap ------------------------------------------------
|
|
def _file_overlap_edges(
|
|
self, surfaces: list[DraftSurface]
|
|
) -> list[tuple[int, int]]:
|
|
edges: list[tuple[int, int]] = []
|
|
for i, a in enumerate(surfaces):
|
|
for b in surfaces[i + 1 :]:
|
|
# Different repos can't share a working-tree path — no collision.
|
|
if a.project_id != b.project_id:
|
|
continue
|
|
# Mixed shared/non-shared overlaps are owned by rule 3.
|
|
if a.touches_shared != b.touches_shared:
|
|
continue
|
|
if self._globs_overlap(a.intends_to_touch, b.intends_to_touch):
|
|
edges.append(self._order_edge(a, b))
|
|
return edges
|
|
|
|
# --- rule 2: migration chain ---------------------------------------------
|
|
@staticmethod
|
|
def _migration_chain_edges(
|
|
surfaces: list[DraftSurface],
|
|
) -> list[tuple[int, int]]:
|
|
# Migrations serialize per project (each repo has its own alembic chain);
|
|
# two repos' migrations are independent. Within a project, non-shared
|
|
# migrations chain BEFORE shared ones (the ``touches_shared`` sort key) so
|
|
# this never contradicts rule 3's shared-last ordering into a cycle.
|
|
by_project: dict[object, list[DraftSurface]] = defaultdict(list)
|
|
for s in surfaces:
|
|
if s.adds_migration:
|
|
by_project[s.project_id].append(s)
|
|
edges: list[tuple[int, int]] = []
|
|
for group in by_project.values():
|
|
migs = sorted(group, key=lambda s: (s.touches_shared, s.priority, s.idx))
|
|
edges.extend((prev.idx, cur.idx) for prev, cur in pairwise(migs))
|
|
return edges
|
|
|
|
# --- rule 3: shared-last -------------------------------------------------
|
|
def _shared_last_edges(self, surfaces: list[DraftSurface]) -> list[tuple[int, int]]:
|
|
edges: list[tuple[int, int]] = []
|
|
for s in surfaces:
|
|
if not s.touches_shared:
|
|
continue
|
|
for other in surfaces:
|
|
if other.idx == s.idx or other.touches_shared:
|
|
continue
|
|
# A shared edit only runs after a NON-shared task in the SAME repo.
|
|
if other.project_id != s.project_id:
|
|
continue
|
|
if self._globs_overlap(other.intends_to_touch, s.intends_to_touch):
|
|
edges.append((other.idx, s.idx))
|
|
return edges
|
|
|
|
# --- rule 4: cell contention (warnings only) -----------------------------
|
|
@staticmethod
|
|
def _contention_warnings(
|
|
waves: list[list[int]],
|
|
cell_of: Callable[[int], str],
|
|
cell_capacity: dict[str, int],
|
|
) -> list[str]:
|
|
warnings: list[str] = []
|
|
for wave_no, wave in enumerate(waves):
|
|
counts: dict[str, int] = defaultdict(int)
|
|
for idx in wave:
|
|
counts[cell_of(idx)] += 1
|
|
for cell, n in sorted(counts.items()):
|
|
cap = cell_capacity.get(cell)
|
|
if cap is not None and n > cap:
|
|
warnings.append(
|
|
f"wave {wave_no}: {n} tasks target {cell} (capacity {cap})"
|
|
)
|
|
return warnings
|
|
|
|
# --- topological layering (existence + cycle check) ----------------------
|
|
def _toposort(self, edges: list[tuple[int, int]], n: int) -> list[list[int]]:
|
|
"""Layer the DAG into waves; raise on an out-of-range edge or a cycle."""
|
|
self._check_edges_in_range(edges, n)
|
|
indeg, adj = self._build_graph(edges, n)
|
|
return self._kahn_layers(indeg, adj, n)
|
|
|
|
@staticmethod
|
|
def _check_edges_in_range(edges: list[tuple[int, int]], n: int) -> None:
|
|
for a, b in edges:
|
|
if not (0 <= a < n and 0 <= b < n):
|
|
raise SequencingError(
|
|
f"edge ({a}, {b}) references a draft outside 0..{n - 1}"
|
|
)
|
|
|
|
@staticmethod
|
|
def _build_graph(
|
|
edges: list[tuple[int, int]], n: int
|
|
) -> tuple[list[int], dict[int, list[int]]]:
|
|
indeg = [0] * n
|
|
adj: dict[int, list[int]] = defaultdict(list)
|
|
for a, b in edges:
|
|
adj[a].append(b)
|
|
indeg[b] += 1
|
|
return indeg, adj
|
|
|
|
def _kahn_layers(
|
|
self, indeg: list[int], adj: dict[int, list[int]], n: int
|
|
) -> list[list[int]]:
|
|
remaining = set(range(n))
|
|
waves: list[list[int]] = []
|
|
while remaining:
|
|
ready = sorted(i for i in remaining if indeg[i] == 0)
|
|
if not ready:
|
|
raise SequencingError("collision graph has a cycle")
|
|
waves.append(ready)
|
|
remaining -= set(ready)
|
|
self._relax(indeg, adj, ready)
|
|
return waves
|
|
|
|
@staticmethod
|
|
def _relax(indeg: list[int], adj: dict[int, list[int]], ready: list[int]) -> None:
|
|
for i in ready:
|
|
for nbr in adj[i]:
|
|
indeg[nbr] -= 1
|
|
|
|
# --- helpers -------------------------------------------------------------
|
|
@staticmethod
|
|
def _order_edge(a: DraftSurface, b: DraftSurface) -> tuple[int, int]:
|
|
"""Edge from the more-important draft (lower ``(priority, idx)``) first."""
|
|
first, second = (a, b) if (a.priority, a.idx) <= (b.priority, b.idx) else (b, a)
|
|
return (first.idx, second.idx)
|
|
|
|
@staticmethod
|
|
def _globs_overlap(a: list[str], b: list[str]) -> bool:
|
|
"""True if any path in ``a`` overlaps any in ``b`` (equality, fnmatch in
|
|
either direction, or directory-prefix containment)."""
|
|
for pa in a:
|
|
for pb in b:
|
|
if pa == pb or fnmatch(pa, pb) or fnmatch(pb, pa):
|
|
return True
|
|
if pa.startswith(pb.rstrip("/") + "/") or pb.startswith(
|
|
pa.rstrip("/") + "/"
|
|
):
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def _dedupe(edges: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
seen: set[tuple[int, int]] = set()
|
|
out: list[tuple[int, int]] = []
|
|
for edge in edges:
|
|
if edge not in seen:
|
|
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]
|