feat(runtime): stamp spawned containers with the stack's own compose-project labels

Agent spawns (all five provider paths), intake/secretary chats, and
sandbox sidecars now carry com.docker.compose.project/service/oneoff/
config-hash labels copied from the orchestrator's own compose project,
so a Docker UI (UGOS) groups them under the stack's project and they
die with the stack: bare compose stop/restart affects them, compose
down removes them (the config-hash label must be PRESENT for down to
even see the container — compose filters its API listing on that key
before the orphan predicate runs, verified live), and up -d deliberately
does not resurrect them since the orchestrator respawns its own agents.

Self-discovery reads the orchestrator's own container id from
/proc/self/mountinfo keyed on the root-independent /containers/<id>/
segment — the UGREEN NAS data-root is /volume1/@docker on btrfs, so its
mountinfo reads /@docker/containers/..., never the textbook
/var/lib/docker path (verified against the live NAS) — with a HOSTNAME
short-id fallback, then one docker inspect cached per process. Only
definitive outcomes cache; a transient inspect failure logs and retries
on the next spawn. Outside compose the helper yields nothing and every
spawn command is byte-for-byte unchanged.
This commit is contained in:
Renn F
2026-07-30 17:51:12 +02:00
parent 93739a9dca
commit 27e58f469f
14 changed files with 856 additions and 0 deletions
@@ -189,6 +189,34 @@ async def test_gemini_spawn_wires_gateway_env_and_image_last() -> None:
)
async def test_gemini_spawn_adds_compose_labels_before_image(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the orchestrator resolves its own compose project, the sibling
agent container carries it too (UGOS grouping + `down --remove-orphans`)
— and the labels land BEFORE the image, never after (docker parses
anything past the image as the container command, not a flag)."""
async def _fake_label_args(service: str) -> list[str]:
return ["--label", f"com.docker.compose.service={service}"]
monkeypatch.setattr(
"roboco.llm.providers.gemini.compose_label_args", _fake_label_args
)
host = _FakeHost()
provider = GeminiCliProvider(host, image="roboco-agent-gemini:test")
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_config())
cmd = list(exec_mock.call_args.args)
assert "com.docker.compose.service=be-dev-1" in cmd
assert cmd.index("com.docker.compose.service=be-dev-1") < cmd.index(
"roboco-agent-gemini:test"
)
assert cmd[-1] == "roboco-agent-gemini:test"
async def test_gemini_spawn_mounts_auth_when_present(
_isolate_gemini_auth: Path,
) -> None:
+74
View File
@@ -266,6 +266,34 @@ async def test_grok_spawn_wires_gateway_env_and_image_last() -> None:
)
async def test_grok_spawn_adds_compose_labels_before_image(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the orchestrator resolves its own compose project, the sibling
agent container carries it too (UGOS grouping + `down --remove-orphans`)
— and the labels land BEFORE the image, never after (docker parses
anything past the image as the container command, not a flag)."""
async def _fake_label_args(service: str) -> list[str]:
return ["--label", f"com.docker.compose.service={service}"]
monkeypatch.setattr(
"roboco.llm.providers.grok.compose_label_args", _fake_label_args
)
host = _FakeHost()
provider = GrokCliProvider(host, image="roboco-agent-grok:test")
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_config())
cmd = list(exec_mock.call_args.args)
assert "com.docker.compose.service=be-dev-1" in cmd
assert cmd.index("com.docker.compose.service=be-dev-1") < cmd.index(
"roboco-agent-grok:test"
)
assert cmd[-1] == "roboco-agent-grok:test"
async def test_grok_spawn_mounts_auth_when_present(_isolate_grok_auth: Path) -> None:
(_isolate_grok_auth / "auth.json").write_text("{}", encoding="utf-8")
host = _FakeHost()
@@ -421,6 +449,29 @@ async def test_codex_spawn_wires_gateway_env_and_image_last() -> None:
)
async def test_codex_spawn_adds_compose_labels_before_image(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_label_args(service: str) -> list[str]:
return ["--label", f"com.docker.compose.service={service}"]
monkeypatch.setattr(
"roboco.llm.providers.codex.compose_label_args", _fake_label_args
)
host = _FakeHost()
provider = CodexCliProvider(host, image="roboco-agent-codex:test")
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_codex_config())
cmd = list(exec_mock.call_args.args)
assert "com.docker.compose.service=be-dev-1" in cmd
assert cmd.index("com.docker.compose.service=be-dev-1") < cmd.index(
"roboco-agent-codex:test"
)
assert cmd[-1] == "roboco-agent-codex:test"
async def test_codex_spawn_mounts_auth_when_present(
_isolate_codex_auth: Path,
) -> None:
@@ -571,6 +622,29 @@ async def test_kimi_spawn_wires_gateway_env_and_image_last() -> None:
)
async def test_kimi_spawn_adds_compose_labels_before_image(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_label_args(service: str) -> list[str]:
return ["--label", f"com.docker.compose.service={service}"]
monkeypatch.setattr(
"roboco.llm.providers.kimi.compose_label_args", _fake_label_args
)
host = _FakeHost()
provider = KimiCliProvider(host, image="roboco-agent-kimi:test")
with patch(
"asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())
) as exec_mock:
await provider.spawn(_kimi_config())
cmd = list(exec_mock.call_args.args)
assert "com.docker.compose.service=be-dev-1" in cmd
assert cmd.index("com.docker.compose.service=be-dev-1") < cmd.index(
"roboco-agent-kimi:test"
)
assert cmd[-1] == "roboco-agent-kimi:test"
async def test_kimi_spawn_mounts_auth_when_present(_isolate_kimi_auth: Path) -> None:
creds_dir = _isolate_kimi_auth / "credentials"
creds_dir.mkdir(parents=True, exist_ok=True)
+302
View File
@@ -0,0 +1,302 @@
"""roboco.runtime.compose_labels — self-id + cached compose-project discovery.
Every docker-run site splices ``compose_label_args(service)`` in; on a dev
machine / CI runner / the eval harness (never a compose-managed container)
discovery finds nothing and every call site is byte-for-byte unchanged —
that fallback is exercised implicitly by every OTHER spawn-cmd test in the
suite (none of them run inside a real compose stack), so this file focuses
on the helper's own mechanics: mountinfo parsing (including the real UGREEN
NAS btrfs shape), the HOSTNAME fallback, the docker-inspect path, the
definitive-vs-transient cache semantics, and the once-per-process cache.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.runtime import compose_labels
if TYPE_CHECKING:
from pathlib import Path
_FAKE_CONTAINER_ID = "a" * 64
# From a live UGREEN NAS container (DockerRootDir /volume1/@docker, a btrfs
# mount) — captured 2026-07-30. Root field (4th) carries the bind-mount
# source path; NOT /var/lib/docker.
_NAS_HOSTNAME_LINE = (
"614 613 0:59 /@docker/containers/"
"87ea7acf042857b338432c5a06563cdc4d5cef97d959efcb57da154b34e924ba/hostname "
"/etc/hostname rw,relatime - btrfs /dev/bcache0 "
"rw,ssd,space_cache=v2,subvolid=257\n"
)
@pytest.fixture(autouse=True)
def _reset_cache(monkeypatch: pytest.MonkeyPatch) -> None:
"""Every test starts as if discovery never ran (module-level cache)."""
monkeypatch.setattr(compose_labels, "_cache", compose_labels._DiscoveryCache())
@pytest.fixture(autouse=True)
def _clear_hostname_env(monkeypatch: pytest.MonkeyPatch) -> None:
"""Deterministic: the mountinfo-only tests must not accidentally pick up
a real HOSTNAME from the runner's own environment via the fallback tier.
Tests exercising the fallback set HOSTNAME explicitly."""
monkeypatch.delenv("HOSTNAME", raising=False)
def _proc(returncode: int = 0, stdout: bytes = b"", stderr: bytes = b"") -> MagicMock:
proc = MagicMock()
proc.returncode = returncode
proc.communicate = AsyncMock(return_value=(stdout, stderr))
return proc
# ---------------------------------------------------------------------------
# _id_from_mountinfo / _id_from_hostname_env / _own_container_id
# ---------------------------------------------------------------------------
def test_own_container_id_none_when_mountinfo_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
compose_labels, "_MOUNTINFO_PATH", str(tmp_path / "does-not-exist")
)
assert compose_labels._own_container_id() is None
def test_own_container_id_none_when_no_docker_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A real host's mountinfo (not a Docker container) has no matching line."""
mountinfo = tmp_path / "mountinfo"
mountinfo.write_text("1 2 0:1 / / rw,relatime - ext4 /dev/root rw\n")
monkeypatch.setattr(compose_labels, "_MOUNTINFO_PATH", str(mountinfo))
assert compose_labels._own_container_id() is None
def test_own_container_id_parses_docker_bind_mount(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The textbook /var/lib/docker data-root shape."""
mountinfo = tmp_path / "mountinfo"
mountinfo.write_text(
"614 613 253:1 /var/lib/docker/containers/"
f"{_FAKE_CONTAINER_ID}/hostname /etc/hostname rw,relatime "
"- ext4 /dev/root rw\n"
)
monkeypatch.setattr(compose_labels, "_MOUNTINFO_PATH", str(mountinfo))
assert compose_labels._own_container_id() == _FAKE_CONTAINER_ID
def test_own_container_id_parses_nas_btrfs_data_root(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The real deploy target: UGREEN NAS, DockerRootDir /volume1/@docker, a
btrfs subvolume mount — NOT /var/lib/docker. The id must still parse
since the regex keys on the root-independent `/containers/<id>/<file>`
suffix, not a specific data-root prefix."""
mountinfo = tmp_path / "mountinfo"
mountinfo.write_text(_NAS_HOSTNAME_LINE)
monkeypatch.setattr(compose_labels, "_MOUNTINFO_PATH", str(mountinfo))
assert (
compose_labels._own_container_id()
== "87ea7acf042857b338432c5a06563cdc4d5cef97d959efcb57da154b34e924ba"
)
def test_own_container_id_falls_back_to_hostname_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""No usable mountinfo (e.g. a runtime that doesn't bind-mount per-container
config) => fall back to Docker's default HOSTNAME-is-the-short-id."""
mountinfo = tmp_path / "mountinfo"
mountinfo.write_text("1 2 0:1 / / rw,relatime - ext4 /dev/root rw\n")
monkeypatch.setattr(compose_labels, "_MOUNTINFO_PATH", str(mountinfo))
monkeypatch.setenv("HOSTNAME", "545315347e2f")
assert compose_labels._own_container_id() == "545315347e2f"
def test_hostname_env_rejects_a_real_hostname(monkeypatch: pytest.MonkeyPatch) -> None:
"""A non-container HOSTNAME (a real machine name) must not be mistaken
for a container id."""
monkeypatch.setenv("HOSTNAME", "MacBook-Pro.local")
assert compose_labels._id_from_hostname_env() is None
def test_mountinfo_takes_priority_over_hostname_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Mountinfo wins when both are available — robust to a hostname override."""
mountinfo = tmp_path / "mountinfo"
mountinfo.write_text(
"614 613 253:1 /var/lib/docker/containers/"
f"{_FAKE_CONTAINER_ID}/hostname /etc/hostname rw,relatime "
"- ext4 /dev/root rw\n"
)
monkeypatch.setattr(compose_labels, "_MOUNTINFO_PATH", str(mountinfo))
monkeypatch.setenv("HOSTNAME", "545315347e2f")
assert compose_labels._own_container_id() == _FAKE_CONTAINER_ID
# ---------------------------------------------------------------------------
# compose_label_args — end to end (discovery + cache)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_compose_label_args_empty_outside_a_container(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""No container id at all (dev machine / test env) => no labels, no docker call."""
monkeypatch.setattr(compose_labels, "_own_container_id", lambda: None)
with patch("asyncio.create_subprocess_exec") as create_exec:
result = await compose_labels.compose_label_args("be-dev-1")
assert result == []
create_exec.assert_not_called()
@pytest.mark.asyncio
async def test_compose_label_args_present_when_project_resolved(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(compose_labels, "_own_container_id", lambda: _FAKE_CONTAINER_ID)
with patch(
"asyncio.create_subprocess_exec",
AsyncMock(return_value=_proc(returncode=0, stdout=b"roboco-nas\n")),
):
result = await compose_labels.compose_label_args("be-dev-1")
assert result == [
"--label",
"com.docker.compose.project=roboco-nas",
"--label",
"com.docker.compose.service=be-dev-1",
"--label",
"com.docker.compose.oneoff=False",
"--label",
f"com.docker.compose.config-hash={compose_labels._CONFIG_HASH_PLACEHOLDER}",
]
@pytest.mark.asyncio
async def test_hostname_fallback_id_reaches_docker_inspect(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""End to end: mountinfo empty, HOSTNAME=12-hex => docker inspect is
called with that hostname value as the target container id."""
mountinfo = tmp_path / "mountinfo"
mountinfo.write_text("1 2 0:1 / / rw,relatime - ext4 /dev/root rw\n")
monkeypatch.setattr(compose_labels, "_MOUNTINFO_PATH", str(mountinfo))
monkeypatch.setenv("HOSTNAME", "545315347e2f")
create_exec = AsyncMock(return_value=_proc(returncode=0, stdout=b"roboco-nas\n"))
with patch("asyncio.create_subprocess_exec", create_exec):
result = await compose_labels.compose_label_args("be-dev-1")
assert "com.docker.compose.project=roboco-nas" in result
assert create_exec.call_args.args[-1] == "545315347e2f"
@pytest.mark.asyncio
async def test_compose_label_args_empty_when_inspect_fails_transiently(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(compose_labels, "_own_container_id", lambda: _FAKE_CONTAINER_ID)
with patch(
"asyncio.create_subprocess_exec",
AsyncMock(return_value=_proc(returncode=1, stderr=b"no such container\n")),
):
result = await compose_labels.compose_label_args("be-dev-1")
assert result == []
# Transient (nonzero inspect) — must NOT poison the cache forever.
assert compose_labels._cache.discovered is False
@pytest.mark.asyncio
async def test_transient_inspect_failure_retries_on_next_call(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A first-call transient failure (e.g. daemon not ready yet) must not
cache a permanent None — the very next spawn gets a fresh attempt."""
monkeypatch.setattr(compose_labels, "_own_container_id", lambda: _FAKE_CONTAINER_ID)
with patch(
"asyncio.create_subprocess_exec",
AsyncMock(return_value=_proc(returncode=1, stderr=b"daemon not ready\n")),
):
first = await compose_labels.compose_label_args("be-dev-1")
assert first == []
with patch(
"asyncio.create_subprocess_exec",
AsyncMock(return_value=_proc(returncode=0, stdout=b"roboco-nas\n")),
):
second = await compose_labels.compose_label_args("be-dev-1")
assert "com.docker.compose.project=roboco-nas" in second
@pytest.mark.asyncio
async def test_compose_label_args_empty_when_label_absent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A real container not launched by compose has no project label — the
`--format` template resolves to an empty string, not an error. This IS
a definitive outcome (a completed inspect), so it gets cached."""
monkeypatch.setattr(compose_labels, "_own_container_id", lambda: _FAKE_CONTAINER_ID)
with patch(
"asyncio.create_subprocess_exec",
AsyncMock(return_value=_proc(returncode=0, stdout=b"\n")),
):
result = await compose_labels.compose_label_args("be-dev-1")
assert result == []
assert compose_labels._cache.discovered is True
assert compose_labels._cache.project is None
@pytest.mark.asyncio
async def test_compose_label_args_empty_when_docker_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""docker CLI absent (FileNotFoundError from create_subprocess_exec) —
transient, not cached."""
monkeypatch.setattr(compose_labels, "_own_container_id", lambda: _FAKE_CONTAINER_ID)
with patch(
"asyncio.create_subprocess_exec",
AsyncMock(side_effect=FileNotFoundError("docker not found")),
):
result = await compose_labels.compose_label_args("be-dev-1")
assert result == []
assert compose_labels._cache.discovered is False
@pytest.mark.asyncio
async def test_compose_label_args_empty_on_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(compose_labels, "_own_container_id", lambda: _FAKE_CONTAINER_ID)
proc = MagicMock()
proc.communicate = AsyncMock(side_effect=TimeoutError)
with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
result = await compose_labels.compose_label_args("be-dev-1")
assert result == []
assert compose_labels._cache.discovered is False
@pytest.mark.asyncio
async def test_discovery_runs_docker_inspect_only_once(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Second/third call for a different service reuses the cached project —
the whole point of caching it process-wide."""
monkeypatch.setattr(compose_labels, "_own_container_id", lambda: _FAKE_CONTAINER_ID)
create_exec = AsyncMock(return_value=_proc(returncode=0, stdout=b"roboco-nas\n"))
with patch("asyncio.create_subprocess_exec", create_exec):
first = await compose_labels.compose_label_args("be-dev-1")
second = await compose_labels.compose_label_args("fe-qa-1")
assert create_exec.call_count == 1
assert "com.docker.compose.service=be-dev-1" in first
assert "com.docker.compose.service=fe-qa-1" in second
# Both share the same resolved project.
assert "com.docker.compose.project=roboco-nas" in first
assert "com.docker.compose.project=roboco-nas" in second
+48
View File
@@ -789,6 +789,54 @@ class TestSpawnIntakeSession:
# 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_spawn_adds_compose_labels_before_image(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When the orchestrator resolves its own compose project, the
persistent intake container carries it too — spliced in right before
the trailing image element (docker run flags must precede the
image)."""
orch = _make_minimal_orchestrator()
run_calls: list[list[str]] = []
_wire_spawn_mocks(monkeypatch, orch, run_calls)
async def _fake_label_args(service: str) -> list[str]:
return ["--label", f"com.docker.compose.service={service}"]
monkeypatch.setattr(
"roboco.runtime.orchestrator.compose_label_args", _fake_label_args
)
await orch.spawn_intake_session("sess-labels", project_slug="roboco")
assert len(run_calls) == 1
cmd = run_calls[0]
assert cmd[-3] == "--label"
assert cmd[-2] == f"com.docker.compose.service={INTAKE_AGENT_ID}"
@pytest.mark.asyncio
async def test_spawn_omits_compose_labels_outside_compose(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The real helper returns [] outside a compose stack — the cmd
shape (image last) is byte-for-byte unchanged from today."""
orch = _make_minimal_orchestrator()
run_calls: list[list[str]] = []
_wire_spawn_mocks(monkeypatch, orch, run_calls)
async def _no_labels(_service: str) -> list[str]:
return []
monkeypatch.setattr(
"roboco.runtime.orchestrator.compose_label_args", _no_labels
)
await orch.spawn_intake_session("sess-no-labels", project_slug="roboco")
assert len(run_calls) == 1
assert not any(a.startswith("com.docker.compose.") for a in run_calls[0])
@pytest.mark.asyncio
async def test_scope_must_be_exactly_one(self) -> None:
orch = _make_minimal_orchestrator()
+50
View File
@@ -120,3 +120,53 @@ async def test_spawn_container_stale_clear_runs_with_teardown_sandbox_false(
teardown_sandbox=False,
stop_reason="pre_spawn_stale_clear",
)
@pytest.mark.asyncio
async def test_spawn_container_adds_compose_labels_before_image_args(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the orchestrator resolves its own compose project, the spawned
agent container carries it too — inserted before
`_append_image_and_claude_args` runs, so the label flags precede the
image argument in the final docker run cmd."""
orch = AgentOrchestrator.__new__(AgentOrchestrator)
calls: list[str] = []
_stub_spawn_container_collaborators(monkeypatch, orch, calls)
async def _fake_label_args(service: str) -> list[str]:
return ["--label", f"com.docker.compose.service={service}"]
monkeypatch.setattr(
"roboco.runtime.orchestrator.compose_label_args", _fake_label_args
)
exec_mock = AsyncMock(return_value=_fake_proc())
monkeypatch.setattr(asyncio, "create_subprocess_exec", exec_mock)
await orch._spawn_container(_config(["postgres"]))
cmd = list(exec_mock.call_args.args)
assert cmd == ["--label", "com.docker.compose.service=dev-1"]
@pytest.mark.asyncio
async def test_spawn_container_omits_compose_labels_outside_compose(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The real helper returns [] outside a compose stack (dev machines, CI,
the eval harness) — the cmd is byte-for-byte unchanged from today."""
orch = AgentOrchestrator.__new__(AgentOrchestrator)
calls: list[str] = []
_stub_spawn_container_collaborators(monkeypatch, orch, calls)
async def _no_labels(_service: str) -> list[str]:
return []
monkeypatch.setattr("roboco.runtime.orchestrator.compose_label_args", _no_labels)
exec_mock = AsyncMock(return_value=_fake_proc())
monkeypatch.setattr(asyncio, "create_subprocess_exec", exec_mock)
await orch._spawn_container(_config(["postgres"]))
cmd = list(exec_mock.call_args.args)
assert cmd == []
@@ -129,6 +129,63 @@ async def test_provision_labels_are_correct() -> None:
assert "roboco.sandbox.owner=roboco-agent-dev-2" in labels
@pytest.mark.asyncio
async def test_provision_adds_compose_labels_when_orchestrator_is_compose_managed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the orchestrator resolves its own compose project, every sandbox
sidecar carries it too — so `docker compose down --remove-orphans` sweeps
the sidecar away with the stack, and UGOS groups it under the same
project."""
async def _fake_label_args(service: str) -> list[str]:
return [
"--label",
"com.docker.compose.project=roboco-nas",
"--label",
f"com.docker.compose.service={service}",
"--label",
"com.docker.compose.oneoff=False",
"--label",
"com.docker.compose.config-hash=roboco-sidecar",
]
monkeypatch.setattr(sandbox_module, "compose_label_args", _fake_label_args)
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.provision("dev-3", ["postgres"])
run_call = next(c for c in runner.calls if c[0] == "run")
label_indices = [i for i, a in enumerate(run_call) if a == "--label"]
labels = [run_call[i + 1] for i in label_indices]
assert "com.docker.compose.project=roboco-nas" in labels
expected_service = sandbox_module.SANDBOX_ENGINES["postgres"].container_name(
"dev-3"
)
assert f"com.docker.compose.service={expected_service}" in labels
@pytest.mark.asyncio
async def test_provision_omits_compose_labels_outside_compose(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The real discovery helper returns [] outside a compose stack (dev
machines, CI, the eval harness) — no com.docker.compose.* label leaks in."""
async def _no_labels(_service: str) -> list[str]:
return []
monkeypatch.setattr(sandbox_module, "compose_label_args", _no_labels)
runner = _FakeRunner(run_rc=0, exec_rc=0)
provisioner = SandboxProvisioner(network=_NETWORK, runner=runner)
await provisioner.provision("dev-4", ["postgres"])
run_call = next(c for c in runner.calls if c[0] == "run")
assert not any(a.startswith("com.docker.compose.") for a in run_call)
@pytest.mark.asyncio
async def test_provision_mongo_engine() -> None:
runner = _FakeRunner(run_rc=0, exec_rc=0)
@@ -145,6 +145,69 @@ async def test_shutdown_mid_spawn_removes_container_and_skips_registration(
assert closed == ["sess-sec-orphan"]
@pytest.mark.asyncio
async def test_secretary_spawn_adds_compose_labels_before_image(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When the orchestrator resolves its own compose project, the persistent
Secretary container carries it too spliced in right before the
trailing image element (docker run flags must precede the image)."""
orch = _make_orchestrator()
removed: list[str] = []
_wire_secretary_spawn_mocks(monkeypatch, orch, removed, flip_running_on_run=False)
captured: list[list[str]] = []
async def _run_capture(cmd: list[str]) -> str:
captured.append(cmd)
return "containerid0123456789"
monkeypatch.setattr(orch, "_run_container_cmd", _run_capture)
async def _fake_label_args(service: str) -> list[str]:
return ["--label", f"com.docker.compose.service={service}"]
monkeypatch.setattr(
"roboco.runtime.orchestrator.compose_label_args", _fake_label_args
)
await orch.spawn_secretary_session("sess-labels", initial_message=None)
assert len(captured) == 1
cmd = captured[0]
assert cmd[-3] == "--label"
assert cmd[-2] == f"com.docker.compose.service={SECRETARY_AGENT_ID}"
@pytest.mark.asyncio
async def test_secretary_spawn_omits_compose_labels_outside_compose(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The real helper returns [] outside a compose stack — the cmd shape
(image last) is byte-for-byte unchanged from today."""
orch = _make_orchestrator()
removed: list[str] = []
_wire_secretary_spawn_mocks(monkeypatch, orch, removed, flip_running_on_run=False)
captured: list[list[str]] = []
async def _run_capture(cmd: list[str]) -> str:
captured.append(list(cmd))
return "containerid0123456789"
monkeypatch.setattr(orch, "_run_container_cmd", _run_capture)
async def _no_labels(_service: str) -> list[str]:
return []
monkeypatch.setattr("roboco.runtime.orchestrator.compose_label_args", _no_labels)
await orch.spawn_secretary_session("sess-no-labels", initial_message=None)
assert len(captured) == 1
assert not any(a.startswith("com.docker.compose.") for a in captured[0])
@pytest.mark.asyncio
async def test_running_spawn_registers_normally(
monkeypatch: pytest.MonkeyPatch,