Skip to content
Merged
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
44 changes: 39 additions & 5 deletions src/agent_harness/adapters/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,28 +177,62 @@ def close(self) -> None:
self.started = False


def _diagnosis(stderr: str) -> str:
"""The line a person can act on, not the last line the CLI printed.

Go template noise is dropped: it is a consequence of the daemon being
absent, never the reason, and letting it win means readiness blames
reflection for a stopped service.
"""
lines = [line.strip() for line in stderr.splitlines() if line.strip()]
useful = [
line
for line in lines
if not line.startswith("template:") and "reflect:" not in line and line != "ERROR:"
]
return (useful or lines or ["no reason given"])[0].removeprefix("ERROR: ").strip()


def _server_version(stdout: str) -> str:
for line in stdout.splitlines():
stripped = line.strip()
if stripped.startswith("Server Version:"):
return stripped.split(":", 1)[1].strip()
return ""


class DockerEnvironmentFactory:
name = "docker"
api_version = API_VERSION
version = "docker-cli"

def check(self) -> tuple[bool, str]:
"""Is a daemon reachable, and what should an operator be told?

`docker info` is asked **without** `--format`. With a format string,
an unreachable daemon does not produce the message a person needs: the
CLI still renders a mostly-nil `Info` struct, so the last line of
stderr is a Go template error about "indirection through nil pointer
to embedded struct field Info", and the line that actually says the
daemon could not be reached is buried above it. Readiness then reports
a reflect error, which names the wrong component — the failure mode
this repository keeps paying for.
"""
if shutil.which("docker") is None:
return False, "docker is not installed or is not on PATH"
try:
result = subprocess.run(
["docker", "info", "--format", "{{.ServerVersion}}"],
["docker", "info"],
capture_output=True,
text=True,
timeout=5,
timeout=10,
check=False,
)
except (OSError, subprocess.SubprocessError) as exc:
return False, f"Docker daemon check failed: {exc}"
if result.returncode != 0:
detail = result.stderr.strip().splitlines()
return False, detail[-1] if detail else "Docker daemon is unavailable"
return True, f"Docker daemon {result.stdout.strip() or 'available'}"
return False, f"Docker daemon is unreachable: {_diagnosis(result.stderr)}"
return True, f"Docker daemon {_server_version(result.stdout) or 'available'}"

def reap(self, worktree: Path) -> None:
"""Remove containers left by a killed controller for one item tree."""
Expand Down
40 changes: 39 additions & 1 deletion tests/test_execution_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@

import pytest

from agent_harness.adapters.docker import DockerEnvironmentFactory, DockerItemEnvironment
from agent_harness.adapters.docker import (
DockerEnvironmentFactory,
DockerItemEnvironment,
_diagnosis,
_server_version,
)
from agent_harness.execution_environment import EnvironmentMount, EnvironmentSpec
from agent_harness.execution_environments import names, probe, resolve

Expand All @@ -21,6 +26,39 @@ def test_docker_backend_is_selected_by_installed_metadata() -> None:
assert backend.name == "docker"


def test_readiness_names_the_daemon_not_a_go_template_error() -> None:
"""What an operator reads when the fleet will not start.

Asked with `--format`, an unreachable daemon makes the CLI render a nil
`Info` struct, so the LAST line of stderr is a Go template error and the
line that says the daemon could not be reached is above it. Reporting the
last line blamed reflection for a stopped service -- found when this image
was first built, where the CLI is installed and no daemon answers.
"""
stderr = (
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock. "
"Is the docker daemon running?\n"
'template: :1:2: executing "" at <.ServerVersion>: reflect: '
"indirection through nil pointer to embedded struct field Info\n"
)

assert _diagnosis(stderr).startswith("Cannot connect to the Docker daemon")
assert "reflect:" not in _diagnosis(stderr)
assert "template:" not in _diagnosis(stderr)
# Nothing usable at all still says which component is being reported on.
assert _diagnosis("") == "no reason given"
assert _diagnosis("template: bad\n") == "template: bad"


def test_readiness_reports_the_server_version_when_a_daemon_answers() -> None:
info = (
"Client:\n Version: 28.0.1\nServer:\n Server Version: 28.0.1\n Storage Driver: overlay2\n"
)

assert _server_version(info) == "28.0.1"
assert _server_version("Client:\n Version: 28.0.1\n") == ""


def test_environment_spec_rejects_host_networking_and_unsafe_mounts(tmp_path: Path) -> None:
with pytest.raises(ValueError, match="host networking"):
EnvironmentSpec(image="rust:1", worktree=tmp_path.resolve(), network="host")
Expand Down
Loading