Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/robocode/utils/apptainer_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
_filtered_repo_mounts,
_find_repo_root,
_get_claude_oauth_token,
_kindergarden_asset_volumes,
_mcp_prestart_wrapper,
container_python,
)
Expand Down Expand Up @@ -235,6 +236,7 @@ def _build_apptainer_cmd(
agent_cmd: list[str],
extra_binds: list[str] | None = None,
ss_pybullet_abs: str | None = None,
kindergarden_asset_binds: list[str] | None = None,
) -> list[str]:
"""Assemble the full ``apptainer exec`` command line.

Expand Down Expand Up @@ -289,6 +291,8 @@ def _build_apptainer_cmd(
]
if ss_pybullet_abs is not None:
cmd += ["--bind", f"{ss_pybullet_abs}:/robocode/third-party/ss-pybullet:ro"]
for bind in kindergarden_asset_binds or []:
cmd += ["--bind", bind]
for bind in extra_binds or []:
cmd += ["--bind", bind]
cmd += [
Expand Down Expand Up @@ -403,6 +407,9 @@ async def run_agent_in_apptainer_sandbox(
ss_pybullet_abs=(
str(ss_pybullet.resolve()) if ss_pybullet is not None else None
),
kindergarden_asset_binds=(
[] if config.blackbox else _kindergarden_asset_volumes()
),
kinder_baselines_abs=(
str(filtered_kinder_baselines.resolve())
if filtered_kinder_baselines is not None
Expand Down Expand Up @@ -520,6 +527,7 @@ def run_genplan_in_apptainer(
bilevel_env: list[str] = []
bilevel_bind: list[str] = []
ss_pybullet_bind: list[str] = []
asset_binds = _kindergarden_asset_volumes()
if ss_pybullet is not None:
ss_pybullet_bind = [
"--bind",
Expand All @@ -545,6 +553,7 @@ def run_genplan_in_apptainer(
f"{filtered_src.resolve()}:/robocode/src",
"--bind",
f"{filtered_kindergarden.resolve()}:/robocode/third-party/kindergarden",
*(tok for bind in asset_binds for tok in ("--bind", bind)),
*ss_pybullet_bind,
*bilevel_bind,
str(sif_path),
Expand Down
65 changes: 62 additions & 3 deletions src/robocode/utils/docker_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
import tempfile
import time
import uuid
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from contextlib import ExitStack, contextmanager, nullcontext
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -176,6 +176,36 @@ def _find_repo_root() -> Path:
)


_LARGE_KINDERGARDEN_ASSET_DIRS = (
Path("src/kinder/envs/dynamic3d/models/assets"),
Path("src/kinder/envs/kinematic3d/assets"),
)


def _ignore_kindergarden_copy_patterns(
kindergarden: Path, blackbox: bool
) -> Callable[[str, list[str]], set[str]]:
"""Skip non-source payloads when staging Kindergarden into /tmp."""
root = kindergarden.resolve()
skip_names = {"tests", "docs", "demos"}
if blackbox:
skip_names.add("envs")

def ignore(dirpath: str, names: list[str]) -> set[str]:
ignored = {name for name in names if name in skip_names}
try:
rel_dir = Path(dirpath).resolve().relative_to(root)
except ValueError:
rel_dir = Path()
for name in names:
child = rel_dir / name
if child in _LARGE_KINDERGARDEN_ASSET_DIRS:
ignored.add(name)
return ignored

return ignore


def _copy_kindergarden_without_tests(
kindergarden: Path, dest: Path, *, blackbox: bool = False
) -> None:
Expand All @@ -184,14 +214,36 @@ def _copy_kindergarden_without_tests(
``demos/`` holds recorded solutions. Blackbox also skips ``kinder/envs/``;
the installable skeleton stays so ``uv sync --frozen`` succeeds.
"""
skip = ("tests", "docs", "demos") + (("envs",) if blackbox else ())
# Large meshes/textures are source-adjacent data, not source code. Copying
# them into every per-run staging directory exhausts /tmp when several
# replicates start, so callers bind-mount the originals read-only.
shutil.copytree(
kindergarden,
dest,
ignore=shutil.ignore_patterns(*skip),
ignore=_ignore_kindergarden_copy_patterns(kindergarden, blackbox),
)


def _kindergarden_asset_mounts() -> list[tuple[Path, str]]:
"""Return existing bulky Kindergarden asset dirs and their container paths."""
kindergarden = _find_repo_root() / "third-party" / "kindergarden"
container_root = "/robocode/third-party/kindergarden"
mounts: list[tuple[Path, str]] = []
for rel_path in _LARGE_KINDERGARDEN_ASSET_DIRS:
host_path = kindergarden / rel_path
if host_path.is_dir():
mounts.append((host_path, f"{container_root}/{rel_path.as_posix()}:ro"))
return mounts


def _kindergarden_asset_volumes() -> list[str]:
"""Return Docker/Apptainer volume specs for bulky Kindergarden assets."""
return [
f"{host.resolve()}:{container}"
for host, container in _kindergarden_asset_mounts()
]


# The two kinder-baselines subpackages robocode depends on (editable path deps in
# uv.lock). Only these are needed for the sandbox's `uv sync --frozen`; the rest
# of the monorepo is not copied/mounted.
Expand Down Expand Up @@ -328,6 +380,7 @@ def _docker_run_prefix(
env_args: list[str] | None = None,
map_host_gateway: bool = False,
ss_pybullet: Path | None = None,
kindergarden_asset_volumes: list[str] | None = None,
extra_volumes: list[str] | None = None,
env_server_port: int | None = None,
) -> list[str]:
Expand Down Expand Up @@ -382,6 +435,8 @@ def _docker_run_prefix(
"-v",
f"{filtered_kindergarden.resolve()}:/robocode/third-party/kindergarden",
]
for volume in kindergarden_asset_volumes or []:
cmd += ["-v", volume]
if filtered_kinder_baselines is not None:
cmd += [
"-v",
Expand Down Expand Up @@ -449,6 +504,7 @@ def run_genplan_in_docker(
auth_args,
firewall_domains,
ss_pybullet=ss_pybullet,
kindergarden_asset_volumes=_kindergarden_asset_volumes(),
) + [DOCKER_PYTHON, "-m", "robocode.approaches.genplan_driver"]
logger.info("Starting genplan Docker container %s", container_name)
try:
Expand Down Expand Up @@ -736,6 +792,9 @@ async def run_agent_in_docker_sandbox(
auth_args,
firewall_domains,
ss_pybullet=ss_pybullet,
kindergarden_asset_volumes=(
[] if config.blackbox else _kindergarden_asset_volumes()
),
extra_volumes=session_volumes + tel_volumes,
env_args=[
"-e",
Expand Down
25 changes: 24 additions & 1 deletion tests/utils/test_apptainer_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,6 @@ def test_build_cmd_basic_shape(tmp_path: Path) -> None:
assert "--cleanenv" in cmd
pwd_idx = cmd.index("--pwd")
assert cmd[pwd_idx + 1] == "/sandbox"

# Env vars are passed as `--env KEY=val` pairs.
assert "CLAUDE_CODE_MAX_OUTPUT_TOKENS=8192" in cmd
assert "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=70" in cmd
Expand All @@ -207,6 +206,30 @@ def test_build_cmd_basic_shape(tmp_path: Path) -> None:
assert cmd[-3:] == ["claude", "--print", "hello"]


def test_build_cmd_mounts_kindergarden_assets_read_only(tmp_path: Path) -> None:
"""Dynamic3D assets are shared rather than copied into per-run staging."""
bind = (
"/host/assets:/robocode/third-party/kindergarden/src/kinder/envs/"
"dynamic3d/models/assets:ro"
)
config = ApptainerSandboxConfig(
sandbox_dir=tmp_path / "sandbox",
sif_path=tmp_path / "robocode-sandbox.sif",
)
cmd = _build_apptainer_cmd(
config,
sandbox_abs="/host/sandbox",
src_abs="/host/src",
kindergarden_abs="/host/kindergarden",
kinder_baselines_abs=None,
auth_args=[],
firewall_domains=[],
agent_cmd=["claude"],
kindergarden_asset_binds=[bind],
)
assert bind in cmd


def test_build_cmd_bilevel_conditional(tmp_path: Path) -> None:
"""The kinder-baselines bind and --extra bilevel sync appear only when requested."""

Expand Down
43 changes: 43 additions & 0 deletions tests/utils/test_docker_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
DOCKER_PYTHON,
DockerSandboxConfig,
_build_docker_auth_args,
_copy_kindergarden_without_tests,
_copy_src,
_docker_run_prefix,
_filtered_repo_mounts,
Expand Down Expand Up @@ -191,6 +192,27 @@ def test_filtered_repo_mounts_default_excludes_demos() -> None:
assert not (kindergarden / "demos").exists()


def test_kindergarden_copy_excludes_large_assets(
tmp_path: Path,
) -> None:
"""Large read-only scene assets are not duplicated in each /tmp staging tree."""
source = tmp_path / "source"
dynamic_assets = source / "src/kinder/envs/dynamic3d/models/assets"
kinematic_assets = source / "src/kinder/envs/kinematic3d/assets"
dynamic_assets.mkdir(parents=True)
kinematic_assets.mkdir(parents=True)
(dynamic_assets / "large-mesh.bin").write_bytes(b"mesh")
(kinematic_assets / "large-texture.png").write_bytes(b"texture")
(source / "pyproject.toml").touch()

destination = tmp_path / "destination"
_copy_kindergarden_without_tests(source, destination)

assert (destination / "pyproject.toml").exists()
assert not (destination / "src/kinder/envs/dynamic3d/models/assets").exists()
assert not (destination / "src/kinder/envs/kinematic3d/assets").exists()


def test_filtered_repo_mounts_default_keeps_package_skeleton() -> None:
"""The demos exclusion must not take the installable skeleton with it."""
with _filtered_repo_mounts() as (_, kindergarden, _tmp, _ss):
Expand Down Expand Up @@ -310,6 +332,27 @@ def test_docker_run_prefix_adds_extra_volumes(tmp_path: Path) -> None:
assert cmd[cmd.index(volume) - 1] == "-v"


def test_docker_run_prefix_mounts_kindergarden_assets_read_only(tmp_path: Path) -> None:
"""Dynamic3D assets are shared rather than copied into per-run staging."""
volume = (
f"{tmp_path.resolve()}/assets:"
"/robocode/third-party/kindergarden/src/kinder/envs/dynamic3d/"
"models/assets:ro"
)
cmd = _docker_run_prefix(
"c",
"img",
tmp_path,
tmp_path / "src",
tmp_path / "kg",
None,
[],
[],
kindergarden_asset_volumes=[volume],
)
assert volume in cmd


def test_docker_run_prefix_mounts_sandbox_not_parent_run_dir(tmp_path: Path) -> None:
"""Only the agent child directory, not its parent Hydra run, is mounted."""
run_dir = tmp_path / "run"
Expand Down
Loading