fix(docker): recover from out-of-band container removal in persistent mode (salvage #36631) (#39415)

Salvage of #36631 (@annguyenNous), rebased onto current main with
regression tests added. Fixes #36266.

When a persistent Docker sandbox container is removed out-of-band (idle
reaper, `docker prune`, OOM kill, daemon restart), the gateway kept
issuing `docker exec` against the dead container ID, returning
"No such container" on every subsequent tool call — the agent was
permanently blocked until the gateway process restarted.

DockerEnvironment.execute() now detects the "No such container" /
"is not running" error after a non-zero exit (gated on
persist_across_processes) and calls _recreate_container(): it tries
label-based reuse first, falls back to a fresh container replaying the
same image + full all_run_args set, re-runs init_session(), and retries
the command once. A genuine non-zero exit is NOT misclassified as
container-gone.

Differs from #36631 as submitted: adds the tests the original lacked.
tests/tools/test_docker_environment.py covers _is_container_gone pattern
matching (incl. the negative/control case), the recover-and-retry path,
the persist_across_processes=False opt-out (no recovery), and the
ordinary-failure passthrough (no spurious recreation). _make_dummy_env
now forwards persist_across_processes.

Verified:
- Unit: 67/67 in test_docker_environment.py (4 new + existing).
- Live E2E against the real docker daemon: started a persistent
  container, `docker rm -f`'d it out-of-band, and the next execute()
  transparently recreated a fresh container and succeeded; a follow-up
  command worked in the recovered container; a real `exit N` passed
  through without triggering recovery.

Co-authored-by: annguyenNous <annguyenNous@users.noreply.github.com>
This commit is contained in:
Ben Barclay
2026-06-05 10:33:44 +10:00
committed by GitHub
co-authored by annguyenNous
parent c54b935873
commit 8a888441d7
2 changed files with 247 additions and 0 deletions
+126
View File
@@ -44,6 +44,7 @@ def _make_dummy_env(**kwargs):
auto_mount_cwd=kwargs.get("auto_mount_cwd", False),
env=kwargs.get("env"),
run_as_host_user=kwargs.get("run_as_host_user", False),
persist_across_processes=kwargs.get("persist_across_processes", True),
)
@@ -1707,3 +1708,128 @@ def test_plain_image_keeps_docker_init_and_run_noexec(monkeypatch):
assert "noexec" in run_mounts[0], (
f"/run must stay noexec for non-s6 images, got: {run_mounts[0]}"
)
# ---------------------------------------------------------------------------
# Out-of-band container removal recovery (issue #36266, PR #36631)
# ---------------------------------------------------------------------------
def test_is_container_gone_matches_removal_errors(monkeypatch):
"""``_is_container_gone`` recognizes the docker errors that mean the
container no longer exists, and does NOT match ordinary command failures.
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
_mock_subprocess_run(monkeypatch)
env = _make_dummy_env()
# Positive: the daemon's "container gone" phrasings.
assert env._is_container_gone(
"Error response from daemon: No such container: hermes-abc123"
)
assert env._is_container_gone("Error: No such container: deadbeef")
assert env._is_container_gone(
"Error response from daemon: Container abc is not running"
)
# Control / negative: a real command failure must NOT be misclassified as
# the container being gone — otherwise every non-zero exit would trigger a
# spurious container recreation.
assert not env._is_container_gone("bash: nonsuch: command not found")
assert not env._is_container_gone("Traceback (most recent call last): ...")
assert not env._is_container_gone("")
assert not env._is_container_gone("permission denied")
def test_execute_recovers_from_out_of_band_removal(monkeypatch):
"""When a persistent container is removed out-of-band, ``execute`` detects
the "No such container" error, recreates the container, and retries once —
returning success transparently.
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
_mock_subprocess_run(monkeypatch)
env = _make_dummy_env(
persistent_filesystem=True,
persist_across_processes=True,
)
# First execute() sees a dead container; second (post-recovery) succeeds.
outputs = iter([
{"output": "Error response from daemon: No such container: hermes-x", "returncode": 1},
{"output": "ok", "returncode": 0},
])
def _fake_super_execute(self, command, cwd="", **kwargs):
return next(outputs)
recreate_calls = []
def _fake_recreate(self):
recreate_calls.append(True)
self._container_id = "recovered-container-id"
return True
monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute)
monkeypatch.setattr(
docker_env.DockerEnvironment, "_recreate_container", _fake_recreate
)
result = env.execute("echo hi")
assert recreate_calls == [True], "recovery should have been attempted exactly once"
assert result.get("returncode") == 0, f"expected success after recovery, got {result!r}"
assert result.get("output") == "ok"
def test_execute_does_not_recover_when_not_persistent(monkeypatch):
"""A non-persistent session must NOT trigger container recreation on a
"No such container" error — recovery is only meaningful for the persistent,
cross-process container that can be removed out-of-band.
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
_mock_subprocess_run(monkeypatch)
env = _make_dummy_env(
persistent_filesystem=True,
persist_across_processes=False,
)
def _fake_super_execute(self, command, cwd="", **kwargs):
return {"output": "No such container: x", "returncode": 1}
def _fail_recreate(self):
pytest.fail("recreation must not run when persist_across_processes is False")
monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute)
monkeypatch.setattr(
docker_env.DockerEnvironment, "_recreate_container", _fail_recreate
)
result = env.execute("echo hi")
assert result.get("returncode") == 1, "the original error must pass through unchanged"
def test_execute_does_not_recover_on_ordinary_failure(monkeypatch):
"""A genuine non-zero exit that is NOT a container-gone error must pass
through without triggering recovery (guards against over-eager recreation).
"""
monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker")
_mock_subprocess_run(monkeypatch)
env = _make_dummy_env(
persistent_filesystem=True,
persist_across_processes=True,
)
def _fake_super_execute(self, command, cwd="", **kwargs):
return {"output": "bash: badcmd: command not found", "returncode": 127}
def _fail_recreate(self):
pytest.fail("recreation must not run for an ordinary command failure")
monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute)
monkeypatch.setattr(
docker_env.DockerEnvironment, "_recreate_container", _fail_recreate
)
result = env.execute("badcmd")
assert result.get("returncode") == 127
assert "command not found" in result.get("output", "")