Files
roboco/tests/unit/services/test_sequencing.py
T
17de29545a [6788ce7f] Silent bug sweep: concurrency, state integrity, engine edge-cases, panel data freshness (#638)
* [943d8c4d] Frontend data freshness and approval-queue reliability audit (#631)

* [233a8b0f] WebSocket reconnect message-loss audit and fix (#625)

* [233a8b0f] fix(panel): add REST catch-up to useNotificationStream on WS reconnect

connection.ts has no message buffering/replay, so a notification published
while the CEO bell's socket was down (disconnected/reconnecting) was lost
forever instead of merely delayed. Add a reconnect-triggered GET
/notifications?unread_only=true catch-up folded into the existing
notification_id dedup so a notification delivered both via catch-up and
live WS is never double-counted, and make clearMessages drop the held
catch-up batch too. use-a2a-live.ts and use-rate-limit-websocket.ts were
audited and already have working reconnect-triggered REST fallbacks
(verified via a2a/page.tsx, rate-limit-banner.tsx, usage-overview-panel.tsx
and their existing F083 tests) so no fix was needed there.

* [233a8b0f] docs(panel): add comprehensive WebSocket hooks reference and reconnect architecture guide

Add panel/docs/frontend/hooks.md with full API reference for useWebSocket, useNotificationStream (with new REST catch-up behavior), useAgentStream, useA2ALiveStream, and useConnectionStatus. Include examples, best practices, and testing guidance.

Add panel/docs/architecture/websocket-reconnect.md documenting the message-loss mitigation pattern: Strategy 1 (REST catch-up for events, used by useNotificationStream) and Strategy 2 (REST invalidation for state, used by A2A/rate-limit consumers), plus the dedup logic ensuring no notification is double-counted on reconnect.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

* [d5315683] fix(frontend): add distinct toast feedback for silently-swallowed x-post and release-proposal statuses, plus regression tests for all 4 approval queues (#626)

Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

* [cd953838] Data-hook null-guard audit and API client 429 retry-by-method fix (#630)

* [cd953838] fix(panel): gate 429 retry by HTTP method, add hook null-guard regression tests

* [cd953838] chore(conventions): waive test-fixture wrapper in hooks null-guard test

* [cd953838] docs(frontend): document API rate-limit retry behavior and null-guard audit results

Added `docs/frontend/api-rate-limiting.md` to document the 429 retry strategy: GET/PUT auto-retry, POST/PATCH/DELETE require X-Idempotency-Key header. Updated `docs/frontend/hooks.md` to confirm the data-hook null-guard audit found all hooks already have correct `enabled` guards and include a regression test suite for the board-review poll on/off behavior and enabled-guard assertions.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>

* [4534c71a] Backend concurrency, state-machine, and engine audit (#634)

* [41de844a] fix(lifecycle): sync CLAIM_RULES with runtime + clear stale claimant on PM hand-off (#627)

Two confirmed state-machine gaps found while auditing lifecycle.py,
task_lifecycle.py, the _ESCALATABLE_TO_BLOCKED bypass, and every
_REVIEW_QUEUE_STATES entry point:

- lifecycle.py's CLAIM_RULES/claim-ActionSpec/StatusTransition table
  did not grant CELL_PM/MAIN_PM re-claim of AWAITING_PM_REVIEW even
  though task.py's runtime _ROLE_CLAIM_STATUSES already granted it
  and claimed the spec agreed -- the two tables had silently drifted,
  breaking i_will_plan re-claim on an awaiting_pm_review task.

- docs_complete's _maybe_advance_to_pm_review pre-assigns a specific
  owning PM via assigned_to but left claimed_by/active_claimant_id
  pointing at the outgoing documenter, unlike every sibling transition
  into a review-queue state. A stale active_claimant_id makes
  content_actions.py's _active_claim_violation wrongly reject the
  newly-assigned PM's own content writes before it formally claims.
  Reassign claimed_by + active_claimant_id to the owning PM alongside
  assigned_to.

Adds a regression test asserting the documenter's stale claim does not
survive the docs_complete -> awaiting_pm_review hand-off.

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>

* [0c46f666] Engine dedup race + sequencing.py edge-case audit (#628)

* [0c46f666] fix(sequencing): dedup race audit + collision-edge fallback bug

Audited the list-open-then-originate dedup pattern across six engines:
RoadmapEngine, XEngine.run_cycle, DepUpdateEngine, and CIWatchEngine each
run inside exactly one sequential orchestrator-loop asyncio task (no other
call site invokes run_cycle), so they cannot race with themselves; their
in-cycle dedup sets/keys are correctly built before any commit. SelfHealEngine
is the same shape. VideoEngine.open_video_task is genuinely different: it is
reachable from the release-publish hook, the feature-spotlight hook, and the
on-demand POST /video/request route, so two overlapping calls for the same
occasion can both pass the "no open task yet" check before either commits.
Fixed by wrapping the check+insert in a short-lived Redis mutex (reusing
HeartbeatMutex) keyed by occasion, mirroring XPostService's existing
lock pattern, with a regression test proving only one of two concurrent
calls creates a task.

Verified ReleaseExecutor's half-landed retry path (release_commit_sha):
apply_version_bumps and write_changelog_entry both run as uncommitted
working-tree edits before commit_and_push's single `git add -A` + commit,
so a bumped-version-without-changelog state can never reach origin (and
therefore can never be observed by a fresh retry clone) - confirmed correct
with a real-git-repo regression test, no fix needed.

Fixed sequencing.py's dev_task_collision_edges: the `if edges: return edges`
short-circuit dropped the same-assignee-lane fallback entirely whenever ANY
surfaced sibling pair produced a collision edge, even for a completely
unrelated same-assignee pair with no declared surface. Now the fallback
always runs, skipping only pairs the analyzer already ordered (so the two
mechanisms can never disagree on direction for the same pair).

Verified sequencing.py rule 3 (all-shared batch generates no edges): correct
by inspection (_shared_last_edges skips every pair when both are shared) and
confirmed with a regression test - no fix needed.

* [0c46f666] docs(reference): concurrency audit summary - engine races, fixes, verified patterns

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>

* [8f7f167a] Redis mutex pre-lock write audit (#629)

* [8f7f167a] Redis mutex pre-lock write audit: add cross-session regression test for XPostService.approve

Audited x_post_service.py, video_post_service.py, release_proposal.py, and
heartbeat_mutex.py for the pre-lock DB-write anti-pattern (a session write
that happens before the SET NX / HeartbeatMutex acquire returns a token,
letting a losing racer's stale write clobber a winner's committed state).

XPostService.approve, VideoPostService.approve, and
ReleaseProposalService.approve/reject already implement the correct
validate-pure-pre-lock, apply-under-lock pattern (the XPostService fix
already shipped per CHANGELOG.md: "X edited_body write deferred into the
single-flight lock (M5)"). HeartbeatMutex holds no AsyncSession at all, so
the anti-pattern is structurally inapplicable there.

Adds a genuine cross-session concurrency regression test to
test_x_post_service.py (a real second DB connection, not an in-process
mock) mirroring VideoPostService's existing cross-session test, proving a
concurrently-committed post survives and the CEO's edited body never lands
on the just-posted row.

* [8f7f167a] Remove redundant inline comments flagged by QA in cross-session regression test

Both comments restated what the surrounding docstrings already say
explicitly, per QA findings F-dbadd8f0 (line 294) and F-27ac051e (line
631) — no behavior change, tests re-verified green against a sandbox
Postgres.

* [8f7f167a] Remove inline trailing comments flagged by QA (correct file this time)

QA findings F-e6f3e6a6 and F-24189858 cited tests/unit/services/
test_x_post_service.py:294 and :631 across 5 revision rounds, but that
file never contained the flagged comment text — a repo-wide grep for
the exact quoted strings shows both comments actually live in the
mirrored tests/unit/services/test_video_post_service.py file, in its
own cross-session concurrency regression tests (the caption-edit and
tiktok-skip tests). Removed both there:
- "# externally visible to the "concurrent" session below" on the
  db_session.commit() call
- "# never attempted without credentials" on the tiktok_poster.calls
  assertion

Both restated what the surrounding docstrings/test names already say;
no behavior change. Verified with the full make quality gate against a
sandbox Postgres/Redis: 13,717 passed, 94.41% coverage, clean except
one pre-existing unrelated failure in tests/unit/api/test_cloud_auth.py
::test_login_route_parses_oauth2_form_not_query_params, which connects
to the app's default localhost:5432 Postgres (not the db_session
sandbox fixture) and is unreachable in this sandboxed environment —
structurally unrelated to the auth subsystem this task never touches.

* [8f7f167a] Redis mutex pre-lock write audit (round 7): add cross-session regression tests for reject() lock protection

Round-7 QA findings F-7eb9fbcb, F-06f39a2e, and F-4d56e49b claim
XPostService.reject(), ReleaseProposalService.reject(), and
release_executor._await_proc() lack lock protection / a CancelledError
handler — but their cited line ranges (255-267, 429-454, 241-257)
describe a pre-fix, shorter version of these functions that predates
commit fb293a787d, already on this branch. At current HEAD:

- x_post_service.py reject() (lines 275-299) acquires _LOCK_PREFIX,
  re-reads under the lock, applies markers.set_x_reject_reason() +
  CANCELLED only inside the critical section, releases in finally.
- release_proposal.py reject() (lines 460-486) does the identical
  dance with _RELEASE_LOCK_PREFIX.
- release_executor.py _await_proc() (lines 257-265) already has an
  `except asyncio.CancelledError` block that kills + reaps the child
  and re-raises, mirroring the TimeoutError handler, with an existing
  dedicated regression test
  (test_await_proc_kills_child_on_outer_cancellation).

The one genuine gap: neither reject() path had a cross-session
(real second DB connection, not an in-process mock) regression test
proving the in-lock re-read catches a concurrent approve/publish that
completes mid-lock-wait — only approve() had one. Added
test_reject_concurrent_approve_completes_during_lock_wait to both
test_x_post_service.py and test_release_proposal_status_guards.py,
mirroring the existing approve() cross-session test: a second engine
commits COMPLETED between reject's pre-lock read and lock acquisition,
and the test asserts the CANCELLED write / reject-reason marker never
lands on the just-completed row.

No production code changed — verified via 103 targeted tests green
against a sandbox Postgres/Redis, plus `make -o sync gate` clean.

* [8f7f167a] Regenerate stale lifecycle artifacts (restore auditor waive_finding)

foundation-check was the only failing gate: the committed lifecycle artifacts
were missing the auditor's waive_finding verb that the lifecycle source
defines, so make quality regenerated them and failed on the diff — nothing to
do with the mutex fix (which passes ruff/mypy/tests/coverage/bandit clean).
make lifecycle restores the drift; this is what the 8 revision rounds kept
missing.

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* [d615f2e3] fix(tests): sync stale CLAIM_RULES pinning assertions with lifecycle.py (#635)

test_claim_rules_match_pre_gateway_table still asserted the pre-audit
two-member frozenset for CELL_PM/MAIN_PM claim rules. CLAIM_RULES in
lifecycle.py already grants both roles claim rights on
Status.AWAITING_PM_REVIEW (added by the state-machine exhaustiveness
audit) so a PM can re-claim its own review-queue task after a respawn.
Updated both assertions to include AWAITING_PM_REVIEW, matching the
actual dict. Grepped the repo for sibling stale copies of the old
literal; found none beyond this test.

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

* [3c4e7a35] fix(quality-gate): reflow CONCURRENCY_AUDIT.md and stub occasion lock in video tests (#636)

Root cause: PR #634's CI failed at the markdown-reflow check (make quality
Makefile:285) on CONCURRENCY_AUDIT.md — a hard-wrapped audit doc left over
from the merged "Engine dedup race + sequencing.py edge-case audit" unit
(PR #628). Fixed with `make reflow-docs` (the exact remedy the CI output
itself named).

Running the full local `make quality` (with a sandbox Postgres/Redis to
get past DB-gated skips) surfaced a second real regression from that same
PR #628 unit: it added a Redis-backed HeartbeatMutex occasion lock to
VideoEngine.open_video_task, but two pre-existing test files
(tests/unit/runtime/test_video_render_loop.py and
tests/integration/test_video_routes.py) call open_video_task without
stubbing that lock, so they failed closed against the suite's
deliberately-unreachable test Redis (_no_live_redis). Fixed by applying
the same lock-stub pattern tests/unit/services/test_video_engine.py
already uses for its own occasion-lock tests: an autouse HeartbeatMutex
stand-in fixture in test_video_render_loop.py, and wrapping the two
route-level video-request tests in test_video_routes.py with the file's
existing _LOCKED patch pair (already used by every other lock-dependent
test in that file).

The one remaining local failure,
test_cloud_auth.py::test_login_route_parses_oauth2_form_not_query_params,
is a pre-existing environment gap unrelated to this branch: it needs a
real Postgres reachable at localhost:5432 (which .github/workflows/ci.yml
provides as a service container) but this dev sandbox has no such binding
— confirmed unrelated to any of the four merged audit units.

make quality now passes clean: 13729 passed, 0 regressions, 94.49% coverage.

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

* [ddc8121f] regenerate lifecycle artifacts for awaiting_pm_review claim rules and waive_finding intent (#637)

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>

---------

Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>

* fix(sequencing): drop lane-fallback edges that would cycle against analyzer edges

The dev-task collision fallback unioned the analyzer's authoritative edges
with same-assignee lane edges, deduping only the direct pair. A lane chain
through an unsurfaced middle sibling could still contradict an analyzer edge
transitively (the shared-last migration order inverts plain priority order),
closing a 3-cycle that made add_dependency raise ConflictError and wedged
every later delegate to that parent. Fallback edges are now accepted only
when they can't close a cycle against the edges already kept; a regression
test reproduces the exact scenario.

Also strip pre-merge cruft: remove the root CONCURRENCY_AUDIT.md working
report, delete the near-duplicate websocket-reconnect.md doc, fix the stale
a2a/page.tsx doc citation, correct the api-rate-limiting doc to state
idempotency-key retry is unimplemented, and fix two lifecycle.py comments
that referenced a guard function which never existed.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech>
Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
Co-authored-by: Frontend Developer 2 <fe-dev-2@roboco.tech>
Co-authored-by: Backend Developer 2 <be-dev-2@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-22 08:11:28 +02:00

597 lines
25 KiB
Python

"""SequencingService — the deterministic collision-sequencing analyzer.
The unit tests pin each rule in isolation; the golden test asserts the analyzer
reproduces the CEO's own hand-sequencing of the 11-item guard-core-app batch
(the effort that motivated the feature, and whose hand-coordination deadlocked
the Main PM): S6 alone last, the R1/R3/R4 migration chain, R2/R3/S8 serialized on
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,
by_osmosis_tail_dev_tasks,
cell_task_wave_chain_depends_on,
dev_task_collision_edges,
)
def _backend(_i: int) -> str:
return "backend"
def _frontend(_i: int) -> str:
return "frontend"
def _wave_of(waves: list[list[int]], idx: int) -> int:
return next(w for w, wave in enumerate(waves) if idx in wave)
# ---------------------------------------------------------------------------
# Per-rule unit tests
# ---------------------------------------------------------------------------
def test_disjoint_surfaces_no_edges() -> None:
s = [
DraftSurface(0, 1, ["a/x.py"], False, False),
DraftSurface(1, 1, ["b/y.py"], False, False),
]
plan = SequencingService().analyze(s, _backend, {"backend": 2})
assert plan.edges == []
assert plan.waves == [[0, 1]]
def test_file_overlap_serializes_more_important_first() -> None:
# idx 1 has the lower priority NUMBER (more important) → it runs first.
s = [
DraftSurface(0, 2, ["svc/threats.py"], False, False),
DraftSurface(1, 1, ["svc/threats.py"], False, False),
]
plan = SequencingService().analyze(s, _backend, {"backend": 2})
assert (1, 0) in plan.edges # more-important runs first, the other waits
def test_migrations_form_serial_chain() -> None:
s = [
DraftSurface(0, 1, ["a.py"], True, False),
DraftSurface(1, 1, ["b.py"], True, False),
DraftSurface(2, 1, ["c.py"], True, False),
]
plan = SequencingService().analyze(s, _backend, {"backend": 2})
assert (0, 1) in plan.edges # no two migrations run in parallel
assert (1, 2) in plan.edges
def test_touches_shared_runs_last() -> None:
s = [
DraftSurface(0, 1, ["page/a.tsx"], False, False),
DraftSurface(1, 1, ["page/b.tsx"], False, False),
DraftSurface(2, 1, ["page/a.tsx", "components/shared.tsx"], False, True),
]
plan = SequencingService().analyze(s, _frontend, {"frontend": 2})
assert plan.waves[-1] == [2] # the shared task is the final wave
def test_all_shared_batch_with_disjoint_surfaces_generates_no_edges() -> None:
# Verifying the claimed rule-3 property: when every draft in the batch
# touches_shared, ``_shared_last_edges`` skips every candidate pair (its
# inner loop continues on `other.touches_shared`), so it contributes no
# edges on its own. With disjoint file surfaces rule 1 (same-shared-status
# overlap) also contributes nothing, so the whole batch runs in one
# parallel wave — confirmed correct, no fix needed.
s = [
DraftSurface(0, 1, ["fe/app/a.tsx"], False, True),
DraftSurface(1, 1, ["fe/app/b.tsx"], False, True),
DraftSurface(2, 1, ["fe/app/c.tsx"], False, True),
]
plan = SequencingService().analyze(s, _frontend, {"frontend": 3})
assert plan.edges == []
assert plan.waves == [[0, 1, 2]]
# And rule 3 in isolation truly contributes zero edges for an all-shared
# set, regardless of overlap — it is rule 1 (same-shared-status overlap),
# not rule 3, that would serialize two OVERLAPPING shared surfaces.
assert SequencingService()._shared_last_edges(s) == []
def test_cycle_is_rejected() -> None:
with pytest.raises(SequencingError):
SequencingService()._toposort([(0, 1), (1, 0)], 2)
def test_existence_check_rejects_out_of_range_edge() -> None:
with pytest.raises(SequencingError):
SequencingService()._toposort([(0, 5)], 2)
def test_shared_migration_chains_after_non_shared_no_cycle() -> None:
# Regression: a draft that is BOTH touches_shared AND adds_migration,
# overlapping a non-shared migration draft on the same file, used to fabricate
# a cycle — rule 2 (migration chain) emitted shared->non-shared while rule 3
# (shared-last) emitted non-shared->shared. The migration chain is now
# shared-last-aware, so the shared draft is ordered LAST and there is no cycle.
s = [
DraftSurface(0, 1, ["svc/threats.py"], True, True), # shared migration
DraftSurface(1, 1, ["svc/threats.py"], True, False), # non-shared migration
]
plan = SequencingService().analyze(s, _backend, {"backend": 2})
assert plan.waves == [[1], [0]] # non-shared first, shared migration last
def test_cross_project_surfaces_do_not_collide() -> None:
# A MegaTask spans repos that don't share a working tree — two migrations in
# different projects run in PARALLEL, and a coincidentally-equal path across
# repos is not a collision.
s = [
DraftSurface(0, 1, ["alembic/x.py"], True, False, project_id="proj-a"),
DraftSurface(1, 1, ["alembic/x.py"], True, False, project_id="proj-b"),
]
plan = SequencingService().analyze(s, _backend, {"backend": 2})
assert plan.waves == [[0, 1]] # independent repos → one parallel wave
def test_cell_contention_warns_not_serializes() -> None:
s = [DraftSurface(i, 1, [f"page/{i}.tsx"], False, False) for i in range(3)]
plan = SequencingService().analyze(s, _frontend, {"frontend": 2})
assert plan.edges == [] # contention never adds an edge
assert any("frontend" in w for w in plan.warnings)
# ---------------------------------------------------------------------------
# Golden test — reproduce the CEO's 4-wave plan for the 11-item batch
# ---------------------------------------------------------------------------
# Index map for the guard-core-app items (see obs: wave-based sequencing).
R1, R2, R3, R4 = 0, 1, 2, 3
S1, S2, S3, S5, S7, S8, S6 = 4, 5, 6, 7, 8, 9, 10
def _guard_core_app_batch() -> list[DraftSurface]:
# (idx, priority, intends_to_touch, adds_migration, touches_shared)
return [
DraftSurface(R1, 1, ["be/services/project_service.py"], True, False),
DraftSurface(R2, 1, ["be/services/threats_service.py"], False, False),
DraftSurface(
R3,
1,
["be/services/threats_service.py", "be/services/behavioral_service.py"],
True,
False,
),
DraftSurface(R4, 1, ["fe/app/rules/page.tsx"], True, False),
DraftSurface(S1, 1, ["fe/app/metrics/page.tsx"], False, False),
DraftSurface(S2, 1, ["fe/app/settings/page.tsx"], False, False),
DraftSurface(S3, 1, ["be/services/dashboard_service.py"], False, False),
DraftSurface(S5, 1, ["be/services/audit_service.py"], False, False),
DraftSurface(S7, 1, ["fe/app/threats/page.tsx"], False, False),
DraftSurface(S8, 1, ["be/services/threats_service.py"], False, False),
DraftSurface(S6, 1, ["fe/components/", "fe/app/"], False, True),
]
def _cell_of(idx: int) -> str:
return "backend" if idx in {R1, R2, R3, S3, S5, S8} else "frontend"
def test_golden_reproduces_ceo_waves() -> None:
plan = SequencingService().analyze(
_guard_core_app_batch(), _cell_of, {"backend": 2, "frontend": 2}
)
# EXACT partition — the CEO's own 4-wave hand-sequencing, locked. The bar is
# "reproduce my exact waves or it's not done", so assert the full partition,
# not just the properties below.
assert plan.waves == [
sorted([R1, R2, S1, S2, S3, S5, S7]), # wave 1: everything unblocked
[R3], # wave 2: the shared+migration hinge
sorted([R4, S8]), # wave 3: after R3
[S6], # wave 4: the shared UI-consistency pass, alone, last
]
# The properties that partition expresses (kept as documentation of WHY):
# S6 (the shared UI-consistency pass) runs alone, last.
assert plan.waves[-1] == [S6]
# R1/R3/R4 form a serial migration chain (no concurrent Alembic heads).
assert (R1, R3) in plan.edges
assert (R3, R4) in plan.edges
# R2/R3/S8 serialize on the shared threats service surface.
assert (R2, R3) in plan.edges
assert (R3, S8) in plan.edges
# 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"
assigned_to: object | None = None
def _edge_set(pairs: list[tuple[object, object]]) -> set[tuple[object, object]]:
return set(pairs)
def _has_cycle(pairs: list[tuple[object, object]]) -> bool:
"""True if the (depends_on, task) edge list contains a directed cycle."""
graph: dict[object, set[object]] = {}
for dep_on, task in pairs:
graph.setdefault(dep_on, set()).add(task)
visiting: set[object] = set()
done: set[object] = set()
def _visit(node: object) -> bool:
visiting.add(node)
for nxt in graph.get(node, ()):
if nxt in visiting or (nxt not in done and _visit(nxt)):
return True
visiting.discard(node)
done.add(node)
return False
nodes = {n for pair in pairs for n in pair}
return any(n not in done and _visit(n) for n in nodes)
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
# ---------------------------------------------------------------------------
# dev_task_collision_edges — undeclared-surface fallback: same-assignee
# same-repo siblings chain by (priority, sequence); cross-dev stays parallel.
# ---------------------------------------------------------------------------
def test_dev_collision_fallback_chains_same_assignee_no_surface() -> None:
# Same dev, same repo, no declared surface -> chain by sequence.
a = _Sib(uuid4(), sequence=0, assigned_to="be-dev-1")
b = _Sib(uuid4(), sequence=1, assigned_to="be-dev-1")
assert dev_task_collision_edges([a, b]) == [(a.id, b.id)]
def test_dev_collision_fallback_skips_cross_assignee() -> None:
# Two different devs on the same repo, no surface -> parallel.
a = _Sib(uuid4(), sequence=0, assigned_to="be-dev-1")
b = _Sib(uuid4(), sequence=1, assigned_to="be-dev-2")
assert dev_task_collision_edges([a, b]) == []
def test_dev_collision_fallback_skips_unassigned() -> None:
# No assignee -> can't determine a per-dev lane -> skip.
a = _Sib(uuid4(), sequence=0)
b = _Sib(uuid4(), sequence=1)
assert dev_task_collision_edges([a, b]) == []
def test_dev_collision_fallback_skips_different_project() -> None:
# Same dev, different repos -> no shared working tree -> no chain.
a = _Sib(uuid4(), sequence=0, assigned_to="be-dev-1", project_id="proj-be")
b = _Sib(uuid4(), sequence=1, assigned_to="be-dev-1", project_id="proj-fe")
assert dev_task_collision_edges([a, b]) == []
def test_dev_collision_fallback_does_not_override_collision_edges() -> None:
# Declared overlapping surface -> collision edge wins; no fallback chain.
a = _Sib(uuid4(), sequence=0, assigned_to="be-dev-1", intends_to_touch=["a.py"])
b = _Sib(uuid4(), sequence=1, assigned_to="be-dev-1", intends_to_touch=["a.py"])
assert dev_task_collision_edges([a, b]) == [(a.id, b.id)]
def test_dev_collision_fallback_orders_by_priority_then_sequence() -> None:
# Mixed priority/sequence -> chain in (priority, sequence) ascending order.
p2s2 = _Sib(uuid4(), priority=2, sequence=2, assigned_to="be-dev-1")
p1s5 = _Sib(uuid4(), priority=1, sequence=5, assigned_to="be-dev-1")
p1s1 = _Sib(uuid4(), priority=1, sequence=1, assigned_to="be-dev-1")
edges = dev_task_collision_edges([p2s2, p1s5, p1s1]) # passed out of order
assert edges == [(p1s1.id, p1s5.id), (p1s5.id, p2s2.id)]
def test_dev_collision_fallback_single_sibling_no_edge() -> None:
# A chain needs >= 2 same-assignee same-project siblings.
solo = _Sib(uuid4(), sequence=0, assigned_to="be-dev-1")
assert dev_task_collision_edges([solo]) == []
def test_dev_collision_fallback_idempotent_on_rerun() -> None:
# Deterministic sort -> two calls return the same edge list.
a = _Sib(uuid4(), sequence=0, assigned_to="be-dev-1")
b = _Sib(uuid4(), sequence=1, assigned_to="be-dev-1")
assert dev_task_collision_edges([a, b]) == dev_task_collision_edges([a, b])
def test_dev_collision_fallback_still_applies_when_another_pair_collides() -> None:
# Regression: a `if edges: return edges` short-circuit used to drop the
# assignee-lane fallback ENTIRELY whenever ANY surfaced pair produced a
# collision edge, even for a totally unrelated same-assignee pair with no
# declared surface at all. (a, b) collide on a.py (different assignees, so
# no lane relationship between them); (c, d) share an assignee/project but
# declare no surface — they must still get lane-ordered.
a = _Sib(
uuid4(),
sequence=0,
intends_to_touch=["a.py"],
assigned_to="be-dev-1",
)
b = _Sib(
uuid4(),
sequence=1,
intends_to_touch=["a.py"],
assigned_to="be-dev-2",
)
c = _Sib(uuid4(), sequence=2, assigned_to="be-dev-3")
d = _Sib(uuid4(), sequence=3, assigned_to="be-dev-3")
edges = _edge_set(dev_task_collision_edges([a, b, c, d]))
assert edges == {(a.id, b.id), (c.id, d.id)}
def test_dev_collision_fallback_covers_unsurfaced_sibling_in_surfaced_lane() -> None:
# Same assignee/project lane mixes a surfaced sibling (touches a.py) with
# an unsurfaced one (no declared surface) and a third surfaced sibling
# that doesn't overlap the first — the analyzer alone wires nothing for
# this lane (no pair overlaps), so the fallback must still chain all three
# by (priority, sequence).
first = _Sib(uuid4(), sequence=0, assigned_to="be-dev-1", intends_to_touch=["a.py"])
bare = _Sib(uuid4(), sequence=1, assigned_to="be-dev-1")
other = _Sib(uuid4(), sequence=2, assigned_to="be-dev-1", intends_to_touch=["b.py"])
edges = dev_task_collision_edges([first, bare, other])
assert edges == [(first.id, bare.id), (bare.id, other.id)]
def test_dev_collision_fallback_never_closes_cycle_against_analyzer() -> None:
# Regression: the analyzer's shared-last migration order inverts priority
# order (s3 before s1), while the same-assignee lane fallback chains by
# priority through the unsurfaced middle sibling (s1 -> s2 -> s3). Naively
# unioning the two closed a 3-cycle s1 -> s3 -> s2 -> s1 that made
# add_dependency raise ConflictError and wedged every later delegate. The
# analyzer edge wins; the fallback edge that would cycle is dropped.
s1 = _Sib(
uuid4(),
priority=1,
sequence=0,
assigned_to="be-dev-1",
adds_migration=True,
touches_shared=True,
)
s2 = _Sib(uuid4(), priority=2, sequence=1, assigned_to="be-dev-1") # unsurfaced
s3 = _Sib(
uuid4(),
priority=3,
sequence=2,
assigned_to="be-dev-1",
adds_migration=True,
touches_shared=False,
)
edges = dev_task_collision_edges([s1, s2, s3])
assert not _has_cycle(edges)
assert (s3.id, s1.id) in edges # authoritative analyzer edge preserved
assert (s2.id, s3.id) not in edges # the cycling fallback edge is dropped
# ---------------------------------------------------------------------------
# cell_task_wave_chain_depends_on — the cell-task wave chain (edge kind 2).
# Pure glue: a new cell-task under root-subtask UT_n depends on every cell-task
# under every root-subtask UT_n itself depends on (the kind-1 wave-chain edges).
# ---------------------------------------------------------------------------
def test_wave_chain_collects_all_predecessor_cell_tasks() -> None:
# Two predecessor root-subtasks: one fans to two cell-tasks, the other to one.
ct_a1, ct_a2, ct_b1 = _Sib(uuid4()), _Sib(uuid4()), _Sib(uuid4())
root_a, root_b = object(), object()
deps = cell_task_wave_chain_depends_on(
[root_a, root_b], {root_a: [ct_a1, ct_a2], root_b: [ct_b1]}
)
assert set(deps) == {ct_a1.id, ct_a2.id, ct_b1.id}
def test_wave_chain_empty_when_no_predecessor_roots() -> None:
assert cell_task_wave_chain_depends_on([], {}) == []
def test_wave_chain_skips_root_with_no_cell_tasks() -> None:
root = object()
assert cell_task_wave_chain_depends_on([root], {root: []}) == []
# A predecessor root absent from the map contributes nothing (no KeyError).
assert cell_task_wave_chain_depends_on([object()], {}) == []
def test_wave_chain_preserves_predecessor_order() -> None:
# Edges are appended in predecessor-root order then cell-task order — stable
# so add_dependency (which dedupes) sees a deterministic sequence.
ct_a, ct_b = _Sib(uuid4()), _Sib(uuid4())
root_a, root_b = object(), object()
deps = cell_task_wave_chain_depends_on(
[root_a, root_b], {root_a: [ct_a], root_b: [ct_b]}
)
assert deps == [ct_a.id, ct_b.id]
# ---------------------------------------------------------------------------
# by_osmosis_tail_dev_tasks — the by-osmosis edge (edge kind 4).
# Pure glue: the first dev task of a cell-task depends on each predecessor
# cell-task's tail (highest-sequence) dev task. Only sequence 0 carries it.
# ---------------------------------------------------------------------------
def test_by_osmosis_skips_non_first_dev_task() -> None:
tail = _Sib(uuid4(), sequence=2)
# is_first_dev_task=False -> no edges, regardless of predecessor groups.
assert by_osmosis_tail_dev_tasks(False, [[tail]]) == []
def test_by_osmosis_picks_max_sequence_per_group() -> None:
t0 = _Sib(uuid4(), sequence=0)
t1 = _Sib(uuid4(), sequence=1)
t2 = _Sib(uuid4(), sequence=2)
assert by_osmosis_tail_dev_tasks(True, [[t0, t1, t2]]) == [t2.id]
def test_by_osmosis_one_tail_per_predecessor_group() -> None:
a_tail = _Sib(uuid4(), sequence=2)
b_tail = _Sib(uuid4(), sequence=4)
a_group = [_Sib(uuid4(), sequence=0), _Sib(uuid4(), sequence=1), a_tail]
b_group = [_Sib(uuid4(), sequence=3), b_tail]
assert by_osmosis_tail_dev_tasks(True, [a_group, b_group]) == [a_tail.id, b_tail.id]
def test_by_osmosis_skips_empty_predecessor_group() -> None:
# A predecessor cell-task with no dev tasks contributes no edge.
tail = _Sib(uuid4(), sequence=1)
assert by_osmosis_tail_dev_tasks(True, [[], [tail]]) == [tail.id]
def test_by_osmosis_no_edges_when_no_predecessor_groups() -> None:
assert by_osmosis_tail_dev_tasks(True, []) == []
# ---------------------------------------------------------------------------
# Declared dependencies (B1b — the CEO's "Depends on" lists become real edges)
# ---------------------------------------------------------------------------
# Live break (S6, 2026-07-01): the draft declared depends-on S1+R2+R3 but only
# the analyzer's file-overlap edges were wired, so S6 started 90s after
# still-running R3. Declared edges are authoritative; derived edges remain the
# safety net — analyze() takes the union.
def test_declared_dependency_creates_edge_between_disjoint_surfaces() -> None:
s = [
DraftSurface(0, 1, ["a/x.py"], False, False),
DraftSurface(1, 1, ["b/y.py"], False, False, declared_depends_on=(0,)),
]
plan = SequencingService().analyze(s, _backend, {"backend": 2})
assert (0, 1) in plan.edges
assert _wave_of(plan.waves, 0) < _wave_of(plan.waves, 1)
def test_declared_union_with_derived_dedupes() -> None:
# Overlap already derives (0, 1) (idx 0 more important); declaring it too
# must not duplicate the edge.
s = [
DraftSurface(0, 1, ["svc/threats.py"], False, False),
DraftSurface(1, 2, ["svc/threats.py"], False, False, declared_depends_on=(0,)),
]
plan = SequencingService().analyze(s, _backend, {"backend": 2})
assert plan.edges.count((0, 1)) == 1
def test_declared_out_of_range_rejected() -> None:
s = [
DraftSurface(0, 1, ["a/x.py"], False, False, declared_depends_on=(7,)),
]
with pytest.raises(SequencingError):
SequencingService().analyze(s, _backend, {"backend": 2})
def test_declared_self_dependency_rejected() -> None:
s = [
DraftSurface(0, 1, ["a/x.py"], False, False, declared_depends_on=(0,)),
]
with pytest.raises(SequencingError):
SequencingService().analyze(s, _backend, {"backend": 2})
def test_declared_cycle_rejected() -> None:
s = [
DraftSurface(0, 1, ["a/x.py"], False, False, declared_depends_on=(1,)),
DraftSurface(1, 1, ["b/y.py"], False, False, declared_depends_on=(0,)),
]
with pytest.raises(SequencingError):
SequencingService().analyze(s, _backend, {"backend": 2})