2026-06-09 17:08:34 +02:00
|
|
|
"""The persistent intake (prompter) live-session spawn/reap path.
|
|
|
|
|
|
|
|
|
|
The intake agent is not task-driven: ``spawn_intake_session`` launches a
|
|
|
|
|
long-lived Agent-SDK driver container (image ENTRYPOINT, NOT ``claude -p``),
|
|
|
|
|
clones the chat scope's repo(s), and registers the live relay session. These
|
|
|
|
|
tests cover the docker-command construction, scope resolution, and the
|
|
|
|
|
spawn/reap orchestration with docker + clone mocked (no daemon, no NAS).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from typing import Any
|
|
|
|
|
from unittest.mock import patch
|
2026-07-03 00:07:55 +02:00
|
|
|
from uuid import UUID, uuid4
|
2026-06-09 17:08:34 +02:00
|
|
|
|
|
|
|
|
import pytest
|
2026-07-03 01:58:38 +02:00
|
|
|
from roboco.config import settings
|
2026-06-09 17:08:34 +02:00
|
|
|
from roboco.runtime.orchestrator import (
|
2026-07-13 15:41:00 +02:00
|
|
|
_INTAKE_WORKSPACE_AMBIENT,
|
2026-06-09 17:08:34 +02:00
|
|
|
INTAKE_AGENT_ID,
|
|
|
|
|
AgentInstance,
|
|
|
|
|
AgentOrchestrator,
|
|
|
|
|
_IntakeRunSpec,
|
|
|
|
|
)
|
|
|
|
|
from roboco.services import prompter_live
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_minimal_orchestrator() -> AgentOrchestrator:
|
|
|
|
|
"""AgentOrchestrator with constructor I/O skipped; _instances ready."""
|
|
|
|
|
with patch.object(AgentOrchestrator, "__init__", return_value=None):
|
|
|
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
|
|
|
orch._instances = {}
|
|
|
|
|
orch._bg_tasks = set()
|
2026-06-29 05:38:21 +02:00
|
|
|
# A minimal orchestrator is a RUNNING one. The non-blocking spawn path
|
|
|
|
|
# reads ``self._running`` after docker run to detect a mid-spawn shutdown
|
|
|
|
|
# (F071); without this the post-docker-run guard would AttributeError on
|
|
|
|
|
# the constructor-skipped instance.
|
|
|
|
|
orch._running = True
|
|
|
|
|
# Concurrent intake starts serialize on this lock; the constructor (skipped
|
|
|
|
|
# here) initializes it.
|
|
|
|
|
orch._intake_spawn_lock = asyncio.Lock()
|
2026-06-09 17:08:34 +02:00
|
|
|
return orch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _spec(**overrides: Any) -> _IntakeRunSpec:
|
|
|
|
|
base: dict[str, Any] = {
|
|
|
|
|
"container_name": "roboco-agent-intake-1",
|
|
|
|
|
"image": "roboco-agent-prompter",
|
|
|
|
|
"hosts": {
|
|
|
|
|
"claude": "/home/runner/.claude",
|
|
|
|
|
"prompt": "/data/prompts-generated/intake-1-prompt.md",
|
|
|
|
|
"workspaces": "/data/workspaces",
|
|
|
|
|
},
|
|
|
|
|
"session_id": "sess-abc",
|
|
|
|
|
"cwd": "/data/workspaces/roboco/board/intake-1",
|
2026-07-25 00:41:13 +02:00
|
|
|
"cli_model": "claude-opus-5",
|
2026-06-09 17:08:34 +02:00
|
|
|
"api_url": "http://roboco-orchestrator:8000",
|
|
|
|
|
"provider_base_url": None,
|
|
|
|
|
"provider_auth_token": None,
|
|
|
|
|
}
|
|
|
|
|
base.update(overrides)
|
|
|
|
|
return _IntakeRunSpec(**base)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
|
|
|
def _fresh_registry() -> Any:
|
|
|
|
|
"""Isolate the process-wide live registry per test."""
|
|
|
|
|
prev = prompter_live._RegistryHolder.instance
|
|
|
|
|
prompter_live._RegistryHolder.instance = prompter_live.PrompterLiveRegistry()
|
|
|
|
|
yield
|
|
|
|
|
prompter_live._RegistryHolder.instance = prev
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _build_intake_run_cmd — the pure docker-argv builder.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBuildIntakeRunCmd:
|
|
|
|
|
def test_image_is_last_and_no_claude_cli_args(self) -> None:
|
|
|
|
|
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
|
|
|
|
|
assert cmd[-1] == "roboco-agent-prompter"
|
|
|
|
|
# The image ENTRYPOINT is the driver — none of the claude CLI flags
|
|
|
|
|
# the task-driven path appends may appear here.
|
|
|
|
|
for flag in (
|
|
|
|
|
"-p",
|
|
|
|
|
"--model",
|
|
|
|
|
"--system-prompt-file",
|
|
|
|
|
"--mcp-config",
|
|
|
|
|
"--tools",
|
|
|
|
|
):
|
|
|
|
|
assert flag not in cmd, f"{flag} must not be in the intake run cmd"
|
|
|
|
|
|
|
|
|
|
def test_no_workdir_settings_or_manifest_mounts(self) -> None:
|
|
|
|
|
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
|
|
|
|
|
joined = " ".join(cmd)
|
|
|
|
|
assert "-w" not in cmd # driver sets cwd via ROBOCO_WORKSPACE/the SDK
|
|
|
|
|
assert "settings.json" not in joined # no hook mount (driver owns 9000)
|
|
|
|
|
assert "mcp-config.json" not in joined # MCP-free live agent
|
|
|
|
|
assert "tool-manifest.json" not in joined
|
|
|
|
|
|
|
|
|
|
def test_env_carries_session_workspace_and_api(self) -> None:
|
|
|
|
|
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
|
|
|
|
|
assert "ROBOCO_PROMPTER_SESSION_ID=sess-abc" in cmd
|
|
|
|
|
assert "ROBOCO_WORKSPACE=/data/workspaces/roboco/board/intake-1" in cmd
|
|
|
|
|
assert "ROBOCO_API_URL=http://roboco-orchestrator:8000" in cmd
|
|
|
|
|
assert "ROBOCO_AGENT_ID=intake-1" in cmd
|
2026-07-25 00:41:13 +02:00
|
|
|
assert "CLAUDE_CODE_SUBAGENT_MODEL=claude-opus-5" in cmd
|
2026-06-09 17:08:34 +02:00
|
|
|
|
|
|
|
|
def test_mounts_prompt_and_workspaces(self) -> None:
|
|
|
|
|
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
|
|
|
|
|
assert (
|
|
|
|
|
"/data/prompts-generated/intake-1-prompt.md:/app/system-prompt.md:ro" in cmd
|
|
|
|
|
)
|
|
|
|
|
assert "/data/workspaces:/data/workspaces" in cmd
|
|
|
|
|
|
|
|
|
|
def test_anthropic_default_omits_provider_env(self) -> None:
|
|
|
|
|
cmd = AgentOrchestrator._build_intake_run_cmd(_spec())
|
|
|
|
|
joined = " ".join(cmd)
|
|
|
|
|
assert "ANTHROPIC_BASE_URL" not in joined
|
|
|
|
|
assert "ANTHROPIC_AUTH_TOKEN" not in joined
|
|
|
|
|
|
|
|
|
|
def test_non_anthropic_injects_provider_env(self) -> None:
|
|
|
|
|
cmd = AgentOrchestrator._build_intake_run_cmd(
|
|
|
|
|
_spec(provider_base_url="http://ollama:11434/v1", provider_auth_token="tok")
|
|
|
|
|
)
|
|
|
|
|
assert "ANTHROPIC_BASE_URL=http://ollama:11434/v1" in cmd
|
|
|
|
|
assert "ANTHROPIC_AUTH_TOKEN=tok" in cmd
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _intake_scope_slugs — project XOR product resolution.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestIntakeScopeSlugs:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_scope_returns_single_slug(self) -> None:
|
|
|
|
|
slugs = await AgentOrchestrator._intake_scope_slugs(
|
|
|
|
|
db=object(), project_slug="roboco", product_id=None
|
|
|
|
|
)
|
|
|
|
|
assert slugs == ["roboco"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_product_scope_resolves_distinct_projects_in_order(self) -> None:
|
|
|
|
|
# distinct_project_ids returns UUIDs in deterministic team order; the
|
|
|
|
|
# primary (cwd) is the first, so order must be preserved (not sorted).
|
|
|
|
|
ids = [
|
|
|
|
|
"11111111-1111-1111-1111-111111111111",
|
|
|
|
|
"22222222-2222-2222-2222-222222222222",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
class _FakeProduct:
|
|
|
|
|
def __init__(self, _db: Any) -> None: ...
|
|
|
|
|
async def distinct_project_ids(self, _pid: Any) -> list[Any]:
|
|
|
|
|
return [UUID(i) for i in ids]
|
|
|
|
|
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get(self, pid: Any) -> Any:
|
|
|
|
|
return SimpleNamespace(slug=f"proj-{str(pid)[0]}")
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.services.product.ProductService", _FakeProduct),
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
slugs = await AgentOrchestrator._intake_scope_slugs(
|
|
|
|
|
db=object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
product_id="33333333-3333-3333-3333-333333333333",
|
|
|
|
|
)
|
|
|
|
|
assert slugs == ["proj-1", "proj-2"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_product_with_no_projects_raises(self) -> None:
|
|
|
|
|
class _FakeProduct:
|
|
|
|
|
def __init__(self, _db: Any) -> None: ...
|
|
|
|
|
async def distinct_project_ids(self, _pid: Any) -> list[Any]:
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch("roboco.services.product.ProductService", _FakeProduct),
|
|
|
|
|
patch("roboco.services.project.get_project_service", lambda _db: object()),
|
|
|
|
|
pytest.raises(ValueError, match="no projects"),
|
|
|
|
|
):
|
|
|
|
|
await AgentOrchestrator._intake_scope_slugs(
|
|
|
|
|
db=object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
product_id="33333333-3333-3333-3333-333333333333",
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-24 01:15:57 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_megatask_scope_resolves_explicit_project_ids_in_order(self) -> None:
|
|
|
|
|
# A MegaTask spans an explicit set of (possibly unrelated) projects; the
|
|
|
|
|
# slugs are resolved in the given order (the first is the primary cwd).
|
|
|
|
|
ids = [
|
|
|
|
|
"11111111-1111-1111-1111-111111111111",
|
|
|
|
|
"22222222-2222-2222-2222-222222222222",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get(self, pid: Any) -> Any:
|
|
|
|
|
return SimpleNamespace(slug=f"proj-{str(pid)[0]}")
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
):
|
|
|
|
|
slugs = await AgentOrchestrator._intake_scope_slugs(
|
|
|
|
|
db=object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
product_id=None,
|
|
|
|
|
project_ids=ids,
|
|
|
|
|
)
|
|
|
|
|
assert slugs == ["proj-1", "proj-2"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_megatask_scope_with_unresolvable_project_raises(self) -> None:
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get(self, _pid: Any) -> Any:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
),
|
|
|
|
|
pytest.raises(ValueError, match="not found"),
|
|
|
|
|
):
|
|
|
|
|
await AgentOrchestrator._intake_scope_slugs(
|
|
|
|
|
db=object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
product_id=None,
|
|
|
|
|
project_ids=["11111111-1111-1111-1111-111111111111"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_megatask_scope_with_one_unresolvable_id_raises(self) -> None:
|
|
|
|
|
# A PARTIAL failure (one of N ids invalid) must fail loud, not silently
|
|
|
|
|
# clone fewer repos than the agent was told it has.
|
|
|
|
|
good = "11111111-1111-1111-1111-111111111111"
|
|
|
|
|
bad = "22222222-2222-2222-2222-222222222222"
|
|
|
|
|
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get(self, pid: Any) -> Any:
|
|
|
|
|
return SimpleNamespace(slug="proj-a") if str(pid) == good else None
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
),
|
|
|
|
|
pytest.raises(ValueError, match="not found"),
|
|
|
|
|
):
|
|
|
|
|
await AgentOrchestrator._intake_scope_slugs(
|
|
|
|
|
db=object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
product_id=None,
|
|
|
|
|
project_ids=[good, bad],
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-09 17:08:34 +02:00
|
|
|
|
2026-07-13 15:41:00 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _dedupe_slugs_by_git_url — a multi-project scope may list several projects
|
|
|
|
|
# pointing at one repo (a monorepo's cell-projects); clone each git_url once.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDedupeSlugsByGitUrl:
|
|
|
|
|
def test_keeps_first_of_shared_git_url_drops_rest(self) -> None:
|
|
|
|
|
result = AgentOrchestrator._dedupe_slugs_by_git_url(
|
|
|
|
|
[
|
|
|
|
|
("be", "https://git.example/o.git"),
|
|
|
|
|
("fe", "https://git.example/o.git"),
|
|
|
|
|
("ux", "https://git.example/o.git"),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
assert result == ["be"]
|
|
|
|
|
|
|
|
|
|
def test_distinct_git_urls_all_kept_in_order(self) -> None:
|
|
|
|
|
result = AgentOrchestrator._dedupe_slugs_by_git_url(
|
|
|
|
|
[
|
|
|
|
|
("a", "https://git.example/a.git"),
|
|
|
|
|
("b", "https://git.example/b.git"),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
assert result == ["a", "b"]
|
|
|
|
|
|
|
|
|
|
def test_empty_or_missing_git_url_never_collapsed(self) -> None:
|
|
|
|
|
# Two projects with no git_url are distinct local repos — both clone.
|
|
|
|
|
result = AgentOrchestrator._dedupe_slugs_by_git_url(
|
|
|
|
|
[("a", None), ("b", ""), ("c", " ")]
|
|
|
|
|
)
|
|
|
|
|
assert result == ["a", "b", "c"]
|
|
|
|
|
|
|
|
|
|
def test_mixed_local_and_shared_repos(self) -> None:
|
|
|
|
|
result = AgentOrchestrator._dedupe_slugs_by_git_url(
|
|
|
|
|
[
|
|
|
|
|
("mono-be", "https://git.example/mono.git"),
|
|
|
|
|
("local-a", None),
|
|
|
|
|
("mono-fe", "https://git.example/mono.git"),
|
|
|
|
|
("local-b", ""),
|
|
|
|
|
("mono-ux", " https://git.example/mono.git "),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
assert result == ["mono-be", "local-a", "local-b"]
|
|
|
|
|
|
|
|
|
|
def test_whitespace_only_git_url_treated_as_empty(self) -> None:
|
|
|
|
|
result = AgentOrchestrator._dedupe_slugs_by_git_url(
|
|
|
|
|
[("a", " "), ("b", " ")]
|
|
|
|
|
)
|
|
|
|
|
assert result == ["a", "b"]
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 00:07:55 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _resolve_history_digest_projects — the prompter-memory ambient's project scope
|
2026-07-03 01:58:38 +02:00
|
|
|
# (covers all three intake scopes: project_slug, product_id, project_ids).
|
2026-07-03 00:07:55 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestResolveHistoryDigestProjects:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_slug_branch_resolves_single_project(self) -> None:
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get_by_slug(self, slug: str) -> Any:
|
|
|
|
|
return SimpleNamespace(slug=slug, id=uuid4())
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
):
|
|
|
|
|
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
|
|
|
|
object(), project_slug="roboco", product_id=None, project_ids=None
|
|
|
|
|
)
|
|
|
|
|
assert [p.slug for p in projects] == ["roboco"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_slug_missing_returns_empty(self) -> None:
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get_by_slug(self, _slug: str) -> Any:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
):
|
|
|
|
|
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
|
|
|
|
object(), project_slug="ghost", product_id=None, project_ids=None
|
|
|
|
|
)
|
|
|
|
|
assert projects == []
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_product_id_branch_delegates_to_ambient_product_projects(
|
|
|
|
|
self,
|
|
|
|
|
) -> None:
|
|
|
|
|
sentinel = [SimpleNamespace(slug="p1", id=uuid4())]
|
|
|
|
|
|
|
|
|
|
async def _fake_product_projects(_db: Any, product_id: str) -> list[Any]:
|
|
|
|
|
assert product_id == "prod-1"
|
|
|
|
|
return sentinel
|
|
|
|
|
|
|
|
|
|
with patch.object(
|
|
|
|
|
AgentOrchestrator, "_ambient_product_projects", _fake_product_projects
|
|
|
|
|
):
|
|
|
|
|
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
|
|
|
|
object(), project_slug=None, product_id="prod-1", project_ids=None
|
|
|
|
|
)
|
|
|
|
|
assert projects is sentinel
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_ids_branch_preserves_order_and_skips_missing(
|
|
|
|
|
self,
|
|
|
|
|
) -> None:
|
|
|
|
|
good1 = "11111111-1111-1111-1111-111111111111"
|
|
|
|
|
missing = "22222222-2222-2222-2222-222222222222"
|
|
|
|
|
good2 = "33333333-3333-3333-3333-333333333333"
|
|
|
|
|
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get(self, pid: Any) -> Any:
|
|
|
|
|
if str(pid) == missing:
|
|
|
|
|
return None
|
|
|
|
|
return SimpleNamespace(slug=f"proj-{str(pid)[0]}", id=pid)
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
):
|
|
|
|
|
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
|
|
|
|
object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
product_id=None,
|
|
|
|
|
project_ids=[good1, missing, good2],
|
|
|
|
|
)
|
|
|
|
|
# Order preserved; the unresolvable id is skipped, not raised — this is
|
|
|
|
|
# a best-effort ambient resolver, not the hard clone-scope resolver.
|
|
|
|
|
assert [p.slug for p in projects] == ["proj-1", "proj-3"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_no_scope_given_returns_empty(self) -> None:
|
|
|
|
|
projects = await AgentOrchestrator._resolve_history_digest_projects(
|
|
|
|
|
object(), project_slug=None, product_id=None, project_ids=None
|
|
|
|
|
)
|
|
|
|
|
assert projects == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _resolve_history_digest_ambient — best-effort: any failure returns None.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestResolveHistoryDigestAmbient:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_failure_returns_none_not_raises(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
|
|
|
|
|
def _boom() -> Any:
|
|
|
|
|
raise RuntimeError("db unavailable")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("roboco.db.base.get_session_factory", _boom)
|
|
|
|
|
|
|
|
|
|
result = await orch._resolve_history_digest_ambient("roboco")
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 01:58:38 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _resolve_ambient_projects — the conventions ambient's project scope. Mirrors
|
|
|
|
|
# _resolve_history_digest_projects's shape, including the MegaTask project_ids
|
|
|
|
|
# scope (the pre-existing gap: this resolver used to stop at project_slug /
|
|
|
|
|
# product_id / task_id and never saw a MegaTask's explicit project_ids).
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestResolveAmbientProjects:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_slug_branch_resolves_single_project(self) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get_by_slug(self, slug: str) -> Any:
|
|
|
|
|
return SimpleNamespace(slug=slug, id=uuid4())
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
):
|
|
|
|
|
projects = await orch._resolve_ambient_projects(
|
|
|
|
|
object(),
|
|
|
|
|
project_slug="roboco",
|
|
|
|
|
task_id=None,
|
|
|
|
|
product_id=None,
|
|
|
|
|
)
|
|
|
|
|
assert [p.slug for p in projects] == ["roboco"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_slug_missing_returns_empty(self) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get_by_slug(self, _slug: str) -> Any:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
):
|
|
|
|
|
projects = await orch._resolve_ambient_projects(
|
|
|
|
|
object(),
|
|
|
|
|
project_slug="ghost",
|
|
|
|
|
task_id=None,
|
|
|
|
|
product_id=None,
|
|
|
|
|
)
|
|
|
|
|
assert projects == []
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_product_id_branch_delegates_to_ambient_product_projects(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
sentinel = [SimpleNamespace(slug="p1", id=uuid4())]
|
|
|
|
|
|
|
|
|
|
async def _fake_product_projects(_db: Any, product_id: str) -> list[Any]:
|
|
|
|
|
assert product_id == "prod-1"
|
|
|
|
|
return sentinel
|
|
|
|
|
|
|
|
|
|
# Patched on the instance (not the class): _ambient_product_projects is
|
|
|
|
|
# a staticmethod, so accessing it via `self.` never binds `self` — but a
|
|
|
|
|
# plain function patched onto the *class* would, since it's no longer
|
|
|
|
|
# wrapped in `staticmethod`. Patching the instance attribute sidesteps
|
|
|
|
|
# the descriptor lookup entirely.
|
|
|
|
|
monkeypatch.setattr(orch, "_ambient_product_projects", _fake_product_projects)
|
|
|
|
|
projects = await orch._resolve_ambient_projects(
|
|
|
|
|
object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
task_id=None,
|
|
|
|
|
product_id="prod-1",
|
|
|
|
|
)
|
|
|
|
|
assert projects is sentinel
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_ids_branch_preserves_order_and_skips_missing(
|
|
|
|
|
self,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""The MegaTask scope: an explicit project_ids list must resolve into
|
|
|
|
|
projects (order preserved, unresolvable ids skipped) — this is the gap
|
|
|
|
|
fix, mirroring the history digest resolver's own project_ids branch."""
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
good1 = "11111111-1111-1111-1111-111111111111"
|
|
|
|
|
missing = "22222222-2222-2222-2222-222222222222"
|
|
|
|
|
good2 = "33333333-3333-3333-3333-333333333333"
|
|
|
|
|
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get(self, pid: Any) -> Any:
|
|
|
|
|
if str(pid) == missing:
|
|
|
|
|
return None
|
|
|
|
|
return SimpleNamespace(slug=f"proj-{str(pid)[0]}", id=pid)
|
|
|
|
|
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
):
|
|
|
|
|
projects = await orch._resolve_ambient_projects(
|
|
|
|
|
object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
task_id=None,
|
|
|
|
|
product_id=None,
|
|
|
|
|
project_ids=[good1, missing, good2],
|
|
|
|
|
)
|
|
|
|
|
assert [p.slug for p in projects] == ["proj-1", "proj-3"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_ids_takes_priority_over_other_scopes(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A MegaTask spawn passes project_slug/product_id as None in practice,
|
|
|
|
|
but the resolver must still prefer the explicit project_ids set over a
|
|
|
|
|
stray product_id/task_id if both were somehow present."""
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
pid = "11111111-1111-1111-1111-111111111111"
|
|
|
|
|
|
|
|
|
|
class _FakeProjectSvc:
|
|
|
|
|
async def get(self, _pid: Any) -> Any:
|
|
|
|
|
return SimpleNamespace(slug="from-ids", id=_pid)
|
|
|
|
|
|
|
|
|
|
async def _fail_product_projects(*_a: Any, **_k: Any) -> list[Any]:
|
|
|
|
|
raise AssertionError("product_id branch must not run")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_ambient_product_projects", _fail_product_projects)
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.services.project.get_project_service",
|
|
|
|
|
lambda _db: _FakeProjectSvc(),
|
|
|
|
|
):
|
|
|
|
|
projects = await orch._resolve_ambient_projects(
|
|
|
|
|
object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
task_id=None,
|
|
|
|
|
product_id="prod-should-be-ignored",
|
|
|
|
|
project_ids=[pid],
|
|
|
|
|
)
|
|
|
|
|
assert [p.slug for p in projects] == ["from-ids"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_no_scope_given_returns_empty(self) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
projects = await orch._resolve_ambient_projects(
|
|
|
|
|
object(),
|
|
|
|
|
project_slug=None,
|
|
|
|
|
task_id=None,
|
|
|
|
|
product_id=None,
|
|
|
|
|
)
|
|
|
|
|
assert projects == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _resolve_conventions_ambient — flag-gated + best-effort, now MegaTask-aware.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestResolveConventionsAmbient:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_flag_off_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
monkeypatch.setattr(settings, "conventions_enabled", False)
|
|
|
|
|
|
|
|
|
|
result = await orch._resolve_conventions_ambient(
|
|
|
|
|
"roboco", project_ids=["11111111-1111-1111-1111-111111111111"]
|
|
|
|
|
)
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_failure_returns_none_not_raises(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
monkeypatch.setattr(settings, "conventions_enabled", True)
|
|
|
|
|
|
|
|
|
|
def _boom() -> Any:
|
|
|
|
|
raise RuntimeError("db unavailable")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("roboco.db.base.get_session_factory", _boom)
|
|
|
|
|
|
|
|
|
|
result = await orch._resolve_conventions_ambient(
|
|
|
|
|
None, project_ids=["11111111-1111-1111-1111-111111111111"]
|
|
|
|
|
)
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_ids_scope_reaches_conventions_layer(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
"""The MegaTask project_ids scope must flow all the way through to
|
|
|
|
|
conventions_ambient_layer — the actual gap this fix closes."""
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
monkeypatch.setattr(settings, "conventions_enabled", True)
|
|
|
|
|
|
|
|
|
|
class _FakeFactory:
|
|
|
|
|
def __call__(self) -> Any:
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
async def __aenter__(self) -> Any:
|
|
|
|
|
return "fake-db"
|
|
|
|
|
|
|
|
|
|
async def __aexit__(self, *_a: Any) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("roboco.db.base.get_session_factory", _FakeFactory)
|
|
|
|
|
|
|
|
|
|
sentinel_projects = [SimpleNamespace(slug="proj-1")]
|
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
|
async def _fake_resolve_projects(_self: Any, _db: Any, **kwargs: Any) -> Any:
|
|
|
|
|
captured["kwargs"] = kwargs
|
|
|
|
|
return sentinel_projects
|
|
|
|
|
|
|
|
|
|
async def _fake_layer(_db: Any, projects: Any) -> str:
|
|
|
|
|
captured["projects"] = projects
|
|
|
|
|
return "RENDERED BLOCK"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
AgentOrchestrator, "_resolve_ambient_projects", _fake_resolve_projects
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"roboco.agents.factories._base.conventions_ambient_layer", _fake_layer
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = await orch._resolve_conventions_ambient(
|
|
|
|
|
None, project_ids=["11111111-1111-1111-1111-111111111111"]
|
|
|
|
|
)
|
|
|
|
|
assert result == "RENDERED BLOCK"
|
|
|
|
|
assert captured["kwargs"]["project_ids"] == [
|
|
|
|
|
"11111111-1111-1111-1111-111111111111"
|
|
|
|
|
]
|
|
|
|
|
assert captured["projects"] is sentinel_projects
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _resolve_intake_ambient — must forward project_ids to BOTH sub-resolvers.
|
|
|
|
|
# Regression test for the gap: it used to thread project_ids only to the
|
|
|
|
|
# history-digest resolver, leaving a MegaTask intake with no conventions block.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestResolveIntakeAmbientThreadsProjectIds:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_project_ids_forwarded_to_conventions_and_history(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
conventions_calls: list[dict[str, Any]] = []
|
|
|
|
|
history_calls: list[dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
async def _conventions(_slug: Any, **kwargs: Any) -> str | None:
|
|
|
|
|
conventions_calls.append(kwargs)
|
|
|
|
|
return "CONVENTIONS"
|
|
|
|
|
|
|
|
|
|
async def _history(_slug: Any, **kwargs: Any) -> str | None:
|
|
|
|
|
history_calls.append(kwargs)
|
|
|
|
|
return "HISTORY"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_resolve_conventions_ambient", _conventions)
|
|
|
|
|
monkeypatch.setattr(orch, "_resolve_history_digest_ambient", _history)
|
|
|
|
|
|
|
|
|
|
result = await orch._resolve_intake_ambient(
|
|
|
|
|
None,
|
|
|
|
|
product_id=None,
|
|
|
|
|
project_ids=["11111111-1111-1111-1111-111111111111"],
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-13 15:41:00 +02:00
|
|
|
assert (
|
|
|
|
|
result
|
|
|
|
|
== f"{_INTAKE_WORKSPACE_AMBIENT}\n\n---\n\nCONVENTIONS\n\n---\n\nHISTORY"
|
|
|
|
|
)
|
2026-07-03 01:58:38 +02:00
|
|
|
assert conventions_calls == [
|
|
|
|
|
{
|
|
|
|
|
"product_id": None,
|
|
|
|
|
"project_ids": ["11111111-1111-1111-1111-111111111111"],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
assert history_calls == [
|
|
|
|
|
{
|
|
|
|
|
"product_id": None,
|
|
|
|
|
"project_ids": ["11111111-1111-1111-1111-111111111111"],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 17:08:34 +02:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# spawn_intake_session / reap_intake_session — orchestration (docker mocked).
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fake_route() -> SimpleNamespace:
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
provider_type=SimpleNamespace(value="anthropic"),
|
|
|
|
|
model_name="opus",
|
|
|
|
|
base_url=None,
|
|
|
|
|
auth_token=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wire_spawn_mocks(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
orch: AgentOrchestrator,
|
|
|
|
|
run_calls: list[list[str]],
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Patch every external boundary spawn_intake_session touches."""
|
|
|
|
|
|
2026-06-24 01:15:57 +02:00
|
|
|
async def _clone(_p: Any, _pr: Any, _pids: Any = None) -> tuple[str, list[str]]:
|
2026-06-09 17:08:34 +02:00
|
|
|
return "/data/workspaces/roboco/board/intake-1", [
|
|
|
|
|
"/data/workspaces/roboco/board/intake-1"
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
async def _route(_aid: str) -> Any:
|
|
|
|
|
return _fake_route()
|
|
|
|
|
|
|
|
|
|
async def _noop(*_a: Any, **_k: Any) -> None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
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, "_ensure_agent_image", _noop)
|
|
|
|
|
monkeypatch.setattr(orch, "_remove_container", _noop)
|
|
|
|
|
monkeypatch.setattr(orch, "_run_container_cmd", _run)
|
|
|
|
|
monkeypatch.setattr(orch, "_fire_audit", lambda **_k: None)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
orch,
|
|
|
|
|
"_generate_composed_prompt",
|
2026-06-22 12:37:46 +02:00
|
|
|
lambda *_args, **_kwargs: Path("/tmp/intake-1-prompt.md"),
|
2026-06-09 17:08:34 +02:00
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
orch,
|
|
|
|
|
"_resolve_intake_host_paths",
|
|
|
|
|
lambda: {
|
|
|
|
|
"claude": "/home/runner/.claude",
|
|
|
|
|
"prompt": "/data/prompts-generated/intake-1-prompt.md",
|
|
|
|
|
"workspaces": "/data/workspaces",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSpawnIntakeSession:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_spawn_registers_session_and_instance(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
|
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
|
|
|
|
|
|
|
|
|
instance = await orch.spawn_intake_session("sess-1", project_slug="roboco")
|
|
|
|
|
|
|
|
|
|
# Live relay session opened for the container.
|
|
|
|
|
session = prompter_live.get_live_registry().get("sess-1")
|
|
|
|
|
assert session is not None
|
|
|
|
|
assert session.agent_id == INTAKE_AGENT_ID
|
|
|
|
|
# Orchestrator instance tracked and marked active.
|
|
|
|
|
assert orch._instances[INTAKE_AGENT_ID] is instance
|
|
|
|
|
assert instance.container_id == "containerid0123456789"
|
|
|
|
|
# The cloned cwd reached the docker cmd.
|
|
|
|
|
assert "ROBOCO_WORKSPACE=/data/workspaces/roboco/board/intake-1" in run_calls[0]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_scope_must_be_exactly_one(self) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
with pytest.raises(ValueError, match="exactly one"):
|
|
|
|
|
await orch.spawn_intake_session("s", project_slug="roboco", product_id="p")
|
|
|
|
|
with pytest.raises(ValueError, match="exactly one"):
|
|
|
|
|
await orch.spawn_intake_session("s")
|
2026-06-24 01:15:57 +02:00
|
|
|
# A MegaTask scope cannot combine with a single-project scope.
|
|
|
|
|
with pytest.raises(ValueError, match="exactly one"):
|
|
|
|
|
await orch.spawn_intake_session(
|
|
|
|
|
"s", project_slug="roboco", project_ids=["p1"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_spawn_accepts_megatask_project_ids_scope(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
|
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
|
|
|
|
|
|
|
|
|
instance = await orch.spawn_intake_session(
|
|
|
|
|
"sess-mega", project_ids=["11111111-1111-1111-1111-111111111111"]
|
|
|
|
|
)
|
|
|
|
|
assert orch._instances[INTAKE_AGENT_ID] is instance
|
|
|
|
|
assert run_calls # the container actually launched for the MegaTask scope
|
2026-06-09 17:08:34 +02:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_spawn_reaps_prior_session_first(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
|
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
|
|
|
|
|
|
|
|
|
stopped: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def _stop(aid: str, **_kw: Any) -> None:
|
|
|
|
|
stopped.append(aid)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "stop_agent", _stop)
|
|
|
|
|
# A prior live container already registered for this agent.
|
|
|
|
|
orch._instances[INTAKE_AGENT_ID] = AgentInstance(agent_id=INTAKE_AGENT_ID)
|
|
|
|
|
|
|
|
|
|
await orch.spawn_intake_session("sess-2", project_slug="roboco")
|
|
|
|
|
assert stopped == [INTAKE_AGENT_ID] # the old one was reaped first
|
|
|
|
|
|
2026-07-03 00:07:55 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_spawn_merges_conventions_and_history_ambient(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
2026-07-13 15:41:00 +02:00
|
|
|
"""The composed prompt's ambient is the workspace note + conventions +
|
|
|
|
|
history-digest blocks joined with compose_prompt's own layer separator."""
|
2026-07-03 00:07:55 +02:00
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
|
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
|
|
|
|
|
|
|
|
|
async def _conventions(*_a: Any, **_k: Any) -> str | None:
|
|
|
|
|
return "CONVENTIONS BLOCK"
|
|
|
|
|
|
|
|
|
|
async def _history(*_a: Any, **_k: Any) -> str | None:
|
|
|
|
|
return "HISTORY BLOCK"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_resolve_conventions_ambient", _conventions)
|
|
|
|
|
monkeypatch.setattr(orch, "_resolve_history_digest_ambient", _history)
|
|
|
|
|
|
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
|
def _spy_prompt(*_args: Any, **kwargs: Any) -> Path:
|
|
|
|
|
captured["ambient"] = kwargs.get("ambient")
|
|
|
|
|
return Path("/tmp/intake-1-prompt.md")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_generate_composed_prompt", _spy_prompt)
|
|
|
|
|
|
|
|
|
|
await orch.spawn_intake_session("sess-merge", project_slug="roboco")
|
|
|
|
|
|
2026-07-13 15:41:00 +02:00
|
|
|
assert (
|
|
|
|
|
captured["ambient"]
|
|
|
|
|
== f"{_INTAKE_WORKSPACE_AMBIENT}\n\n---\n\nCONVENTIONS BLOCK"
|
|
|
|
|
f"\n\n---\n\nHISTORY BLOCK"
|
|
|
|
|
)
|
2026-07-03 00:07:55 +02:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-07-13 15:41:00 +02:00
|
|
|
async def test_spawn_ambient_is_workspace_block_when_both_resolvers_empty(
|
2026-07-03 00:07:55 +02:00
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
2026-07-13 15:41:00 +02:00
|
|
|
"""The workspace note is always present; with no conventions/history the
|
|
|
|
|
ambient is just that block (never None)."""
|
2026-07-03 00:07:55 +02:00
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
|
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
|
|
|
|
|
|
|
|
|
async def _none(*_a: Any, **_k: Any) -> str | None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_resolve_conventions_ambient", _none)
|
|
|
|
|
monkeypatch.setattr(orch, "_resolve_history_digest_ambient", _none)
|
|
|
|
|
|
|
|
|
|
captured: dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
|
def _spy_prompt(*_args: Any, **kwargs: Any) -> Path:
|
|
|
|
|
captured["ambient"] = kwargs.get("ambient")
|
|
|
|
|
return Path("/tmp/intake-1-prompt.md")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_generate_composed_prompt", _spy_prompt)
|
|
|
|
|
|
|
|
|
|
await orch.spawn_intake_session("sess-no-ambient", project_slug="roboco")
|
|
|
|
|
|
2026-07-13 15:41:00 +02:00
|
|
|
assert captured["ambient"] == _INTAKE_WORKSPACE_AMBIENT
|
2026-07-03 00:07:55 +02:00
|
|
|
|
2026-06-09 17:08:34 +02:00
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_initial_message_is_scheduled(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
|
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
|
|
|
|
scheduled: list[tuple[str, str]] = []
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
orch,
|
|
|
|
|
"_schedule_intake_first_message",
|
|
|
|
|
lambda sid, text: scheduled.append((sid, text)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await orch.spawn_intake_session(
|
|
|
|
|
"sess-3", project_slug="roboco", initial_message="build X"
|
|
|
|
|
)
|
|
|
|
|
assert scheduled == [("sess-3", "build X")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStartIntakeSession:
|
|
|
|
|
"""Non-blocking start: relay opens synchronously, spawn runs in the background."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_opens_relay_now_and_schedules_spawn(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
spawned: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def _spawn(session_id: str, **_kw: Any) -> Any:
|
|
|
|
|
spawned.append(session_id)
|
|
|
|
|
return AgentInstance(agent_id=INTAKE_AGENT_ID)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_spawn_intake_container", _spawn)
|
|
|
|
|
|
|
|
|
|
await orch.start_intake_session("sess-A", project_slug="roboco")
|
|
|
|
|
|
|
|
|
|
# Relay is open the instant start returns — the SSE stream can connect
|
|
|
|
|
# before the (slow) container spawn finishes.
|
|
|
|
|
assert prompter_live.get_live_registry().get("sess-A") is not None
|
|
|
|
|
await asyncio.sleep(0) # let the scheduled bg spawn run
|
|
|
|
|
assert spawned == ["sess-A"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_rejects_bad_scope(self) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
with pytest.raises(ValueError, match="exactly one"):
|
|
|
|
|
await orch.start_intake_session("s", project_slug="r", product_id="p")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSpawnGuarded:
|
|
|
|
|
"""A background spawn failure surfaces on the relay instead of dying silently."""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_failure_pushes_error_and_closes(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
registry = prompter_live.get_live_registry()
|
|
|
|
|
registry.open("sess-B", INTAKE_AGENT_ID)
|
|
|
|
|
|
|
|
|
|
pushed: list[tuple[str, dict[str, Any]]] = []
|
|
|
|
|
closed: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def _boom(_session_id: str, **_kw: Any) -> Any:
|
|
|
|
|
raise RuntimeError("clone exploded")
|
|
|
|
|
|
|
|
|
|
def _push(sid: str, ev: dict[str, Any]) -> bool:
|
|
|
|
|
pushed.append((sid, ev))
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_spawn_intake_container", _boom)
|
|
|
|
|
monkeypatch.setattr(registry, "push", _push)
|
|
|
|
|
monkeypatch.setattr(registry, "close", closed.append)
|
|
|
|
|
|
|
|
|
|
await orch._spawn_intake_container_guarded(
|
|
|
|
|
"sess-B", project_slug="roboco", product_id=None, initial_message=None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert len(pushed) == 1
|
|
|
|
|
assert pushed[0][1]["kind"] == "error"
|
|
|
|
|
assert "clone exploded" in pushed[0][1]["text"]
|
|
|
|
|
assert closed == ["sess-B"]
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 05:38:21 +02:00
|
|
|
class TestConcurrentSpawnSerialization:
|
|
|
|
|
"""Two concurrent intake starts must serialize — the intake agent id is a
|
|
|
|
|
single fixed id, so two ``docker run --name roboco-agent-prompter`` calls and
|
|
|
|
|
two ``_instances[INTAKE_AGENT_ID]`` writes racing orphan a container + relay.
|
|
|
|
|
The spawn body (reap-prior → clone → docker run → register) must run under
|
|
|
|
|
a per-agent lock so the second start only begins once the first has fully
|
|
|
|
|
registered (or been reaped).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_concurrent_intake_spawns_do_not_interleave(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
_wire_spawn_mocks(monkeypatch, orch, run_calls=[])
|
|
|
|
|
|
|
|
|
|
# Reap the prior instance on a concurrent start: mock stop_agent so the
|
|
|
|
|
# second spawn's reap doesn't need the real self._lock (not set on the
|
|
|
|
|
# minimal orchestrator). Records that the prior instance was reaped.
|
|
|
|
|
reaped: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def _stop(aid: str, **_kw: Any) -> None:
|
|
|
|
|
reaped.append(aid)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "stop_agent", _stop)
|
|
|
|
|
|
|
|
|
|
# Instrument the first await inside the spawn body (the scope clone) to
|
|
|
|
|
# measure how many spawns are inside the body at once. With a serializing
|
|
|
|
|
# lock the second spawn is parked on lock.acquire() and can't enter clone
|
|
|
|
|
# until the first releases (after fully registering) -> max depth 1.
|
|
|
|
|
# Without the lock both spawns reach clone concurrently -> max depth 2.
|
|
|
|
|
in_clone = 0
|
|
|
|
|
max_depth = 0
|
|
|
|
|
|
|
|
|
|
async def _clone(*_a: Any, **_kw: Any) -> tuple[str, list[str]]:
|
|
|
|
|
nonlocal in_clone, max_depth
|
|
|
|
|
in_clone += 1
|
|
|
|
|
max_depth = max(max_depth, in_clone)
|
|
|
|
|
await asyncio.sleep(0) # yield so the other spawn may enter if not locked
|
|
|
|
|
in_clone -= 1
|
|
|
|
|
return "/data/workspaces/roboco/board/intake-1", ["/cwd"]
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_clone_intake_scope", _clone)
|
|
|
|
|
|
|
|
|
|
await asyncio.gather(
|
|
|
|
|
orch.spawn_intake_session("sess-a", project_slug="roboco"),
|
|
|
|
|
orch.spawn_intake_session("sess-b", project_slug="roboco"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert max_depth == 1 # serialized: never two spawns in the body at once
|
|
|
|
|
# The second start reaped the first's registered instance (proves the two
|
|
|
|
|
# spawns ran in order, not concurrently clobbering the registry).
|
|
|
|
|
assert reaped == [INTAKE_AGENT_ID]
|
|
|
|
|
|
|
|
|
|
|
2026-06-09 17:08:34 +02:00
|
|
|
class TestReapIntakeSession:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_reap_closes_session_and_stops_container(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
stopped: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def _stop(aid: str, **_kw: Any) -> None:
|
|
|
|
|
stopped.append(aid)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "stop_agent", _stop)
|
|
|
|
|
registry = prompter_live.get_live_registry()
|
|
|
|
|
registry.open("sess-x", INTAKE_AGENT_ID)
|
|
|
|
|
|
|
|
|
|
await orch.reap_intake_session("sess-x")
|
|
|
|
|
|
|
|
|
|
assert registry.get("sess-x") is None # relay session closed
|
|
|
|
|
assert stopped == [INTAKE_AGENT_ID]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDeliverWhenReady:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_retries_until_receiver_is_up(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
registry = prompter_live.get_live_registry()
|
|
|
|
|
succeed_on = 2 # fails once, then succeeds
|
|
|
|
|
attempts = {"n": 0}
|
|
|
|
|
|
|
|
|
|
async def _deliver(_sid: str, _text: str) -> bool:
|
|
|
|
|
attempts["n"] += 1
|
|
|
|
|
return attempts["n"] >= succeed_on
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(registry, "deliver", _deliver)
|
|
|
|
|
|
|
|
|
|
await orch._deliver_when_ready("sess-y", "hi", attempts=5, delay=0)
|
|
|
|
|
assert attempts["n"] == succeed_on # stopped as soon as delivery succeeded
|
2026-06-29 05:38:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Non-blocking intake spawn must not orphan a container if shutdown arrives
|
|
|
|
|
# between ``docker run`` and _instances registration: without a post-docker-run
|
|
|
|
|
# shutdown check the just-started container is never recorded so leaks.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSpawnIntakeShutdownNoOrphan:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_shutdown_mid_spawn_removes_container_and_skips_registration(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
"""docker run completes, THEN the orchestrator begins shutting down
|
|
|
|
|
(``_running`` flips to False) before the registration line. The just-
|
|
|
|
|
started container must be removed and NOT registered — otherwise it is
|
|
|
|
|
orphaned (live, untracked by stop())."""
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
|
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
|
|
|
|
removed: list[str] = []
|
|
|
|
|
|
2026-07-08 16:01:01 +02:00
|
|
|
async def _remove(name: str, **_kw: Any) -> None:
|
2026-06-29 05:38:21 +02:00
|
|
|
removed.append(name)
|
|
|
|
|
|
|
|
|
|
async def _run(cmd: list[str]) -> str:
|
|
|
|
|
run_calls.append(cmd)
|
|
|
|
|
# Shutdown arrives AFTER docker run started the container but BEFORE
|
|
|
|
|
# the registration line runs.
|
|
|
|
|
orch._running = False
|
|
|
|
|
return "containerid0123456789"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_run_container_cmd", _run)
|
|
|
|
|
monkeypatch.setattr(orch, "_remove_container", _remove)
|
|
|
|
|
|
|
|
|
|
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-orphan", INTAKE_AGENT_ID)
|
|
|
|
|
|
|
|
|
|
await orch._spawn_intake_container_guarded(
|
|
|
|
|
"sess-orphan",
|
|
|
|
|
project_slug="roboco",
|
|
|
|
|
product_id=None,
|
|
|
|
|
initial_message=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# The just-started container was removed by name (not orphaned). Two
|
|
|
|
|
# removes: the pre-spawn reap of any stale container, then the
|
|
|
|
|
# post-docker-run shutdown guard reaping the just-started one. Without
|
|
|
|
|
# the guard there is only ONE remove (the pre-spawn reap) and the
|
|
|
|
|
# just-started container is orphaned — so asserting two proves the guard
|
|
|
|
|
# ran.
|
|
|
|
|
assert removed == [
|
|
|
|
|
f"roboco-agent-{INTAKE_AGENT_ID}",
|
|
|
|
|
f"roboco-agent-{INTAKE_AGENT_ID}",
|
|
|
|
|
]
|
|
|
|
|
# No instance registered — stop()'s _instances iteration has already
|
|
|
|
|
# run, so a registration now would land a live container nothing stops.
|
|
|
|
|
assert INTAKE_AGENT_ID not in orch._instances
|
|
|
|
|
# Shutdown is not a user-facing failure: the relay closes silently,
|
|
|
|
|
# no error pushed to the SSE stream.
|
|
|
|
|
assert pushed == []
|
|
|
|
|
assert closed == ["sess-orphan"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_running_spawn_registers_normally(
|
|
|
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Sanity: when the orchestrator stays running, the spawn registers the
|
|
|
|
|
instance as before — the shutdown guard does not fire on a healthy spawn."""
|
|
|
|
|
orch = _make_minimal_orchestrator()
|
|
|
|
|
run_calls: list[list[str]] = []
|
|
|
|
|
_wire_spawn_mocks(monkeypatch, orch, run_calls)
|
|
|
|
|
# _wire_spawn_mocks' _remove_container is a no-op; override to record.
|
|
|
|
|
removed: list[str] = []
|
|
|
|
|
|
2026-07-08 16:01:01 +02:00
|
|
|
async def _remove(name: str, **_kw: Any) -> None:
|
2026-06-29 05:38:21 +02:00
|
|
|
removed.append(name)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(orch, "_remove_container", _remove)
|
|
|
|
|
|
|
|
|
|
instance = await orch.spawn_intake_session("sess-ok", project_slug="roboco")
|
|
|
|
|
|
|
|
|
|
assert orch._instances[INTAKE_AGENT_ID] is instance
|
|
|
|
|
# The pre-spawn reap remove is the only remove call (the shutdown guard
|
|
|
|
|
# did NOT remove the just-started container).
|
|
|
|
|
assert removed == [f"roboco-agent-{INTAKE_AGENT_ID}"]
|