fix(gateway): let a dev idle past lane-held code-queue siblings

Per-dev sequenced queues (the prior commit) have a PM delegate a dev's whole
code queue up front, so a dev owns several pending, assigned-but-unclaimed code
leaves at once (seq0 + seq2). The orchestrator's lane barrier holds the seq2
SPAWN while seq0 is non-terminal — but _pending_assignment_guard rejected
i_am_idle for ANY pending assigned task, with no lane awareness. So a dev whose
current leaf just moved to QA (awaiting_qa) could neither idle (guard rejects)
nor proceed cleanly: it was steered to claim seq2 early (the claim path has no
lane/sequence check, since delegate sets `sequence` not `dependency_ids`),
jumping its own queue order, or it looped on the rejection. An adversarial
review of the queue work surfaced this; it is latent until PMs actually
delegate multi-item per-dev queues, so the green suite hid it.

Fix: TaskService.has_earlier_incomplete_code_sibling mirrors the orchestrator's
lane barrier in the service layer; _pending_assignment_guard now drops a dev's
lane-held pending code leaves (via _pending_blocking_idle / _pending_not_lane_held)
so the dev idles cleanly and the orchestrator spawns the next queue item when
the lane clears — preserving one-leaf-at-a-time, in order. `is not True` keeps
it inert under partial test mocks. Tests cover the service primitive (live /
terminal / higher-seq / non-code / missing-field) and the guard (dev idles when
lane-held; still blocks a non-lane-held pending leaf). Full mypy + xenon green.
This commit is contained in:
Renn F
2026-06-16 05:13:00 +02:00
parent e66a79a8ec
commit de1336c74d
4 changed files with 185 additions and 7 deletions
+37 -7
View File
@@ -2928,13 +2928,8 @@ class Choreographer:
if not pending:
return None
agent = await self.task.agent_for(agent_id)
# Board/advisory roles (product_owner, head_marketing, auditor) review
# and advise without ever claiming — they have no i_will_work_on /
# i_will_plan verb. Their one-shot board dispatch is meant to leave the
# coordination task pending for the CEO to reassign to Main PM, so they
# must be allowed to idle after recording their review. Without this
# they wedge: the gate would demand a claim verb the role does not have.
if agent and agent.role in ("product_owner", "head_marketing", "auditor"):
pending = await self._pending_blocking_idle(agent, pending)
if not pending:
return None
first = pending[0]
verb = (
@@ -2954,6 +2949,41 @@ class Choreographer:
context_briefing=briefing,
)
async def _pending_blocking_idle(self, agent: Any, pending: list[Any]) -> list[Any]:
"""Pending tasks that should block i_am_idle, after role exemptions.
Board/advisory roles (product_owner, head_marketing, auditor) review and
advise without ever claiming — they have no i_will_work_on / i_will_plan
verb, and their one-shot dispatch is meant to leave the coordination task
pending for the CEO to reassign. They must idle freely, so nothing blocks
them. Developers own a whole per-dev code queue up front, so a leaf still
waiting behind an earlier non-terminal sibling in its own lane must not
pin them — the orchestrator spawns it when the lane clears (see
``_pending_not_lane_held``). Other roles: every pending task blocks.
"""
if not agent:
return pending
if agent.role in ("product_owner", "head_marketing", "auditor"):
return []
if agent.role == "developer":
return await self._pending_not_lane_held(pending)
return pending
async def _pending_not_lane_held(self, pending: list[Any]) -> list[Any]:
"""Drop a dev's pending code leaves that are waiting behind an earlier
non-terminal sibling in the same dev's lane (per-dev sequenced queues).
Those leaves are spawned by the orchestrator when the lane clears, so
they must not pin the dev to idle. ``is not True`` keeps this inert under
partial test mocks (an AsyncMock returns a truthy stub, not a real bool)
— only a leaf the service positively confirms is lane-held is dropped.
"""
live: list[Any] = []
for t in pending:
if await self.task.has_earlier_incomplete_code_sibling(t) is not True:
live.append(t)
return live
async def _pm_unfinished_review_guard(
self, agent_id: UUID, briefing: dict[str, Any]
) -> Envelope | None: