2026-06-03 06:35:03 +02:00
|
|
|
"""Gateway spawn-cooldown gate.
|
2026-05-03 07:43:05 +02:00
|
|
|
|
2026-06-03 06:35:03 +02:00
|
|
|
`gateway_pre_spawn_check` always reaches
|
|
|
|
|
``roboco.services.gateway.trigger_filter.decide_spawn`` (except the no-task
|
|
|
|
|
carve-out), whose 4-rule cooldown machinery is the real spawn gate.
|
2026-05-03 07:43:05 +02:00
|
|
|
|
2026-06-03 06:35:03 +02:00
|
|
|
Without these assertions a regression that drops the call site would leave
|
|
|
|
|
the orchestrator with no server-side spawn cooldown beyond
|
|
|
|
|
``_pm_respawn_should_gate``.
|
2026-05-03 07:43:05 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from roboco.runtime.orchestrator import gateway_pre_spawn_check
|
|
|
|
|
from roboco.services.gateway.trigger_filter import Decision, SpawnDecision
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-06-03 06:35:03 +02:00
|
|
|
async def test_gateway_enabled_consults_decide_spawn() -> None:
|
|
|
|
|
"""``decide_spawn`` is invoked and its decision propagates."""
|
2026-05-03 07:43:05 +02:00
|
|
|
task_id = str(uuid4())
|
|
|
|
|
|
|
|
|
|
# Stub task row that decide_spawn will receive.
|
|
|
|
|
fake_task_row = MagicMock()
|
|
|
|
|
fake_task_row.status = "pending"
|
|
|
|
|
fake_task_row.active_claimant_id = None
|
|
|
|
|
fake_task_row.last_heartbeat_at = None
|
|
|
|
|
|
|
|
|
|
# Stub the async DB session: count queries return 0, task lookup returns
|
|
|
|
|
# our fake row. ``async with factory() as db`` -> ``db.execute(...)``.
|
|
|
|
|
fake_count_result = MagicMock()
|
|
|
|
|
fake_count_result.scalars.return_value.all.return_value = []
|
|
|
|
|
|
|
|
|
|
fake_task_result = MagicMock()
|
|
|
|
|
fake_task_result.scalars.return_value.first.return_value = fake_task_row
|
|
|
|
|
|
|
|
|
|
fake_db = AsyncMock()
|
|
|
|
|
fake_db.execute = AsyncMock(
|
|
|
|
|
side_effect=[fake_count_result, fake_count_result, fake_task_result]
|
|
|
|
|
)
|
|
|
|
|
fake_db.add = MagicMock()
|
|
|
|
|
fake_db.flush = AsyncMock()
|
|
|
|
|
fake_db.commit = AsyncMock()
|
|
|
|
|
|
|
|
|
|
fake_factory = MagicMock()
|
|
|
|
|
fake_factory.return_value.__aenter__ = AsyncMock(return_value=fake_db)
|
|
|
|
|
fake_factory.return_value.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
|
|
|
|
|
expected = Decision(SpawnDecision.QUEUE, "per-task spawn cooldown active")
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.db.base.get_session_factory", return_value=fake_factory),
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.gateway.trigger_filter.decide_spawn",
|
|
|
|
|
return_value=expected,
|
|
|
|
|
) as mock_decide_spawn,
|
|
|
|
|
):
|
|
|
|
|
outcome, reason = await gateway_pre_spawn_check(
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
trigger_kind="scan",
|
|
|
|
|
target_role="developer",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
mock_decide_spawn.assert_called_once()
|
|
|
|
|
call_kwargs = mock_decide_spawn.call_args.kwargs
|
|
|
|
|
assert call_kwargs["task"] is fake_task_row
|
|
|
|
|
assert call_kwargs["trigger"].kind.value == "scan"
|
|
|
|
|
assert call_kwargs["config"].cooldown_seconds > 0
|
|
|
|
|
|
|
|
|
|
assert outcome == "queue"
|
|
|
|
|
assert reason == "per-task spawn cooldown active"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-06-03 06:35:03 +02:00
|
|
|
async def test_gateway_enabled_skips_decide_spawn_when_no_task_id() -> None:
|
2026-05-03 07:43:05 +02:00
|
|
|
"""No task_id -> early return; ``decide_spawn`` not called.
|
|
|
|
|
|
2026-06-03 06:35:03 +02:00
|
|
|
Documents the no-task-spawn carve-out: idle PM ticks pass through.
|
2026-05-03 07:43:05 +02:00
|
|
|
"""
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.services.gateway.trigger_filter.decide_spawn"
|
|
|
|
|
) as mock_decide_spawn:
|
|
|
|
|
outcome, reason = await gateway_pre_spawn_check(
|
|
|
|
|
task_id=None,
|
|
|
|
|
trigger_kind="scan",
|
|
|
|
|
target_role="main_pm",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert outcome == "spawn"
|
|
|
|
|
assert "no task_id" in reason
|
|
|
|
|
mock_decide_spawn.assert_not_called()
|