feat(dispatch): per-dev sequenced queues for code subtasks (guardrails spec 3)

True two-dev parallelism: a cell PM delegates the FULL set of code units up
front — each dev gets its own queue, both build at the same time, each works
its queue one task at a time in order. Replaces the old ceiling (≤2 code
subtasks per parent, one per dev) which structurally forced under-decomposition.

- Cap: `code` removed from `_SPINE_TYPE_CAPS` — no per-parent code cap (total
  fan-out still bounded by `_SUBTASK_HARD_CAP=12`); `planning`/`documentation`
  stay sequential at 1. `_same_assignee_rejection` exempts `code` so a dev may
  own a queue, but still rejects an exact same-title duplicate (the accidental
  re-delegation bug). `_spine_type_dup_envelope` simplified to the sequential
  spine it now only serves.
- Dispatch barrier: `_blocked_by_earlier_lane_sibling` holds a dev's
  higher-sequence pending code leaf while it still has an earlier non-terminal
  code sibling under the same parent (keyed on assignee, gates only code) — the
  dev works its queue in order. Wired into `_spawn_pending_dev`. Loop-free
  (skip the tick, no reject/respawn) and best-effort (lookup failure → dispatch),
  mirroring the existing merge barrier. The merge barrier is unchanged: leaf
  PRs still merge serially in sequence order into the shared cell branch, so the
  independent build lanes never wedge it.
- Prompt: cell_pm role guidance rewritten from the two-subtask-cap model to the
  per-dev-queue model (delegate all units now; dependent units go in one dev's
  queue, upstream first).

Independent per-dev queues (each lane advances at its own pace) rather than
strict cross-dev wave-sync, by design — more parallel and leaves the
wedge-prone merge barrier untouched. Pairs with the spec-2 idle coverage gate:
removing the code cap lets a PM claim every criterion up front, so that gate is
always satisfiable.
This commit is contained in:
Renn F
2026-06-16 04:10:05 +02:00
parent 1fb723174a
commit e209e285b8
5 changed files with 398 additions and 87 deletions
+17 -17
View File
@@ -49,7 +49,7 @@ When the briefing carries `company_goals`, let the charter guide how you scope a
| `pending` (assigned to you) | `evidence(task_id)` to read scope → `note(scope='decision', ...)``i_will_plan(task_id, plan='...')` |
| `claimed` (your prior claim is intact) | `i_will_plan(task_id, plan='resume: <next step>')` — composes claim+set_plan+start; resumes from `claimed`. **Never `resume` (paused-only), `delegate` (rejected on claimed), `complete`, `escalate_*`, or `unblock` on a claimed task.** |
| `in_progress` (just claimed, no children yet) | `open_session(task_id, channel, topic="<one-line>", relationship_type="discussion")` — populates the Sessions tab — then `delegate(parent_task_id, ...)` per sub_task in your plan |
| `in_progress`, no children yet | `delegate(parent_task_id=task_id, ...)` — one subtask per independent unit; where the work splits, delegate to BOTH devs so they build in parallel |
| `in_progress`, no children yet | `delegate(parent_task_id=task_id, ...)` — one subtask per independent unit; split the units across BOTH devs and delegate the full queue now (each dev works its queue in order, both build in parallel) |
| `in_progress`, children exist and active | `i_am_idle()` — closure dispatcher will respawn you when a child needs review or all children terminal |
| `in_progress`, all children terminal | `note(scope='decision', ...)``submit_up(task_id, notes='...')` |
| `blocked` — waiting on a dependency (another cell's work upstream) | **Wait. Do not escalate.** A dependency block clears itself the moment the upstream task completes — the orchestrator revives you then. Optionally `note(scope='note', text='waiting on <upstream>')`, then `i_am_idle()`. A dependency wait is normal sequencing, NOT a problem to raise: do **not** `escalate_up`, `unblock`, or `notify` the CEO about it. |
@@ -70,13 +70,13 @@ When the briefing carries `company_goals`, let the charter guide how you scope a
## Workflow
0. **On every respawn, FIRST call `triage()`** to see what's already in your queue — new pending children, blocked subtasks needing unblock, awaiting_pm_review subtasks needing your merge. If anything is in flight from your previous respawn, deal with it BEFORE re-decomposing or re-delegating. The spine-type concurrency cap will block duplicate delegations anyway.
0. **On every respawn, FIRST call `triage()`** to see what's already in your queue — new pending children, blocked subtasks needing unblock, awaiting_pm_review subtasks needing your merge. If anything is in flight from your previous respawn, deal with it BEFORE re-decomposing or re-delegating. Same-title duplicate `code` delegations are rejected, but distinct queue items are not — so check existing children before adding more.
1. `evidence(task_id="<your-task>")` -> read the description, acceptance criteria, parent context, **the list of children that already exist**, and Main PM's journal entries to understand intent.
2. **If your task already has subtasks (any non-terminal child), do NOT delegate again.** You are being respawned to coordinate, not to re-decompose. Skip to step 7 (`i_am_idle` until a child needs you) or step 8 (review a child in `awaiting_pm_review`).
3. `note(scope='decision', task_id="<your-task>", text="<approach: which dev gets what, sequencing, risks, why this decomposition>")` — the decision note explains your delegation rationale to QA / Main PM / future agents reading the journal.
4. `i_will_plan(task_id="<your-task>", plan="<scope, subtasks, sequencing, risks>")` -> claims, branches, sets `in_progress`. **If your task is already in `claimed` state on respawn, call `i_will_plan` again — it resumes from claimed back into `in_progress`.**
5. `open_session(task_id, channel="<your-cell>", topic="<one-line about the task>")` — opens a discussion session linked to the task so future commentary surfaces in the panel's Sessions tab. If you skip this, the tab stays empty and PM/CEO can't see the conversation context.
6. `delegate(parent_task_id="<your-task>", assigned_to="<dev-slug-in-your-cell>", ...)`. **One dev subtask per independent unit and where the work genuinely splits, delegate to BOTH your devs so they build in parallel.** Your cell has two developers, and the inherited brief lists this cell's work as independently-shippable units. Each unit is one subtask that flows through the lifecycle as dev → QA → documenter → you (merge); the lifecycle engages those roles automatically, so you do NOT split a *single* unit into per-role subtasks (no "branch naming subtask", "PR workflow subtask", no "verification subtask" — QA *is* the verification step), and you do NOT work around a cap by re-delegating with a different `task_type` (e.g. `task_type='research'`/`'documentation'`) to sneak in an extra sibling. **You may keep up to two non-terminal `code` subtasks at once — one per dev**so when two units are independent (independent files, no shared state), delegate both now and both devs work at the same time. For **dependent** units (one needs the other to land first), delegate the upstream now and defer the downstream to a follow-on `delegate` after the upstream merges — record that deferral in your `decision` note (see Coverage below). When both devs are already busy, a third `code` subtask is capped: `i_am_idle()` and pick it up when a slot frees — do NOT re-delegate. A genuinely atomic change (one file, one behavior) stays one subtask; don't fake-split it just to occupy the second dev.
6. `delegate(parent_task_id="<your-task>", assigned_to="<dev-slug-in-your-cell>", ...)`. **One dev subtask per independent unit, and delegate the FULL set up front — give each of your two devs its own queue of `code` subtasks, not one task each.** Your cell has two developers, and the inherited brief lists this cell's work as independently-shippable units. If the cell has four units, hand be-dev-1 two of them and be-dev-2 the other two — all four delegated now. Each dev works its queue **one task at a time, in the order you delegated them**, and both devs build **at the same time**; the whole decomposition is visible from the start instead of dribbling out one task per respawn. Each unit flows through the lifecycle as dev → QA → documenter → you (merge); the lifecycle engages those roles automatically, so you do NOT split a *single* unit into per-role subtasks (no "branch naming subtask", "PR workflow subtask", no "verification subtask" — QA *is* the verification step), and you do NOT re-delegate with a different `task_type` (e.g. `task_type='research'`/`'documentation'`) to manufacture extra siblings. **There is no two-subtask cap on `code`**the only ceiling is 12 subtasks per parent. For **dependent** units (one must land before another), put them in the **same dev's queue in dependency order** (upstream first): that dev builds them in sequence, so the dependent one waits for the upstream automatically — no need to come back later. A genuinely atomic change (one file, one behavior) stays one subtask; don't fake-split it just to fill a queue.
### Delegation rules (READ THIS BEFORE YOU CALL `delegate` — it saves you wasted turns)
@@ -95,16 +95,15 @@ If you are the **UX cell PM**, `task_type='design'` is your designer's normal wo
**2. `documentation` is NOT delegatable — the lifecycle auto-creates it.** You delegate ONLY the `code` subtask. After it passes QA, the gateway transitions it to `awaiting_documentation` and **spawns a documenter for you automatically**. Do not create a separate `documentation` subtask or assign docs to a developer — such a subtask can never be spawned and becomes a permanent orphan that deadlocks `submit_up` (which requires all subtasks terminal). The reject message reads `task_type='documentation' subtasks are not PM-delegatable`.
**3. The `code` spine is capped at two per parent — one per cell dev.** The gateway allows up to TWO non-terminal `code` subtasks under a single parent, so both your developers can build independent units at the same time (`planning` and `documentation` stay capped at one). A second `code` subtask **to your other dev** is allowed — that is exactly how you parallelize. What's rejected is a second `code` subtask **to the same dev** (give each dev one at a time), or a THIRD while both are in flight (`parent already has 2 non-terminal task_type='code' subtask(s)`). When you hit the cap:
- **Do NOT** retry with a different `task_type` (`research`/`design`) to sneak an extra sibling past the cap — that creates orphans.
- The correct move is `i_am_idle()` — the closure dispatcher respawns you when an in-flight child needs review or completes, freeing a slot.
- For **dependent** units (one must land before the other), do not try to run them together: delegate the upstream now and defer the downstream to a follow-on `delegate` after the upstream merges.
**3. `code` has no per-parent cap — delegate the full per-dev queue up front.** Both your developers build at the same time, and each may hold a *queue* of `code` subtasks (`planning` and `documentation` stay capped at one). The orchestrator runs each dev's queue one task at a time, in delegation order, so you delegate ALL the units now rather than dribbling them out. What's still rejected: an exact-duplicate `code` subtask to the same dev (same title — an accidental re-delegation), and more than 12 subtasks total under one parent.
- **Do NOT** retry with a different `task_type` (`research`/`design`) to manufacture extra siblings — that creates orphans the lifecycle never spawns.
- For **dependent** units (one must land before the other), put them in the **same dev's queue in dependency order** — that dev builds the upstream first, then the dependent one. Do not assign a dependent pair across both devs expecting them to self-order.
### Sizing — split oversized subtasks (READ THIS BEFORE DELEGATING)
One subtask = one focused concern a single developer can finish and a single QA pass can verify. A subtask that carries a long acceptance list (more than ~5 criteria) or spans multiple concerns — several files/modules, more than one layer, or "and also…" scope — is too big: it drives multi-round QA failures and a PM revision loop, because QA can't pass a partial and the dev keeps re-touching unrelated parts.
When the work in front of you is that large, **decompose it into several smaller subtasks before delegating**, one per concern, each with its own 24 acceptance criteria and its own dev→QA pass. **Hand independent concerns to BOTH devs at once** (the code spine allows two in flight) so the cell delivers in parallel; for concerns where one must land before the next, delegate the upstream now and defer the dependent one to a follow-on delegate after it merges. Prefer several small subtasks that each pass QA once over one big subtask that fails QA four times. The only exception is a genuinely atomic change (a single file, a single behavior) — that stays one subtask.
When the work in front of you is that large, **decompose it into several smaller subtasks before delegating**, one per concern, each with its own 24 acceptance criteria and its own dev→QA pass. **Split the concerns across BOTH devs and delegate them all now** so the cell delivers in parallel — each dev gets a queue and works it in order; for concerns where one must land before the next, put both in the same dev's queue, upstream first. Prefer several small subtasks that each pass QA once over one big subtask that fails QA four times. The only exception is a genuinely atomic change (a single file, a single behavior) — that stays one subtask.
### How to write `acceptance_criteria` (READ THIS BEFORE DELEGATING)
@@ -134,14 +133,14 @@ Decomposition is where scope silently disappears. The failure mode: your cell-PM
**The rule: before you `i_am_idle()` after delegating, account for EVERY acceptance criterion on your cell-PM task.** Walk the list. For each criterion, name the subtask whose `acceptance_criteria` cover it. Three legal outcomes per criterion — and only three:
1. **Covered now** — a subtask you just delegated has an `acceptance_criteria` entry that satisfies it. Make the mapping **machine-explicit**: pass `covers_parent_criteria=[<criterion ids>]` on that `delegate` so the gateway records which of YOUR criteria the child owns. The criterion ids are in your briefing under `parent_ac_coverage` (each `{id, text, claimed, verified}`); the ones still without a home are listed in `unclaimed_parent_acs`. Phrase the child's criteria so a reader can also trace each back by eye.
2. **Covered later, in sequence** — it belongs to a follow-on subtask that is *gated behind* the current one (the spine cap means one `code` subtask at a time). Record the deferral in your `decision` note ("criterion 7 → second subtask after the first lands") so the deferral is intentional and visible, not forgotten.
2. **Covered later, in sequence** — it belongs to a follow-on subtask that runs after the current one. Delegate that follow-on **now too**, placed later in the same dev's queue (a dev can hold a queue), so the criterion is claimed immediately and simply builds in turn. Record the sequencing in your `decision` note ("criterion 7 → be-dev-1's second queue item, after the first lands") so the order is intentional and visible.
3. **Out of scope for your cell** — it genuinely belongs to another cell or the Main PM aggregate. Say so in the `decision` note. Do not silently drop it.
A criterion that fits none of the three is dropped scope — you under-decomposed. The fix is to widen a subtask's criteria or add a sequenced subtask, **before** idling. Never idle on a partial decomposition assuming you'll "remember the rest on respawn" — on respawn you'll see existing children and the anti-pattern rules will (correctly) stop you from re-decomposing, so the dropped criteria stay dropped. Map coverage now, while you still can.
This is the same discipline the `submit_up` checklist enforces at the end — pulled to the front, where a gap costs one extra `delegate` instead of a full cell revision loop.
**The gateway now backs this up.** Once you start declaring `covers_parent_criteria`, `i_am_idle()` is **rejected** while any of your criteria remain in `unclaimed_parent_acs` — the reject names the uncovered criteria, and the fix is one more `delegate` (covering them) or, for a sequenced follow-on, leave the upstream child live and idle once every criterion is claimed by *some* live subtask. Check `parent_ac_coverage` in the response after each `delegate`: when `unclaimed_parent_acs` is empty, your decomposition covers the task and you may idle. (Mapping coverage is opt-in by design — if you never pass `covers_parent_criteria`, the gate stays silent — but declaring it is the expected practice and the only way the cell self-checks for dropped scope.)
**The gateway now backs this up.** Once you start declaring `covers_parent_criteria`, `i_am_idle()` is **rejected** while any of your criteria remain in `unclaimed_parent_acs` — the reject names them, and the fix is one more `delegate` covering them. Because a dev can hold a queue, delegate every sequenced follow-on now too — each claims its criterion immediately and just builds in turn — so all criteria are claimed before you idle. Check `parent_ac_coverage` in the response after each `delegate`: when `unclaimed_parent_acs` is empty, your decomposition covers the task and you may idle. (Mapping coverage is opt-in by design — if you never pass `covers_parent_criteria`, the gate stays silent — but declaring it is the expected practice and the only way the cell self-checks for dropped scope.)
7. `i_am_idle()` -> wait. The orchestrator's closure dispatcher will respawn you when (a) a subtask reaches `awaiting_pm_review` for your review, or (b) all your subtasks are terminal and your task is ready to submit up.
8. On respawn for a subtask: `evidence(subtask_id)` -> review diff + dev's `reflect` note + QA's `learning` note + doc's commits -> `note(scope='decision', text='merge rationale')` -> `complete(subtask_id, notes=...)`. The leaf PR auto-merges into your cell branch.
9. On respawn after all subtasks terminal: `evidence(your_task_id)` -> read every child's journal aggregate -> `note(scope='reflect', text='<aggregate review: what landed, what's notable, any caveats>')` -> `note(scope='decision', text='submit-up rationale')` -> `submit_up(your_task_id, notes=...)`. Main PM takes over.
@@ -184,13 +183,14 @@ The PM journal is what makes the cell legible to Main PM and CEO. Skipping entri
- ❌ Calling `complete` on a parent task whose subtasks aren't all terminal. The gateway returns a `tracing_gap` envelope with `missing` containing `subtasks not all terminal`. Wait for the closure dispatcher to bring you back.
- ❌ Assigning a subtask to another cell's developer or to Main PM. Subtasks must go to a dev slug in YOUR cell. The gateway rejects cross-cell delegation chains.
- ❌ Calling `i_will_work_on` (that's a developer verb). Yours is `i_will_plan`.
-Concluding "I cannot delegate" after a cap rejection. With two `code`
subtasks already in flight (both devs busy) the gateway rejects a third
(`parent already has 2 non-terminal task_type='code' subtask(s)`) — that
means the cell is already at full parallel capacity, not that you failed.
Verify with `triage()`; if both dev subtasks are in flight, `i_am_idle()`
and let the chain progress. A second `code` subtask to your *other* dev,
however, is allowed — that's the parallel path, not a rejection.
-Dribbling out one `code` subtask per dev and idling. There is no
two-subtask cap — delegate each dev its full queue of units up front. The
orchestrator runs each dev's queue one at a time, in order, so the later
items wait their turn on their own; you do not hold them back manually.
- ❌ Re-delegating the *same* unit to the same dev. An exact same-title `code`
subtask to a dev that already owns one is rejected as an accidental
duplicate — distinct queue items (different titles) are exactly what you
want, but don't repeat one.
## Web research
+71
View File
@@ -6684,6 +6684,12 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
"""Validate and spawn a dev agent for a pending, pre-assigned task."""
if self._is_agent_active(agent_slug):
return
# Per-dev queue order: hold a dev's higher-sequence code leaf while it
# still has an earlier non-terminal code sibling under the same parent,
# so the dev works its queue one task at a time, in order. Loop-free —
# just not dispatched this tick.
if await self._blocked_by_earlier_lane_sibling(task):
return
validation_issue = await self._validate_task_for_spawn(client, task, agent_slug)
if validation_issue:
logger.warning(
@@ -7049,6 +7055,71 @@ Never `commit`, never write code, never run `git`. PMs coordinate.
return True
return False
async def _blocked_by_earlier_lane_sibling(self, task: dict[str, Any]) -> bool:
"""True if the SAME dev has an earlier non-terminal code sibling.
Per-dev sequenced queues (Spec 3): a PM delegates a full queue of code
subtasks to each cell dev up front. This BUILD/dispatch barrier holds a
dev's higher-sequence code leaf until its own lower-sequence code
siblings under the same parent are terminal, so the dev works its queue
one live task at a time, in order while the other dev's lane runs
concurrently (true two-dev parallelism).
Distinct from :meth:`_blocked_by_earlier_sibling` (the MERGE barrier,
keyed on team): this is keyed on the assignee and only gates ``code``.
Loop-free (skip this tick no reject, no respawn churn) and best-effort
(any lookup failure falls through to dispatch so the check never wedges).
"""
if str(task.get("task_type") or "") != "code":
return False
parent_id = task.get("parent_task_id")
seq = task.get("sequence")
owner = task.get("assigned_to") or task.get("claimed_by")
if not parent_id or seq is None or not owner:
return False
from uuid import UUID
from roboco.db.base import get_session_factory
from roboco.models.base import TaskStatus
from roboco.services.task import get_task_service
terminal = {TaskStatus.COMPLETED, TaskStatus.CANCELLED}
try:
session_factory = get_session_factory()
async with session_factory() as db:
task_svc = get_task_service(db)
siblings = await task_svc.get_subtasks(UUID(str(parent_id)))
except Exception as exc:
logger.debug(
"lane-order check failed; dispatching anyway",
task_id=task.get("id"),
error=str(exc),
)
return False
task_id = str(task.get("id"))
return any(
self._is_earlier_live_lane_sibling(
sib, task_id=task_id, owner=str(owner), seq=seq, terminal=terminal
)
for sib in siblings
)
@staticmethod
def _is_earlier_live_lane_sibling(
sib: Any, *, task_id: str, owner: str, seq: int, terminal: set[Any]
) -> bool:
"""True if ``sib`` is a lower-sequence non-terminal code task for ``owner``."""
if str(sib.id) == task_id:
return False
sib_type = getattr(sib, "task_type", None)
sib_type_val = getattr(sib_type, "value", sib_type)
return (
str(getattr(sib, "assigned_to", None)) == owner
and str(sib_type_val) == "code"
and (getattr(sib, "sequence", 0) or 0) < seq
and getattr(sib, "status", None) not in terminal
)
async def _dispatch_pm_review_work(self, client: httpx.AsyncClient) -> None:
"""
Dispatch PM review work to cell PMs or Main PM.
+71 -52
View File
@@ -3373,11 +3373,14 @@ class Choreographer:
)
_TERMINAL_STATUSES: ClassVar[frozenset[str]] = frozenset({"completed", "cancelled"})
# Per-parent concurrency cap by spine task_type. `code` is capped at the
# number of developers in a cell (2) so both can build independent units in
# parallel; `planning` and `documentation` stay sequential (one at a time).
# Per-parent concurrency cap by spine task_type. `planning` and
# `documentation` stay sequential (one non-terminal at a time). `code` has
# NO per-parent cap: a PM delegates a full per-dev queue up front (Spec 3
# per-dev sequenced queues) — both cell devs build in parallel, each works
# its own queue in sequence order, and the orchestrator's per-lane dispatch
# barrier (`_blocked_by_earlier_lane_sibling`) keeps a dev to one live task
# at a time. Total fan-out is still bounded by `_SUBTASK_HARD_CAP`.
_SPINE_TYPE_CAPS: ClassVar[dict[str, int]] = {
"code": 2,
"planning": 1,
"documentation": 1,
}
@@ -3450,38 +3453,26 @@ class Choreographer:
def _spine_type_dup_envelope(
new_type: str, sibling: Any, sib_assignee: str, cap: int = 1
) -> Envelope:
"""Rule-1 rejection: spine-type concurrency cap hit."""
capacity = (
f"The {new_type!r} spine is sequential — only one non-terminal at a time."
if cap == 1
else (
f"The {new_type!r} spine is capped at {cap} concurrent "
f"(one per cell developer) and both are already in flight."
)
)
parallel_hint = (
"If the work is genuinely parallel (two independent modules), "
"split this parent into two sibling parents instead of two code "
"subtasks under one parent.\n\n"
if cap == 1
else (
f"You may run up to {cap} code subtasks at once (one per dev) "
"when they touch independent files; a further one must wait for "
"an in-flight sibling to finish.\n\n"
)
)
"""Rule-1 rejection: a sequential spine (planning/documentation) is full.
Only ``planning`` and ``documentation`` are cap-limited now (1 at a
time); ``code`` has no per-parent cap (Spec 3 per-dev queues), so this
only ever fires for the sequential spines. ``cap`` is kept for the
message but is 1 in every live call.
"""
return Envelope.invalid_state(
message=(
f"parent already has {cap} non-terminal "
f"task_type={new_type!r} subtask(s) "
f"(e.g. {sibling.id}, assigned_to={sib_assignee!r}, "
f"status={sibling.status}). {capacity}"
f"status={sibling.status}). The {new_type!r} spine is "
"sequential — only one non-terminal at a time."
),
remediate=(
"Drive an existing sibling to completion / cancel it before "
"delegating another of the same type. "
+ parallel_hint
+ "**DO NOT work around this by delegating again with a "
"delegating another of the same type. If the work is genuinely "
"parallel, split this parent into two sibling parents instead.\n\n"
"**DO NOT work around this by delegating again with a "
"different task_type** (e.g. 'documentation' or "
"'research' as a 'verification' subtask). The lifecycle "
"handles QA, documentation, and PM-review automatically "
@@ -3532,18 +3523,22 @@ class Choreographer:
new_type: str,
new_team: str,
new_assignee: str,
new_title: str = "",
) -> Envelope | None:
"""Apply Rule-2 (same-assignee) then Rule-1 (spine concurrency cap).
Rule 2: a PM never gives one agent two non-terminal subtasks of the
same type under one parent.
Rule 2: prevent accidental duplicate delegation to one agent — for
``planning`` / ``documentation`` no two same-type subtasks per parent;
for ``code`` only an exact same-title repeat (a dev may own a sequenced
queue otherwise).
Rule 1: a parent may hold at most ``_SPINE_TYPE_CAPS[type]`` non-terminal
subtasks of a spine type. ``code`` is capped at 2 (one per cell dev) so
both developers can build independent units in parallel; ``planning``
and ``documentation`` stay at 1. ``planning`` on a different team does
not count toward the cap — that's main_pm's legitimate cross-cell fanout
(see :meth:`_is_cross_team_planning`).
subtasks of a spine type. ``code`` has no per-parent cap (a full per-dev
queue is delegated up front; the per-lane dispatch barrier and
``_SUBTASK_HARD_CAP`` bound it); ``planning`` and ``documentation`` stay
at 1. ``planning`` on a different team does not count toward the cap —
that's main_pm's legitimate cross-cell fanout (see
:meth:`_is_cross_team_planning`).
"""
live = [
s
@@ -3551,22 +3546,44 @@ class Choreographer:
if str(getattr(s, "status", "")) not in cls._TERMINAL_STATUSES
]
return cls._same_assignee_rejection(
live, new_type, new_assignee
live, new_type, new_assignee, new_title
) or cls._spine_cap_rejection(live, new_type, new_team)
@staticmethod
def _norm_title(title: str) -> str:
"""Lower-case, whitespace-collapsed title for duplicate comparison."""
return " ".join((title or "").lower().split())
@classmethod
def _same_assignee_rejection(
cls, live: list[Any], new_type: str, new_assignee: str
cls, live: list[Any], new_type: str, new_assignee: str, new_title: str = ""
) -> Envelope | None:
"""Rule 2: a PM never gives one agent two same-type subtasks per parent."""
"""Rule 2: prevent accidental duplicate delegation to one agent.
For ``planning`` / ``documentation`` (sequential spines) a PM never
gives one agent two non-terminal subtasks of the same type under one
parent. For ``code`` a dev legitimately owns a *queue* of sequenced
subtasks (Spec 3 per-dev parallelism), so same-type alone is allowed —
only an exact same-title duplicate (the accidental re-delegation bug,
e.g. "Cell PM created two code tasks for be-dev-1") is rejected.
"""
if not new_assignee:
return None
norm_new = cls._norm_title(new_title)
for sibling in live:
if (
str(getattr(sibling, "assigned_to", "") or "") == new_assignee
and str(getattr(sibling, "task_type", "")) == new_type
):
return cls._same_assignee_dup_envelope(new_type, new_assignee, sibling)
if str(getattr(sibling, "assigned_to", "") or "") != new_assignee:
continue
if str(getattr(sibling, "task_type", "")) != new_type:
continue
if new_type == "code":
# A distinct queue item is fine; an exact title repeat is not.
sib_title = cls._norm_title(str(getattr(sibling, "title", "") or ""))
if norm_new and sib_title == norm_new:
return cls._same_assignee_dup_envelope(
new_type, new_assignee, sibling
)
continue
return cls._same_assignee_dup_envelope(new_type, new_assignee, sibling)
return None
@classmethod
@@ -3599,18 +3616,19 @@ class Choreographer:
) -> Envelope | None:
"""Block PM-decomposition over-spread (the smoke-run runaway pattern).
Two rules, both rooted in bounded per-parent concurrency:
Two rules:
1. **Same-type concurrency cap**: a parent may hold at most
``_SPINE_TYPE_CAPS[type]`` non-terminal subtasks of a spine type —
``code`` is capped at 2 (one per cell developer, so both build
independent units in parallel); ``planning`` and ``documentation``
stay at 1. Exception: ``planning`` subtasks on different teams do
not count toward the cap — that's main_pm's legitimate cross-cell
fanout.
2. **Same-assignee same-type** (fallback): a PM never delegates two
subtasks of the same type to the same agent under the same parent
(so the two parallel ``code`` slots always go to different devs).
``planning`` and ``documentation`` stay at 1. ``code`` has no
per-parent cap (a full per-dev sequenced queue is delegated up front;
concurrency is bounded by the per-lane dispatch barrier and
``_SUBTASK_HARD_CAP``). Exception: ``planning`` on a different team
does not count — that's main_pm's legitimate cross-cell fanout.
2. **Same-assignee duplicate** (fallback): a PM never delegates two
``planning``/``documentation`` subtasks of the same type to one
agent under one parent; for ``code`` a dev may own a queue, so only
an exact same-title repeat is blocked (accidental re-delegation).
Both rules surface an existing sibling id so the PM can finish
or cancel it instead of guessing.
@@ -3621,6 +3639,7 @@ class Choreographer:
str(inputs.task_type or ""),
str(inputs.team or ""),
str(inputs.assigned_to or ""),
str(inputs.title or ""),
)
async def _delegate_static_guards(
+40 -18
View File
@@ -1,16 +1,16 @@
"""Spine-cap concurrency rules for delegated subtasks.
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).
- ``code``: no per-parent cap. A PM delegates a full per-dev queue up front
(per-dev sequenced queues); both cell devs build in parallel and each works
its own queue one task at a time (enforced by the orchestrator's per-lane
dispatch barrier, not this cap). Total fan-out is bounded by the 12-subtask
hard cap. Only an exact same-title duplicate to one dev is rejected here.
- ``planning`` / ``documentation``: 1.
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).
"""
from __future__ import annotations
@@ -26,6 +26,7 @@ def _sibling(
team: str,
status: str = "pending",
assignee: str = "some-pm",
title: str = "some unit",
) -> MagicMock:
sib = MagicMock()
sib.id = "11111111-aaaa-bbbb-cccc-dddddddddddd"
@@ -33,6 +34,7 @@ def _sibling(
sib.task_type = task_type
sib.team = team
sib.assigned_to = assignee
sib.title = title
return sib
@@ -91,36 +93,56 @@ def test_one_code_sibling_allows_a_second_parallel_dev() -> None:
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."""
def test_third_code_subtask_is_allowed_queue() -> None:
"""No per-parent code cap: a third (and beyond) code subtask is allowed so a
PM can delegate a full per-dev queue up front. Concurrency is bounded by the
per-lane dispatch barrier, not by rejecting the delegate."""
sibs = [
_sibling(task_type="code", team="backend", assignee="be-dev-1"),
_sibling(task_type="code", team="backend", assignee="be-dev-2"),
_sibling(task_type="code", team="backend", assignee="be-dev-1", title="u1"),
_sibling(task_type="code", team="backend", assignee="be-dev-2", title="u2"),
]
env = Choreographer._sibling_cap_envelope(
siblings=sibs,
new_type="code",
new_team="backend",
new_assignee="be-dev-1",
new_title="u3",
)
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state", body
assert env is None, f"A third code subtask (queue) must be allowed. Got: {env}"
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")
def test_second_distinct_code_to_same_dev_is_allowed_queue() -> None:
"""A dev legitimately owns a QUEUE — a second code subtask to the same dev
with a DISTINCT title is allowed (it's the dev's next queue item)."""
sib = _sibling(
task_type="code", team="backend", assignee="be-dev-1", title="add login form"
)
env = Choreographer._sibling_cap_envelope(
siblings=[sib],
new_type="code",
new_team="backend",
new_assignee="be-dev-1",
new_title="add logout button",
)
assert env is None, f"A distinct queue item for the same dev must pass. Got: {env}"
def test_exact_duplicate_code_to_same_dev_is_rejected() -> None:
"""An exact same-title code subtask to a dev that already owns one is an
accidental re-delegation rejected even though queues are allowed.
Title match is case/whitespace-insensitive."""
sib = _sibling(
task_type="code", team="backend", assignee="be-dev-1", title="Add Login Form"
)
env = Choreographer._sibling_cap_envelope(
siblings=[sib],
new_type="code",
new_team="backend",
new_assignee="be-dev-1",
new_title="add login form",
)
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state", body
assert env.as_dict()["error"] == "invalid_state", env
def test_documentation_still_capped_at_one() -> None:
@@ -0,0 +1,199 @@
"""Per-dev sequenced queues: a dev works its own code queue one task at a time.
A PM delegates a full per-dev queue of `code` subtasks up front. The dispatch
barrier holds a dev's higher-sequence code leaf until its own lower-sequence
code siblings under the same parent are terminal, so the dev works its queue in
order while the OTHER dev's lane runs concurrently (two-dev parallelism).
Keyed on the assignee (not the team like the merge barrier) and gates only
`code`. Loop-free (not dispatched, not rejected); best-effort on lookup failure.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.models.base import TaskStatus
from roboco.runtime.orchestrator import AgentOrchestrator
def _new_orchestrator() -> AgentOrchestrator:
return AgentOrchestrator.__new__(AgentOrchestrator)
def _sibling(
seq: int,
owner: str,
status: TaskStatus,
*,
task_type: str = "code",
) -> MagicMock:
return MagicMock(
id=uuid4(),
sequence=seq,
assigned_to=owner,
status=status,
task_type=task_type,
)
def _patch_siblings(siblings: list[MagicMock]) -> Any:
"""Patch the orchestrator's direct-DB sibling lookup to return ``siblings``."""
svc = MagicMock()
svc.get_subtasks = AsyncMock(return_value=siblings)
class _CM:
async def __aenter__(self) -> MagicMock:
return MagicMock()
async def __aexit__(self, *_a: Any) -> bool:
return False
factory = MagicMock(return_value=_CM())
return (
patch("roboco.db.base.get_session_factory", return_value=factory),
patch("roboco.services.task.get_task_service", return_value=svc),
)
def _task(seq: int, owner: str, *, task_type: str = "code") -> dict[str, Any]:
return {
"id": str(uuid4()),
"parent_task_id": str(uuid4()),
"sequence": seq,
"assigned_to": owner,
"task_type": task_type,
}
@pytest.mark.asyncio
async def test_blocks_when_same_dev_has_earlier_active_code_sibling() -> None:
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
siblings = [_sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is True
@pytest.mark.asyncio
async def test_not_blocked_when_earlier_same_dev_sibling_terminal() -> None:
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
siblings = [
_sibling(0, "be-dev-1", TaskStatus.COMPLETED),
_sibling(0, "be-dev-1", TaskStatus.CANCELLED),
]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_other_devs_earlier_sibling_does_not_block() -> None:
"""The whole point of two-dev parallelism: be-dev-2's in-flight wave-0 leaf
must NOT hold be-dev-1's own wave-0 leaf. Lanes are independent."""
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
siblings = [_sibling(0, "be-dev-2", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_earlier_non_code_sibling_does_not_block() -> None:
"""Only the code queue is gated this way; a planning/doc sibling is irrelevant."""
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
siblings = [_sibling(0, "be-dev-1", TaskStatus.IN_PROGRESS, task_type="planning")]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_higher_sequence_same_dev_sibling_does_not_block() -> None:
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
siblings = [_sibling(1, "be-dev-1", TaskStatus.IN_PROGRESS)]
p1, p2 = _patch_siblings(siblings)
with p1, p2:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_non_code_task_is_never_gated_without_db() -> None:
orch = _new_orchestrator()
# A planning/doc task short-circuits before any lookup.
task = _task(1, "fe-pm", task_type="planning")
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_no_parent_or_owner_short_circuits_without_db() -> None:
orch = _new_orchestrator()
assert (
await orch._blocked_by_earlier_lane_sibling(
{"id": str(uuid4()), "sequence": 0, "task_type": "code"}
)
is False
)
@pytest.mark.asyncio
async def test_db_failure_falls_through_to_dispatch() -> None:
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
boom = patch(
"roboco.db.base.get_session_factory", side_effect=RuntimeError("db down")
)
with boom:
assert await orch._blocked_by_earlier_lane_sibling(task) is False
@pytest.mark.asyncio
async def test_spawn_pending_dev_holds_gated_lane_before_validating(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""_spawn_pending_dev must short-circuit a gated lane before validating or
spawning the dev's earlier queue item is still live."""
orch = _new_orchestrator()
task = _task(1, "be-dev-1")
spawn = AsyncMock()
validate = AsyncMock()
monkeypatch.setattr(orch, "_is_agent_active", MagicMock(return_value=False))
monkeypatch.setattr(
orch, "_blocked_by_earlier_lane_sibling", AsyncMock(return_value=True)
)
monkeypatch.setattr(orch, "_validate_task_for_spawn", validate)
monkeypatch.setattr(orch, "spawn_agent", spawn)
await orch._spawn_pending_dev(cast("Any", MagicMock()), task, "be-dev-1")
spawn.assert_not_awaited()
validate.assert_not_awaited()
@pytest.mark.asyncio
async def test_spawn_pending_dev_proceeds_when_lane_clear(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the lane is clear (no earlier sibling), the dev is spawned normally."""
orch = _new_orchestrator()
task = _task(0, "be-dev-1")
spawn = AsyncMock()
monkeypatch.setattr(orch, "_is_agent_active", MagicMock(return_value=False))
monkeypatch.setattr(
orch, "_blocked_by_earlier_lane_sibling", AsyncMock(return_value=False)
)
monkeypatch.setattr(orch, "_validate_task_for_spawn", AsyncMock(return_value=None))
monkeypatch.setattr(orch, "spawn_agent", spawn)
monkeypatch.setattr(orch, "_get_prompt_for_agent", MagicMock(return_value="prompt"))
monkeypatch.setattr(orch, "_task_git_context", MagicMock(return_value={}))
await orch._spawn_pending_dev(cast("Any", MagicMock()), task, "be-dev-1")
spawn.assert_awaited_once()