mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -42,6 +42,7 @@ from typing import TYPE_CHECKING, Protocol
|
|||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.llm.providers._docker import container_running, stop_container
|
from roboco.llm.providers._docker import container_running, stop_container
|
||||||
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
||||||
|
from roboco.runtime.compose_labels import compose_label_args
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
|
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
|
||||||
@@ -149,6 +150,7 @@ class CodexCliProvider(AgentProvider):
|
|||||||
self._append_codex_auth_mount(cmd)
|
self._append_codex_auth_mount(cmd)
|
||||||
self._append_usage_mount(cmd, hosts)
|
self._append_usage_mount(cmd, hosts)
|
||||||
self._append_codex_env(cmd, config, initial_prompt)
|
self._append_codex_env(cmd, config, initial_prompt)
|
||||||
|
cmd.extend(await compose_label_args(config.agent_id))
|
||||||
cmd.append(self._image)
|
cmd.append(self._image)
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ from typing import TYPE_CHECKING, Protocol
|
|||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.llm.providers._docker import container_running, stop_container
|
from roboco.llm.providers._docker import container_running, stop_container
|
||||||
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
||||||
|
from roboco.runtime.compose_labels import compose_label_args
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
|
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
|
||||||
@@ -174,6 +175,7 @@ class GeminiCliProvider(AgentProvider):
|
|||||||
self._append_gemini_auth_mount(cmd)
|
self._append_gemini_auth_mount(cmd)
|
||||||
self._append_usage_mount(cmd, hosts)
|
self._append_usage_mount(cmd, hosts)
|
||||||
self._append_gemini_env(cmd, config, initial_prompt)
|
self._append_gemini_env(cmd, config, initial_prompt)
|
||||||
|
cmd.extend(await compose_label_args(config.agent_id))
|
||||||
cmd.append(self._image)
|
cmd.append(self._image)
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from typing import TYPE_CHECKING, Protocol
|
|||||||
|
|
||||||
from roboco.llm.providers._docker import container_running, stop_container
|
from roboco.llm.providers._docker import container_running, stop_container
|
||||||
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
||||||
|
from roboco.runtime.compose_labels import compose_label_args
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
|
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
|
||||||
@@ -144,6 +145,7 @@ class GrokCliProvider(AgentProvider):
|
|||||||
self._append_grok_auth_mount(cmd)
|
self._append_grok_auth_mount(cmd)
|
||||||
self._append_usage_mount(cmd, hosts)
|
self._append_usage_mount(cmd, hosts)
|
||||||
self._append_grok_env(cmd, config, initial_prompt)
|
self._append_grok_env(cmd, config, initial_prompt)
|
||||||
|
cmd.extend(await compose_label_args(config.agent_id))
|
||||||
cmd.append(self._image)
|
cmd.append(self._image)
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ from typing import TYPE_CHECKING, Protocol
|
|||||||
from roboco.config import settings
|
from roboco.config import settings
|
||||||
from roboco.llm.providers._docker import container_running, stop_container
|
from roboco.llm.providers._docker import container_running, stop_container
|
||||||
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult
|
||||||
|
from roboco.runtime.compose_labels import compose_label_args
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
|
from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig
|
||||||
@@ -175,6 +176,7 @@ class KimiCliProvider(AgentProvider):
|
|||||||
self._append_kimi_auth_mount(cmd)
|
self._append_kimi_auth_mount(cmd)
|
||||||
self._append_usage_mount(cmd, hosts)
|
self._append_usage_mount(cmd, hosts)
|
||||||
self._append_kimi_env(cmd, config, initial_prompt)
|
self._append_kimi_env(cmd, config, initial_prompt)
|
||||||
|
cmd.extend(await compose_label_args(config.agent_id))
|
||||||
cmd.append(self._image)
|
cmd.append(self._image)
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Discover the orchestrator's own Docker Compose project, once per process.
|
||||||
|
|
||||||
|
Every container the orchestrator creates as a sibling of itself — agent
|
||||||
|
spawns (including the persistent intake/secretary chats) and sandbox
|
||||||
|
DB/Redis/Mongo sidecars — should carry the same
|
||||||
|
``com.docker.compose.project``/``com.docker.compose.service`` labels the
|
||||||
|
compose stack stamps on its own service containers. That buys two things: a
|
||||||
|
Docker UI (e.g. UGOS) groups every spawned container under the same project
|
||||||
|
as the stack, and `docker compose down --remove-orphans` sweeps them away
|
||||||
|
WITH the stack — the CEO wants agents to die with their orchestrator, since
|
||||||
|
an agent whose orchestrator is gone can't do anything.
|
||||||
|
|
||||||
|
**Full compose-lifecycle semantics (intended, CEO-decided).** These labeled
|
||||||
|
sibling containers are NOT compose-file services — they exist only via
|
||||||
|
`docker run`, invisible to the compose file itself. That gives them exactly
|
||||||
|
the lifecycle a plain `--label` buys and no more: a bare `docker compose
|
||||||
|
stop` stops them (label-matched, like any other project container) and a
|
||||||
|
bare `docker compose restart` restarts them; `docker compose down` removes
|
||||||
|
them — verified empirically both with and without `--remove-orphans` (see
|
||||||
|
below for why `--remove-orphans` specifically needs a 4th label to even see
|
||||||
|
them). Critically, `docker compose up -d` does NOT resurrect them — `up`
|
||||||
|
only reconciles containers for services declared in the compose file, and a
|
||||||
|
labeled sidecar declares no service. This is deliberate, not a gap: an agent
|
||||||
|
without its orchestrator can't do anything, so it should never survive a
|
||||||
|
`down`, and after a redeploy (`up -d` bringing the orchestrator back) the
|
||||||
|
orchestrator's own startup respawns its agents itself — nothing about
|
||||||
|
resurrecting them is compose's job.
|
||||||
|
|
||||||
|
Verified empirically (live `docker compose down --remove-orphans`, compose
|
||||||
|
v5.3.1): a sidecar needs FOUR labels, not the three one would guess from
|
||||||
|
project/service/oneoff alone. Compose's own container listing
|
||||||
|
(``getDefaultFilters`` in ``docker/compose`` ``pkg/compose/containers.go``)
|
||||||
|
unconditionally adds a ``label=com.docker.compose.config-hash`` filter to
|
||||||
|
the Docker API query it runs BEFORE the orphan predicate ever sees a
|
||||||
|
container — a sidecar missing that label key is invisible to `down` at the
|
||||||
|
API level, orphan or not, no matter how correctly project/service/oneoff are
|
||||||
|
set. Its value is never read (only the label's presence gates the API-level
|
||||||
|
list), so a fixed placeholder is correct.
|
||||||
|
|
||||||
|
Self-identification reads this container's own ``/proc/self/mountinfo``
|
||||||
|
first, rather than its hostname. Docker's default (no ``hostname:`` in
|
||||||
|
compose — true today for the orchestrator service in both docker-compose.yml
|
||||||
|
and docker-compose.registry.yml) sets a container's hostname to its own
|
||||||
|
short id, which would work too — but silently breaks the moment a future
|
||||||
|
compose edit adds an explicit ``hostname:`` for the orchestrator service,
|
||||||
|
with nothing erroring anywhere to catch it. Docker bind-mounts /etc/hostname,
|
||||||
|
/etc/hosts, and resolv.conf from a per-container config dir into every
|
||||||
|
container regardless of any hostname override, so the real id is recoverable
|
||||||
|
from our own mountinfo independent of hostname entirely — and independent of
|
||||||
|
the docker data-root path too: a real UGREEN NAS container's mountinfo reads
|
||||||
|
``/@docker/containers/<id>/hostname`` (data-root ``/volume1/@docker``, a
|
||||||
|
btrfs mount), not the textbook ``/var/lib/docker/containers/<id>/hostname``,
|
||||||
|
so the pattern below keys on the root-independent ``/containers/<id>/<file>``
|
||||||
|
suffix rather than assuming a specific data-root prefix. The HOSTNAME env var
|
||||||
|
is a second-tier fallback (Docker's default-hostname-is-the-short-id
|
||||||
|
behaviour, verified true for the orchestrator service today) for a runtime
|
||||||
|
that doesn't bind-mount those per-container files the way Docker does.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
_MOUNTINFO_PATH = "/proc/self/mountinfo"
|
||||||
|
# Root-independent: matches both /var/lib/docker/containers/<id>/hostname and
|
||||||
|
# a NAS's /@docker/containers/<id>/hostname (or any other docker data-root) —
|
||||||
|
# see module docstring.
|
||||||
|
_CONTAINER_ID_RE = re.compile(
|
||||||
|
r"/containers/([0-9a-f]{64})/(?:hostname|hosts|resolv\.conf)"
|
||||||
|
)
|
||||||
|
_HOSTNAME_ID_RE = re.compile(r"^[0-9a-f]{12,64}$")
|
||||||
|
_DOCKER_INSPECT_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
|
# Any value works (compose never reads it — see module docstring); it only
|
||||||
|
# has to be a present label key.
|
||||||
|
_CONFIG_HASH_PLACEHOLDER = "roboco-sidecar"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _DiscoveryCache:
|
||||||
|
"""Mutated in place (never rebound) so `_discover` needs no `global`.
|
||||||
|
|
||||||
|
``discovered`` only flips True on a DEFINITIVE outcome (a resolved
|
||||||
|
project, or a confirmed "not under compose") — a transient failure
|
||||||
|
(docker missing, a timeout, a nonzero inspect) leaves it False so the
|
||||||
|
next spawn retries instead of caching a false negative forever.
|
||||||
|
"""
|
||||||
|
|
||||||
|
discovered: bool = False
|
||||||
|
project: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_lock = asyncio.Lock()
|
||||||
|
_cache = _DiscoveryCache()
|
||||||
|
|
||||||
|
|
||||||
|
def _id_from_mountinfo() -> str | None:
|
||||||
|
"""This process's own container id, parsed from /proc/self/mountinfo.
|
||||||
|
|
||||||
|
None outside a container (file absent/unreadable) or under a container
|
||||||
|
runtime that doesn't bind-mount per-container config the way Docker does.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with Path(_MOUNTINFO_PATH).open(encoding="utf-8") as fh:
|
||||||
|
content = fh.read()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
match = _CONTAINER_ID_RE.search(content)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _id_from_hostname_env() -> str | None:
|
||||||
|
"""Fallback: Docker's default (no `hostname:` override) sets HOSTNAME to
|
||||||
|
the container's own short id; `docker inspect` accepts a short-id prefix.
|
||||||
|
"""
|
||||||
|
hostname = os.environ.get("HOSTNAME", "")
|
||||||
|
return hostname if _HOSTNAME_ID_RE.match(hostname) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _own_container_id() -> str | None:
|
||||||
|
"""Mountinfo first (robust to a hostname override), HOSTNAME second."""
|
||||||
|
return _id_from_mountinfo() or _id_from_hostname_env()
|
||||||
|
|
||||||
|
|
||||||
|
async def _inspect_compose_project(container_id: str) -> tuple[bool, str | None]:
|
||||||
|
"""`docker inspect` our own compose-project label.
|
||||||
|
|
||||||
|
Returns ``(definitive, project)``. ``definitive`` is False for a
|
||||||
|
transient failure (docker CLI missing, a timeout, a nonzero inspect exit
|
||||||
|
— daemon not ready yet, etc.) that deserves a retry on the next call, and
|
||||||
|
True for a completed inspect regardless of whether the label was present
|
||||||
|
(a container genuinely not started by compose has no label — that is a
|
||||||
|
real, stable answer, not a failure).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"docker",
|
||||||
|
"inspect",
|
||||||
|
"--format",
|
||||||
|
'{{ index .Config.Labels "com.docker.compose.project" }}',
|
||||||
|
container_id,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
stdout, _ = await asyncio.wait_for(
|
||||||
|
proc.communicate(), timeout=_DOCKER_INSPECT_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except (OSError, TimeoutError):
|
||||||
|
return False, None
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return False, None
|
||||||
|
return True, (stdout.decode().strip() or None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _discover() -> str | None:
|
||||||
|
"""Resolve + cache the orchestrator's own compose project, once.
|
||||||
|
|
||||||
|
Lock-guarded so two concurrent first-callers (e.g. two agent spawns
|
||||||
|
racing at startup) can't both run `docker inspect`; every call after a
|
||||||
|
DEFINITIVE resolution is a plain attribute read once the lock is free.
|
||||||
|
"""
|
||||||
|
async with _lock:
|
||||||
|
if _cache.discovered:
|
||||||
|
return _cache.project
|
||||||
|
container_id = _own_container_id()
|
||||||
|
if not container_id:
|
||||||
|
_cache.project = None
|
||||||
|
_cache.discovered = True
|
||||||
|
logger.info("compose project discovery: no container id found")
|
||||||
|
return None
|
||||||
|
definitive, project = await _inspect_compose_project(container_id)
|
||||||
|
if not definitive:
|
||||||
|
logger.warning(
|
||||||
|
"compose project discovery failed transiently; will retry",
|
||||||
|
container_id=container_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
_cache.project = project
|
||||||
|
_cache.discovered = True
|
||||||
|
if project:
|
||||||
|
logger.info("compose project resolved", project=project)
|
||||||
|
else:
|
||||||
|
logger.info("container is not part of a compose project")
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
async def compose_label_args(service: str) -> list[str]:
|
||||||
|
"""Ready-to-splice ``--label`` argv for a sibling container of ``service``.
|
||||||
|
|
||||||
|
Empty when the orchestrator isn't running under compose, or discovery
|
||||||
|
hasn't yet succeeded — every call site's docker run cmd is then
|
||||||
|
byte-for-byte unchanged, matching current behaviour on dev machines /
|
||||||
|
tests / the eval harness (and self-heals on the next spawn after a
|
||||||
|
transient discovery failure).
|
||||||
|
"""
|
||||||
|
project = await _discover()
|
||||||
|
if not project:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
"--label",
|
||||||
|
f"com.docker.compose.project={project}",
|
||||||
|
"--label",
|
||||||
|
f"com.docker.compose.service={service}",
|
||||||
|
"--label",
|
||||||
|
"com.docker.compose.oneoff=False",
|
||||||
|
"--label",
|
||||||
|
f"com.docker.compose.config-hash={_CONFIG_HASH_PLACEHOLDER}",
|
||||||
|
]
|
||||||
@@ -72,6 +72,7 @@ from roboco.models.runtime import (
|
|||||||
WaitingRecord,
|
WaitingRecord,
|
||||||
)
|
)
|
||||||
from roboco.models.sandbox import SandboxInfo
|
from roboco.models.sandbox import SandboxInfo
|
||||||
|
from roboco.runtime.compose_labels import compose_label_args
|
||||||
from roboco.runtime.sandbox import SandboxProvisioner
|
from roboco.runtime.sandbox import SandboxProvisioner
|
||||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||||
from roboco.services.task import (
|
from roboco.services.task import (
|
||||||
@@ -3704,6 +3705,7 @@ class AgentOrchestrator:
|
|||||||
self._append_sandbox_marker_env(cmd, config.sandbox_available_services)
|
self._append_sandbox_marker_env(cmd, config.sandbox_available_services)
|
||||||
else:
|
else:
|
||||||
self._append_gate_env(cmd)
|
self._append_gate_env(cmd)
|
||||||
|
cmd.extend(await compose_label_args(config.agent_id))
|
||||||
self._append_image_and_claude_args(cmd, config, initial_prompt)
|
self._append_image_and_claude_args(cmd, config, initial_prompt)
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
@@ -5373,6 +5375,9 @@ class AgentOrchestrator:
|
|||||||
model=route.model_name,
|
model=route.model_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Insert before the trailing image element (docker run flags must
|
||||||
|
# precede the image, not follow it).
|
||||||
|
cmd[-1:-1] = await compose_label_args(INTAKE_AGENT_ID)
|
||||||
container_id = await self._run_container_cmd(cmd)
|
container_id = await self._run_container_cmd(cmd)
|
||||||
|
|
||||||
# Shutdown may have begun while this (non-blocking) spawn was in flight
|
# Shutdown may have begun while this (non-blocking) spawn was in flight
|
||||||
@@ -5582,6 +5587,9 @@ class AgentOrchestrator:
|
|||||||
model=route.model_name,
|
model=route.model_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
# Insert before the trailing image element (docker run flags must
|
||||||
|
# precede the image, not follow it).
|
||||||
|
cmd[-1:-1] = await compose_label_args(SECRETARY_AGENT_ID)
|
||||||
container_id = await self._run_container_cmd(cmd)
|
container_id = await self._run_container_cmd(cmd)
|
||||||
|
|
||||||
# Shutdown may have begun while this (non-blocking) spawn was in flight
|
# Shutdown may have begun while this (non-blocking) spawn was in flight
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from roboco.models.sandbox import (
|
|||||||
SandboxEngine,
|
SandboxEngine,
|
||||||
SandboxInfo,
|
SandboxInfo,
|
||||||
)
|
)
|
||||||
|
from roboco.runtime.compose_labels import compose_label_args
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
@@ -197,6 +198,7 @@ class SandboxProvisioner:
|
|||||||
SANDBOX_LABEL,
|
SANDBOX_LABEL,
|
||||||
"--label",
|
"--label",
|
||||||
f"{_OWNER_LABEL_KEY}={_owner_label(agent_id)}",
|
f"{_OWNER_LABEL_KEY}={_owner_label(agent_id)}",
|
||||||
|
*await compose_label_args(name),
|
||||||
]
|
]
|
||||||
for mount in engine.tmpfs:
|
for mount in engine.tmpfs:
|
||||||
args += ["--tmpfs", mount]
|
args += ["--tmpfs", mount]
|
||||||
|
|||||||
@@ -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(
|
async def test_gemini_spawn_mounts_auth_when_present(
|
||||||
_isolate_gemini_auth: Path,
|
_isolate_gemini_auth: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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:
|
async def test_grok_spawn_mounts_auth_when_present(_isolate_grok_auth: Path) -> None:
|
||||||
(_isolate_grok_auth / "auth.json").write_text("{}", encoding="utf-8")
|
(_isolate_grok_auth / "auth.json").write_text("{}", encoding="utf-8")
|
||||||
host = _FakeHost()
|
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(
|
async def test_codex_spawn_mounts_auth_when_present(
|
||||||
_isolate_codex_auth: Path,
|
_isolate_codex_auth: Path,
|
||||||
) -> None:
|
) -> 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:
|
async def test_kimi_spawn_mounts_auth_when_present(_isolate_kimi_auth: Path) -> None:
|
||||||
creds_dir = _isolate_kimi_auth / "credentials"
|
creds_dir = _isolate_kimi_auth / "credentials"
|
||||||
creds_dir.mkdir(parents=True, exist_ok=True)
|
creds_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -789,6 +789,54 @@ class TestSpawnIntakeSession:
|
|||||||
# The cloned cwd reached the docker cmd.
|
# The cloned cwd reached the docker cmd.
|
||||||
assert "ROBOCO_WORKSPACE=/data/workspaces/roboco/board/intake-1" in run_calls[0]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_scope_must_be_exactly_one(self) -> None:
|
async def test_scope_must_be_exactly_one(self) -> None:
|
||||||
orch = _make_minimal_orchestrator()
|
orch = _make_minimal_orchestrator()
|
||||||
|
|||||||
@@ -120,3 +120,53 @@ async def test_spawn_container_stale_clear_runs_with_teardown_sandbox_false(
|
|||||||
teardown_sandbox=False,
|
teardown_sandbox=False,
|
||||||
stop_reason="pre_spawn_stale_clear",
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_provision_mongo_engine() -> None:
|
async def test_provision_mongo_engine() -> None:
|
||||||
runner = _FakeRunner(run_rc=0, exec_rc=0)
|
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"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_running_spawn_registers_normally(
|
async def test_running_spawn_registers_normally(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user