mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
135 lines
4.7 KiB
Python
135 lines
4.7 KiB
Python
"""Spawner attribution: every ``agent.spawned`` audit row names its dispatcher.
|
|||
|
|
|
||
|
|
During the 2026-07-02 live run a rogue spawner could not be identified from the
|
||
|
|
audit log — ``agent.spawned`` rows carry the container/model but not WHICH
|
||
|
|
dispatch loop launched them. ``spawn_agent`` now takes ``spawned_by`` and
|
||
|
|
stamps it into the ``agent.spawned`` / ``agent.spawn_failed`` details, and an
|
||
|
|
AST sweep holds every call site to passing it.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import ast
|
||
|
|
import asyncio
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
from unittest.mock import AsyncMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.models.runtime import AgentInstance
|
||
|
|
from roboco.runtime.orchestrator import AgentConfig, AgentOrchestrator, AgentState
|
||
|
|
|
||
|
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||
|
|
|
||
|
|
|
||
|
|
def _make_orchestrator(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
captured: list[dict[str, Any]],
|
||
|
|
container_result: Any,
|
||
|
|
) -> AgentOrchestrator:
|
||
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
||
|
|
orch._instances = {}
|
||
|
|
orch._lock = asyncio.Lock()
|
||
|
|
orch._bg_tasks = set()
|
||
|
|
orch._running = True
|
||
|
|
monkeypatch.setattr(orch, "_fire_audit", lambda **kw: captured.append(kw))
|
||
|
|
monkeypatch.setattr(orch, "_record_spawn_session", AsyncMock(return_value=None))
|
||
|
|
monkeypatch.setattr(orch, "_spawn_container", container_result)
|
||
|
|
return orch
|
||
|
|
|
||
|
|
|
||
|
|
def _config_and_instance() -> tuple[AgentConfig, AgentInstance]:
|
||
|
|
config = AgentConfig(
|
||
|
|
agent_id="be-dev-1",
|
||
|
|
blueprint_path=Path(),
|
||
|
|
model="opus",
|
||
|
|
provider_type="anthropic",
|
||
|
|
)
|
||
|
|
instance = AgentInstance(
|
||
|
|
agent_id="be-dev-1", state=AgentState.STARTING, config=config
|
||
|
|
)
|
||
|
|
return config, instance
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_launch_spawn_audit_carries_spawned_by(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""``agent.spawned`` details must name the dispatcher that launched it."""
|
||
|
|
captured: list[dict[str, Any]] = []
|
||
|
|
orch = _make_orchestrator(
|
||
|
|
monkeypatch, captured, AsyncMock(return_value="c0ffee" * 11)
|
||
|
|
)
|
||
|
|
config, instance = _config_and_instance()
|
||
|
|
|
||
|
|
await orch._launch_spawn(
|
||
|
|
"task-1", config, instance, None, None, spawned_by="_dispatch_qa_work"
|
||
|
|
)
|
||
|
|
|
||
|
|
spawned = [c for c in captured if c["event_type"] == "agent.spawned"]
|
||
|
|
assert len(spawned) == 1
|
||
|
|
assert spawned[0]["details"]["spawned_by"] == "_dispatch_qa_work"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_spawn_failed_audit_carries_spawned_by(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""A failed launch must attribute the spawner too — a rogue dispatcher
|
||
|
|
that keeps crashing containers is exactly the live-debug case."""
|
||
|
|
captured: list[dict[str, Any]] = []
|
||
|
|
orch = _make_orchestrator(
|
||
|
|
monkeypatch, captured, AsyncMock(side_effect=RuntimeError("boom"))
|
||
|
|
)
|
||
|
|
config, instance = _config_and_instance()
|
||
|
|
|
||
|
|
with pytest.raises(RuntimeError):
|
||
|
|
await orch._launch_spawn(
|
||
|
|
"task-1", config, instance, None, None, spawned_by="_spawn_pending_dev"
|
||
|
|
)
|
||
|
|
|
||
|
|
failed = [c for c in captured if c["event_type"] == "agent.spawn_failed"]
|
||
|
|
assert len(failed) == 1
|
||
|
|
assert failed[0]["details"]["spawned_by"] == "_spawn_pending_dev"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_launch_spawn_without_attribution_stamps_unspecified(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""The field is always present so audit queries never KeyError."""
|
||
|
|
captured: list[dict[str, Any]] = []
|
||
|
|
orch = _make_orchestrator(
|
||
|
|
monkeypatch, captured, AsyncMock(return_value="c0ffee" * 11)
|
||
|
|
)
|
||
|
|
config, instance = _config_and_instance()
|
||
|
|
|
||
|
|
await orch._launch_spawn("task-1", config, instance, None, None)
|
||
|
|
|
||
|
|
spawned = [c for c in captured if c["event_type"] == "agent.spawned"]
|
||
|
|
assert spawned[0]["details"]["spawned_by"] == "unspecified"
|
||
|
|
|
||
|
|
|
||
|
|
def _spawn_agent_calls_missing_spawned_by(path: Path) -> list[str]:
|
||
|
|
"""Return ``file:line`` for spawn_agent() calls without a spawned_by kwarg."""
|
||
|
|
tree = ast.parse(path.read_text())
|
||
|
|
missing: list[str] = []
|
||
|
|
for node in ast.walk(tree):
|
||
|
|
if not isinstance(node, ast.Call):
|
||
|
|
continue
|
||
|
|
func = node.func
|
||
|
|
if not (isinstance(func, ast.Attribute) and func.attr == "spawn_agent"):
|
||
|
|
continue
|
||
|
|
if not any(kw.arg == "spawned_by" for kw in node.keywords):
|
||
|
|
missing.append(f"{path.name}:{node.lineno}")
|
||
|
|
return missing
|
||
|
|
|
||
|
|
|
||
|
|
def test_every_spawn_agent_call_site_passes_spawned_by() -> None:
|
||
|
|
"""Sweep-guard over the whole package: a dispatcher added without
|
||
|
|
attribution fails here, not in a 3am live-debug session."""
|
||
|
|
missing: list[str] = []
|
||
|
|
for path in sorted((REPO_ROOT / "roboco").rglob("*.py")):
|
||
|
|
missing.extend(_spawn_agent_calls_missing_spawned_by(path))
|
||
|
|
assert not missing, f"spawn_agent() calls missing spawned_by=: {missing}"
|