fix: post-finale completeness sweep — routing surface, provider config, budgets, compose env, interactive exemption (#661)

This commit is contained in:
Renzo F
2026-07-23 09:41:27 +02:00
committed by GitHub
parent 21d6730400
commit d4b7e1e7b8
45 changed files with 2064 additions and 256 deletions
+21
View File
@@ -277,6 +277,27 @@ def test_task_update_sequence_rejects_negative() -> None:
TaskUpdate(sequence=-1)
def test_task_update_budget_usd_accepts_null() -> None:
"""null clears the cap back to the TaskType default — always valid."""
assert TaskUpdate(budget_usd=None).budget_usd is None
def test_task_update_budget_usd_accepts_positive() -> None:
budget = 5.0
assert TaskUpdate(budget_usd=budget).budget_usd == budget
def test_task_update_budget_usd_rejects_zero() -> None:
"""gt=0 — a 0 budget would block every claim immediately (#654)."""
with pytest.raises(ValueError, match="budget_usd"):
TaskUpdate(budget_usd=0)
def test_task_update_budget_usd_rejects_negative() -> None:
with pytest.raises(ValueError, match="budget_usd"):
TaskUpdate(budget_usd=-5)
# ---------------------------------------------------------------------------
# task_to_response / task_list_to_response
# ---------------------------------------------------------------------------
@@ -41,9 +41,30 @@ def test_render_config_toml_marks_gateway_pair_required() -> None:
assert "required" not in parsed["mcp_servers"]["roboco-optimal"]
def test_render_config_toml_empty_when_no_servers() -> None:
assert cc.render_config_toml({}) == ""
assert cc.render_config_toml({"mcpServers": {}}) == ""
def test_render_config_toml_widens_startup_timeout_on_required_servers() -> None:
# The CLI's default 10s MCP startup timeout fail-fast-aborts the session on
# a cold uv wheel cache; the gateway pair gets a wider budget.
parsed = tomllib.loads(cc.render_config_toml(_SAMPLE_MCP))
timeout = cc._REQUIRED_MCP_STARTUP_TIMEOUT_SEC
assert parsed["mcp_servers"]["roboco-flow"]["startup_timeout_sec"] == timeout
assert parsed["mcp_servers"]["roboco-do"]["startup_timeout_sec"] == timeout
assert "startup_timeout_sec" not in parsed["mcp_servers"]["roboco-optimal"]
def test_render_config_toml_disables_subagents_unconditionally() -> None:
# Fleet-wide subagent ban (CEO, 2026-07-09) — a global switch, not
# per-role, so it renders even with no MCP servers configured at all.
no_servers = tomllib.loads(cc.render_config_toml({}))
empty_servers = tomllib.loads(cc.render_config_toml({"mcpServers": {}}))
with_servers = tomllib.loads(cc.render_config_toml(_SAMPLE_MCP))
assert no_servers["agents"]["enabled"] is False
assert empty_servers["agents"]["enabled"] is False
assert with_servers["agents"]["enabled"] is False
def test_render_config_toml_no_mcp_servers_key_when_no_servers() -> None:
assert "mcp_servers" not in tomllib.loads(cc.render_config_toml({}))
assert "mcp_servers" not in tomllib.loads(cc.render_config_toml({"mcpServers": {}}))
def test_sandbox_level_developer_is_workspace_write() -> None:
@@ -125,8 +125,13 @@ def test_write_policy_toml_writes_file(tmp_path: Path) -> None:
assert "run_shell_command" in written
def test_gemini_cli_args_is_yolo_only() -> None:
assert gc.gemini_cli_args() == ["--approval-mode", "yolo"]
def test_gemini_cli_args_is_yolo_plus_default_max_turns() -> None:
assert gc.gemini_cli_args() == ["--approval-mode", "yolo", "--max-turns", "200"]
def test_gemini_cli_args_max_turns_is_overridable() -> None:
args = gc.gemini_cli_args(max_turns=7)
assert args[args.index("--max-turns") + 1] == "7"
def test_main_writes_settings_and_args(
@@ -161,4 +166,50 @@ def test_main_writes_settings_and_args(
assert args_path.read_text(encoding="utf-8").splitlines() == [
"--approval-mode",
"yolo",
"--max-turns",
"200",
]
def test_main_honors_max_turns_env_override(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mcp_path = tmp_path / "mcp-config.json"
mcp_path.write_text(json.dumps(_SAMPLE_MCP), encoding="utf-8")
args_path = tmp_path / "gemini-args"
monkeypatch.setattr(gc, "GEMINI_SETTINGS_PATH", tmp_path / ".gemini" / "s.json")
monkeypatch.setattr(gc, "GEMINI_MEMORY_PATH", tmp_path / ".gemini" / "GEMINI.md")
monkeypatch.setattr(gc, "GEMINI_POLICIES_DIR", tmp_path / ".gemini" / "policies")
monkeypatch.setattr(gc, "GEMINI_ARGS_PATH", args_path)
monkeypatch.setenv("ROBOCO_AGENT_ID", "be-dev-1")
monkeypatch.setenv("ROBOCO_MCP_CONFIG", str(mcp_path))
monkeypatch.setenv("ROBOCO_GEMINI_MAX_TURNS", "42")
assert gc.main() == 0
assert args_path.read_text(encoding="utf-8").splitlines()[-2:] == [
"--max-turns",
"42",
]
def test_main_falls_back_to_default_max_turns_on_bad_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
mcp_path = tmp_path / "mcp-config.json"
mcp_path.write_text(json.dumps(_SAMPLE_MCP), encoding="utf-8")
args_path = tmp_path / "gemini-args"
monkeypatch.setattr(gc, "GEMINI_SETTINGS_PATH", tmp_path / ".gemini" / "s.json")
monkeypatch.setattr(gc, "GEMINI_MEMORY_PATH", tmp_path / ".gemini" / "GEMINI.md")
monkeypatch.setattr(gc, "GEMINI_POLICIES_DIR", tmp_path / ".gemini" / "policies")
monkeypatch.setattr(gc, "GEMINI_ARGS_PATH", args_path)
monkeypatch.setenv("ROBOCO_AGENT_ID", "be-dev-1")
monkeypatch.setenv("ROBOCO_MCP_CONFIG", str(mcp_path))
monkeypatch.setenv("ROBOCO_GEMINI_MAX_TURNS", "not-a-number")
assert gc.main() == 0
assert args_path.read_text(encoding="utf-8").splitlines()[-2:] == [
"--max-turns",
"200",
]
@@ -15,10 +15,19 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.config import settings
from roboco.llm.providers import GeminiCliProvider, ProviderError, SpawnResult
from roboco.llm.providers import gemini as gemini_module
from roboco.models.runtime import OrchestratorAgentConfig
def test_gemini_cli_model_is_a_real_settings_field() -> None:
# Parity with codex_cli_model (roboco.config.Settings.codex_cli_model) —
# gemini.py reads settings.gemini_cli_model, not a raw os.environ.get.
assert settings.gemini_cli_model == gemini_module._GEMINI_CLI_MODEL
assert settings.gemini_cli_model == "gemini-2.5-pro"
@pytest.fixture(autouse=True)
def _isolate_gemini_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point GEMINI_AUTH_HOST_PATH at a fresh tmp dir so tests never mount the
+4 -2
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.models.base import ModelProvider
from roboco.models.base import AssignmentScope, ModelProvider
from roboco.services.llm import ModelRoutingService, _ResolvedAssignment
_AGENT_SLUG = "be-dev-1"
@@ -23,7 +23,9 @@ def _disabled_resolved() -> _ResolvedAssignment:
provider = MagicMock(
enabled=False, id="prov-disabled", type=ModelProvider.OLLAMA_CLOUD
)
return _ResolvedAssignment(provider=provider, model_name="grok-build")
return _ResolvedAssignment(
provider=provider, model_name="grok-build", scope=AssignmentScope.GLOBAL
)
def _svc() -> ModelRoutingService:
+165
View File
@@ -0,0 +1,165 @@
"""Task.budget_usd / Project.monthly_budget_usd validation (#654).
The task-budgets feature's own design says "0 rejected — a zero budget
silently blocks everything" (every claim is refused from the first tick),
so every schema that can set these fields must reject 0 and negative values
at the pydantic boundary a 422, never a stored self-DoS. Null ("no cap")
stays valid throughout. Mirrors test_project_sandbox_services.py's style
(domain-model `pytest.raises(ValidationError)` coverage).
"""
from __future__ import annotations
from uuid import uuid4
import pytest
from pydantic import ValidationError
from roboco.models.base import Team
from roboco.models.project import Project, ProjectCreate, ProjectUpdate
from roboco.models.task import Task, TaskUpdate
def _task(budget_usd: float | None = None) -> Task:
return Task(
title="Add user lookup endpoint",
description="Add GET /v1/users/{id} returning user JSON.",
acceptance_criteria=["returns 404 for unknown user"],
created_by=uuid4(),
team=Team.BACKEND,
budget_usd=budget_usd,
)
def _project(monthly_budget_usd: float | None = None) -> Project:
return Project(
name="P",
slug="p",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=uuid4(),
monthly_budget_usd=monthly_budget_usd,
)
# ---------------------------------------------------------------------------
# Task.budget_usd
# ---------------------------------------------------------------------------
def test_task_defaults_budget_usd_to_none() -> None:
assert _task().budget_usd is None
def test_task_accepts_positive_budget_usd() -> None:
budget = 12.5
assert _task(budget_usd=budget).budget_usd == budget
def test_task_rejects_zero_budget_usd() -> None:
with pytest.raises(ValidationError, match="budget_usd"):
_task(budget_usd=0)
def test_task_rejects_negative_budget_usd() -> None:
with pytest.raises(ValidationError, match="budget_usd"):
_task(budget_usd=-5)
# ---------------------------------------------------------------------------
# roboco.models.task.TaskUpdate.budget_usd (domain update model)
# ---------------------------------------------------------------------------
def test_task_update_accepts_null_budget_usd() -> None:
assert TaskUpdate(budget_usd=None).budget_usd is None
def test_task_update_accepts_positive_budget_usd() -> None:
budget = 3.0
assert TaskUpdate(budget_usd=budget).budget_usd == budget
def test_task_update_rejects_zero_budget_usd() -> None:
with pytest.raises(ValidationError, match="budget_usd"):
TaskUpdate(budget_usd=0)
def test_task_update_rejects_negative_budget_usd() -> None:
with pytest.raises(ValidationError, match="budget_usd"):
TaskUpdate(budget_usd=-1)
# ---------------------------------------------------------------------------
# Project.monthly_budget_usd
# ---------------------------------------------------------------------------
def test_project_defaults_monthly_budget_usd_to_none() -> None:
assert _project().monthly_budget_usd is None
def test_project_accepts_positive_monthly_budget_usd() -> None:
cap = 100.0
assert _project(monthly_budget_usd=cap).monthly_budget_usd == cap
def test_project_rejects_zero_monthly_budget_usd() -> None:
with pytest.raises(ValidationError, match="monthly_budget_usd"):
_project(monthly_budget_usd=0)
def test_project_rejects_negative_monthly_budget_usd() -> None:
with pytest.raises(ValidationError, match="monthly_budget_usd"):
_project(monthly_budget_usd=-5)
# ---------------------------------------------------------------------------
# ProjectCreate.monthly_budget_usd
# ---------------------------------------------------------------------------
def test_project_create_accepts_null_monthly_budget_usd() -> None:
assert (
ProjectCreate(
name="P",
slug="p",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
).monthly_budget_usd
is None
)
def test_project_create_rejects_zero_monthly_budget_usd() -> None:
with pytest.raises(ValidationError, match="monthly_budget_usd"):
ProjectCreate(
name="P",
slug="p",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
monthly_budget_usd=0,
)
# ---------------------------------------------------------------------------
# ProjectUpdate.monthly_budget_usd
# ---------------------------------------------------------------------------
def test_project_update_accepts_null_monthly_budget_usd() -> None:
assert ProjectUpdate(monthly_budget_usd=None).monthly_budget_usd is None
def test_project_update_accepts_positive_monthly_budget_usd() -> None:
cap = 50.0
assert ProjectUpdate(monthly_budget_usd=cap).monthly_budget_usd == cap
def test_project_update_rejects_zero_monthly_budget_usd() -> None:
with pytest.raises(ValidationError, match="monthly_budget_usd"):
ProjectUpdate(monthly_budget_usd=0)
def test_project_update_rejects_negative_monthly_budget_usd() -> None:
with pytest.raises(ValidationError, match="monthly_budget_usd"):
ProjectUpdate(monthly_budget_usd=-5)
@@ -0,0 +1,195 @@
"""Codex (OPENAI) and Gemini (GEMINI) are V1 delivery-roles-only — neither has
an interactive-session driver image (unlike GROK's dedicated
GROK_PROMPTER_IMAGE / GROK_SECRETARY_IMAGE). Routing either to the persistent
Intake/Secretary agent must refuse loudly instead of silently falling through
to the plain Claude SDK-driver image with a mismatched provider env.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
import pytest
from roboco.models.base import ModelProvider
from roboco.runtime.orchestrator import (
_INTERACTIVE_UNSUPPORTED_PROVIDERS,
INTAKE_AGENT_ID,
SECRETARY_AGENT_ID,
AgentOrchestrator,
_reject_interactive_unsupported_provider,
)
from roboco.services import prompter_live
from roboco.services.llm import (
INTERACTIVE_AGENT_SLUGS,
INTERACTIVE_UNSUPPORTED_PROVIDERS,
)
def _make_minimal_orchestrator() -> AgentOrchestrator:
with patch.object(AgentOrchestrator, "__init__", return_value=None):
orch = AgentOrchestrator.__new__(AgentOrchestrator)
orch._instances = {}
orch._bg_tasks = set()
orch._running = True
orch._intake_spawn_lock = asyncio.Lock()
orch._secretary_spawn_lock = asyncio.Lock()
return orch
@pytest.fixture(autouse=True)
def _fresh_registry() -> Any:
prev = prompter_live._RegistryHolder.instance
prompter_live._RegistryHolder.instance = prompter_live.PrompterLiveRegistry()
yield
prompter_live._RegistryHolder.instance = prev
# ---------------------------------------------------------------------------
# Unit-level: the pure guard function itself.
# ---------------------------------------------------------------------------
class TestRejectInteractiveUnsupportedProvider:
def test_guard_set_matches_the_resolver_exemption_set(self) -> None:
"""The orchestrator's literal must track the resolver's canonical
tuple (kept separate to avoid a runtime import cycle)."""
assert tuple(_INTERACTIVE_UNSUPPORTED_PROVIDERS) == tuple(
INTERACTIVE_UNSUPPORTED_PROVIDERS
)
def test_resolver_slugs_match_the_orchestrator_agent_ids(self) -> None:
"""The resolver's exemption must cover exactly the two interactive
agents the orchestrator spawns a renamed id would silently
un-exempt a chat."""
assert set(INTERACTIVE_AGENT_SLUGS) == {INTAKE_AGENT_ID, SECRETARY_AGENT_ID}
@pytest.mark.parametrize("provider", [ModelProvider.OPENAI, ModelProvider.GEMINI])
def test_raises_for_delivery_only_providers(self, provider: ModelProvider) -> None:
with pytest.raises(RuntimeError, match="delivery-roles-only"):
_reject_interactive_unsupported_provider(INTAKE_AGENT_ID, provider)
@pytest.mark.parametrize(
"provider",
[
ModelProvider.ANTHROPIC,
ModelProvider.GROK,
ModelProvider.OLLAMA_CLOUD,
ModelProvider.LOCAL,
],
)
def test_passes_for_interactive_capable_providers(
self, provider: ModelProvider
) -> None:
_reject_interactive_unsupported_provider(INTAKE_AGENT_ID, provider) # no raise
# ---------------------------------------------------------------------------
# Intake spawn refusal — surfaces on the relay, container never launched.
# ---------------------------------------------------------------------------
class TestIntakeSpawnRefusesDeliveryOnlyProvider:
@pytest.mark.parametrize("provider", [ModelProvider.OPENAI, ModelProvider.GEMINI])
@pytest.mark.asyncio
async def test_refuses_before_any_container_work(
self, monkeypatch: pytest.MonkeyPatch, provider: ModelProvider
) -> None:
orch = _make_minimal_orchestrator()
async def _clone(*_a: Any, **_k: Any) -> tuple[str, list[str]]:
return "/data/workspaces/roboco/board/intake-1", ["/cwd"]
async def _route(_aid: str) -> Any:
return SimpleNamespace(
provider_type=provider,
model_name="whatever",
base_url=None,
auth_token=None,
)
run_calls: list[list[str]] = []
async def _run(cmd: list[str]) -> str:
run_calls.append(cmd)
return "containerid0123456789"
monkeypatch.setattr(orch, "_clone_intake_scope", _clone)
monkeypatch.setattr(orch, "_resolve_agent_route", _route)
monkeypatch.setattr(
orch, "_generate_composed_prompt", lambda *_a, **_k: Path("/tmp/p.md")
)
monkeypatch.setattr(orch, "_run_container_cmd", _run)
registry = prompter_live.get_live_registry()
pushed: list[tuple[str, dict[str, Any]]] = []
closed: list[str] = []
monkeypatch.setattr(registry, "push", lambda sid, ev: pushed.append((sid, ev)))
monkeypatch.setattr(registry, "close", closed.append)
registry.open("sess-refuse", INTAKE_AGENT_ID)
await orch._spawn_intake_container_guarded(
"sess-refuse", project_slug="roboco", product_id=None, initial_message=None
)
assert not run_calls # no container was ever launched
assert len(pushed) == 1
assert pushed[0][1]["kind"] == "error"
assert "delivery-roles-only" in pushed[0][1]["text"]
assert closed == ["sess-refuse"]
assert INTAKE_AGENT_ID not in orch._instances
# ---------------------------------------------------------------------------
# Secretary spawn refusal — same shape, same guard.
# ---------------------------------------------------------------------------
class TestSecretarySpawnRefusesDeliveryOnlyProvider:
@pytest.mark.parametrize("provider", [ModelProvider.OPENAI, ModelProvider.GEMINI])
@pytest.mark.asyncio
async def test_refuses_before_any_container_work(
self, monkeypatch: pytest.MonkeyPatch, provider: ModelProvider
) -> None:
orch = _make_minimal_orchestrator()
async def _route(_aid: str) -> Any:
return SimpleNamespace(
provider_type=provider,
model_name="whatever",
base_url=None,
auth_token=None,
)
run_calls: list[list[str]] = []
async def _run(cmd: list[str]) -> str:
run_calls.append(cmd)
return "containerid0123456789"
monkeypatch.setattr(orch, "_resolve_agent_route", _route)
monkeypatch.setattr(
orch, "_generate_composed_prompt", lambda *_a, **_k: Path("/tmp/p.md")
)
monkeypatch.setattr(orch, "_run_container_cmd", _run)
registry = prompter_live.get_live_registry()
pushed: list[tuple[str, dict[str, Any]]] = []
closed: list[str] = []
monkeypatch.setattr(registry, "push", lambda sid, ev: pushed.append((sid, ev)))
monkeypatch.setattr(registry, "close", closed.append)
registry.open("sess-sec-refuse", SECRETARY_AGENT_ID)
await orch._spawn_secretary_container_guarded(
"sess-sec-refuse", initial_message=None
)
assert not run_calls # no container was ever launched
assert len(pushed) == 1
assert pushed[0][1]["kind"] == "error"
assert "delivery-roles-only" in pushed[0][1]["text"]
assert closed == ["sess-sec-refuse"]
assert SECRETARY_AGENT_ID not in orch._instances
@@ -47,6 +47,7 @@ def _claim_task(
last_heartbeat_at=None,
active_claimant_id=None,
orchestration_markers={},
dev_notes=None,
)
@@ -229,6 +230,30 @@ async def test_inherit_conflict_notes_the_task() -> None:
assert note is not None
assert "a.py, b.py" in note
assert "sync_branch" in note
# The dev must actually SEE it — dev_notes rides evidence(), the marker
# does not.
assert task.dev_notes is not None
assert "a.py, b.py" in task.dev_notes
assert "[BASE INHERITANCE]" in task.dev_notes
@pytest.mark.asyncio
async def test_inherit_merged_push_failed_notes_the_dev() -> None:
svc = _service()
task = _claim_task("feature/backend/AAA--BBB")
proj_svc, git_svc, _ = _patched_deps(svc, {"status": "merged_push_failed"})
with (
patch(
"roboco.services.project.get_project_service",
MagicMock(return_value=proj_svc),
),
patch("roboco.services.git.get_git_service", MagicMock(return_value=git_svc)),
):
await svc._inherit_upstream_base(task, uuid4())
assert task.dev_notes is not None
assert "push to origin failed" in task.dev_notes
@pytest.mark.asyncio