2026-06-14 04:34:02 +02:00
|
|
|
"""Spine-cap concurrency rules for delegated subtasks.
|
2026-05-15 08:21:08 +02:00
|
|
|
|
2026-06-14 04:34:02 +02:00
|
|
|
The per-parent cap is type-aware (`_SPINE_TYPE_CAPS`):
|
|
|
|
|
- ``code``: 2 — one per cell developer, so both build independent units in
|
|
|
|
|
parallel. A third concurrent code subtask is rejected; a second code
|
|
|
|
|
subtask to the SAME developer is rejected (same-assignee rule).
|
|
|
|
|
- ``planning`` / ``documentation``: 1.
|
2026-05-15 08:21:08 +02:00
|
|
|
|
2026-06-14 04:34:02 +02:00
|
|
|
Cross-team exemption:
|
|
|
|
|
- ``planning`` on a different team does NOT count toward the cap — that's
|
|
|
|
|
main_pm's legitimate cross-cell fanout (be-pm + fe-pm + ux-pm in parallel).
|
|
|
|
|
- ``code`` / ``documentation`` count regardless of team (a parent's code
|
|
|
|
|
spine is bounded by the two devs in its one cell).
|
2026-05-15 08:21:08 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
|
|
|
|
from roboco.services.gateway.choreographer._impl import Choreographer
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 04:34:02 +02:00
|
|
|
def _sibling(
|
|
|
|
|
*,
|
|
|
|
|
task_type: str,
|
|
|
|
|
team: str,
|
|
|
|
|
status: str = "pending",
|
|
|
|
|
assignee: str = "some-pm",
|
|
|
|
|
) -> MagicMock:
|
2026-05-15 08:21:08 +02:00
|
|
|
sib = MagicMock()
|
|
|
|
|
sib.id = "11111111-aaaa-bbbb-cccc-dddddddddddd"
|
|
|
|
|
sib.status = status
|
|
|
|
|
sib.task_type = task_type
|
|
|
|
|
sib.team = team
|
2026-06-14 04:34:02 +02:00
|
|
|
sib.assigned_to = assignee
|
2026-05-15 08:21:08 +02:00
|
|
|
return sib
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cross_team_planning_fanout_is_allowed() -> None:
|
|
|
|
|
"""main-pm: planning→be-pm (backend) exists; planning→fe-pm (frontend) must pass."""
|
2026-06-14 04:34:02 +02:00
|
|
|
sib = _sibling(task_type="planning", team="backend", assignee="be-pm")
|
|
|
|
|
env = Choreographer._sibling_cap_envelope(
|
|
|
|
|
siblings=[sib],
|
2026-05-15 08:21:08 +02:00
|
|
|
new_type="planning",
|
|
|
|
|
new_team="frontend",
|
|
|
|
|
new_assignee="fe-pm",
|
|
|
|
|
)
|
|
|
|
|
assert env is None, (
|
|
|
|
|
"Cross-team planning fanout (backend↔frontend) must NOT be rejected. "
|
|
|
|
|
f"Got envelope: {env}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_cross_team_planning_third_cell_also_allowed() -> None:
|
|
|
|
|
"""Third cell (ux_ui) is also a valid cross-team planning fanout target."""
|
2026-06-14 04:34:02 +02:00
|
|
|
sib = _sibling(task_type="planning", team="backend", assignee="be-pm")
|
|
|
|
|
env = Choreographer._sibling_cap_envelope(
|
|
|
|
|
siblings=[sib],
|
2026-05-15 08:21:08 +02:00
|
|
|
new_type="planning",
|
|
|
|
|
new_team="ux_ui",
|
|
|
|
|
new_assignee="ux-pm",
|
|
|
|
|
)
|
|
|
|
|
assert env is None, env
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_same_team_planning_still_rejected() -> None:
|
2026-06-14 04:34:02 +02:00
|
|
|
"""Two planning subtasks on the SAME team is over-decomposition —
|
|
|
|
|
must still be blocked by the spine-cap (planning cap is 1)."""
|
|
|
|
|
sib = _sibling(task_type="planning", team="backend", assignee="be-pm")
|
|
|
|
|
env = Choreographer._sibling_cap_envelope(
|
|
|
|
|
siblings=[sib],
|
2026-05-15 08:21:08 +02:00
|
|
|
new_type="planning",
|
|
|
|
|
new_team="backend",
|
2026-06-14 04:34:02 +02:00
|
|
|
new_assignee="be-pm-2",
|
2026-05-15 08:21:08 +02:00
|
|
|
)
|
|
|
|
|
assert env is not None
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
assert body["error"] == "invalid_state", body
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 04:34:02 +02:00
|
|
|
def test_one_code_sibling_allows_a_second_parallel_dev() -> None:
|
|
|
|
|
"""Code cap is 2 — one in flight to be-dev-1 must NOT block a second to
|
|
|
|
|
be-dev-2. This is the two-devs-per-cell parallelism the cap exists to allow."""
|
|
|
|
|
sib = _sibling(task_type="code", team="backend", assignee="be-dev-1")
|
|
|
|
|
env = Choreographer._sibling_cap_envelope(
|
|
|
|
|
siblings=[sib],
|
2026-05-15 08:21:08 +02:00
|
|
|
new_type="code",
|
2026-06-14 04:34:02 +02:00
|
|
|
new_team="backend",
|
|
|
|
|
new_assignee="be-dev-2",
|
|
|
|
|
)
|
|
|
|
|
assert env is None, f"A second parallel code subtask must be allowed. Got: {env}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_two_code_siblings_reject_a_third() -> None:
|
|
|
|
|
"""Both cell devs busy (2 non-terminal code subtasks) → a third is capped."""
|
|
|
|
|
sibs = [
|
|
|
|
|
_sibling(task_type="code", team="backend", assignee="be-dev-1"),
|
|
|
|
|
_sibling(task_type="code", team="backend", assignee="be-dev-2"),
|
|
|
|
|
]
|
|
|
|
|
env = Choreographer._sibling_cap_envelope(
|
|
|
|
|
siblings=sibs,
|
|
|
|
|
new_type="code",
|
|
|
|
|
new_team="backend",
|
|
|
|
|
new_assignee="be-dev-1",
|
2026-05-15 08:21:08 +02:00
|
|
|
)
|
|
|
|
|
assert env is not None
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
assert body["error"] == "invalid_state", body
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 04:34:02 +02:00
|
|
|
def test_second_code_to_same_dev_is_rejected() -> None:
|
|
|
|
|
"""The same developer never holds two code subtasks under one parent
|
|
|
|
|
(same-assignee rule), even though the cap is 2."""
|
|
|
|
|
sib = _sibling(task_type="code", team="backend", assignee="be-dev-1")
|
|
|
|
|
env = Choreographer._sibling_cap_envelope(
|
|
|
|
|
siblings=[sib],
|
|
|
|
|
new_type="code",
|
|
|
|
|
new_team="backend",
|
|
|
|
|
new_assignee="be-dev-1",
|
|
|
|
|
)
|
|
|
|
|
assert env is not None
|
|
|
|
|
body = env.as_dict()
|
|
|
|
|
assert body["error"] == "invalid_state", body
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_documentation_still_capped_at_one() -> None:
|
|
|
|
|
"""Documentation subtasks stay capped at 1 regardless of team."""
|
|
|
|
|
sib = _sibling(task_type="documentation", team="backend", assignee="be-doc")
|
|
|
|
|
env = Choreographer._sibling_cap_envelope(
|
|
|
|
|
siblings=[sib],
|
2026-05-15 08:21:08 +02:00
|
|
|
new_type="documentation",
|
|
|
|
|
new_team="frontend",
|
|
|
|
|
new_assignee="fe-doc",
|
|
|
|
|
)
|
|
|
|
|
assert env is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_planning_with_missing_team_still_rejected() -> None:
|
2026-06-14 04:34:02 +02:00
|
|
|
"""If either side has no team (defensive), fall back to the strict cap —
|
|
|
|
|
don't let an empty-team escape hatch sneak a second planning past."""
|
|
|
|
|
sib_no_team = _sibling(task_type="planning", team="", assignee="be-pm")
|
|
|
|
|
env = Choreographer._sibling_cap_envelope(
|
|
|
|
|
siblings=[sib_no_team],
|
2026-05-15 08:21:08 +02:00
|
|
|
new_type="planning",
|
|
|
|
|
new_team="backend",
|
2026-06-14 04:34:02 +02:00
|
|
|
new_assignee="be-pm-2",
|
2026-05-15 08:21:08 +02:00
|
|
|
)
|
|
|
|
|
assert env is not None, "Empty team on sibling must NOT bypass the cap"
|
|
|
|
|
|
2026-06-14 04:34:02 +02:00
|
|
|
sib = _sibling(task_type="planning", team="backend", assignee="be-pm")
|
|
|
|
|
env2 = Choreographer._sibling_cap_envelope(
|
|
|
|
|
siblings=[sib],
|
2026-05-15 08:21:08 +02:00
|
|
|
new_type="planning",
|
|
|
|
|
new_team="",
|
2026-06-14 04:34:02 +02:00
|
|
|
new_assignee="be-pm-2",
|
2026-05-15 08:21:08 +02:00
|
|
|
)
|
|
|
|
|
assert env2 is not None, "Empty team on new task must NOT bypass the cap"
|