"""Parked-provider spawn must short-circuit BEFORE the expensive prepare. ``spawn_agent`` runs the parked-provider check (``_provider_spawn_parked``) to avoid re-spawning a container into a rate-limited / overloaded provider every dispatcher tick. The check only needs ``provider_type``, which is cheaply resolvable via ``_resolve_agent_route``. Running the full ``_prepare_agent_spawn`` first — which writes the blueprint / settings / briefing / MCP-config files, ensures the agent image, and registers a STARTING ``AgentInstance`` in ``_instances`` — only to bail at the parked check wastes all that file I/O every tick the provider stays parked, and leaves a STARTING instance registered then downgraded to OFFLINE. The fix: resolve the route + run the parked check BEFORE ``_prepare_agent_spawn``, bailing with a minimal unregistered OFFLINE instance. The existing-running check stays first (inside the lock) so a running agent is never bailed. """ from __future__ import annotations import asyncio from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock import pytest from roboco.models.runtime import AgentInstance from roboco.runtime.orchestrator import AgentOrchestrator, AgentState def _make_orchestrator() -> AgentOrchestrator: # __new__ + skip __init__: avoid all constructor I/O. orch = AgentOrchestrator.__new__(AgentOrchestrator) orch._instances = {} orch._lock = asyncio.Lock() orch._tick_handled_tasks = set() orch._bg_tasks = set() orch._running = True return orch def _wire(monitor: dict[str, Any]) -> Any: """Build the mock wiring closure capturing call counts in ``monitor``.""" async def _readiness_gate(_aid: str, _tid: str | None) -> None: return None async def _git_context(_gc: Any, _tid: str | None) -> None: return None async def _route(_aid: str) -> Any: monitor["route_calls"] += 1 return SimpleNamespace( provider_type=SimpleNamespace(value="anthropic"), model_name="opus", base_url=None, auth_token=None, ) async def _prepare(*_a: Any, **_k: Any) -> Any: monitor["prepare_calls"] += 1 # Mirrors the real prepare's registration side-effect so the RED test # observes the STARTING instance the current code leaks. cfg = SimpleNamespace(provider_type="anthropic", model="opus") inst = AgentInstance( agent_id="be-dev-1", state=AgentState.STARTING, config=None ) return cfg, inst, None return _readiness_gate, _git_context, _route, _prepare @pytest.mark.asyncio async def test_parked_spawn_skips_prepare_and_does_not_register( monkeypatch: pytest.MonkeyPatch, ) -> None: """When the provider is parked, ``_prepare_agent_spawn`` (all the file writes + image ensure + STARTING registration) must NOT run, and no instance may be left registered in ``_instances``.""" orch = _make_orchestrator() monitor = {"route_calls": 0, "prepare_calls": 0} _rg, _gc, _route, _prepare = _wire(monitor) monkeypatch.setattr(orch, "_readiness_gate", _rg) monkeypatch.setattr(orch, "_resolve_spawn_git_context", _gc) monkeypatch.setattr(orch, "_resolve_agent_route", _route) monkeypatch.setattr(orch, "_prepare_agent_spawn", _prepare) monkeypatch.setattr(orch, "_provider_spawn_parked", AsyncMock(return_value=True)) result = await orch.spawn_agent(agent_id="be-dev-1", task_id="task-9") # The parked check ran (route resolved for the cheap provider_type lookup). assert monitor["route_calls"] >= 1 # The expensive prepare was NOT called — the whole point of the fix. assert monitor["prepare_calls"] == 0 # Bailed with an OFFLINE instance, no container launched. assert isinstance(result, AgentInstance) assert result.state is AgentState.OFFLINE # No STARTING/OFFLINE instance left lingering in _instances. assert "be-dev-1" not in orch._instances # Task marked handled so later dispatchers in this tick skip it. assert "task-9" in orch._tick_handled_tasks @pytest.mark.asyncio async def test_not_parked_spawn_still_runs_prepare_and_launches( monkeypatch: pytest.MonkeyPatch, ) -> None: """Sanity: when the provider is NOT parked, the normal prepare + launch path runs unchanged — the shortcut only short-circuits the parked case.""" orch = _make_orchestrator() monitor = {"route_calls": 0, "prepare_calls": 0} _rg, _gc, _route, _prepare = _wire(monitor) launched: list[bool] = [] async def _launch(*_a: Any, **_k: Any) -> AgentInstance: launched.append(True) return AgentInstance( agent_id="be-dev-1", state=AgentState.ACTIVE, config=None, ) monkeypatch.setattr(orch, "_readiness_gate", _rg) monkeypatch.setattr(orch, "_resolve_spawn_git_context", _gc) monkeypatch.setattr(orch, "_resolve_agent_route", _route) monkeypatch.setattr(orch, "_prepare_agent_spawn", _prepare) monkeypatch.setattr(orch, "_provider_spawn_parked", AsyncMock(return_value=False)) monkeypatch.setattr(orch, "_launch_spawn", _launch) await orch.spawn_agent(agent_id="be-dev-1", task_id="task-9") assert monitor["prepare_calls"] == 1 assert launched == [True] @pytest.mark.asyncio async def test_running_agent_not_bailed_by_parked_check( monkeypatch: pytest.MonkeyPatch, ) -> None: """A running (ACTIVE) agent whose provider gets parked mid-flight must be returned as-is — the parked shortcut must NOT replace a live instance with a fresh OFFLINE one. The existing-running check stays first.""" orch = _make_orchestrator() monitor = {"route_calls": 0, "prepare_calls": 0} _rg, _gc, _route, _prepare = _wire(monitor) existing = AgentInstance( agent_id="be-dev-1", state=AgentState.ACTIVE, config=None, ) orch._instances["be-dev-1"] = existing monkeypatch.setattr(orch, "_readiness_gate", _rg) monkeypatch.setattr(orch, "_resolve_spawn_git_context", _gc) monkeypatch.setattr(orch, "_resolve_agent_route", _route) monkeypatch.setattr(orch, "_prepare_agent_spawn", _prepare) monkeypatch.setattr(orch, "_provider_spawn_parked", AsyncMock(return_value=True)) result = await orch.spawn_agent(agent_id="be-dev-1", task_id="task-9") # The running instance is returned untouched — parked check never reached. assert result is existing assert monitor["prepare_calls"] == 0