Files
roboco/tests/unit/runtime/test_sandbox_provision_spawn.py
T
47d78f50ee feat(sandbox): on-demand provisioning via request_sandbox verb (#338)
* feat(sandbox): on-demand request_sandbox verb replaces eager provisioning

Sandboxes were provisioned at every agent spawn for opted-in projects,
so every role paid the sidecar spin-up and a provisioning failure
refused the spawn. Provisioning now happens when an agent asks: the
request_sandbox do-verb (dev + QA) reaches the orchestrator through
ContentActionsDeps, ensure_sandbox provisions idempotently with an
in-memory per-agent cache (evicted at teardown and janitor sweep), and
creds return in the envelope payload including ready-to-export
ROBOCO_TEST_* values. Spawn now only injects a marker env naming the
available services plus a briefing line; sandbox failures can no longer
refuse a spawn. Teardown lifecycle unchanged.

* feat(sandbox): harden request_sandbox + Phase 3 wiring proof and docs

Hardening from adversarial review: ensure_sandbox now provisions the
project's full opted-in set on first request (a later superset can
never tear down a live sandbox mid-use), serializes per-agent behind an
asyncio lock (a client timeout-retry no longer races its own in-flight
provision), and verifies container liveness on every cache hit (a dead
sandbox evicts and re-provisions instead of serving dead creds). MCP
client budget 720->1080s for the full-set cold case. Phase 3: e2e smoke
wiring test (manifest grants + guard-chain envelopes over the real
API), sandbox-db/tools/map docs and CLAUDE.md rewritten for on-demand.

* feat(sandbox): release sandboxes when the agent's work ends

CEO directive: sidecars must not dangle once the agent is done. The six
work-ending verbs (i_am_done, unclaim, i_am_idle, pass_review,
fail_review, i_documented) now release the caller's sandbox best-effort
on their success path via release_sandbox (lock + teardown + cache
evict; a no-sandbox agent costs a dict lookup). Container removal and
the janitor remain the backstop; a re-request provisions fresh.

* test(sandbox): monkeypatch the release hook instead of method assignment

mypy method-assign rejected the direct AsyncMock assignments; the prior
static gate ran before this test file landed.

* test(sandbox): guard envelope evidence for mypy in verb tests

* chore(prompts): regenerate verb tables for request_sandbox

* chore: resolve merge with master (breadcrumbs + statement budget)

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-08 16:40:02 +02:00

273 lines
10 KiB
Python

"""`AgentOrchestrator._sandbox_available_services` — the spawn-time availability
probe — and `ensure_sandbox` — the on-demand provision/cache path used by the
`request_sandbox` do-verb.
Off (flag or project) => [], byte-for-byte identical to legacy behavior. A
project lookup hiccup degrades to "no sandbox available" (best-effort,
matching the ambient-conventions-resolution convention). Provisioning itself
no longer happens at spawn time — a spawn never fails on sandbox
infrastructure; `ensure_sandbox` is the only path that calls
`SandboxProvisioner.provision`, and it is idempotent via an in-memory cache.
"""
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.config import settings
from roboco.models.sandbox import SandboxConnection, SandboxInfo
from roboco.runtime.orchestrator import AgentOrchestrator
def _make_orchestrator() -> tuple[AgentOrchestrator, MagicMock]:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._bg_tasks = set()
orch._running = True
orch._sandbox_info = {}
sandbox = MagicMock()
sandbox.provision = AsyncMock()
# Live by default so cache-hit tests that don't care about liveness pass
# through; tests exercising DEFECT 3 (dead-container eviction) override.
sandbox.is_live = AsyncMock(return_value=True)
orch._sandbox = sandbox
return orch, sandbox
@asynccontextmanager
async def _fake_db_ctx(db: Any) -> Any:
yield db
# ---------------------------------------------------------------------------
# _sandbox_available_services (spawn-time probe, no provisioning)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_flag_off_returns_empty_without_project_lookup(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", False)
orch, sandbox = _make_orchestrator()
with patch("roboco.services.project.get_project_service") as get_svc:
result = await orch._sandbox_available_services("roboco-api")
assert result == []
get_svc.assert_not_called()
sandbox.provision.assert_not_called()
@pytest.mark.asyncio
async def test_project_without_sandbox_services_returns_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
orch, sandbox = _make_orchestrator()
project = MagicMock(sandbox_services=None)
project_service = MagicMock()
project_service.get_by_slug = AsyncMock(return_value=project)
with (
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(MagicMock())),
patch(
"roboco.services.project.get_project_service",
return_value=project_service,
),
):
result = await orch._sandbox_available_services("roboco-api")
assert result == []
sandbox.provision.assert_not_called()
@pytest.mark.asyncio
async def test_missing_project_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
orch, _sandbox = _make_orchestrator()
project_service = MagicMock()
project_service.get_by_slug = AsyncMock(return_value=None)
with (
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(MagicMock())),
patch(
"roboco.services.project.get_project_service",
return_value=project_service,
),
):
result = await orch._sandbox_available_services("roboco-api")
assert result == []
@pytest.mark.asyncio
async def test_opted_in_project_returns_services_without_provisioning(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
orch, sandbox = _make_orchestrator()
project = MagicMock(sandbox_services=["postgres"])
project_service = MagicMock()
project_service.get_by_slug = AsyncMock(return_value=project)
with (
patch("roboco.db.base.get_db_context", return_value=_fake_db_ctx(MagicMock())),
patch(
"roboco.services.project.get_project_service",
return_value=project_service,
),
):
result = await orch._sandbox_available_services("roboco-api")
assert result == ["postgres"]
sandbox.provision.assert_not_called()
@pytest.mark.asyncio
async def test_project_lookup_failure_degrades_to_no_sandbox(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
orch, sandbox = _make_orchestrator()
with patch("roboco.db.base.get_db_context", side_effect=RuntimeError("db down")):
result = await orch._sandbox_available_services("roboco-api")
assert result == []
sandbox.provision.assert_not_called()
# ---------------------------------------------------------------------------
# ensure_sandbox (on-demand provision + cache, called by request_sandbox)
# ---------------------------------------------------------------------------
def _info(services: dict[str, SandboxConnection]) -> SandboxInfo:
return SandboxInfo(services=services)
@pytest.mark.asyncio
async def test_ensure_sandbox_miss_provisions_and_caches() -> None:
orch, sandbox = _make_orchestrator()
info = _info({"postgres": SandboxConnection(host="h", port=5432, password="pw")})
sandbox.provision.return_value = info
result = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"])
assert result is info
sandbox.provision.assert_awaited_once_with("dev-1", ["postgres"])
assert orch._sandbox_info["dev-1"] is info
@pytest.mark.asyncio
async def test_ensure_sandbox_cache_hit_skips_second_provision() -> None:
orch, sandbox = _make_orchestrator()
info = _info({"postgres": SandboxConnection(host="h", port=5432, password="pw")})
sandbox.provision.return_value = info
first = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"])
second = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"])
assert first is second is info
sandbox.provision.assert_awaited_once()
@pytest.mark.asyncio
async def test_ensure_sandbox_first_subset_request_provisions_full_opted_set() -> None:
"""DEFECT 1 fix: a first request for a subset of the project's opted-in
set provisions the FULL opted set — not just what this call named — so a
later call for the rest of that set is a guaranteed cache hit and never
falls through to a fresh provision() (whose pre-clear teardown() would
otherwise kill the live container the agent is already using)."""
orch, sandbox = _make_orchestrator()
info = _info(
{
"postgres": SandboxConnection(host="h", port=5432, password="pw"),
"redis": SandboxConnection(host="h", port=6379, password="rw"),
}
)
sandbox.provision.return_value = info
first = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres", "redis"])
second = await orch.ensure_sandbox(
"dev-1", ["postgres", "redis"], ["postgres", "redis"]
)
assert first is second is info
sandbox.provision.assert_awaited_once_with("dev-1", ["postgres", "redis"])
assert orch._sandbox_info["dev-1"] is info
@pytest.mark.asyncio
async def test_ensure_sandbox_cache_is_per_agent_slug() -> None:
"""Caller A's cache entry never leaks to caller B (cross-agent isolation)."""
orch, sandbox = _make_orchestrator()
info_a = _info({"postgres": SandboxConnection(host="a", port=5432, password="pa")})
info_b = _info({"postgres": SandboxConnection(host="b", port=5432, password="pb")})
sandbox.provision.side_effect = [info_a, info_b]
result_a = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"])
result_b = await orch.ensure_sandbox("dev-2", ["postgres"], ["postgres"])
assert result_a is info_a
assert result_b is info_b
assert orch._sandbox_info["dev-1"] is info_a
assert orch._sandbox_info["dev-2"] is info_b
@pytest.mark.asyncio
async def test_ensure_sandbox_concurrent_calls_serialize_on_agent_lock() -> None:
"""DEFECT 2 fix: two concurrent ensure_sandbox calls for the same agent
(e.g. a client timeout + retry) must serialize behind the per-agent lock
so only one provision() ever runs — never a race between provision() and
a concurrent teardown()."""
orch, sandbox = _make_orchestrator()
info = _info({"postgres": SandboxConnection(host="h", port=5432, password="pw")})
calls = 0
async def _slow_provision(_agent_id: str, _services: list[str]) -> SandboxInfo:
nonlocal calls
calls += 1
await asyncio.sleep(0.05)
return info
sandbox.provision.side_effect = _slow_provision
results = await asyncio.gather(
orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"]),
orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"]),
)
assert results[0] is results[1] is info
assert calls == 1
@pytest.mark.asyncio
async def test_ensure_sandbox_cache_hit_with_dead_container_reprovisions() -> None:
"""DEFECT 3 fix: a cache hit whose container is no longer live (OOM-killed,
manually removed) is evicted and re-provisioned with fresh creds, rather
than handing back creds for a container that no longer exists."""
orch, sandbox = _make_orchestrator()
stale_info = _info(
{"postgres": SandboxConnection(host="h", port=5432, password="pw-old")}
)
fresh_info = _info(
{"postgres": SandboxConnection(host="h", port=5432, password="pw-new")}
)
sandbox.provision.side_effect = [stale_info, fresh_info]
sandbox.is_live.return_value = False
first = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"])
second = await orch.ensure_sandbox("dev-1", ["postgres"], ["postgres"])
expected_provision_calls = 2
assert first is stale_info
assert second is fresh_info
assert sandbox.provision.await_count == expected_provision_calls
assert orch._sandbox_info["dev-1"] is fresh_info
sandbox.is_live.assert_awaited_once_with("dev-1", ["postgres"])