Files
roboco/tests/unit/runtime/test_sandbox_provision_spawn.py
T
8f6dde9a50 feat(sandbox): pluggable per-engine registry (postgres/redis/mongo) (#324)
* feat(sandbox): pluggable per-engine registry (postgres/redis/mongo)

Replaces the hardcoded postgres+redis branches in the provisioner and the
env emitter with a registry of SandboxEngine specs (image, run args,
readiness probe, connection, ROBOCO_TEST_* env) in a pure low module
(roboco/models/sandbox.py). VALID_SANDBOX_SERVICES is derived from the
registry — single source of truth — and the provisioner + orchestrator
iterate it, so adding an engine is one class + one registry line, not
another branch. Adds a mongo:8-alpine engine (ROBOCO_TEST_MONGO_*) as the
third service alongside postgres/redis.

Also fixes the cold-pull loop that stranded v0.19.0 board agents with
empty error strings: docker run pulled inline under a 20s deadline, so a
NAS cold pull was killed, cancelled, and re-pulled from scratch forever.
_ensure_image now inspects + pulls (300s) before run; provisioning errors
log type+message so a bare TimeoutError no longer shows as "".

Panel edit-project dialog: postgres/redis toggles -> a Set<string>
multi-select driven by a SANDBOX_SERVICES catalog, so new engines appear
in the UI by adding to the catalog.

Tests: engine parity (allowlist==registry, unique slugs/images, no None
leak in env, SandboxInfo aggregates every engine), mongo provision + env
injection, plus the existing postgres/redis provision/env/spawn/janitor
suite updated to the registry shape. 821 unit / 5 skip green; ruff + mypy
(360 files) clean.

* docs(sandbox): reflect pluggable engine registry + mongo across docs

CHANGELOG (0.19.0): Added entry for the pluggable sandbox engine registry
(postgres/redis/mongo) + Fixed entry for the cold-pull loop/empty-error
strand that boarded v0.19.0 board agents.

docs/map (9 files): sandbox subsystem blurbs, SandboxProvisioner rows,
_maybe_provision_sandbox/_append_sandbox_env rows, feature-flag rows, the
migration-057 row + v0.17.0 delta, and the models.md VALID_SANDBOX_SERVICES
note — all retitled to DB/Redis/Mongo via the engine registry
(roboco/models/sandbox.py), with the one-class-one-line extension story and
the _ensure_image cold-pull fix. Production-network (roboco_data) lines left
as postgres+redis — mongo is sandbox-only, not a prod service.

docs/rag (3 files): sandbox-db.md rewritten around the registry (engine list,
generic _provision_engine, image pre-pull, ROBOCO_TEST_DB_*/REDIS_*/MONGO_*
incl. MONGO_AUTH_DB=admin, single emit_env); config-reference sandbox flag
row + subsection retitled; db-network-isolation framing broadened to
postgres/redis/mongo. preconditions-and-rejections left untouched (its hit
was an unrelated gateway see-also link).

* test(e2e): harden umbrella close terminal reads with bounded wait-for-state

The MegaTask umbrella close test flaked once on CI (ceo-approve returned
200 but the re-fetch saw awaiting_pm_review) then passed on re-run. The
production path is deterministic: complete -> main_pm_complete ->
submit_pm_review -> escalate_to_ceo -> ceo_approve -> commit, all on one
session, all awaited; the fire-and-forget completion hooks are isolated
(own session, best-effort, never touch task.status or the request session).
20 local runs could not reproduce it.

The one real surface is the read pattern: the e2e stack commits on the
uvicorn thread's loop and reads via a separate loop (run_db -> asyncio.run
with a fresh engine), so a terminal single point-read can race a
still-draining completion hook on a contended runner. Replace the two
terminal point-reads with a bounded wait_for_status poll. Strictly better
than a one-shot read: absorbs the transient, and a genuine state bug still
surfaces via the timeout branch asserting against the last-read state.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-07 13:59:53 +02:00

158 lines
5.3 KiB
Python

"""`AgentOrchestrator._maybe_provision_sandbox` — the spawn-time decision gate.
Off (flag or project) => None, byte-for-byte identical to legacy behavior. A
project lookup hiccup degrades to "no sandbox" (best-effort, matching the
ambient-conventions-resolution convention); an actual provisioning failure
IS fail-loud — an agent whose gate can't run must never spawn.
"""
from __future__ import annotations
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, AgentReadinessError
def _make_orchestrator() -> tuple[AgentOrchestrator, MagicMock]:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._bg_tasks = set()
orch._running = True
sandbox = MagicMock()
sandbox.provision = AsyncMock()
orch._sandbox = sandbox
return orch, sandbox
@asynccontextmanager
async def _fake_db_ctx(db: Any) -> Any:
yield db
@pytest.mark.asyncio
async def test_flag_off_returns_none_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._maybe_provision_sandbox("dev-1", "roboco-api", "task-1")
assert result is None
get_svc.assert_not_called()
sandbox.provision.assert_not_called()
@pytest.mark.asyncio
async def test_project_without_sandbox_services_returns_none(
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._maybe_provision_sandbox("dev-1", "roboco-api", "task-1")
assert result is None
sandbox.provision.assert_not_called()
@pytest.mark.asyncio
async def test_missing_project_returns_none(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._maybe_provision_sandbox("dev-1", "roboco-api", "task-1")
assert result is None
@pytest.mark.asyncio
async def test_opted_in_project_provisions_sandbox(
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)
info = SandboxInfo(
services={
"postgres": SandboxConnection(
host="h", port=5432, password="pw", user="sandbox", database="sandbox"
)
}
)
sandbox.provision.return_value = info
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._maybe_provision_sandbox("dev-1", "roboco-api", "task-1")
assert result is info
sandbox.provision.assert_awaited_once_with("dev-1", ["postgres"])
@pytest.mark.asyncio
async def test_provisioning_failure_raises_readiness_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
orch, sandbox = _make_orchestrator()
project = MagicMock(sandbox_services=["postgres", "redis"])
project_service = MagicMock()
project_service.get_by_slug = AsyncMock(return_value=project)
sandbox.provision.side_effect = RuntimeError("boom")
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,
),
pytest.raises(AgentReadinessError, match="sandbox provisioning failed"),
):
await orch._maybe_provision_sandbox("dev-1", "roboco-api", "task-1")
@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._maybe_provision_sandbox("dev-1", "roboco-api", "task-1")
assert result is None
sandbox.provision.assert_not_called()