5c12c6bf4e
The install itself now works end to end on a fresh macOS account — venv built,
package installed from git, entry point linked — and `doctor` then died with an
unhandled traceback:
PermissionError: [Errno 13] Permission denied: 'ip'
netpool's probe helpers caught only FileNotFoundError. That is not the only way
a probe binary can be unavailable: when a name on PATH exists but this user
cannot execute it, exec fails with EACCES, and CPython reports that in
preference to the ENOENT from the other PATH entries. So `except
FileNotFoundError` misses it and the crash propagates all the way out of
`doctor`. Reproduced directly: a mode-000 file named `ip` on PATH yields
exactly the error above.
_run_ok's docstring already stated the intent — treat an unavailable binary as
failure rather than crashing — so this widens the catch to OSError to match
what it says. Any OSError means the probe could not run, which for a
fail-closed check is indistinguishable from "not present". The same narrow
catch is fixed in overlapping_routes and in the two docker probes
(compose ls, docker ps), which are the same shape and equally reachable.
Deliberately not touched: the FileNotFoundError catches around file I/O in
bottle_state and orchestrator/service, where the narrow exception is correct.
The harness also required `bot-bottle` on PATH before running doctor, which
could never be true: install.sh prints the PATH line rather than editing a
shell profile, by design, so on a fresh account the entry point is installed
and working but not on PATH. It now looks where the installer actually puts
it (~/.local/bin, then the venv) and notes when it's running by absolute path.
That was the harness failing a run for a reason the installer intends.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
83 lines
3.0 KiB
Python
83 lines
3.0 KiB
Python
"""Active-agent enumeration for the docker backend.
|
|
|
|
Returns `ActiveAgent` records the CLI `active` command and the
|
|
dashboard agents pane consume. Docker query failures raise rather
|
|
than masquerading as an authoritative empty result.
|
|
|
|
The parser (`_parse_services_by_project`) is exposed for direct
|
|
unit testing; the docker `docker ps` invocation is in
|
|
`_query_services_by_project`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
|
|
from .. import ActiveAgent, EnumerationError
|
|
from ...bottle_state import read_metadata
|
|
from .compose import compose_project_name, list_active_slugs
|
|
|
|
|
|
def enumerate_active() -> list[ActiveAgent]:
|
|
"""All currently-running docker-backed agents."""
|
|
slugs = list_active_slugs(
|
|
include_stopped=False,
|
|
warn_on_error=False,
|
|
raise_on_error=True,
|
|
)
|
|
if not slugs:
|
|
return []
|
|
services_by_project = _query_services_by_project()
|
|
out: list[ActiveAgent] = []
|
|
for slug in slugs:
|
|
project = compose_project_name(slug)
|
|
services = services_by_project.get(project, set())
|
|
metadata = read_metadata(slug)
|
|
out.append(ActiveAgent(
|
|
backend_name="docker",
|
|
slug=slug,
|
|
agent_name=metadata.agent_name if metadata else "?",
|
|
started_at=metadata.started_at if metadata else "",
|
|
services=tuple(sorted(services)),
|
|
label=metadata.label if metadata else "",
|
|
color=metadata.color if metadata else "",
|
|
))
|
|
return out
|
|
|
|
|
|
def _parse_services_by_project(stdout: str) -> dict[str, set[str]]:
|
|
"""Parse `docker ps` output formatted as
|
|
`<project-label>\\t<service-label>` (one line per container)
|
|
into a `{project: {service, ...}}` mapping. Pure function for
|
|
testing — the docker invocation is in `_query_services_by_project`."""
|
|
out: dict[str, set[str]] = {}
|
|
for line in stdout.splitlines():
|
|
project, _, service = line.partition("\t")
|
|
if not project or not service:
|
|
continue
|
|
out.setdefault(project, set()).add(service)
|
|
return out
|
|
|
|
|
|
def _query_services_by_project() -> dict[str, set[str]]:
|
|
"""One `docker ps` call → `{project: {service, ...}}`. Used
|
|
by the CLI's `active` and the dashboard's agents pane —
|
|
one subprocess per refresh tick, not one per bottle."""
|
|
try:
|
|
r = subprocess.run(
|
|
[
|
|
"docker", "ps",
|
|
"--filter", "label=com.docker.compose.project",
|
|
"--format",
|
|
'{{.Label "com.docker.compose.project"}}'
|
|
"\t"
|
|
'{{.Label "com.docker.compose.service"}}',
|
|
],
|
|
capture_output=True, text=True, check=False,
|
|
)
|
|
except OSError as exc:
|
|
# Missing, or on PATH but not executable by this user (PermissionError).
|
|
raise EnumerationError(f"docker ps failed: docker unavailable ({exc})") from exc
|
|
if r.returncode != 0:
|
|
raise EnumerationError(f"docker ps failed: {r.stderr.strip()}")
|
|
return _parse_services_by_project(r.stdout or "")
|