fix(dispatch): age-gate the creator-skip so an unassigned PM-routed task can't be skipped forever

_route_unassigned_pm_task's creator-skip guard (don't auto-claim a
task back to the PM that just created it — it is about to assign it
one tool-call later) had no aging: when the creator-PM's session
ended without assigning, the task was skipped every ~30s dispatch
tick forever. Live: a be-pm-created fix task sat pending-unassigned
for 21 hours with 'Skipping auto-claim: routing target is the
creator' firing every tick.

The skip now applies only while the task is younger than
_CREATOR_ROUTE_GRACE_SECONDS (600) — past grace (or on an
unparseable/missing created_at, via the existing fail-open
_get_task_age) routing proceeds: the creator-PM claims its own task
and triages/delegates on spawn, which is legal and unguarded for a
cell PM. Extracted _creator_route_should_skip to hold the xenon B
ceiling.

Known-separate class, deliberately untouched here: a code-typed task
with an unmapped team (None/fullstack/system) routes to main-pm as a
deliberate never-strand fallback, but main-pm's claim is refused by
MAIN_PM_NO_CODE — a pre-existing every-tick claim-reject loop for
that shape regardless of creator, with no live instances today.

Gate: full make quality green (suite passed through coverage; xenon/
bandit/pip-audit/deptry/alembic/import-linter/foundation-check all
exit 0).
This commit is contained in:
Renn F
2026-08-01 00:28:28 +02:00
parent 3efc96e402
commit be9034363e
2 changed files with 167 additions and 19 deletions
+47 -18
View File
@@ -143,6 +143,13 @@ _SHUTDOWN_DRAIN_TIMEOUT_SECONDS = 5.0
# to whatever exit the monitor is now looking at, rather than mis-attributed.
_EXPECTED_STOP_FRESH_SECONDS = 120.0
_EXPECTED_STOP_MAX_ENTRIES = 200
# _route_unassigned_pm_task's creator-skip guard: a PM that just created a task
# is about to assign it one tool-call later, so racing in and claiming it for
# the PM would hijack the delegation. That's only true for a few seconds —
# past this grace the creator's session is long gone and the skip would hold
# the task pending-unassigned forever. Generous enough to cover the PM's next
# tool call; short enough that a genuinely abandoned task recovers fast.
_CREATOR_ROUTE_GRACE_SECONDS = 600
_HTTP_TOO_MANY_REQUESTS = 429
_HTTP_OK = 200
_HTTP_MULTIPLE_CHOICES = 300 # first non-2xx status; 2xx == [_HTTP_OK, this)
@@ -14019,6 +14026,42 @@ Start now: evidence(task_id="{task_id}")
)
return False
def _creator_route_should_skip(
self, task: dict[str, Any], agent_id: str, routing: str
) -> bool:
"""The creator-skip guard for ``_route_unassigned_pm_task``.
A PM that just created this task is about to assign it (e.g. be-pm
creating a code subtask to hand to be-dev-1 one tool-call later);
racing in and claiming for the PM would hijack that delegation. True
only while the task is still within ``_CREATOR_ROUTE_GRACE_SECONDS``
of creation past that the creator's session is long gone (it
exited without assigning), so this falls through (False) to normal
routing instead of skipping forever. Fails open on an
unparseable/missing ``created_at`` (treated as OLD) routing is the
safe default, the skip is only an optimization.
"""
created_by = task.get("created_by")
if not created_by or self._resolve_agent_slug(str(created_by)) != agent_id:
return False
age = self._get_task_age(task)
if age is not None and age.total_seconds() < _CREATOR_ROUTE_GRACE_SECONDS:
logger.info(
"Skipping auto-claim: routing target is the creator",
task_id=task.get("id"),
creator=agent_id,
routing=routing,
)
return True
logger.info(
"Creator-skip grace elapsed; routing task to its creator",
task_id=task.get("id"),
creator=agent_id,
routing=routing,
age_seconds=None if age is None else int(age.total_seconds()),
)
return False
async def _route_unassigned_pm_task(
self, client: httpx.AsyncClient, task: dict[str, Any]
) -> None:
@@ -14046,24 +14089,10 @@ Start now: evidence(task_id="{task_id}")
await self._handle_board_assigned_task(task, agent_id)
return
# Don't auto-claim back to the creator. A PM that just created this
# task is about to assign it (e.g. be-pm creating a code subtask to
# hand to be-dev-1 one tool-call later). Racing in and claiming for
# the PM hijacks the delegation — the PM ends up owning a code task
# it never intended to work on itself. Skip this tick and let the
# next dispatch pick it up once assigned_to is set, OR re-evaluate
# when we have a clearer signal the creator won't route it.
created_by = task.get("created_by")
if created_by:
creator_slug = self._resolve_agent_slug(str(created_by))
if creator_slug == agent_id:
logger.info(
"Skipping auto-claim: routing target is the creator",
task_id=task.get("id"),
creator=creator_slug,
routing=routing,
)
return
# Don't auto-claim back to the creator while the task is fresh — see
# _creator_route_should_skip.
if self._creator_route_should_skip(task, agent_id, routing):
return
logger.info(
"Routing task",
@@ -14,12 +14,13 @@ round-tripping the claim endpoint.
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.runtime.orchestrator import _CREATOR_ROUTE_GRACE_SECONDS, AgentOrchestrator
if TYPE_CHECKING:
from collections.abc import AsyncIterator
@@ -96,6 +97,124 @@ async def test_ready_task_still_dispatches() -> None:
spawn.assert_awaited_once()
# ---------------------------------------------------------------------------
# _creator_route_should_skip / _route_unassigned_pm_task — the creator-skip
# guard is age-gated: young means "the creator-PM is about to assign it one
# tool-call later," old means the creator's session is long gone and the
# skip would otherwise wedge the task pending-unassigned forever.
# ---------------------------------------------------------------------------
def _iso_age(seconds: float) -> str:
return (datetime.now(UTC) - timedelta(seconds=seconds)).isoformat()
async def _route_with_creator_task(
orch: AgentOrchestrator, task: dict[str, Any]
) -> Any:
"""Run `_route_unassigned_pm_task` with routing/claim/spawn stubbed so only
the creator-skip guard's own age logic decides the outcome. Returns the
(claim, spawn) mocks for assertion."""
client = cast("httpx.AsyncClient", object())
with (
patch.object(orch, "_pending_claim_blocked", new=AsyncMock(return_value=False)),
patch.object(orch, "_classify_task_routing", return_value="cell_pm"),
patch.object(orch, "_get_routing_target", return_value="be-pm"),
patch.object(orch, "_resolve_agent_slug", return_value="be-pm"),
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch, "_claim_task_for_agent", new=AsyncMock(return_value=True)
) as claim,
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._route_unassigned_pm_task(client, task)
return claim, spawn
@pytest.mark.asyncio
async def test_young_creator_task_still_skipped() -> None:
"""(a) Fresh task, creator == routing target: skip, no claim/spawn."""
orch = _orch()
task = _pending_task(created_by=str(uuid4()), created_at=_iso_age(30))
claim, spawn = await _route_with_creator_task(orch, task)
claim.assert_not_awaited()
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_old_creator_task_falls_through_to_routing() -> None:
"""(b) 11+ minutes past creation, creator == routing target: the grace
has elapsed, so normal claim + spawn proceeds (the creator's session
exited without assigning — self-claim is safe for PM-routed work)."""
orch = _orch()
task = _pending_task(
created_by=str(uuid4()),
created_at=_iso_age(_CREATOR_ROUTE_GRACE_SECONDS + 60),
)
claim, spawn = await _route_with_creator_task(orch, task)
claim.assert_awaited_once()
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_unparseable_created_at_fails_open_to_routing() -> None:
"""(c) A garbage created_at can't be aged, so the guard fails open
(treats it as OLD) rather than skip forever."""
orch = _orch()
task = _pending_task(created_by=str(uuid4()), created_at="not-a-timestamp")
claim, spawn = await _route_with_creator_task(orch, task)
claim.assert_awaited_once()
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_missing_created_at_fails_open_to_routing() -> None:
"""(c) No created_at at all — same fail-open treatment as unparseable."""
orch = _orch()
task = _pending_task(created_by=str(uuid4()))
task.pop("created_at", None)
claim, spawn = await _route_with_creator_task(orch, task)
claim.assert_awaited_once()
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_different_creator_dispatches_regardless_of_age() -> None:
"""(d) creator != routing target: the guard never engages, so behavior is
unchanged whether the task is brand new or ancient."""
orch = _orch()
for age_seconds in (5, _CREATOR_ROUTE_GRACE_SECONDS + 3600):
task = _pending_task(created_by=str(uuid4()), created_at=_iso_age(age_seconds))
client = cast("httpx.AsyncClient", object())
with (
patch.object(
orch, "_pending_claim_blocked", new=AsyncMock(return_value=False)
),
patch.object(orch, "_classify_task_routing", return_value="cell_pm"),
patch.object(orch, "_get_routing_target", return_value="be-pm"),
patch.object(orch, "_resolve_agent_slug", return_value="main-pm"),
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(
orch, "_claim_task_for_agent", new=AsyncMock(return_value=True)
) as claim,
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._route_unassigned_pm_task(client, task)
claim.assert_awaited_once()
spawn.assert_awaited_once()
# ---------------------------------------------------------------------------
# _pending_claim_blocked — the DB-backed probe itself
# ---------------------------------------------------------------------------