mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(dispatch): prefilter sequence-held dev tasks before spawn (#519)
* fix(dispatch): prefilter sequence-held dev tasks before spawn _spawn_pending_dev booted a full dev container for a pre-assigned pending task that the assignee-blind sequence guard would refuse at the claim chokepoint (a non-terminal lower-sequence same-parent sibling — not a declared dependency). _blocked_by_earlier_lane_sibling is narrower (same dev's lane) and _validate_task_for_spawn checks declared deps, not sequence siblings, so the container spawned, the first claim hit _claim_blocked_by_sequence and was refused, and the agent exited only to be re-spawned next tick — pure churn until the predecessor went terminal. Mirror the PM path's _pending_claim_blocked prefilter (the exact claim-gate predicate, fails open) at the top of _spawn_pending_dev, before the narrower per-dev lane probe. Reuses the helper so it can't drift from the chokepoint. * fix(dispatch): prefilter sequence-held dev tasks before spawn _spawn_pending_dev booted a full dev container for a pre-assigned pending task that the assignee-blind sequence guard would refuse at the claim chokepoint (a non-terminal lower-sequence same-parent sibling — not a declared dependency). _blocked_by_earlier_lane_sibling is narrower (same dev's lane) and _validate_task_for_spawn checks declared deps, not sequence siblings, so the container spawned, the first claim hit _claim_blocked_by_sequence and was refused, and the agent exited only to be re-spawned next tick — pure churn until the predecessor went terminal. Mirror the PM path's _pending_claim_blocked prefilter (the exact claim-gate predicate, fails open) at the top of _spawn_pending_dev, before the narrower per-dev lane probe. Reuses the helper so it can't drift from the chokepoint. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"""Dev dispatch sequence pacing — no doomed spawn for a sequence-held task.
|
||||
|
||||
``_spawn_pending_dev`` boots a full dev container for a pre-assigned pending
|
||||
task. Before this guard, a task held only by the assignee-blind sequence
|
||||
guard (a non-terminal lower-sequence same-parent sibling, not a declared
|
||||
dependency) passed every pre-spawn gate — ``_blocked_by_earlier_lane_sibling``
|
||||
is narrower (same dev's lane) and ``_validate_task_for_spawn`` checks declared
|
||||
dependencies, not sequence siblings — so the container spawned, the agent's
|
||||
first claim hit ``_claim_blocked_by_sequence`` at the chokepoint and was
|
||||
refused, and the agent exited only to be re-spawned next tick. Mirror the PM
|
||||
path's ``_pending_claim_blocked`` prefilter so a sequence-held task is skipped
|
||||
pre-spawn instead of churned.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
|
||||
def _orch() -> AgentOrchestrator:
|
||||
orch = object.__new__(AgentOrchestrator)
|
||||
orch._instances = {}
|
||||
return orch
|
||||
|
||||
|
||||
def _pending_dev_task(**over: Any) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"id": str(uuid4()),
|
||||
"status": "pending",
|
||||
"team": "backend",
|
||||
"task_type": "code",
|
||||
"title": "Sequenced code leaf",
|
||||
"assigned_to": str(uuid4()),
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequence_held_dev_task_not_spawned() -> None:
|
||||
"""A sequence-held task skips spawn entirely — no container, no churn."""
|
||||
orch = _orch()
|
||||
task = _pending_dev_task()
|
||||
client = cast("httpx.AsyncClient", object())
|
||||
with (
|
||||
patch.object(orch, "_is_agent_active", return_value=False),
|
||||
patch.object(orch, "_pending_claim_blocked", new=AsyncMock(return_value=True)),
|
||||
patch.object(orch, "_blocked_by_earlier_lane_sibling", new=AsyncMock()) as lane,
|
||||
patch.object(orch, "_pm_respawn_should_gate", new=AsyncMock()) as respawn,
|
||||
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
|
||||
):
|
||||
await orch._spawn_pending_dev(client, task, "be-dev-1")
|
||||
|
||||
spawn.assert_not_awaited()
|
||||
# Sequence guard is broader and checked first — narrower per-dev probe never runs.
|
||||
lane.assert_not_awaited()
|
||||
respawn.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ready_dev_task_still_spawns() -> None:
|
||||
"""A clear task (prefilter False, lane clear, respawn gate clear, valid)
|
||||
proceeds to spawn unchanged."""
|
||||
orch = _orch()
|
||||
task = _pending_dev_task()
|
||||
client = cast("httpx.AsyncClient", object())
|
||||
with (
|
||||
patch.object(orch, "_is_agent_active", return_value=False),
|
||||
patch.object(orch, "_pending_claim_blocked", new=AsyncMock(return_value=False)),
|
||||
patch.object(
|
||||
orch, "_blocked_by_earlier_lane_sibling", new=AsyncMock(return_value=False)
|
||||
),
|
||||
patch.object(
|
||||
orch, "_pm_respawn_should_gate", new=AsyncMock(return_value=False)
|
||||
),
|
||||
patch.object(
|
||||
orch, "_validate_task_for_spawn", new=AsyncMock(return_value=None)
|
||||
),
|
||||
patch.object(
|
||||
orch, "_get_prompt_for_agent", new=AsyncMock(return_value="prompt")
|
||||
),
|
||||
patch.object(orch, "_task_git_context", return_value=None),
|
||||
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
|
||||
):
|
||||
await orch._spawn_pending_dev(client, task, "be-dev-1")
|
||||
|
||||
spawn.assert_awaited_once()
|
||||
Reference in New Issue
Block a user