mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(kimi): spawn's own prepared instance no longer counts against the concurrency cap (#714)
This commit is contained in:
@@ -3120,7 +3120,9 @@ class AgentOrchestrator:
|
|||||||
# existing-running check above stays first, so a live agent is never
|
# existing-running check above stays first, so a live agent is never
|
||||||
# replaced by this bail. Fail-open: a tracker read error never blocks.
|
# replaced by this bail. Fail-open: a tracker read error never blocks.
|
||||||
route = await self._resolve_agent_route(agent_id, task_id)
|
route = await self._resolve_agent_route(agent_id, task_id)
|
||||||
skip_reason = await self._spawn_gate_skip_reason(route.provider_type.value)
|
skip_reason = await self._spawn_gate_skip_reason(
|
||||||
|
route.provider_type.value, exclude_agent_id=agent_id
|
||||||
|
)
|
||||||
if skip_reason:
|
if skip_reason:
|
||||||
self._mark_task_handled(task_id)
|
self._mark_task_handled(task_id)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -3145,7 +3147,9 @@ class AgentOrchestrator:
|
|||||||
# parked case is already handled above; this guards the window between
|
# parked case is already handled above; this guards the window between
|
||||||
# the pre-check and the launch. Fail-open: a tracker read error never
|
# the pre-check and the launch. Fail-open: a tracker read error never
|
||||||
# blocks spawning.
|
# blocks spawning.
|
||||||
skip_reason = await self._spawn_gate_skip_reason(config.provider_type)
|
skip_reason = await self._spawn_gate_skip_reason(
|
||||||
|
config.provider_type, exclude_agent_id=agent_id
|
||||||
|
)
|
||||||
if skip_reason:
|
if skip_reason:
|
||||||
return self._bail_prepared_instance(
|
return self._bail_prepared_instance(
|
||||||
instance, agent_id, task_id, config.provider_type, skip_reason
|
instance, agent_id, task_id, config.provider_type, skip_reason
|
||||||
@@ -10186,39 +10190,52 @@ Start by:
|
|||||||
return settings.kimi_max_concurrent
|
return settings.kimi_max_concurrent
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _live_provider_instance_count(self, provider_type: str) -> int:
|
def _live_provider_instance_count(
|
||||||
|
self, provider_type: str, exclude_agent_id: str | None = None
|
||||||
|
) -> int:
|
||||||
"""Count ``_instances`` entries for *provider_type* still holding a slot.
|
"""Count ``_instances`` entries for *provider_type* still holding a slot.
|
||||||
|
|
||||||
Mirrors ``_existing_running_instance``'s liveness definition: any
|
Mirrors ``_existing_running_instance``'s liveness definition: any
|
||||||
state other than OFFLINE/WAITING_LONG occupies (or is about to
|
state other than OFFLINE/WAITING_LONG occupies (or is about to
|
||||||
occupy) a container.
|
occupy) a container. *exclude_agent_id* drops the agent being
|
||||||
|
spawned from the count — ``_prepare_agent_spawn`` registers its
|
||||||
|
STARTING instance before the post-prepare gate re-check, and with
|
||||||
|
cap=1 a self-counting check cancels every spawn forever.
|
||||||
"""
|
"""
|
||||||
return sum(
|
return sum(
|
||||||
1
|
1
|
||||||
for inst in self._instances.values()
|
for agent_id, inst in self._instances.items()
|
||||||
if inst.config is not None
|
if agent_id != exclude_agent_id
|
||||||
|
and inst.config is not None
|
||||||
and inst.config.provider_type == provider_type
|
and inst.config.provider_type == provider_type
|
||||||
and inst.state not in (AgentState.OFFLINE, AgentState.WAITING_LONG)
|
and inst.state not in (AgentState.OFFLINE, AgentState.WAITING_LONG)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _provider_spawn_at_capacity(self, provider_type: str | None) -> bool:
|
def _provider_spawn_at_capacity(
|
||||||
|
self, provider_type: str | None, exclude_agent_id: str | None = None
|
||||||
|
) -> bool:
|
||||||
"""True when *provider_type* is at its concurrency cap, if any."""
|
"""True when *provider_type* is at its concurrency cap, if any."""
|
||||||
cap = self._provider_concurrency_cap(provider_type)
|
cap = self._provider_concurrency_cap(provider_type)
|
||||||
if cap is None or provider_type is None:
|
if cap is None or provider_type is None:
|
||||||
return False
|
return False
|
||||||
return self._live_provider_instance_count(provider_type) >= cap
|
live = self._live_provider_instance_count(provider_type, exclude_agent_id)
|
||||||
|
return live >= cap
|
||||||
|
|
||||||
async def _spawn_gate_skip_reason(self, provider_type: str | None) -> str | None:
|
async def _spawn_gate_skip_reason(
|
||||||
|
self, provider_type: str | None, exclude_agent_id: str | None = None
|
||||||
|
) -> str | None:
|
||||||
"""Why ``spawn_agent`` should bail before launch, or None to proceed.
|
"""Why ``spawn_agent`` should bail before launch, or None to proceed.
|
||||||
|
|
||||||
Checked in order: provider-parked (rate-limited/overloaded), then the
|
Checked in order: provider-parked (rate-limited/overloaded), then the
|
||||||
provider's concurrency cap (currently kimi-only, see
|
provider's concurrency cap (currently kimi-only, see
|
||||||
``_provider_concurrency_cap``). The returned string is both the skip
|
``_provider_concurrency_cap``). The returned string is both the skip
|
||||||
reason and the log event name — same shape both callers already used.
|
reason and the log event name — same shape both callers already used.
|
||||||
|
*exclude_agent_id* is the agent this spawn is FOR: its own registered
|
||||||
|
instance must never count against its own capacity check.
|
||||||
"""
|
"""
|
||||||
if await self._provider_spawn_parked(provider_type):
|
if await self._provider_spawn_parked(provider_type):
|
||||||
return "Spawn skipped: provider rate-limited (parked)"
|
return "Spawn skipped: provider rate-limited (parked)"
|
||||||
if self._provider_spawn_at_capacity(provider_type):
|
if self._provider_spawn_at_capacity(provider_type, exclude_agent_id):
|
||||||
return "Spawn skipped: kimi concurrency cap reached"
|
return "Spawn skipped: kimi concurrency cap reached"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -68,12 +68,21 @@ def _wire(monitor: dict[str, Any], provider_value: str) -> Any:
|
|||||||
auth_token=None,
|
auth_token=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _prepare(*_a: Any, **_k: Any) -> Any:
|
async def _prepare(agent_id: str, *_a: Any, **_k: Any) -> Any:
|
||||||
|
# Registers the STARTING instance under the requested agent_id like the
|
||||||
|
# real _prepare_agent_spawn does — the post-prepare gate re-check runs
|
||||||
|
# against _instances WITH this entry present (the self-count regression
|
||||||
|
# only reproduces with it registered).
|
||||||
monitor["prepare_calls"] += 1
|
monitor["prepare_calls"] += 1
|
||||||
cfg = SimpleNamespace(provider_type=provider_value, model="model")
|
cfg = SimpleNamespace(provider_type=provider_value, model="model")
|
||||||
inst = AgentInstance(
|
inst = AgentInstance(
|
||||||
agent_id="spawning-agent", state=AgentState.STARTING, config=None
|
agent_id=agent_id,
|
||||||
|
state=AgentState.STARTING,
|
||||||
|
config=AgentConfig(
|
||||||
|
agent_id=agent_id, blueprint_path=Path(), provider_type=provider_value
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
monitor["orch"]._instances[agent_id] = inst
|
||||||
return cfg, inst, None
|
return cfg, inst, None
|
||||||
|
|
||||||
return _readiness_gate, _git_context, _route, _prepare
|
return _readiness_gate, _git_context, _route, _prepare
|
||||||
@@ -85,6 +94,7 @@ def _wire_orch(
|
|||||||
monitor: dict[str, Any],
|
monitor: dict[str, Any],
|
||||||
provider_value: str,
|
provider_value: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
monitor["orch"] = orch
|
||||||
_rg, _gc, _route, _prepare = _wire(monitor, provider_value)
|
_rg, _gc, _route, _prepare = _wire(monitor, provider_value)
|
||||||
monkeypatch.setattr(orch, "_readiness_gate", _rg)
|
monkeypatch.setattr(orch, "_readiness_gate", _rg)
|
||||||
monkeypatch.setattr(orch, "_resolve_spawn_git_context", _gc)
|
monkeypatch.setattr(orch, "_resolve_spawn_git_context", _gc)
|
||||||
@@ -147,6 +157,32 @@ def test_live_provider_instance_count_and_capacity() -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sole_kimi_spawn_passes_its_own_post_prepare_cap_check(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The 2026-07-29 fleet wedge: with cap=1 and NO other kimi instance, the
|
||||||
|
post-prepare gate re-check counted the spawn's own just-registered
|
||||||
|
STARTING instance (1 >= 1) and cancelled every kimi spawn forever. The
|
||||||
|
agent being spawned must be excluded from its own capacity check."""
|
||||||
|
orch = _make_orchestrator()
|
||||||
|
monitor = {"route_calls": 0, "prepare_calls": 0}
|
||||||
|
_wire_orch(orch, monkeypatch, monitor, "kimi")
|
||||||
|
|
||||||
|
launched: list[bool] = []
|
||||||
|
|
||||||
|
async def _launch(*_a: Any, **_k: Any) -> AgentInstance:
|
||||||
|
launched.append(True)
|
||||||
|
return AgentInstance(agent_id="main-pm", state=AgentState.ACTIVE, config=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(orch, "_launch_spawn", _launch)
|
||||||
|
|
||||||
|
await orch.spawn_agent(agent_id="main-pm", task_id="task-1")
|
||||||
|
|
||||||
|
assert monitor["prepare_calls"] == 1
|
||||||
|
assert launched == [True]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_second_concurrent_kimi_spawn_skipped_while_first_live(
|
async def test_second_concurrent_kimi_spawn_skipped_while_first_live(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user