Files
opensquilla--opensquilla/tests/test_gateway/test_task_runtime_reserved_slots.py
2026-07-13 13:12:33 +08:00

247 lines
6.9 KiB
Python

"""TaskRuntime reserves capacity for non-subagent tasks under fan-out load."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import pytest
import structlog.testing
from opensquilla.gateway.routing import RouteEnvelope, SourceKind
from opensquilla.gateway.task_runtime import TaskRuntime
from opensquilla.session.models import AgentTaskRecord, AgentTaskStatus
@dataclass
class _StubStorage:
records: dict
@classmethod
def fresh(cls) -> _StubStorage:
return cls(records={})
async def create_agent_task(self, record: AgentTaskRecord) -> None:
self.records[record.task_id] = record
async def get_agent_task(self, task_id):
return self.records.get(task_id)
async def list_agent_tasks(self, **_):
return list(self.records.values())
async def update_agent_task(self, task_id, **fields):
rec = self.records.get(task_id)
if rec is None:
return
for k, v in fields.items():
setattr(rec, k, v)
def _envelope(session_key: str = "agent:s:main") -> RouteEnvelope:
return RouteEnvelope(
source_kind=SourceKind.SYSTEM,
source_name="test",
agent_id="s",
session_key=session_key,
)
@pytest.mark.asyncio
async def test_subagent_reserved_slots_clamp_when_reserved_exceeds_max() -> None:
"""When config asks reserved >= max, clamp to max-1 (avoiding deadlock)."""
storage = _StubStorage.fresh()
async def handler(_):
return None
with structlog.testing.capture_logs() as logs:
rt = TaskRuntime(
storage=storage,
turn_handler=handler,
max_concurrency=1,
subagent_reserved_slots=2,
)
assert rt._subagent_reserved_slots == 0
assert {
"event": "task_runtime.subagent_reserved_slots_clamped",
"requested": 2,
"max_concurrency": 1,
"clamped_to": 0,
"log_level": "warning",
} in logs
@pytest.mark.asyncio
async def test_subagent_reserved_slots_clamp_to_max_minus_one() -> None:
"""When max=4 and reserved=4, clamp to 3 so subagents can still acquire."""
storage = _StubStorage.fresh()
async def handler(_):
return None
rt = TaskRuntime(
storage=storage,
turn_handler=handler,
max_concurrency=4,
subagent_reserved_slots=4,
)
assert rt._subagent_reserved_slots == 3
@pytest.mark.asyncio
async def test_default_run_kind_acquires_normally() -> None:
"""A non-subagent task acquires a slot and increments counters."""
storage = _StubStorage.fresh()
started = asyncio.Event()
finish = asyncio.Event()
async def handler(run):
started.set()
await finish.wait()
rt = TaskRuntime(
storage=storage,
turn_handler=handler,
max_concurrency=2,
subagent_reserved_slots=1,
)
handle = await rt.enqueue(_envelope("agent:s:main"), "hi", run_kind="default")
await asyncio.wait_for(started.wait(), timeout=1.0)
# While in-flight, counters reflect the running default-kind task.
assert rt._global_in_flight == 1
assert rt._subagent_in_flight == 0
finish.set()
await rt.wait(handle.task_id, timeout=1.0)
assert rt._global_in_flight == 0
@pytest.mark.asyncio
async def test_subagent_blocks_when_reserved_floor_would_be_violated() -> None:
"""With max=2 and reserved=1, only one subagent can be in flight at once."""
storage = _StubStorage.fresh()
sub_started = []
finish_first = asyncio.Event()
finish_second = asyncio.Event()
async def handler(run):
sub_started.append(run.task_id)
if len(sub_started) == 1:
await finish_first.wait()
else:
await finish_second.wait()
rt = TaskRuntime(
storage=storage,
turn_handler=handler,
max_concurrency=2,
subagent_reserved_slots=1,
)
h1 = await rt.enqueue(_envelope("agent:s:one"), "a", run_kind="subagent")
h2 = await rt.enqueue(_envelope("agent:s:two"), "b", run_kind="subagent")
# Give the loop a few cycles for h1 to start; h2 must remain queued
# because only 1 subagent slot is available (max=2 - reserved=1).
for _ in range(20):
await asyncio.sleep(0)
assert len(sub_started) == 1
assert rt._subagent_in_flight == 1
finish_first.set()
# After h1 finishes, h2 should be admitted.
for _ in range(50):
await asyncio.sleep(0)
if len(sub_started) == 2:
break
assert len(sub_started) == 2
finish_second.set()
await rt.wait(h1.task_id, timeout=1.0)
await rt.wait(h2.task_id, timeout=1.0)
assert rt._global_in_flight == 0
assert rt._subagent_in_flight == 0
@pytest.mark.asyncio
async def test_no_reservation_leaks_counters_after_handler_error() -> None:
"""An exception in the turn handler must still drop the in-flight count."""
storage = _StubStorage.fresh()
async def handler(_):
raise RuntimeError("boom")
rt = TaskRuntime(
storage=storage,
turn_handler=handler,
max_concurrency=2,
subagent_reserved_slots=1,
)
handle = await rt.enqueue(_envelope(), "x", run_kind="subagent")
record = await rt.wait(handle.task_id, timeout=1.0)
assert record.status == AgentTaskStatus.FAILED
assert rt._global_in_flight == 0
assert rt._subagent_in_flight == 0
@pytest.mark.asyncio
async def test_send_uses_default_run_kind_for_parent_wake() -> None:
"""TaskRuntime.send (parent wake path) defaults run_kind=default so wakes
don't consume reserved subagent capacity.
"""
storage = _StubStorage.fresh()
seen: list[str] = []
async def handler(run):
seen.append(run.run_kind)
rt = TaskRuntime(
storage=storage,
turn_handler=handler,
max_concurrency=2,
subagent_reserved_slots=1,
)
# Seed envelope cache by enqueueing once, then send.
h0 = await rt.enqueue(_envelope("agent:s:main"), "init", run_kind="default")
await rt.wait(h0.task_id, timeout=1.0)
h1 = await rt.send("agent:s:main", "wake")
await rt.wait(h1.task_id, timeout=1.0)
assert "subagent" not in seen
assert seen == ["default", "default"]
@pytest.mark.asyncio
async def test_send_passes_stream_event_sink_to_parent_wake_task() -> None:
storage = _StubStorage.fresh()
seen_sinks = []
async def handler(run):
seen_sinks.append(run.stream_event_sink)
rt = TaskRuntime(
storage=storage,
turn_handler=handler,
max_concurrency=2,
subagent_reserved_slots=1,
)
h0 = await rt.enqueue(_envelope("agent:s:main"), "init", run_kind="default")
await rt.wait(h0.task_id, timeout=1.0)
async def sink(_event) -> None:
return None
h1 = await rt.send("agent:s:main", "wake", stream_event_sink=sink)
await rt.wait(h1.task_id, timeout=1.0)
assert seen_sinks == [None, sink]