fix(docker): container reuse + bounded-sync cleanup + orphan reaper (#20561) - #33645
Conversation
Issue #20561 (Docker containers accumulate) needs a way to identify hermes-created containers from the outside — both for the orphan reaper (a follow-up commit) and for operators triaging `docker ps -a | grep hermes-` after a SIGKILL leaves stragglers. The previous `hermes-<hex>` name prefix was the only signal, which broke down under cross-process reuse (planned) and against any custom `--name` someone might pass via `docker_extra_args`. This commit adds three labels at `docker run` time: --label hermes-agent=1 # global sweep target --label hermes-task-id=<sanitized> # per-task reuse key --label hermes-profile=<sanitized> # per-profile isolation key Values are sanitized to `[A-Za-z0-9_.-]` and truncated to 63 chars so the label round-trips cleanly through `docker ps --filter label=key=value`. Empty or non-string inputs collapse to "unknown" rather than producing an unqueryable empty value. No behavior change: the labels are pure metadata. The follow-up commits in this PR (cleanup-fix + orphan reaper) are what use them. Refs #20561
The Docker backend docs claim "Single persistent container — ONE long- lived container shared across sessions, /new, /reset, and delegate_task subagents. Stopped/removed on shutdown." In practice the code only honored that contract within a single Python process via the in-memory \`_active_environments[task_id]\` cache. Every \`hermes chat\` invocation spawned a fresh \`hermes-<hex>\` container; older containers piled up in \`Exited\` state and accumulated until manual \`docker rm\` (issue #20561). Three root causes, all addressed by this commit: 1. No cross-process container discovery. 2. \`cleanup()\` used fire-and-forget \`subprocess.Popen("... &", shell=True)\` which raced with parent-process exit — when Python exited promptly the detached shell child got killed mid-\`docker stop\`, leaving stopped containers behind. 3. The \`docker rm\` step in cleanup was gated on \`not self._persistent\` (the bind-mount-persistence flag). Default config sets \`container_persistent: true\`, so the default happy path skipped \`rm\` entirely — even when the user explicitly didn't want cross-process reuse, containers leaked. Fix: * Add \`DockerEnvironment.__init__(persist_across_processes=True)\`. When true, init probes \`docker ps -a --filter label=hermes-agent=1 --filter label=hermes-task-id=<task> --filter label=hermes-profile=<profile>\` and reuses a matching container (running → attach; stopped → \`docker start\` → attach; \`docker start\` failure → fall through to a fresh \`docker run\`). Multiple matches prefer the running one, with the stragglers left for the orphan reaper (next commit) to clean up. * Rewrite \`cleanup()\`. Uses \`subprocess.run(..., timeout=30)\` on a daemon \`threading.Thread\`, not the racy \`Popen(... &)\`. The \`_persistent\` guard is dropped on the \`rm\` step — \`rm\` now runs whenever \`persist_across_processes\` is false, regardless of the bind-mount-persistence setting. The leak class is gone in all combinations. * Add \`wait_for_cleanup(timeout)\`. \`tools/terminal_tool.py\`'s atexit hook calls this on every active env, blocking up to 15s for the cleanup thread before interpreter exit. Without this, \`hermes /quit\` raced the daemon-thread teardown and dropped the stop/rm work. * New config \`terminal.docker_persist_across_processes\` (default \`true\` — restores the documented contract). Set \`false\` for hard per-process isolation. Wired through all four config-bridge sites (cli.py env_mappings, gateway/run.py _terminal_env_map, hermes_cli/config.py _config_to_env_sync, tests/conftest.py env-strip list); regression-pinned by \`test_docker_persist_across_processes_is_bridged_everywhere\` matching the existing pattern for docker_run_as_host_user / docker_env. Reuse intentionally does NOT compare image / mounts / resources — only the labels. Operators changing those settings should set \`docker_persist_across_processes: false\` (or \`docker rm -f\` the labeled container) to force a fresh start. This keeps the probe cheap and the failure mode obvious. Coverage: 12 new unit tests in tests/tools/test_docker_environment.py covering reuse paths (running, stopped, fallback, opt-out, duplicate preference) and cleanup behavior (persist-mode no-rm, opt-out always-rm, no-Popen, wait_for_cleanup semantics, partial-init safety). Plus one config-bridge regression pin. Refs #20561
The cleanup-fix in the previous commit handles the graceful-exit leak: a
Hermes process that runs ``atexit`` will now actually wait on the docker
stop/rm worker thread, so containers either survive (persist mode) or are
fully removed (opt-out mode) by the time the interpreter exits.
But ``atexit`` doesn't fire on SIGKILL, OOM-kill, or terminal-window
close. Containers from those exits stay parked with no surviving Python
process to reuse or remove them, so they accumulate until the operator
intervenes with ``docker rm -f``. The cleanup-fix doesn't help this class
— there's no live cleanup() to fix.
This commit adds the safety net: a startup orphan reaper that runs once
per Hermes process and removes long-Exited hermes-labeled containers
that the prior commit couldn't reach.
Implementation:
* New ``reap_orphan_containers()`` in ``tools/environments/docker.py``.
Filters: ``label=hermes-agent=1`` + ``status=exited`` + (optional)
``label=hermes-profile=<current>``. Per-container ``docker inspect``
parses ``State.FinishedAt`` (with nanosecond-precision trimming for
Python's microsecond-bound ``fromisoformat``); containers older than
the threshold get ``docker rm -f``'d. The ``status=exited`` filter is
load-bearing — a running container may belong to a sibling Hermes
process whose reuse path will pick it up; killing it would crash the
sibling mid-command. Single-container failures are logged and the
sweep continues to the next candidate.
* New ``_maybe_reap_docker_orphans()`` helper in
``tools/terminal_tool.py``. Wired into ``_create_environment()`` for
``env_type == "docker"``. Gated by:
- ``terminal.docker_orphan_reaper: true`` (default; opt-out for
operators running multiple Hermes processes in the same profile
who don't trust the conservative defaults)
- ``_docker_orphan_reaper_ran`` module flag with double-checked
locking — parallel subagents and RL rollouts don't trigger N
concurrent docker ps storms
- Age threshold = ``2 × TERMINAL_LIFETIME_SECONDS`` with a 60s floor
(so ``TERMINAL_LIFETIME_SECONDS=0`` doesn't race the user's own
setup)
- Profile scoping — a research profile NEVER reaps the default
profile's stragglers
- Exception swallow — a janitor failure must never block container
creation
* New config ``terminal.docker_orphan_reaper`` wired through all four
config-bridge sites (cli.py, gateway/run.py, hermes_cli/config.py,
tests/conftest.py) and pinned by
``test_docker_orphan_reaper_is_bridged_everywhere``.
Coverage:
* 9 new unit tests in test_docker_environment.py — happy path, recent-
container sparing, profile scoping, unparseable-timestamp safety,
docker-ps-failure handling, partial-failure continuation, nanosecond
timestamp parsing, zero-value FinishedAt rejection.
* 6 new integration tests in test_docker_orphan_reaper_integration.py
— once-per-process gate, disable-flag respected, lifetime doubling
with 60s floor, current-profile filter wiring, exception swallow.
* 1 new bridge-invariant regression test.
Closes #20561 (combined with the two prior commits on this branch).
🔎 Lint report:
|
| Rule | Count |
|---|---|
invalid-argument-type |
1 |
invalid-assignment |
1 |
First entries
tests/tools/test_docker_environment.py:605: [invalid-argument-type] invalid-argument-type: Argument to function `_sanitize_label_value` is incorrect: Expected `str`, found `None`
tests/gateway/test_agent_cache.py:1149: [invalid-assignment] invalid-assignment: Object of type `(tid) -> None` is not assignable to attribute `cleanup_vm` of type `def cleanup_vm(task_id: str, *, force_remove: bool = False) -> Unknown`
✅ Fixed issues (1):
| Rule | Count |
|---|---|
invalid-assignment |
1 |
First entries
tests/gateway/test_agent_cache.py:1149: [invalid-assignment] invalid-assignment: Object of type `(tid) -> None` is not assignable to attribute `cleanup_vm` of type `def cleanup_vm(task_id: str) -> Unknown`
Unchanged: 5044 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
E2E verified against real Docker daemonRan an end-to-end harness against the real Commit 1 — labels
Commit 2 — cross-process reuse + cleanup
Commit 3 — orphan reaper
Test artifactHarness is at |
…20561) The first iteration of this PR did docker stop on every cleanup in persist mode (only skipping docker rm). Ben caught this as contradicting the documented "ONE long-lived container shared across sessions" semantics: stopping the container on every Hermes /quit kills any background processes inside (npm watchers, pytest watchers, long-running scripts) — exactly the case persist mode is supposed to protect. This commit splits the cleanup paths cleanly: * **Persist mode (default)** — cleanup() is a NO-OP for the container. Container stays running, processes survive, next Hermes process attaches via the existing label probe in ~ms instead of waiting for docker start. Resource reclamation happens via the orphan reaper at next startup (2 × lifetime_seconds threshold), which covers the SIGKILL / OOM / abandoned-laptop cases. * **Opt-out mode (persist_across_processes=False)** — unchanged: docker stop + docker rm -f on cleanup as before. * **Explicit teardown** — new cleanup(force_remove=True) kwarg overrides persist mode and tears the container down unconditionally. cleanup_vm(task_id) now defaults to force_remove=True since it's the user-driven reset path (called from AIAgent.close(), /reset-style flows, and the idle reaper's per-turn cleanup). The idle reaper in _cleanup_inactive_envs calls env.cleanup() directly with no kwargs, so idle persist-mode envs are no-op'd — the container survives the in-process pop and the next tool call re-probes via labels. No state leak: _container_id is still cleared on the in-process handle. E2E verified against real Docker: ✓ Container is still running after cleanup() ✓ Background process (sleep loop) survived cleanup() ✓ Filesystem state preserved across cleanup() ✓ In-process container_id cleared (next __init__ will re-probe) ✓ Background process visible from reused env (no docker start happened) ✓ force_remove=True removed the container even in persist mode ✓ cleanup_vm() removed the container (defaults to force_remove=True) Test changes: * Replaces `test_cleanup_with_persist_only_stops_no_rm` with `test_cleanup_with_persist_is_noop_for_container` — asserts neither stop nor rm runs in persist mode, and the in-process handle is cleared so re-probe works. * Adds `test_cleanup_force_remove_stops_and_rms_even_in_persist_mode` — covers the new kwarg. * Updates `test_cleanup_uses_subprocess_run_not_detached_shell` and `test_wait_for_cleanup_after_cleanup_returns_true` to pass `force_remove=True` so they actually exercise the docker code path (default no-op would trivially pass). cleanup_vm() forwards `force_remove` only to backends whose cleanup() accepts the kwarg (currently just DockerEnvironment) via runtime signature inspection — Modal/Daytona/SSH `cleanup()` signatures are unchanged. Refs #20561
Commit 4 (
|
…tainer on session close) Commit 4 made cleanup_vm() default to force_remove=True, which was wrong: cleanup_vm() is called from AIAgent.close() (TUI session close at tui_gateway/server.py:2991, gateway session teardown at gateway/run.py:3569) and from per-turn cleanup (agent/chat_completion_helpers.py:1517). All three are session-lifecycle events that should honor persist mode, not explicit user-initiated teardown. Ben reported the symptom: container shared between multiple TUI sessions (good) but killed as soon as any session closed (bad). With force_remove=True as the default, every `session.close` JSON-RPC tore down the container. The fix is to flip cleanup_vm()'s force_remove default back to False. The kwarg still exists for future explicit-teardown paths (`/reset`-style flows, "destroy my sandbox" commands) that haven't been wired up yet. Two new unit tests pin the behavior: * `test_cleanup_vm_default_honors_persist_mode` — asserts `cleanup_vm(task_id)` does neither docker stop nor docker rm on a persist-mode container (the regression Ben caught). * `test_cleanup_vm_force_remove_tears_down_persist_container` — asserts the kwarg still flows through the runtime-signature-inspection plumbing to the backend's cleanup(). E2E verified against real Docker (in addition to all 17 existing checks): ✓ Default cleanup_vm() leaves persist-mode container running ✓ cleanup_vm(force_remove=True) removed the container Refs #20561
Commit 5 (
|
…an reaper (#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR #33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (#33645 commits 1-5). Refs #20561
…an reaper (NousResearch#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR NousResearch#33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (NousResearch#33645 commits 1-5). Refs NousResearch#20561 #AI commit#
…an reaper (NousResearch#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR NousResearch#33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (NousResearch#33645 commits 1-5). Refs NousResearch#20561
#7440) (#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in #7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in #33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages #7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes #7440. Fixes #7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
NousResearch#7440) (NousResearch#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in NousResearch#7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in NousResearch#33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages NousResearch#7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes NousResearch#7440. Fixes NousResearch#7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
…an reaper (#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR #33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (#33645 commits 1-5). Refs #20561
#7440) (#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in #7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in #33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages #7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes #7440. Fixes #7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
NousResearch#7440) (NousResearch#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in NousResearch#7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in NousResearch#33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages NousResearch#7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes NousResearch#7440. Fixes NousResearch#7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
…an reaper (NousResearch#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR NousResearch#33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (NousResearch#33645 commits 1-5). Refs NousResearch#20561
NousResearch#7440) (NousResearch#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in NousResearch#7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in NousResearch#33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages NousResearch#7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes NousResearch#7440. Fixes NousResearch#7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
…an reaper (NousResearch#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR NousResearch#33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (NousResearch#33645 commits 1-5). Refs NousResearch#20561
NousResearch#7440) (NousResearch#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in NousResearch#7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in NousResearch#33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages NousResearch#7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes NousResearch#7440. Fixes NousResearch#7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
…an reaper (NousResearch#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR NousResearch#33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (NousResearch#33645 commits 1-5). Refs NousResearch#20561
NousResearch#7440) (NousResearch#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in NousResearch#7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in NousResearch#33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages NousResearch#7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes NousResearch#7440. Fixes NousResearch#7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
…an reaper (NousResearch#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR NousResearch#33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (NousResearch#33645 commits 1-5). Refs NousResearch#20561
NousResearch#7440) (NousResearch#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in NousResearch#7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in NousResearch#33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages NousResearch#7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes NousResearch#7440. Fixes NousResearch#7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
…an reaper (NousResearch#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR NousResearch#33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (NousResearch#33645 commits 1-5). Refs NousResearch#20561
NousResearch#7440) (NousResearch#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in NousResearch#7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in NousResearch#33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages NousResearch#7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes NousResearch#7440. Fixes NousResearch#7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
…an reaper (NousResearch#20561) Updates the Docker Backend section of the user-guide configuration page to match the actual behavior shipped in PR NousResearch#33645. Pre-PR the docs claimed "container is stopped and removed on shutdown," which was never quite true for the documented happy path and is now actively wrong: in default mode the container survives across Hermes processes so background processes (npm watchers, dev servers, long-running pytest) carry over the way the "ONE long-lived container shared across sessions" promise requires. Changes to `website/docs/user-guide/configuration.md`: * Reworked the intro paragraph at the top of the Docker Backend section to describe the actual cross-process reuse contract. * Expanded the YAML example with the new keys `docker_persist_across_processes` and `docker_orphan_reaper`, plus the pre-existing-but-undocumented `docker_env`, `timeout`, and `lifetime_seconds`. Clarified the `container_persistent` comment to disambiguate from `docker_persist_across_processes`. * Added a `docker_env` vs `docker_forward_env` explainer (one injects literal KEY=value, the other forwards values from the host/.env — easy to confuse). * Replaced the one-line "Container lifecycle" paragraph with a full subsection covering: - the three labels Hermes tags every container with (hermes-agent, hermes-task-id, hermes-profile) - the label-probe reuse mechanism on startup - a teardown-trigger table with four rows for every situation that destroys the container in default mode - edge cases (OOM kill, profile switching) * Added an "Environment variable overrides" table covering all TERMINAL_* env vars relevant to the Docker backend, including the previously-undocumented `TERMINAL_DOCKER_ENV` and `HERMES_DOCKER_BINARY`. Changes to `website/docs/user-guide/docker.md`: * Extended the cross-link admonition (around l.227) so the Hermes-in-Docker page points at the new terminal-backend keys (`docker_env`, `docker_persist_across_processes`, `docker_orphan_reaper`) alongside the ones already mentioned. No code changes. Behavior already covered by tests added in earlier commits on this branch (NousResearch#33645 commits 1-5). Refs NousResearch#20561
NousResearch#7440) (NousResearch#39412) When `docker run -d` fails after Docker has already created the container object (e.g. exit 125 when the daemon isn't ready, or a timeout mid image pull), the code raised before `self._container_id` was set — so the container leaked permanently in "Created" state. Reported in NousResearch#7439: 110+ orphaned containers accumulated over 3 days from hourly cron- scheduled gateway sessions hitting a Docker Desktop startup race. The orphan reaper added in NousResearch#33645 (reap_orphan_containers) does NOT cover this case: it filters `status=exited`, but a failed-create container is in `Created` state, so it slips through and is never reaped. Wrap the `docker run -d` call in try/except and `docker rm -f` the container by its known name before re-raising. Salvages NousResearch#7440 by @Tranquil-Flow. Their branch predated the cross-process reuse + labels rework on `main`, so a cherry-pick conflicted; reconstructed the same intent (plus their two regression tests, adapted to mock the new reuse `docker ps` probe) against current `main`. Verified adversarially: reverted just the product change to origin/main's `docker.py`, ran the two new tests -> both FAIL with `assert 0 == 1 ("docker rm should be called once")`. With the fix applied, both pass; full test_docker_environment.py is 65/65 green. Closes NousResearch#7440. Fixes NousResearch#7439. Co-authored-by: Evi Nova <66773372+Tranquil-Flow@users.noreply.github.com>
Closes #20561.
Problem
The Docker backend docs claim:
In practice the code only honored that contract within a single Python process via the in-memory
_active_environments[task_id]cache. Everyhermes chatinvocation spawned a freshhermes-<hex>container, and three separate bugs let the old containers pile up inExitedstate indefinitely:docker psprobe — the only signal that a container belonged to hermes-agent was thehermes-<hex>name prefix, and nothing in the code used it.cleanup()was fire-and-forget.subprocess.Popen("... &", shell=True)raced with parent-process exit — when Python exited promptly (or got SIGKILL'd), the detached shell child got killed mid-docker stop, leaving stopped containers behind.docker rmstep in cleanup was gated onnot self._persistent. Default config setscontainer_persistent: true, so the default happy path skippedrmentirely. The result: stopped containers from every priorhermes chatsession, forever, until manualdocker container prune.Fix — three commits, configurable, each one solves one root cause
Commit 1 —
tag containers with hermes-agent labels for identificationAdds
--label hermes-agent=1 --label hermes-task-id=<sanitized> --label hermes-profile=<sanitized>todocker run. Pure metadata change, zero behavioral change. Sets up the next two commits.Labels are sanitized to
[A-Za-z0-9_.-](≤63 chars) so profile/task names containing slashes, colons, or unicode round-trip cleanly throughdocker ps --filter label=key=value. Empty/non-string inputs collapse to"unknown".Commit 2 —
reuse containers across processes + fix cleanup leaksDockerEnvironment.__init__(persist_across_processes=True)(default on, matching the documented contract). Probesdocker ps -a --filter label=hermes-task-id=<task> --filter label=hermes-profile=<profile>. Match found → if running, attach; if stopped,docker startthen attach; ondocker startfailure, falls through to a freshdocker run. Multiple matches prefer the running one (stragglers left for the reaper). Reuse matches on labels only — image / mounts / resources are not compared. Operators who change those settings should set the kill-switch ordocker rm -fthe labeled container.subprocess.Popen("... &", shell=True)withsubprocess.run(..., timeout=30)on a daemonthreading.Thread. Drops the_persistentguard onrm—rmnow runs wheneverpersist_across_processesis false, regardless of the bind-mount-persistence setting. Newwait_for_cleanup(timeout)method thattools/terminal_tool.py's atexit hook calls on every active env (15s timeout), so docker stop/rm actually completes before the interpreter exits.terminal.docker_persist_across_processes(defaulttrue). Setfalsefor hard per-process isolation (no reuse, container removed on exit). Wired through all four config-bridge sites (cli.py env_mappings, gateway/run.py _terminal_env_map, hermes_cli/config.py _config_to_env_sync, tests/conftest.py env-strip list) and pinned bytest_docker_persist_across_processes_is_bridged_everywhere.Commit 3 —
startup orphan reaper for crashed-process containersThe cleanup-fix in commit 2 handles graceful exits. It doesn't help SIGKILL / OOM / terminal-window-close exits that bypass
atexit— the leaked container has no surviving Python process to reuse or remove it.This commit adds the safety net:
reap_orphan_containers()intools/environments/docker.py. Sweepslabel=hermes-agent=1+status=exitedcontainers older than the age threshold. Thestatus=exitedfilter is load-bearing — a running container may belong to a sibling Hermes process whose reuse path will pick it up; killing it would crash the sibling mid-command. Parsesdocker inspect State.FinishedAt(with nanosecond-precision trimming for Python's microsecond-boundfromisoformat); zero-value / unparseable timestamps cause the candidate to be skipped (conservative)._maybe_reap_docker_orphans()helper intools/terminal_tool.py. Gated byterminal.docker_orphan_reaper(defaulttrue), a once-per-process flag with double-checked locking (parallel subagents don't trigger N concurrent docker ps storms), and scoped to the current profile (a research profile NEVER reaps default's stragglers). Age threshold is2 × TERMINAL_LIFETIME_SECONDSwith a 60s floor.Configuration summary
Two new keys, both default-on, both with kill-switches:
Set either via
hermes config setor in~/.hermes/config.yaml. Both auto-bridge to env vars (TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES,TERMINAL_DOCKER_ORPHAN_REAPER) and propagate through CLI and gateway entry points.Behavior change
Default behavior changes with this PR. Before: each
hermes chatinvocation created a fresh container; old containers accumulated forever. After: each invocation reuses the previously-stopped container (matching the documented "ONE long-lived container shared across sessions"); a startup sweep removes containers from crashed prior processes after 2×lifetime_seconds.Users who want the old per-process behavior can set
terminal.docker_persist_across_processes: false. That path now also fully removes containers on exit (the gating bug that previously preventeddocker rmis fixed).Tests
22 new unit tests + 6 new integration tests + 2 new bridge-invariant regression tests across:
tests/tools/test_docker_environment.py(was 23 tests, now 48)tests/tools/test_docker_orphan_reaper_integration.py(new, 6 tests) — terminal_tool-side gates: once-per-process behavior, disable-flag respected, lifetime doubling with 60s floor, current-profile filter wiring, exception swallowtests/tools/test_terminal_config_env_sync.py(was 6, now 8) — new bridge-invariant regression pins for both config keys, same pattern as existingdocker_run_as_host_user/docker_envpinsFull suite of
tests/tools/(~5615 tests across 231 files) — 0 failures.Implementation notes for reviewers
tools/environments/docker.pyisbackend/docker(cross-lane), so this needs Teknium review per hermes-agent-dev scope rule. Flagging@teknium1.wait_for_cleanupjoin is bounded at 15s per env in the atexit hook. Worst-case shutdown delay = 15s × number of active envs; in practice 1–2 envs and most cleanups complete in <1s.--force-reap-allflag).Commits