fix(agent): pin uv to baked /app/.venv so MCP/SDK servers start instantly (#179)

Every agent MCP server is launched as `uv run python -m roboco.mcp.<server>`
(via the orchestrator-generated mcp-config.json) and the SDK server via
`uv run python -m roboco.agent_sdk.server` (sdk-startup-hook.sh) — both with
cwd = the agent's WORKSPACE, not /app. `uv run` then resolves a cwd-relative
`.venv` (≠ the image's baked /app/.venv), ignores VIRTUAL_ENV with a warning,
and RE-SYNCS the full dependency set (torch/lancedb/pyarrow/scipy, ~350MB)
into a fresh venv on every spawn.

A warm host uv wheel cache masks this (fast re-resolve from cached wheels —
earlier runs this session opened PR #26/#28/#29 fine). On a COLD cache (first
spawn after an image rebuild — exactly when deploying new fixes) the download
takes minutes, the MCP servers never register, and the agent burns its whole
budget with "No such tool available: mcp__roboco-*" before reaping. Observed
this session: be-dev-1 never claimed; /tmp/sdk-server.log showed the live
torch/lancedb download + the `VIRTUAL_ENV ... will be ignored` warning.

Fix: set UV_PROJECT_ENVIRONMENT=/app/.venv in (1) every MCP server's env in
the generated mcp-config.json (one place — shared mcp_env dict) and (2) the
SDK startup hook. uv then reuses the pre-baked image venv instantly,
regardless of cwd or cache state. Not a regression from this session's code
(none of #172b/#175/#176/#177/#178 touched the launch/venv path — verified);
a pre-existing launch-cwd fragility that rebuilding to deploy exposed.

Test: _generate_mcp_config asserts every server env pins
UV_PROJECT_ENVIRONMENT=/app/.venv. make quality green.
This commit is contained in:
Renn F
2026-05-23 01:23:18 +02:00
parent 879b8cb991
commit 3742483e1c
3 changed files with 49 additions and 0 deletions
+11
View File
@@ -12,6 +12,17 @@ LOG_FILE="/tmp/sdk-server.log"
BRIEFING_FILE="/app/briefing.md" BRIEFING_FILE="/app/briefing.md"
PRECOMPACT_FILE="/tmp/roboco-precompact-${AGENT_ID}.md" PRECOMPACT_FILE="/tmp/roboco-precompact-${AGENT_ID}.md"
# #179: this hook runs with cwd = the agent's workspace, so a bare
# `uv run` resolves a cwd-relative `.venv` (≠ the baked /app/.venv),
# ignores VIRTUAL_ENV with a warning, and RE-SYNCS the full dependency
# set (torch/lancedb/pyarrow/scipy, ~350MB) into a fresh venv. On a cold
# uv wheel cache (first spawn after an image rebuild) that download takes
# minutes and the SDK/MCP layer never comes up before the agent reaps.
# Pin uv to the pre-baked image venv so it starts instantly regardless
# of cwd. (The orchestrator sets the same var in every MCP server's env
# in the generated mcp-config.json — keep both in sync.)
export UV_PROJECT_ENVIRONMENT=/app/.venv
# --- SDK bring-up --------------------------------------------------------- # --- SDK bring-up ---------------------------------------------------------
if ! curl -sf "http://localhost:${SDK_PORT}/health" >/dev/null 2>&1; then if ! curl -sf "http://localhost:${SDK_PORT}/health" >/dev/null 2>&1; then
echo "[SDK] Starting for agent ${AGENT_ID} on port ${SDK_PORT}..." echo "[SDK] Starting for agent ${AGENT_ID} on port ${SDK_PORT}..."
+13
View File
@@ -1878,6 +1878,19 @@ class AgentOrchestrator:
"ROBOCO_ORCHESTRATOR_URL": api_url, "ROBOCO_ORCHESTRATOR_URL": api_url,
"ROBOCO_AGENT_ID": agent_uuid, "ROBOCO_AGENT_ID": agent_uuid,
"ROBOCO_AGENT_ROLE": agent_role, "ROBOCO_AGENT_ROLE": agent_role,
# #179: every MCP server is launched as `uv run python -m
# roboco.mcp.<server>` by Claude Code, with cwd = the agent's
# WORKSPACE (not /app). Without this, `uv run` resolves a
# cwd-relative `.venv` (≠ the baked /app/.venv), ignores the
# image's VIRTUAL_ENV with a warning, and RE-SYNCS the full
# dependency set (torch/lancedb/pyarrow/scipy, ~350MB) into a
# fresh venv on every spawn — masked by a warm uv wheel cache,
# but on a cold cache (first spawn after an image rebuild) the
# download takes minutes and the MCP servers never come up
# before the agent burns its budget. Pinning the project env
# to the pre-baked venv makes `uv run` reuse it instantly,
# regardless of cwd.
"UV_PROJECT_ENVIRONMENT": "/app/.venv",
} }
# Add git context if available # Add git context if available
@@ -9,9 +9,11 @@ etc. The flag tells the CLI to load ONLY the servers from --mcp-config.
from __future__ import annotations from __future__ import annotations
import json
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest
from roboco.models.runtime import OrchestratorAgentConfig, SpawnGitContext from roboco.models.runtime import OrchestratorAgentConfig, SpawnGitContext
from roboco.runtime.orchestrator import AgentOrchestrator from roboco.runtime.orchestrator import AgentOrchestrator
@@ -67,3 +69,26 @@ class TestSpawnStrictMcpConfig:
f"--strict-mcp-config should appear near --mcp-config; " f"--strict-mcp-config should appear near --mcp-config; "
f"strict_idx={strict_idx}, mcp_idx={mcp_idx}. Cmd: {cmd}" 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']}"
)