v0.17.0 — Wave 3: sandbox DB, DB isolation, mobile UI, cloud auth, X account, roadmap engine (#303)

* feat(sandbox): throwaway per-agent Postgres/Redis sandbox containers

Orchestrator-provisioned sibling containers per agent spawn
(SandboxProvisioner, roboco/runtime/sandbox.py). Per-project opt-in via
projects.sandbox_services (migration 057); master switch
ROBOCO_SANDBOX_DB_ENABLED, default-off, armed in the NAS compose only.

When active, ROBOCO_TEST_DB_* / ROBOCO_TEST_REDIS_* point at the sandbox
and the prod-creds gate-env injection is suppressed (sandbox replaces,
never coexists). Sandbox lifetime tracks the agent container: teardown at
every removal path, orphan janitor at startup + each reaper tick with a
grace window for mid-flight spawns. The pre-spawn stale-clear spares the
just-provisioned sandbox; provision pre-clears stale same-named
containers from a crash-missed teardown.

Panel: per-project sandbox-service switches in the edit dialog + feature
flag card entry.

* docs: CLAUDE.md entry for the sandboxed dev DB/Redis subsystem

* feat(security): isolate prod Postgres/Redis from agent containers (roboco_data network)

Second user-defined bridge roboco_data carries postgres+redis only; the
orchestrator is multi-homed (default + data). Spawned agents and their
sandbox sidecars stay on roboco_default and can no longer resolve or
reach roboco-postgres:5432 / roboco-redis:6379 (redis has no auth —
membership is its only containment). Normal bridge, so host-published
ports (15432/16379) keep working. Applied to both build composes and
the registry compose; docker-compose.yml re-synced byte-identical with
docker-compose.yaml (it had drifted by the sandbox flag block).

ROBOCO_DB_NETWORK_ISOLATED (config default false, armed alongside the
topology) suppresses the legacy _append_gate_env prod-creds injection:
under isolation those creds dead-end, and unreachable creds are worse
than none. DB-needing projects opt into sandbox_services instead. The
flag is deliberately not a panel feature flag - it must travel with the
compose networks: stanzas.

Preserved by construction: agent<->agent A2A and orchestrator->agent SDK
polls on :9000, MCP->orchestrator on :8000, ollama reachability, docker
exec/inspect (daemon socket), host port publishing.

* feat(panel): full mobile responsiveness pass

Shared primitives: useIsMobile (useSyncExternalStore, hydration-safe,
memoized matchMedia subscribe), ResponsiveTable table->card switch below
md (single subtree mounted, no duplicated interactive rows), scrollable
snap TabsList in the base primitive (justify-center-safe so the first
tab stays reachable on overflow), persistent md:hidden bottom tab bar
(Overview/Tasks/Kanban/Chat, safe-area padded).

Applied: card lists for tasks/projects/products/work-sessions/sessions
+ the three raw metrics tables; CEO approval queue / release proposal /
playbook review action rows stack on narrow; command-center reorders
approvals above the fold on mobile; task-header metadata wraps;
Communications + A2A become URL-driven single-pane drill-downs below lg
(fixes the unconstrained-height ScrollArea bug) with dvh heights;
recharts label density/radius adapts via useIsMobile; git diff viewer
gets mobile font + wrap toggle; vh->dvh sweep; chat composers get
safe-area-inset padding; dashboard main p-4 md:p-6 + pb-20 for the bar.

Verified at 375px on the built app: bottom bar, drawer, approval-first
overview, swipeable kanban tab strip. All gates green (eslint, tsc,
vitest 249, next build 24/24 routes).

* feat(auth): cloud auth via FastAPI Users (default-off, single-user cookie session)

ROBOCO_CLOUD_AUTH_ENABLED (default off) lets the panel/API be exposed
beyond localhost without changing the CEO's local no-login flow while
off — get_agent_context and the WS gate are byte-for-byte unchanged in
off-mode. On: header-trust dies for humans — any agent-role claim (ceo
or a privileged PM/board role) with no valid HMAC token or session
cookie is 401, closing the header-spoof hole on the host-published
:8000 port for every role. The agent-fleet HMAC path and the system
self-PATCH keep working unmodified in both modes.

Single seeded CEO user (migration 058 users table, UserTable), no
registration router — idempotent env-driven upsert at startup by PK.
Cookie transport (httponly/secure/samesite=lax) + a JWTStrategy bound
to a fingerprint of the current password hash (rotating the password
invalidates every prior session). Sliding 30-day session: every
authenticated request re-mints the cookie, so an active session never
expires — no unexpected logouts.

Panel: (auth)/login page + proxy.ts (Next 16 rename of middleware; probes
/auth/status over the docker-internal URL, fails open to off) gate the
dashboard; client.ts gets withCredentials + 401->/login. nginx unchanged.

Review hardening: broadened the on-mode rejection from ceo-only to every
non-CEO role without a valid token (was only closed when
ROBOCO_AGENT_AUTH_REQUIRED was also armed); Next-16 proxy.ts rename to
clear the middleware deprecation warning.

* feat(x): RoboCo X account engine — HoM drafts, per-post CEO approval (default-off)

ROBOCO_X_ENGINE_ENABLED (default off, inert without creds). Mirrors the
ReleaseManagerEngine held-artifact shape: XEngine drafts a post when a
release publishes (via a draft_release_post seam on ReleaseProposalService
.approve) and drafts replies to meaningful mentions (dedicated poll loop,
x_seen_mentions dedup ledger, per-cycle/open caps). Drafting is
local-model-only, clamped to 280 chars. Nothing auto-posts — every tweet
is a held task (source x_post/x_reply, confirmed_by_human=False,
Secretary-owned, dispatcher-skipped) the CEO edits/approves/rejects in a
panel queue.

The four OAuth 1.0a secrets live Fernet-encrypted in a singleton
x_credentials row (migration 059, all-or-nothing, API returns only
has_credentials); decryption is server-side, agents never hold creds or
egress. Hand-rolled OAuth 1.0a HMAC-SHA1 signer, no new dependency;
NullXClient makes the unconfigured path a graceful no-op.

XPostService.approve (CEO-only) is the sole caller of post_tweet.

Review hardening: closed a double-post race — the approve path now
re-reads committed task state inside the Redis lock and commits COMPLETED
before releasing, so a concurrent approve that acquires the lock after the
winner released can't re-post (SET-NX is non-waiting, and the route-level
commit landed after the lock dropped). Added a regression test.

* feat(roadmap): board roadmap engine — PO proposes themed cycles, CEO approves per-item (default-off)

ROBOCO_ROADMAP_ENGINE_ENABLED (default off). Weekly, RoadmapEngine opens
ONE held exploration task (source=board_roadmap, confirmed_by_human=False,
Product-Owner-assigned), deduped to one open cycle. A dedicated one-shot
_dispatch_roadmap_exploration spawns the PO solo (not the two-reviewer
board path, which would also spawn HoM + fire Approve-&-Start). The PO
explores read-only (git/KB/metrics/releases/charter/web) and makes one
propose_roadmap call (PO-only content verb) authoring a themed cycle —
goal + 3-7 item drafts — persisted as a roadmap_cycle marker (no table,
no migration; head stays 059).

The CEO acts per-item in the panel roadmap queue: approve materializes a
BACKLOG task (source=roadmap, no assignee — never auto-starts), reject
records a reason; all-items-terminal completes the exploration task.
RoadmapService is idempotent per item. Dispatchers skip board_roadmap.

Includes a real SQLAlchemy dirty-check fix (deep-copy the JSON marker
before mutating, or the in-place edit + reassign compares equal to its
own baseline and the UPDATE is skipped).

Review hardening: create_task_from_draft now honors a draft-declared
source only from a {prompter, roadmap} whitelist — drafts are
LLM-authored, so an unbounded source could impersonate a privileged
origin (release_manager would even wedge that engine's dedup).

* chore(release): 0.17.0

Wave 3 — six default-off subsystems: sandboxed dev DB/Redis, prod
Postgres/Redis network isolation, full mobile UI pass, cloud auth
(FastAPI Users), the RoboCo X account engine, and the board roadmap
engine. Plus the waves 1+2 work already on master since 0.16.0.

Version bumped across the canonical set (config.py, __init__.py,
pyproject.toml, panel/package.json, uv.lock); CHANGELOG [Unreleased]
cut to [0.17.0]; docs/map delta added.

Compose: every optional feature armed :-true in the NAS composes, OFF
in the user-facing registry compose. Two opt-in exceptions default off
(CLOUD_AUTH — needs email/password/secret + TLS, would otherwise fail
startup; ROUTING_STRICT — fail-closed spawning). DB_NETWORK_ISOLATED
stays on in both (coupled to the roboco_data topology).

* chore(compose): arm cloud_auth + routing_strict ON in the NAS composes

Every feature defaults ON in the NAS composes per policy — these two
were wrongly left off. Both keep the ${VAR:-true} form so the operator
controls the real runtime via .env: cloud auth needs
ROBOCO_CLOUD_AUTH_EMAIL/_PASSWORD/_SECRET + TLS set there before a boot
(else startup fails loud), and routing_strict is fail-closed. Registry
compose keeps both off.

* fix(ci): reflow board.md prose (quality gate) + document v0.17.0 env creds

The roadmap section added hard-wrapped prose that failed the markdown
prose gate; reflowed (token-invariant). Also brought .env.example
current: cloud auth (now armed — needs SECRET or startup fails), routing
strict, the X engine (panel-entered OAuth), and web research.

* fix(ci): reduce cyclomatic complexity of five wave-3 blocks (xenon gate)

The wave-3 subagents introduced C-rank functions the CI xenon gate
rejects (my per-item reviews ran ruff/mypy/pytest but not xenon):
- sandbox.janitor_sweep -> extract _list_labeled_sandboxes /
  _list_live_agent_containers / _prune_grace
- x_client.fetch_mentions -> extract _parse_mention_items
- x_engine.run_cycle -> extract _process_mentions
- orchestrator._dispatch_pm_work -> extract the source-skip into a
  MODULE-level _is_held_ceo_source (module, not method, so the
  wholesale-mocked dispatcher unit tests exercise the real logic)
- auth/seed.ensure_seed_user -> extract _apply_seed_updates (module avg -> A)

Behavior-preserving; full suite green (11902), xenon clean.

* fix(ci): declare pyjwt + fastapi-users-db-sqlalchemy as direct deps (deptry)

The cloud-auth code imports jwt and fastapi_users_db_sqlalchemy directly
but they were only transitive deps (via fastapi-users), which deptry
(quality gate, DEP003) rejects. Declared explicitly; deptry roboco/ clean.
Missed originally because local make quality stopped at earlier gates
before reaching deptry.

* feat(x): gate mention replies behind ROBOCO_X_REPLIES_ENABLED (default off)

Per CEO decision: the X engine should only post about releases by
default. Reading mentions needs a paid X API tier, so the mention-reply
half is now a deliberate opt-in on top of release posting.

New default-off flag x_replies_enabled gates the mentions poll loop
(_x_mentions_poll_loop) and XEngine.run_cycle; release-post drafting
(the release-proposal approve hook) is unaffected and still runs when
x_engine_enabled + credentials are set. Added to FEATURE_FLAGS + the
panel card. Tests: release posting works with replies off; run_cycle +
the poll loop are no-ops with replies off.

* fix: 401 only redirects to /login when cloud auth is on; panel-token strips .env quotes

Two bugs that together dead-ended login in secure mode:
- client.ts redirected to /login on ANY 401, so a mismatched panel
  token (header-trust/secure mode, cloud auth off) bounced the user to a
  login page whose backend route isn't mounted -> 404. Now it probes
  /auth/status (bare fetch, no interceptor re-entry) and only redirects
  when cloud_auth_enabled.
- make panel-token read the .env secret with grep|cut without stripping
  surrounding quotes, so a quoted ROBOCO_AGENT_AUTH_SECRET produced a
  token signed with the quotes included — which never verifies against
  the orchestrator (docker-compose/pydantic unquote the secret). Now
  strips surrounding single/double quotes.

* fix: git-log 500 on '|' in commit message; X queue shows an empty state

- GET /api/git/log 500'd (ValueError: Invalid isoformat) when a commit
  SUBJECT contained a '|' (e.g. the 'curl|sh' lockdown commit): the
  fixed '|' field delimiter let the subject's pipe shift the split so
  author+date collapsed into one field. Switched to \x1f (Unit
  Separator), which can't appear in commit content. Regression test with
  a piped subject.
- The X Post Queue returned null when empty, so there was no visible
  place for the X drafts. It now renders a discoverable empty state
  pointing at Settings -> X credentials.

* docs: bring docs/rag + docs/map current for v0.17.0 (waves 1-3)

Agent-facing RAG corpus and codebase map updated for every feature in
the 0.17.0 span, code-verified:
- wave 3: sandbox DB, DB network isolation, cloud auth, X engine
  (+ x_replies_enabled sub-flag), board roadmap engine — new RAG
  architecture pages + role/tool/config-reference updates; new symbols,
  migrations 057-059, panel surfaces, and the get_agent_context
  dual-path across the map slices.
- waves 1-2: A2A live view + switchboard, prompter memory
  (search_past_tasks), Secretary edit access + PM-lighter scope, the
  PR-gate auto-submit turn cut (ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED).
- correctness fix: api-routes-schemas.md no longer claims the A2A admin
  routes are reachable by any authenticated agent — they carry a
  _require_ceo gate (wave 2c).

docs/internal, _front.md deltas, and the frozen _complete_map.md
snapshot untouched.

* fix(rag): atomic upsert for indexed-doc tracking (kills e2e segfault)

The indexed-document tracking write used check-then-insert in two paths
(IndexedDocumentRepository.upsert_batch and the file-source
_upsert_doc_record). Under concurrent indexing both callers saw no row
and both inserted, so the second violated uq_indexed_doc_source and
poisoned its transaction — surfacing in CI as the intermittent
_checkin_failed SIGSEGV on the failed connection's pool checkin.

Both paths now use INSERT ... ON CONFLICT DO UPDATE against the
constraint: coalesce keeps an existing title/preview when the new value
is empty (matching the old guards) and metadata is jsonb-merged. The
batch dedupes within itself first (ON CONFLICT can't touch a row twice
in one statement). expire_all after the Core upsert keeps same-session
ORM reads consistent with the merged DB row.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 19:24:00 +02:00
committed by GitHub
co-authored by Renn F
parent 12745352aa
commit 3ccc723cd4
179 changed files with 12734 additions and 948 deletions
+13
View File
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
def test_gate_env_injects_test_db_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "toolchain_match_enabled", True)
monkeypatch.setattr(settings, "db_network_isolated", False)
monkeypatch.setattr(settings, "database_host", "roboco-postgres")
monkeypatch.setattr(settings, "database_port", 5432)
monkeypatch.setattr(settings, "database_user", "roboco")
@@ -38,3 +39,15 @@ def test_gate_env_inert_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
cmd: list[str] = []
AgentOrchestrator._append_gate_env(cmd)
assert cmd == []
def test_gate_env_suppressed_under_db_network_isolation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With postgres/redis on the data-only network, agents can't reach the
prod host — the injection must vanish even with toolchain-match on."""
monkeypatch.setattr(settings, "toolchain_match_enabled", True)
monkeypatch.setattr(settings, "db_network_isolated", True)
cmd: list[str] = []
AgentOrchestrator._append_gate_env(cmd)
assert cmd == []
@@ -51,6 +51,8 @@ def _make_orchestrator() -> AgentOrchestrator:
"_ci_watch_task",
"_dep_update_task",
"_release_manager_task",
"_x_mentions_task",
"_roadmap_engine_task",
):
setattr(orch, attr, None)
return orch
+155
View File
@@ -0,0 +1,155 @@
"""Board roadmap exploration dispatch — Product-Owner-solo (v1), never the
two-reviewer board-review gate, never the dev/PM delivery dispatchers.
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import ROADMAP_SOURCE
def _make_orch() -> AgentOrchestrator:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
cast("Any", orch)._pm_respawn_tracker = {}
cast("Any", orch)._schedule_respawn_persist = lambda *_a, **_k: None
orch._instances = {}
orch._board_dispatched = set()
return orch
def _roadmap_task(
*, orchestration_markers: dict[str, Any] | None = None
) -> dict[str, Any]:
return {
"id": str(uuid4()),
"status": "pending",
"team": "board",
"title": "Roadmap exploration cycle",
"description": "Explore and propose a themed roadmap cycle.",
"assigned_to": "product-owner",
"source": ROADMAP_SOURCE,
"orchestration_markers": orchestration_markers,
}
@pytest.mark.asyncio
async def test_roadmap_dispatch_spawns_only_product_owner() -> None:
"""A roadmap exploration task must spawn the Product Owner alone — Head of
Marketing is out of scope for v1 (non-goal: HoM co-authoring)."""
orch = _make_orch()
task = _roadmap_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_roadmap_exploration(task)
spawn.assert_awaited_once()
calls = list(spawn.await_args_list)
assert calls[0].kwargs["agent_id"] == "product-owner"
assert calls[0].kwargs["task_id"] == task["id"]
@pytest.mark.asyncio
async def test_roadmap_dispatch_is_one_shot() -> None:
"""Re-ticking a still-unauthored, still-pending cycle must NOT respawn —
board roles have no progression verb, so a respawn would just loop."""
orch = _make_orch()
task = _roadmap_task()
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "_task_git_context", return_value=None),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_roadmap_exploration(task)
await orch._dispatch_roadmap_exploration(task)
spawn.assert_awaited_once()
@pytest.mark.asyncio
async def test_roadmap_dispatch_skips_once_authored() -> None:
"""Once ``propose_roadmap`` has stamped the roadmap_cycle marker, the
dispatcher must not spawn again — the CEO roadmap queue owns the rest."""
orch = _make_orch()
task = _roadmap_task(
orchestration_markers={"roadmap_cycle": {"goal": "x", "items": []}}
)
with (
patch.object(orch, "_is_agent_active", return_value=False),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_roadmap_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_roadmap_dispatch_skips_active_po() -> None:
orch = _make_orch()
task = _roadmap_task()
with (
patch.object(orch, "_is_agent_active", return_value=True),
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
):
await orch._dispatch_roadmap_exploration(task)
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_dispatch_pm_work_routes_roadmap_source_away_from_board_handler() -> None:
"""A board_roadmap task must ride the dedicated roadmap dispatcher, never
the two-reviewer ``_handle_board_assigned_task`` (which would also spawn
Head of Marketing and fire the Approve & Start handoff — both wrong here)."""
task = _roadmap_task()
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=[task])
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="product-owner")
stub._BOARD_AGENTS = frozenset({"product-owner", "head-marketing"})
stub._dispatch_roadmap_exploration = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._route_unassigned_pm_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._dispatch_roadmap_exploration.assert_awaited_once()
stub._handle_board_assigned_task.assert_not_awaited()
stub._handle_pm_assigned_task.assert_not_awaited()
@pytest.mark.asyncio
async def test_roadmap_tasks_are_never_routed_by_dev_dispatch() -> None:
tasks = [_roadmap_task()]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
def test_roadmap_prompt_names_solo_po_and_real_verbs() -> None:
"""The prompt must steer the PO to its real verbs (triage / propose_roadmap
/ i_am_idle), make the solo-authorship explicit, and away from
claim/plan/delegate it does not have."""
orch = _make_orch()
prompt = orch._build_roadmap_prompt(_roadmap_task())
assert "triage()" in prompt
assert "propose_roadmap(" in prompt
assert "i_am_idle()" in prompt
assert "Head of Marketing is not" in prompt
assert "involved in this cycle" in prompt
assert "do not" in prompt.lower()
@@ -0,0 +1,28 @@
"""The roadmap-engine orchestrator loop is fully dormant when disabled
(default).
With ``roadmap_engine_enabled`` off, ``_roadmap_engine_loop`` must return
immediately — no sleep, no HTTP, no DB, no Product-Owner spawn — so a
standard deployment behaves exactly as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_roadmap_engine_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
# Gated off -> returns at once. If the gate were missing it would sleep the
# full interval and this wait_for would time out.
await asyncio.wait_for(AgentOrchestrator._roadmap_engine_loop(stub), timeout=1.0)
+164
View File
@@ -0,0 +1,164 @@
"""Sandbox env injection: `_append_sandbox_env` + the `_spawn_container` branch.
A sandbox-active spawn must inject `ROBOCO_TEST_DB_*` / `ROBOCO_TEST_REDIS_*`
pointed at the sandbox and MUST NOT also run the legacy `_append_gate_env`
prod-creds injection — sandbox replaces, never coexists with, prod creds.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from roboco.models.runtime import (
OrchestratorAgentConfig,
PostgresSandbox,
RedisSandbox,
SandboxInfo,
)
from roboco.runtime.orchestrator import AgentOrchestrator
def _config(sandbox_info: SandboxInfo | None = None) -> OrchestratorAgentConfig:
return OrchestratorAgentConfig(
agent_id="dev-1",
blueprint_path=Path(),
mcp_config_path=Path("/tmp/mcp.json"),
sandbox_info=sandbox_info,
)
def test_append_sandbox_env_injects_postgres_and_redis() -> None:
info = SandboxInfo(
postgres=PostgresSandbox(
host="roboco-sandbox-pg-dev-1",
port=5432,
user="sandbox",
password="pgpw",
database="sandbox",
),
redis=RedisSandbox(
host="roboco-sandbox-redis-dev-1", port=6379, password="rdpw"
),
)
cmd: list[str] = []
AgentOrchestrator._append_sandbox_env(cmd, _config(info))
assert "ROBOCO_TEST_DB_HOST=roboco-sandbox-pg-dev-1" in cmd
assert "ROBOCO_TEST_DB_PORT=5432" in cmd
assert "ROBOCO_TEST_DB_USER=sandbox" in cmd
assert "ROBOCO_TEST_DB_PASSWORD=pgpw" in cmd
assert "ROBOCO_TEST_DB_ADMIN_DB=sandbox" in cmd
assert "ROBOCO_TEST_REDIS_HOST=roboco-sandbox-redis-dev-1" in cmd
assert "ROBOCO_TEST_REDIS_PORT=6379" in cmd
assert "ROBOCO_TEST_REDIS_PASSWORD=rdpw" in cmd
def test_append_sandbox_env_postgres_only_omits_redis_vars() -> None:
info = SandboxInfo(
postgres=PostgresSandbox(
host="roboco-sandbox-pg-dev-1",
port=5432,
user="sandbox",
password="pgpw",
database="sandbox",
)
)
cmd: list[str] = []
AgentOrchestrator._append_sandbox_env(cmd, _config(info))
assert "ROBOCO_TEST_DB_HOST=roboco-sandbox-pg-dev-1" in cmd
assert not any(v.startswith("ROBOCO_TEST_REDIS_") for v in cmd)
def test_append_sandbox_env_noop_without_sandbox_info() -> None:
cmd: list[str] = []
AgentOrchestrator._append_sandbox_env(cmd, _config(None))
assert cmd == []
def _fake_proc() -> AsyncMock:
proc = AsyncMock()
proc.communicate = AsyncMock(return_value=(b"", b""))
proc.returncode = 0
return proc
def _stub_spawn_container_collaborators(
monkeypatch: pytest.MonkeyPatch, orch: AgentOrchestrator, calls: list[str]
) -> None:
monkeypatch.setattr(orch, "_provider_for", lambda *_a: None)
monkeypatch.setattr(orch, "_remove_container", AsyncMock(return_value=None))
monkeypatch.setattr(orch, "_resolve_host_paths", lambda *_a: {})
monkeypatch.setattr(
AgentOrchestrator,
"_build_mount_args",
staticmethod(lambda *_a: []),
)
monkeypatch.setattr(orch, "_append_agent_auth_env", lambda *_a: None)
monkeypatch.setattr(orch, "_append_git_context_env", lambda *_a: None)
monkeypatch.setattr(orch, "_append_gate_env", lambda *_a: calls.append("gate"))
monkeypatch.setattr(
orch,
"_append_sandbox_env",
lambda *_a: calls.append("sandbox"),
)
monkeypatch.setattr(orch, "_append_image_and_claude_args", lambda *_a: None)
monkeypatch.setattr(
asyncio, "create_subprocess_exec", AsyncMock(return_value=_fake_proc())
)
@pytest.mark.asyncio
async def test_spawn_container_uses_sandbox_env_when_sandbox_active(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
calls: list[str] = []
_stub_spawn_container_collaborators(monkeypatch, orch, calls)
info = SandboxInfo(
postgres=PostgresSandbox(
host="h", port=5432, user="sandbox", password="pw", database="sandbox"
)
)
await orch._spawn_container(_config(info))
assert calls == ["sandbox"]
@pytest.mark.asyncio
async def test_spawn_container_uses_legacy_gate_env_without_sandbox(
monkeypatch: pytest.MonkeyPatch,
) -> None:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
calls: list[str] = []
_stub_spawn_container_collaborators(monkeypatch, orch, calls)
await orch._spawn_container(_config(None))
assert calls == ["gate"]
@pytest.mark.asyncio
async def test_spawn_container_stale_clear_spares_fresh_sandbox(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The pre-spawn stale-clear must not tear down the sandbox that was
just provisioned for this very spawn (teardown_sandbox=False)."""
orch = AgentOrchestrator.__new__(AgentOrchestrator)
calls: list[str] = []
_stub_spawn_container_collaborators(monkeypatch, orch, calls)
remove = AsyncMock(return_value=None)
monkeypatch.setattr(orch, "_remove_container", remove)
info = SandboxInfo(
postgres=PostgresSandbox(
host="h", port=5432, user="sandbox", password="pw", database="sandbox"
)
)
await orch._spawn_container(_config(info))
remove.assert_awaited_once_with("roboco-agent-dev-1", teardown_sandbox=False)
@@ -0,0 +1,155 @@
"""`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.runtime import PostgresSandbox, 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(
postgres=PostgresSandbox(
host="h", port=5432, user="sandbox", password="pw", 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()
@@ -0,0 +1,224 @@
"""SandboxProvisioner: throwaway per-spawn Postgres/Redis sibling containers.
All docker calls are mocked — no real docker in unit tests. Readiness
deadlines are monkeypatched down so the timeout path runs in milliseconds.
"""
from __future__ import annotations
import time
import pytest
from roboco.runtime import sandbox as sandbox_module
from roboco.runtime.sandbox import SandboxProvisioner, SandboxProvisionError
_NETWORK = "roboco_default"
_PG_PORT = 5432
_REDIS_PORT = 6379
class _FakeRunner:
"""Records every docker invocation; behavior configured per test."""
def __init__(
self,
*,
run_rc: int = 0,
exec_rc: int = 0,
teardown_rc: int = 0,
ps_output: bytes = b"",
ps_live_output: bytes = b"",
) -> None:
self.calls: list[list[str]] = []
self.run_rc = run_rc
self.exec_rc = exec_rc
self.teardown_rc = teardown_rc
self.ps_output = ps_output
self.ps_live_output = ps_live_output
self._ps_call_count = 0
async def __call__(
self, args: list[str], _timeout: float
) -> tuple[int, bytes, bytes]:
self.calls.append(args)
verb = args[0]
if verb == "run":
return self.run_rc, b"container-id\n", b""
if verb == "exec":
return self.exec_rc, b"", b""
if verb in ("stop", "kill", "rm"):
return self.teardown_rc, b"", b""
if verb == "ps":
self._ps_call_count += 1
# First ps call = the sandbox-labeled listing; second = live agents.
if self._ps_call_count == 1:
return 0, self.ps_output, b""
return 0, self.ps_live_output, b""
raise AssertionError(f"unexpected docker verb: {verb}")
@pytest.fixture(autouse=True)
def _fast_readiness_deadlines(monkeypatch: pytest.MonkeyPatch) -> None:
"""Shrink the polling deadlines so the timeout path is fast in tests."""
monkeypatch.setattr(sandbox_module, "_PG_READY_DEADLINE_SECONDS", 0.05)
monkeypatch.setattr(sandbox_module, "_REDIS_READY_DEADLINE_SECONDS", 0.05)
monkeypatch.setattr(sandbox_module, "_READY_POLL_INTERVAL_SECONDS", 0.01)
@pytest.mark.asyncio
async def test_provision_both_services_happy_path() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
info = await provisioner.provision("dev-1", ["postgres", "redis"])
assert info.postgres is not None
assert info.postgres.host == "roboco-sandbox-pg-dev-1"
assert info.postgres.port == _PG_PORT
assert info.postgres.user == "sandbox"
assert info.postgres.database == "sandbox"
assert info.redis is not None
assert info.redis.host == "roboco-sandbox-redis-dev-1"
assert info.redis.port == _REDIS_PORT
# Passwords are per-sandbox random tokens, not equal to each other.
assert info.postgres.password != info.redis.password
@pytest.mark.asyncio
async def test_provision_labels_are_correct() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.provision("dev-2", ["postgres"])
run_call = next(c for c in runner.calls if c[0] == "run")
assert "--network" in run_call
assert run_call[run_call.index("--network") + 1] == _NETWORK
label_indices = [i for i, a in enumerate(run_call) if a == "--label"]
labels = [run_call[i + 1] for i in label_indices]
assert sandbox_module.SANDBOX_LABEL in labels
assert "roboco.sandbox.owner=roboco-agent-dev-2" in labels
@pytest.mark.asyncio
async def test_provision_readiness_timeout_tears_down_and_raises() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=1) # container starts, never ready
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
with pytest.raises(SandboxProvisionError):
await provisioner.provision("dev-3", ["postgres"])
# Teardown attempted for the container that failed readiness.
teardown_verbs = {c[0] for c in runner.calls if c[0] in ("stop", "kill", "rm")}
assert "stop" in teardown_verbs or "rm" in teardown_verbs
rm_calls = [c for c in runner.calls if c[0] == "rm"]
assert any("roboco-sandbox-pg-dev-3" in c for c in rm_calls)
@pytest.mark.asyncio
async def test_provision_run_failure_tears_down_and_raises() -> None:
runner = _FakeRunner(run_rc=1) # docker run itself fails
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
with pytest.raises(SandboxProvisionError):
await provisioner.provision("dev-4", ["redis"])
@pytest.mark.asyncio
async def test_provision_rejects_unknown_service() -> None:
runner = _FakeRunner()
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
with pytest.raises(SandboxProvisionError):
await provisioner.provision("dev-5", ["mysql"])
# Nothing was ever run for an unknown service.
assert runner.calls == []
@pytest.mark.asyncio
async def test_teardown_idempotent_on_missing_container() -> None:
runner = _FakeRunner(teardown_rc=1) # "no such container" for every verb
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
# Must not raise even though every teardown call reports failure.
await provisioner.teardown("never-provisioned")
verbs = [c[0] for c in runner.calls]
assert "rm" in verbs
@pytest.mark.asyncio
async def test_janitor_removes_orphaned_sandbox_only() -> None:
# Two sandboxes on the host: one owned by a still-live agent, one orphaned.
ps_output = (
b"roboco-sandbox-pg-alive\troboco-agent-alive\n"
b"roboco-sandbox-pg-orphan\troboco-agent-orphan\n"
)
live_output = b"roboco-agent-alive\n"
runner = _FakeRunner(ps_output=ps_output, ps_live_output=live_output)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.janitor_sweep()
rm_calls = [c for c in runner.calls if c[0] == "rm"]
torn_down = {c[-1] for c in rm_calls}
assert "roboco-sandbox-pg-orphan" in torn_down
assert "roboco-sandbox-pg-alive" not in torn_down
@pytest.mark.asyncio
async def test_janitor_noop_when_no_sandboxes() -> None:
runner = _FakeRunner(ps_output=b"")
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.janitor_sweep()
assert all(c[0] != "rm" for c in runner.calls)
@pytest.mark.asyncio
async def test_provision_preclears_stale_sandboxes_before_run() -> None:
"""A crash-missed teardown leaves same-named containers; provision must
clear them first or `docker run` fails on the name conflict."""
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.provision("dev-6", ["postgres"])
first_run = next(i for i, c in enumerate(runner.calls) if c[0] == "run")
preclear_rms = [
c for c in runner.calls[:first_run] if c[0] == "rm" and c[-1].endswith("dev-6")
]
assert any("roboco-sandbox-pg-dev-6" in c for c in preclear_rms)
assert any("roboco-sandbox-redis-dev-6" in c for c in preclear_rms)
@pytest.mark.asyncio
async def test_janitor_grace_skips_freshly_provisioned_owner() -> None:
"""A sandbox is provisioned before its agent container exists — a sweep
racing that mid-flight spawn must not reap the fresh sandbox."""
ps_output = b"roboco-sandbox-pg-fresh\troboco-agent-fresh\n"
runner = _FakeRunner(ps_output=ps_output, ps_live_output=b"")
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
provisioner._provisioned_at = {"roboco-agent-fresh": time.monotonic()}
await provisioner.janitor_sweep()
assert all(c[0] != "rm" for c in runner.calls)
@pytest.mark.asyncio
async def test_janitor_reaps_after_grace_expiry() -> None:
ps_output = b"roboco-sandbox-pg-old\troboco-agent-old\n"
runner = _FakeRunner(ps_output=ps_output, ps_live_output=b"")
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
provisioner._provisioned_at = {
"roboco-agent-old": time.monotonic()
- 10 * sandbox_module._JANITOR_GRACE_SECONDS
}
await provisioner.janitor_sweep()
rm_calls = [c for c in runner.calls if c[0] == "rm"]
assert any("roboco-sandbox-pg-old" in c for c in rm_calls)
assert provisioner._provisioned_at == {}
@@ -0,0 +1,120 @@
"""Sandbox teardown/janitor wiring in the orchestrator's removal + reaper paths.
`_remove_container` is the single chokepoint every removal path routes
through (stop_agent, reaper kills, pre-spawn stale-clear), so sandbox
teardown lives there rather than duplicated at each call site. Gated on the
flag: when off, behavior must stay byte-for-byte identical to before this
feature (no extra docker calls).
"""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings
from roboco.runtime.orchestrator import AgentOrchestrator
class _FakeProc:
def __init__(self, returncode: int) -> None:
self.returncode = returncode
async def wait(self) -> int:
return self.returncode
async def communicate(self) -> tuple[bytes, bytes]:
return b"", b""
async def _fake_create_subprocess_exec(*args: Any, **_kwargs: Any) -> _FakeProc:
if args[1] == "inspect":
return _FakeProc(1) # container does not exist -> skip log dump
if args[1] == "rm":
return _FakeProc(0)
raise AssertionError(f"unexpected docker args: {args}")
def _make_orchestrator() -> tuple[AgentOrchestrator, MagicMock]:
orch = AgentOrchestrator.__new__(AgentOrchestrator)
sandbox = MagicMock()
sandbox.teardown = AsyncMock()
sandbox.janitor_sweep = AsyncMock()
orch._sandbox = sandbox
return orch, sandbox
@pytest.mark.asyncio
async def test_remove_container_tears_down_sandbox_when_flag_on(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
orch, sandbox = _make_orchestrator()
await orch._remove_container("roboco-agent-dev-1")
sandbox.teardown.assert_awaited_once_with("dev-1")
@pytest.mark.asyncio
async def test_remove_container_teardown_sandbox_false_skips_even_when_flag_on(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
orch, sandbox = _make_orchestrator()
await orch._remove_container("roboco-agent-dev-1", teardown_sandbox=False)
sandbox.teardown.assert_not_called()
@pytest.mark.asyncio
async def test_remove_container_skips_sandbox_teardown_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", False)
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
orch, sandbox = _make_orchestrator()
await orch._remove_container("roboco-agent-dev-1")
sandbox.teardown.assert_not_called()
@pytest.mark.asyncio
async def test_sandbox_janitor_sweep_noop_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", False)
orch, sandbox = _make_orchestrator()
await orch._sandbox_janitor_sweep()
sandbox.janitor_sweep.assert_not_called()
@pytest.mark.asyncio
async def test_sandbox_janitor_sweep_runs_when_flag_on(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
orch, sandbox = _make_orchestrator()
await orch._sandbox_janitor_sweep()
sandbox.janitor_sweep.assert_awaited_once()
@pytest.mark.asyncio
async def test_sandbox_janitor_sweep_swallows_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "sandbox_db_enabled", True)
orch, sandbox = _make_orchestrator()
sandbox.janitor_sweep.side_effect = RuntimeError("boom")
await orch._sandbox_janitor_sweep() # must not raise
@@ -0,0 +1,67 @@
"""X post/reply proposals are CEO-gated artifacts, never delivery work.
Mirrors the release-manager dispatch-skip tests: an ``x_post``/``x_reply``
task must never be handed to the PM-triage path or the dev-assignment path,
regardless of ``confirmed_by_human`` (unlike self-heal, there is no CEO gate
that ever lifts an X draft into delivery work it is acted on only by the
x routes + post service).
"""
from __future__ import annotations
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.runtime.orchestrator import AgentOrchestrator
from roboco.services.task import X_POST_SOURCE, X_REPLY_SOURCE
def _task(tid: str, source: str, *, assigned_to: str | None = None) -> dict[str, Any]:
return {"id": tid, "source": source, "assigned_to": assigned_to}
@pytest.mark.asyncio
async def test_x_posts_are_never_routed_by_pm_dispatch() -> None:
tasks = [
_task("A", X_POST_SOURCE, assigned_to="secretary-1"),
_task("B", X_REPLY_SOURCE, assigned_to="secretary-1"),
_task("C", "manual"), # ordinary unassigned -> routing still happens
]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._resolve_agent_slug = MagicMock(return_value="secretary-1")
stub._BOARD_AGENTS = frozenset()
stub._route_unassigned_pm_task = AsyncMock()
stub._handle_pm_assigned_task = AsyncMock()
stub._handle_board_assigned_task = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_pm_work(cast("AgentOrchestrator", stub), client)
stub._handle_pm_assigned_task.assert_not_awaited()
stub._handle_board_assigned_task.assert_not_awaited()
routed = [c.args[1]["id"] for c in stub._route_unassigned_pm_task.await_args_list]
assert routed == ["C"]
@pytest.mark.asyncio
async def test_x_posts_are_never_routed_by_dev_dispatch() -> None:
tasks = [
_task("A", X_POST_SOURCE, assigned_to="secretary-1"),
_task("B", X_REPLY_SOURCE, assigned_to="secretary-1"),
]
stub = MagicMock()
stub._fetch_tasks = AsyncMock(return_value=tasks)
stub._is_task_handled_this_tick = MagicMock(return_value=False)
stub._dev_dispatch_one = AsyncMock()
client: Any = MagicMock()
await AgentOrchestrator._dispatch_dev_work(cast("AgentOrchestrator", stub), client)
stub._dev_dispatch_one.assert_not_awaited()
if __name__ == "__main__":
pytest.main([__file__, "-q"])
@@ -0,0 +1,39 @@
"""The X-mentions orchestrator loop is fully dormant when disabled (default).
With ``x_engine_enabled`` off, ``_x_mentions_poll_loop`` must return
immediately no sleep, no HTTP, no DB so a standard deployment behaves
exactly as today.
"""
from __future__ import annotations
import asyncio
import types
from typing import cast
import pytest
from roboco.config import settings as cfg
from roboco.runtime.orchestrator import AgentOrchestrator
@pytest.mark.asyncio
async def test_x_mentions_loop_returns_immediately_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "x_engine_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
# Gated off -> returns at once. If the gate were missing it would sleep the
# full interval and this wait_for would time out.
await asyncio.wait_for(AgentOrchestrator._x_mentions_poll_loop(stub), timeout=1.0)
@pytest.mark.asyncio
async def test_x_mentions_loop_dormant_when_only_replies_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Engine on but the reply sub-switch off (the release-posts-only default):
the mentions loop still never runs."""
monkeypatch.setattr(cfg, "x_engine_enabled", True)
monkeypatch.setattr(cfg, "x_replies_enabled", False)
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
await asyncio.wait_for(AgentOrchestrator._x_mentions_poll_loop(stub), timeout=1.0)