mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Agents with a write workspace (developer/product_owner/head_marketing/documenter) run with cwd = their git workspace clone. Claude Code launches each MCP server (flow/do/git-readonly/optimal/docs/search) and the SDK server as `uv run python -m ...` from that cwd. When the clone's uv.lock drifts from the baked image, `uv run` re-resolves and re-syncs /app/.venv against the clone's lock — a multi-minute stall on a cold wheel cache — so the servers never reach "connected": they sit at status="pending" and the agent gets ZERO gateway verbs. It then can't claim/commit/idle (all MCP verbs), its Stop is rejected, and it respawns in a loop redoing work it can't submit. UV_PROJECT_ENVIRONMENT pins the venv location but does NOT stop the cwd-relative resolve/sync (confirmed empirically on uv 0.11.1); `--no-sync` does, so the servers reuse the baked /app/.venv as-is and start instantly. The /app-cwd roles (qa/cell_pm/main_pm/auditor) were unaffected because their env already matches. - orchestrator.py: --no-sync on all 6 generated MCP servers - docker/scripts/sdk-startup-hook.sh: --no-sync on the agent_sdk.server launch - test_spawn_strict_mcp.py: assert every server's args start with run,--no-sync
105 lines
4.6 KiB
Python
105 lines
4.6 KiB
Python
"""Wave E3: agent spawn cmd uses --strict-mcp-config to suppress builtin
|
|
Anthropic connectors (Gmail / Google Calendar / Notion / Google Drive).
|
|
|
|
Without --strict-mcp-config, the Claude Code CLI auto-registers its
|
|
builtin MCP connectors alongside our --mcp-config entries — they appear
|
|
in the agent's tool inventory as `mcp__claude_ai_Gmail__authenticate`
|
|
etc. The flag tells the CLI to load ONLY the servers from --mcp-config.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from roboco.models.runtime import OrchestratorAgentConfig, SpawnGitContext
|
|
from roboco.runtime.orchestrator import AgentOrchestrator
|
|
|
|
_MAX_FLAG_ADJACENCY = 3
|
|
|
|
|
|
def _make_dev_config() -> OrchestratorAgentConfig:
|
|
"""Minimal AgentConfig for a developer."""
|
|
return OrchestratorAgentConfig(
|
|
agent_id="be-dev-1",
|
|
blueprint_path=Path("/app/agents/blueprints/be-dev-1.md"),
|
|
model="sonnet",
|
|
mcp_config_path=Path("/app/mcp-config.json"),
|
|
git_context=SpawnGitContext(
|
|
project_slug="roboco-api",
|
|
branch_name="feature/backend/TASK0001",
|
|
),
|
|
)
|
|
|
|
|
|
def _build_image_args(config: OrchestratorAgentConfig) -> list[str]:
|
|
"""Invoke _append_image_and_claude_args against an empty cmd."""
|
|
cmd: list[str] = []
|
|
with patch(
|
|
"roboco.runtime.orchestrator._resolve_agent_cli_model",
|
|
return_value="claude-sonnet-4-6",
|
|
):
|
|
AgentOrchestrator._append_image_and_claude_args(cmd, config, None)
|
|
return cmd
|
|
|
|
|
|
class TestSpawnStrictMcpConfig:
|
|
"""Agent spawn cmd includes --strict-mcp-config."""
|
|
|
|
def test_cmd_contains_strict_mcp_config_flag(self) -> None:
|
|
"""docker run cmd for an agent includes --strict-mcp-config."""
|
|
cmd = _build_image_args(_make_dev_config())
|
|
assert "--strict-mcp-config" in cmd, (
|
|
f"--strict-mcp-config flag missing from spawn cmd. Without it, "
|
|
f"the Claude CLI auto-registers builtin connectors (Gmail, "
|
|
f"Calendar, Notion, Drive) alongside our roboco MCP servers. "
|
|
f"Full cmd: {cmd}"
|
|
)
|
|
|
|
def test_strict_mcp_config_paired_with_mcp_config(self) -> None:
|
|
"""--strict-mcp-config appears alongside --mcp-config /app/mcp-config.json."""
|
|
cmd = _build_image_args(_make_dev_config())
|
|
assert "--mcp-config" in cmd
|
|
mcp_idx = cmd.index("--mcp-config")
|
|
assert cmd[mcp_idx + 1] == "/app/mcp-config.json"
|
|
strict_idx = cmd.index("--strict-mcp-config")
|
|
assert abs(strict_idx - mcp_idx) <= _MAX_FLAG_ADJACENCY, (
|
|
f"--strict-mcp-config should appear near --mcp-config; "
|
|
f"strict_idx={strict_idx}, mcp_idx={mcp_idx}. Cmd: {cmd}"
|
|
)
|
|
|
|
|
|
class TestMcpConfigPinsBakedVenv:
|
|
"""#179: every generated MCP server launch must pin uv to the baked
|
|
image venv so `uv run` (cwd = workspace) reuses /app/.venv instead of
|
|
re-syncing the full dependency set (~350MB) on every spawn."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_every_mcp_server_env_pins_uv_project_environment(self) -> None:
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
# be-dev-1 is a known agent (resolves role + uuid); generation is
|
|
# otherwise pure (writes a json file and returns its path).
|
|
config_path = await orch._generate_mcp_config("be-dev-1")
|
|
config = json.loads(Path(config_path).read_text())
|
|
servers = config["mcpServers"]
|
|
assert servers, "expected at least the four core MCP servers"
|
|
for name, spec in servers.items():
|
|
assert spec["command"] == "uv", f"{name} should launch via uv"
|
|
assert spec["env"].get("UV_PROJECT_ENVIRONMENT") == "/app/.venv", (
|
|
f"MCP server {name!r} is missing UV_PROJECT_ENVIRONMENT="
|
|
f"/app/.venv — without it `uv run` re-downloads deps into a "
|
|
f"cwd-relative venv on every spawn (#179). env={spec['env']}"
|
|
)
|
|
# Pinning the env location is necessary but NOT sufficient: from a
|
|
# workspace-clone cwd `uv run` still discovers the clone project and
|
|
# re-syncs the pinned venv against its drifted lock, which stalls and
|
|
# leaves the server stuck at status="pending" (zero gateway verbs).
|
|
# `--no-sync` skips that resync — it must come right after `run`.
|
|
assert spec["args"][:2] == ["run", "--no-sync"], (
|
|
f"MCP server {name!r} must launch with `uv run --no-sync ...` so "
|
|
f"a drifted workspace-clone lock can't trigger a resync stall; "
|
|
f"got args={spec['args']}"
|
|
)
|