mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user