feat(docker): support shared container identities
This commit is contained in:
@@ -1389,6 +1389,7 @@ def _probe_remote_backend(env_type: str) -> str | None:
|
||||
"docker_extra_args": config.get("docker_extra_args", []),
|
||||
"docker_shm_size": config.get("docker_shm_size", "1g"),
|
||||
"docker_persist_across_processes": config.get("docker_persist_across_processes", True),
|
||||
"docker_shared_container_key": config.get("docker_shared_container_key", ""),
|
||||
"docker_orphan_reaper": config.get("docker_orphan_reaper", True),
|
||||
}
|
||||
|
||||
|
||||
@@ -677,6 +677,7 @@ def load_cli_config() -> Dict[str, Any]:
|
||||
"docker_network": "TERMINAL_DOCKER_NETWORK",
|
||||
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"docker_shared_container_key": "TERMINAL_DOCKER_SHARED_CONTAINER_KEY",
|
||||
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
# Persistent shell (non-local backends)
|
||||
|
||||
@@ -2378,6 +2378,7 @@ if _config_path.exists():
|
||||
"docker_network": "TERMINAL_DOCKER_NETWORK",
|
||||
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"docker_shared_container_key": "TERMINAL_DOCKER_SHARED_CONTAINER_KEY",
|
||||
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||
|
||||
@@ -3517,6 +3517,7 @@ TERMINAL_CONFIG_ENV_MAP = {
|
||||
"docker_shm_size": "TERMINAL_DOCKER_SHM_SIZE",
|
||||
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"docker_shared_container_key": "TERMINAL_DOCKER_SHARED_CONTAINER_KEY",
|
||||
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||
|
||||
@@ -54,6 +54,7 @@ def _make_dummy_env(**kwargs):
|
||||
run_as_host_user=kwargs.get("run_as_host_user", False),
|
||||
extra_args=kwargs.get("extra_args", []),
|
||||
persist_across_processes=kwargs.get("persist_across_processes", True),
|
||||
shared_container_key=kwargs.get("shared_container_key", ""),
|
||||
shm_size=kwargs.get("shm_size", docker_env._DEFAULT_SHM_SIZE),
|
||||
)
|
||||
|
||||
@@ -715,6 +716,27 @@ def test_labels_attribute_populated_after_init(monkeypatch):
|
||||
}
|
||||
|
||||
|
||||
def test_shared_container_key_replaces_profile_identity(monkeypatch):
|
||||
"""Trusted profiles using the same explicit key share the reuse label."""
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "research")
|
||||
_mock_subprocess_run(monkeypatch)
|
||||
|
||||
env = _make_dummy_env(task_id="abc", shared_container_key="team/workspace")
|
||||
|
||||
assert env._labels["hermes-profile"] == "team_workspace"
|
||||
|
||||
|
||||
def test_empty_shared_container_key_preserves_profile_isolation(monkeypatch):
|
||||
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
|
||||
monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "research")
|
||||
_mock_subprocess_run(monkeypatch)
|
||||
|
||||
env = _make_dummy_env(task_id="abc", shared_container_key="")
|
||||
|
||||
assert env._labels["hermes-profile"] == "research"
|
||||
|
||||
|
||||
# ── Cross-process container reuse (issue #20561) ──────────────────
|
||||
|
||||
|
||||
|
||||
@@ -153,6 +153,15 @@ def _get_active_profile_name() -> str:
|
||||
return "default"
|
||||
|
||||
|
||||
def _container_identity(shared_key: str = "") -> str:
|
||||
"""Return the profile label used for reuse and orphan reaping.
|
||||
|
||||
Profiles remain isolated by default. An explicit shared key lets trusted
|
||||
profiles that intentionally share a workspace use one Docker identity.
|
||||
"""
|
||||
return _sanitize_label_value(shared_key or _get_active_profile_name())
|
||||
|
||||
|
||||
def reap_orphan_containers(
|
||||
*,
|
||||
max_age_seconds: int = 600,
|
||||
@@ -895,6 +904,7 @@ class DockerEnvironment(BaseEnvironment):
|
||||
extra_args: list = None,
|
||||
persist_across_processes: bool = True,
|
||||
shm_size: str = _DEFAULT_SHM_SIZE,
|
||||
shared_container_key: str = "",
|
||||
):
|
||||
if cwd == "~":
|
||||
cwd = "/root"
|
||||
@@ -1376,9 +1386,9 @@ class DockerEnvironment(BaseEnvironment):
|
||||
# * future cross-process reuse (`hermes-task-id`, `hermes-profile`)
|
||||
# * operators running `docker ps --filter label=hermes-agent=1`
|
||||
# Values are limited to the safe character set defined by
|
||||
# _sanitize_label_value(); the active Hermes profile is captured at
|
||||
# _sanitize_label_value(); the configured reuse identity is captured at
|
||||
# container-start time and never changes for the container's lifetime.
|
||||
profile_name = _sanitize_label_value(_get_active_profile_name())
|
||||
profile_name = _container_identity(shared_container_key)
|
||||
task_label = _sanitize_label_value(task_id)
|
||||
label_args = [
|
||||
"--label", "hermes-agent=1",
|
||||
|
||||
@@ -1203,13 +1203,11 @@ def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None:
|
||||
max_age = lifetime * 2
|
||||
|
||||
try:
|
||||
from tools.environments.docker import (
|
||||
reap_orphan_containers, _get_active_profile_name,
|
||||
)
|
||||
from tools.environments.docker import reap_orphan_containers, _container_identity
|
||||
except ImportError:
|
||||
return
|
||||
try:
|
||||
profile = _get_active_profile_name()
|
||||
profile = _container_identity(container_config.get("docker_shared_container_key", ""))
|
||||
removed = reap_orphan_containers(
|
||||
max_age_seconds=max_age, profile_filter=profile,
|
||||
)
|
||||
@@ -1864,6 +1862,9 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
"docker_persist_across_processes": os.getenv(
|
||||
"TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "true"
|
||||
).lower() in {"true", "1", "yes"},
|
||||
"docker_shared_container_key": os.getenv(
|
||||
"TERMINAL_DOCKER_SHARED_CONTAINER_KEY", ""
|
||||
).strip(),
|
||||
# Startup orphan reaper for hermes-tagged containers left behind by
|
||||
# crashed / SIGKILL'd previous processes that bypassed atexit.
|
||||
# Conservative: only sweeps Exited containers older than 2× the
|
||||
@@ -1921,6 +1922,7 @@ def _container_config_from_config(config: Dict[str, Any]) -> dict:
|
||||
"docker_shm_size": config.get("docker_shm_size", "1g"),
|
||||
"docker_network": config.get("docker_network", True),
|
||||
"docker_persist_across_processes": config.get("docker_persist_across_processes", True),
|
||||
"docker_shared_container_key": config.get("docker_shared_container_key", ""),
|
||||
"docker_orphan_reaper": config.get("docker_orphan_reaper", True),
|
||||
}
|
||||
|
||||
@@ -1996,6 +1998,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||
False if session_scoped
|
||||
else cc.get("docker_persist_across_processes", True)
|
||||
),
|
||||
shared_container_key=cc.get("docker_shared_container_key", ""),
|
||||
shm_size=cc.get("docker_shm_size", "1g"),
|
||||
)
|
||||
# Marker read by is_persistent_env(): a session-scoped container
|
||||
|
||||
@@ -263,6 +263,7 @@ terminal:
|
||||
# Cross-process container reuse (defaults match the "one long-lived
|
||||
# container shared across sessions" contract — see Container lifecycle).
|
||||
docker_persist_across_processes: true # Reuse container across Hermes restarts
|
||||
docker_shared_container_key: "" # Opt in trusted profiles to one identity
|
||||
docker_orphan_reaper: true # Sweep abandoned Exited containers at startup
|
||||
|
||||
# Cross-backend lifecycle settings (apply to docker as well)
|
||||
@@ -284,9 +285,9 @@ Every Hermes-managed container is tagged with three labels so subsequent process
|
||||
|
||||
- `hermes-agent=1` — marks it as Hermes-managed
|
||||
- `hermes-task-id=<sanitized task_id>` — keys the per-task reuse probe
|
||||
- `hermes-profile=<sanitized profile name>` — scopes reuse and reaping to the active Hermes profile
|
||||
- `hermes-profile=<sanitized profile name>` — scopes reuse and reaping to the active Hermes profile by default; when `docker_shared_container_key` is set, its sanitized value is used instead
|
||||
|
||||
On startup, Hermes runs `docker ps --filter label=hermes-task-id=<id> --filter label=hermes-profile=<profile>` and **attaches to the existing container** when it finds one. If the container is `exited` (e.g. after a Docker daemon restart), it's `docker start`'d and reused — filesystem state and any installed packages survive, but in-container background processes do not.
|
||||
On startup, Hermes runs `docker ps --filter label=hermes-task-id=<id> --filter label=hermes-profile=<identity>` and **attaches to the existing container** when it finds one. The identity is the active profile unless `docker_shared_container_key` explicitly opts trusted profiles into a common value. If the container is `exited` (e.g. after a Docker daemon restart), it's `docker start`'d and reused — filesystem state and any installed packages survive, but in-container background processes do not.
|
||||
|
||||
When a Hermes process exits — `/quit`, closing a TUI session, gateway shutdown, even SIGKILL — the cleanup path is a **no-op for the container in default mode**. The container keeps running. The next Hermes process attaches to it in milliseconds via the label probe. This is the behavior the "one long-lived container shared across sessions" contract requires: it's the only way background processes (npm watchers, dev servers, long-running pytest) survive across sessions.
|
||||
|
||||
@@ -303,6 +304,7 @@ Edge cases worth knowing:
|
||||
|
||||
- **OOM kill of in-container PID 1** transitions the container to `Exited`. Next reuse will `docker start` it; filesystem state survives, bg processes do not.
|
||||
- **Switching profiles** isolates containers from each other — a container labeled `hermes-profile=work` is invisible to a Hermes process running under `hermes-profile=research`. The orphan reaper is profile-scoped too, so cross-profile containers don't get reaped accidentally, but they also won't get cleaned up automatically until you start Hermes again under their original profile.
|
||||
- **Explicit cross-profile sharing** — set the same non-empty `docker_shared_container_key` under `terminal:` for profiles that intentionally collaborate in one trusted workspace. This replaces only their container identity label; task, egress, and network compatibility checks still apply. Profiles without the key remain isolated.
|
||||
|
||||
Parallel subagents spawned via `delegate_task(tasks=[...])` share this one container — concurrent `cd`, env mutations, and writes to the same path will collide. If a subagent needs an isolated sandbox, it must register a per-task image override via `register_task_env_overrides()`, which RL and benchmark environments (TerminalBench2, HermesSweEnv, etc.) do automatically for their per-task Docker images.
|
||||
|
||||
@@ -329,6 +331,7 @@ Every key under `terminal:` has an env-var override of the form `TERMINAL_<KEY_U
|
||||
| `TERMINAL_DOCKER_RUN_AS_HOST_USER` | `docker_run_as_host_user` | `true` / `false` |
|
||||
| `TERMINAL_DOCKER_NETWORK` | `docker_network` | `true` / `false` — default `true`; `false` = `--network=none` |
|
||||
| `TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES` | `docker_persist_across_processes` | `true` / `false` — default `true` |
|
||||
| `TERMINAL_DOCKER_SHARED_CONTAINER_KEY` | `docker_shared_container_key` | Explicit shared identity for trusted profiles; empty by default |
|
||||
| `TERMINAL_DOCKER_ORPHAN_REAPER` | `docker_orphan_reaper` | `true` / `false` — default `true` |
|
||||
| `TERMINAL_CONTAINER_CPU` | `container_cpu` | CPU cores |
|
||||
| `TERMINAL_CONTAINER_MEMORY` | `container_memory` | MB |
|
||||
|
||||
Reference in New Issue
Block a user