Compare commits

..

3 Commits

Author SHA1 Message Date
didericis-claude 5323fc1b53 feat(cleanup): walk every backend, reap smolmachines orphans too
test / unit (pull_request) Successful in 26s
test / integration (pull_request) Successful in 42s
`./cli.py cleanup` previously called only the env-var-selected
backend's `prepare_cleanup` / `cleanup` — so a leftover smolvm
machine + bundle container + bundle network from a crashed
smolmachines bottle would survive a default `docker`-mode cleanup
indefinitely.

Smolmachines now has a real `cleanup` module (alongside
`enumerate.py` from issue #77) that walks:

  - smolvm machines named `claude-bottle-*` (via
    `smolvm machine ls --json`)
  - bundle containers `claude-bottle-sidecars-*`
  - bundle networks `claude-bottle-bundle-*`

Cleanup runs stop+delete on the machines, force-rm on the
containers, network rm on the networks. Each step is best-effort
so a failed rm doesn't block the rest.

`cli.py cleanup` walks every backend in `known_backend_names()`
and runs each backend's `cleanup` after a single y/N prompt that
shows a combined plan.

State dirs (`~/.claude-bottle/state/<slug>/`) are shared layout
with the docker backend, which still owns the orphan-state-dir
bucket. It now consults `enumerate_active_bottles()` for the
cross-backend live identity set so a running smolmachines
bottle's state dir isn't reaped during a cleanup.

Tests: smolmachines cleanup (prepare + cleanup ordering + failure
handling); cross-backend orphan protection on the docker
state-dir check; CLI cmd_cleanup walks both backends, short-
circuits on all-empty, aborts on N. 617 unit tests pass.

End-to-end verified on this host:
  $ smolvm machine ls --json | jq '.[].name'
  "claude-bottle-researcher-m3hxd"
  $ ./cli.py cleanup
  --- smolmachines backend ---
  smolvm machine:  claude-bottle-researcher-m3hxd
  remove all of the above? [y/N]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:21:10 -04:00
didericis-claude 5d740a6948 style(backend): drop stale "moved/removed" pointer comments
test / unit (pull_request) Successful in 25s
test / integration (pull_request) Successful in 41s
test / unit (push) Successful in 27s
test / integration (push) Successful in 41s
PR #78 review comments 580, 582, 584. Each was a comment
describing what the previous refactor removed or relocated —
information that's in git history, not load-bearing for a
reader of the file as-is.

- claude_bottle/backend/docker/cleanup.py: drop trailing
  "enumerate_active moved to enumerate.py" note.
- tests/unit/test_dashboard_active_agents.py: drop module
  docstring paragraph about which tests moved where.
- tests/unit/test_docker_enumerate_active.py: drop
  "noop-when-docker-missing lives at the cross-backend gate
  now" trailing comment.

607 tests still pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:16:28 -04:00
didericis-claude 3b418580a9 refactor(backend): has_backend() helper + docker/enumerate split + ActiveAgent rename
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 42s
Addresses PR #78 review feedback:

- New `has_backend(name)` on the backend package + abstract
  `BottleBackend.is_available()` on each concrete subclass.
  Replaces inline `shutil.which("docker") is None` checks in
  docker/cleanup.py:178 and smolmachines/enumerate.py:73.
  Docker → `shutil.which("docker") is not None`; smolmachines
  → `smolvm.is_available()`. Cross-backend `enumerate_active_
  agents()` skips backends whose `is_available()` is False so a
  docker-only host doesn't fail when iterating past
  smolmachines (and vice versa).
- Move docker `enumerate_active` + parser helpers out of
  cleanup.py into a new `backend/docker/enumerate.py`, mirroring
  the smolmachines/enumerate.py layout. cleanup.py is now
  purely about prepare_cleanup / cleanup; the active-listing
  concern owns its own file.
- Drop the `ActiveAgent = ActiveBottle` alias in dashboard.py.
  The canonical name is `ActiveAgent` (the thing running inside
  a bottle is always called "agent" in this codebase; the bottle
  is the container). Renamed `enumerate_active_bottles` →
  `enumerate_active_agents` to match.

Tests:
- `test_backend_selection.TestEnumerateActiveAgents
  .test_skips_unavailable_backends` locks down the
  `is_available()`-gated iteration.
- New `TestHasBackend` covers `has_backend("docker")` consulting
  the backend's `is_available`, and unknown-name → False.
- Existing tests follow the rename; the docker-availability-
  side-effect test in `test_docker_enumerate_active` moves up
  to the cross-backend layer (where the gate lives now).

607 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 19:03:16 -04:00
14 changed files with 302 additions and 198 deletions
+46 -14
View File
@@ -19,7 +19,7 @@ backend exposes five methods:
cleanup(plan) -> None
Actually removes everything described by the cleanup plan.
enumerate_active() -> Sequence[ActiveBottle]
enumerate_active() -> Sequence[ActiveAgent]
Return every currently-running bottle on this backend, with
enough metadata for callers (CLI `list active`, dashboard
agents pane) to render a row.
@@ -106,9 +106,11 @@ class ExecResult:
@dataclass(frozen=True)
class ActiveBottle:
"""One currently-running bottle, as the CLI `list active` and
dashboard agents pane render it.
class ActiveAgent:
"""One currently-running agent, as the CLI `list active` and
dashboard agents pane render it. ("Agent" is the project's
consistent name for the thing running inside a bottle — the
bottle is the container, the agent is what runs in it.)
Fields are deliberately backend-neutral. `services` is the set
of sidecar daemons currently up for this bottle (`pipelock`,
@@ -302,12 +304,23 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""Remove everything described by the cleanup plan."""
@abstractmethod
def enumerate_active(self) -> Sequence[ActiveBottle]:
"""Return every currently-running bottle on this backend.
def enumerate_active(self) -> Sequence[ActiveAgent]:
"""Return every currently-running agent on this backend.
Empty when none. Backend-specific: docker queries `docker
compose ls`; smolmachines queries `smolvm machine ls --json`
+ cross-references its bundle container."""
@classmethod
@abstractmethod
def is_available(cls) -> bool:
"""Whether this backend's runtime prerequisites are satisfied
on the current host. Docker → `docker` on PATH; smolmachines
→ `smolvm` on PATH. Used by the cross-backend
`enumerate_active_agents` / `cmd_cleanup` to skip backends
the operator hasn't installed, so a docker-only host
doesn't fail when `cli.py list active` walks past
smolmachines."""
# Import concrete backend classes AFTER the base types are defined, so
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
@@ -352,26 +365,45 @@ def known_backend_names() -> tuple[str, ...]:
return tuple(sorted(_BACKENDS))
def enumerate_active_bottles() -> list[ActiveBottle]:
"""All currently-running bottles, across every backend. Used by
CLI `list active` and the dashboard's agents pane so neither
has to know which backends exist. Ordered by backend name,
then slug."""
out: list[ActiveBottle] = []
def has_backend(name: str) -> bool:
"""Whether the named backend's runtime prerequisites are
available on the current host. Cross-backend callers (list,
cleanup) skip unavailable backends so a docker-only host
doesn't fail when the smolmachines backend isn't installed,
and vice versa.
Returns False for unknown names so callers can pass
arbitrary input without separate validation."""
if name not in _BACKENDS:
return False
return _BACKENDS[name].is_available()
def enumerate_active_agents() -> list[ActiveAgent]:
"""All currently-running agents, across every available
backend. Used by CLI `list active` and the dashboard's agents
pane so neither has to know which backends exist. Skips
backends whose `is_available()` reports False. Ordered by
backend name, then by whatever each backend's
`enumerate_active` returns."""
out: list[ActiveAgent] = []
for name in known_backend_names():
if not has_backend(name):
continue
out.extend(_BACKENDS[name].enumerate_active())
return out
__all__ = [
"ActiveBottle",
"ActiveAgent",
"Bottle",
"BottleBackend",
"BottleCleanupPlan",
"BottlePlan",
"BottleSpec",
"ExecResult",
"enumerate_active_bottles",
"enumerate_active_agents",
"get_bottle_backend",
"has_backend",
"known_backend_names",
]
+19 -7
View File
@@ -1,10 +1,11 @@
"""DockerBottleBackend — the Docker implementation of BottleBackend.
This module is a thin façade. The real work lives in three siblings:
This module is a thin façade. The real work lives in four siblings:
- prepare.py — host-side resolution into a DockerBottlePlan
- launch.py — bring-up + teardown context manager
- cleanup.py — orphan enumeration, removal, and active listing
- prepare.py — host-side resolution into a DockerBottlePlan
- launch.py — bring-up + teardown context manager
- cleanup.py — orphan enumeration + removal
- enumerate.py — active-agent listing
The base class's `prepare` template runs cross-backend host-side
validation before calling `_resolve_plan` here.
@@ -12,12 +13,14 @@ validation before calling `_resolve_plan` here.
from __future__ import annotations
import shutil
from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Sequence
from .. import ActiveBottle, BottleBackend, BottleSpec
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
from . import launch as _launch
from . import prepare as _prepare
from .bottle import DockerBottle
@@ -36,6 +39,15 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
name = "docker"
@classmethod
def is_available(cls) -> bool:
"""`docker` on PATH is sufficient; we don't probe `docker info`
eagerly because the cross-backend enumerator runs this on
every `list active` and we'd pay a subprocess per call. A
broken daemon will surface its own error during prepare /
launch."""
return shutil.which("docker") is not None
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
@@ -65,5 +77,5 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def cleanup(self, plan: DockerBottleCleanupPlan) -> None:
_cleanup.cleanup(plan)
def enumerate_active(self) -> Sequence[ActiveBottle]:
return _cleanup.enumerate_active()
def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active()
+8 -80
View File
@@ -1,4 +1,4 @@
"""Cleanup + active-listing for the Docker bottle backend.
"""Cleanup for the Docker bottle backend.
PRD 0018 chunk 4: cleanup is centered on `docker compose ls`.
Pre-compose code paths could leave bare containers / networks
@@ -18,8 +18,8 @@ scan, just as a fallback bucket alongside the project list.
`cleanup` removes everything in the plan.
`list_active` queries the same compose project namespace and prints
each project's services for ad-hoc inspection.
Active-agent enumeration lives in `backend/docker/enumerate.py`
(mirror of `backend/smolmachines/enumerate.py`).
"""
from __future__ import annotations
@@ -29,16 +29,10 @@ import subprocess
from ... import supervise as _supervise
from ...log import info, warn
from .. import ActiveBottle
from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_state import bottle_state_dir, is_preserved, read_metadata
from .compose import (
COMPOSE_PROJECT_PREFIX,
compose_project_name,
list_active_slugs,
list_compose_projects,
)
from .bottle_state import bottle_state_dir, is_preserved
from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
def _list_prefixed_containers() -> list[str]:
@@ -124,15 +118,15 @@ def prepare_cleanup() -> DockerBottleCleanupPlan:
"""Enumerate everything cleanup will touch. No removals.
Pulls the union of live identities across backends via
`enumerate_active_bottles()` so the orphan-state-dir bucket
`enumerate_active_agents()` so the orphan-state-dir bucket
doesn't include slugs whose smolmachines VM is still up."""
docker_mod.require_docker()
projects = list_compose_projects()
project_set = set(projects)
# Late import to avoid a circular at module-load time —
# the backend package's __init__ imports this module.
from .. import enumerate_active_bottles
protected = {b.slug for b in enumerate_active_bottles()}
from .. import enumerate_active_agents
protected = {a.slug for a in enumerate_active_agents()}
return DockerBottleCleanupPlan(
projects=tuple(projects),
stray_containers=tuple(_list_prefixed_containers()),
@@ -184,69 +178,3 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
shutil.rmtree(path, ignore_errors=True)
except OSError as e:
warn(f"failed to remove {path}: {e}")
def enumerate_active() -> list[ActiveBottle]:
"""All currently-running docker-backed bottles as
`ActiveBottle` records. Backend-agnostic shape — the CLI
`list active` command and the dashboard agents pane both
consume this. Empty list when docker is unreachable or
nothing's running."""
# docker on PATH? Defensive — `list active` shouldn't die
# just because the docker backend isn't usable on this host.
if shutil.which("docker") is None:
return []
slugs = list_active_slugs(include_stopped=False)
if not slugs:
return []
services_by_project = _query_services_by_project()
out: list[ActiveBottle] = []
for slug in slugs:
project = compose_project_name(slug)
services = services_by_project.get(project, set())
metadata = read_metadata(slug)
out.append(ActiveBottle(
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)),
))
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, ...}}`. Moved
here from the dashboard so the same query backs the CLI's
`list active` and the dashboard's agents pane."""
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 FileNotFoundError:
return {}
if r.returncode != 0:
return {}
return _parse_services_by_project(r.stdout or "")
+80
View File
@@ -0,0 +1,80 @@
"""Active-agent enumeration for the docker backend.
Mirrors `backend/smolmachines/enumerate.py`: returns
`ActiveAgent` records the CLI `list active` command and the
dashboard agents pane consume. Empty when docker isn't reachable
— gated by `has_backend('docker')` at the cross-backend caller
so this module trusts that docker is available when called.
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
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. Caller is
responsible for gating on `has_backend('docker')` if it
matters; if docker is missing the `docker ps` call below
returns an empty list silently."""
slugs = list_active_slugs(include_stopped=False)
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)),
))
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 `list 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 FileNotFoundError:
return {}
if r.returncode != 0:
return {}
return _parse_services_by_project(r.stdout or "")
+11 -2
View File
@@ -7,11 +7,12 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Sequence
from .. import ActiveBottle, BottleBackend, BottleSpec
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
from . import launch as _launch
from . import prepare as _prepare
from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
from .bottle_plan import SmolmachinesBottlePlan
@@ -30,6 +31,14 @@ class SmolmachinesBottleBackend(
name = "smolmachines"
@classmethod
def is_available(cls) -> bool:
"""`smolvm` on PATH. The backend additionally needs macOS
for libkrun + TSI, but `enumerate_active` / `cleanup` are
host-shell ops that gracefully no-op on Linux too — the
runtime check happens at `prepare`."""
return _smolvm.is_available()
def _resolve_plan(
self, spec: BottleSpec, *, stage_dir: Path
) -> SmolmachinesBottlePlan:
@@ -73,5 +82,5 @@ class SmolmachinesBottleBackend(
def cleanup(self, plan: SmolmachinesBottleCleanupPlan) -> None:
_cleanup.cleanup(plan)
def enumerate_active(self) -> Sequence[ActiveBottle]:
def enumerate_active(self) -> Sequence[ActiveAgent]:
return _enumerate.enumerate_active()
@@ -10,7 +10,7 @@
State dirs live under `~/.claude-bottle/state/<identity>/` —
shared layout with the docker backend, which has the single
orphan-state-dir enumerator (it already consults
`enumerate_active_bottles()` so a live smolmachines bottle's dir
`enumerate_active_agents()` so a live smolmachines bottle's dir
is preserved).
`cleanup` removes everything in the plan: stop + delete each VM,
@@ -20,7 +20,6 @@ best-effort — a failure on one resource doesn't block the others."""
from __future__ import annotations
import json
import shutil
import subprocess
from ...log import info, warn
@@ -121,7 +120,10 @@ def _list_claude_bottle_machines() -> list[str]:
def _list_bundle_containers() -> list[str]:
"""All docker containers named `claude-bottle-sidecars-*`,
running or stopped. Empty when docker isn't installed."""
if shutil.which("docker") is None:
# Late import: `backend/__init__` imports this module
# transitively via the smolmachines backend.
from .. import has_backend
if not has_backend("docker"):
return []
r = subprocess.run(
["docker", "ps", "-a",
@@ -140,7 +142,8 @@ def _list_bundle_containers() -> list[str]:
def _list_bundle_networks() -> list[str]:
"""All docker networks named `claude-bottle-bundle-*`. Empty
when docker isn't installed."""
if shutil.which("docker") is None:
from .. import has_backend
if not has_backend("docker"):
return []
r = subprocess.run(
["docker", "network", "ls",
+30 -17
View File
@@ -1,26 +1,30 @@
"""Active-bottle enumeration for the smolmachines backend (PRD
"""Active-agent enumeration for the smolmachines backend (PRD
0023 chunk 4 follow-up + issue #77).
Returns a list of `ActiveBottle` records — same shape the docker
Returns a list of `ActiveAgent` records — same shape the docker
backend produces — so CLI `list active` and the dashboard agents
pane render both backends through one code path.
A smolmachines bottle is "active" when its smolvm guest is
A smolmachines agent is "active" when its smolvm guest is
running. We cross-reference against the per-bottle sidecar
bundle container to populate the `services` field (which daemons
are up in the bundle); without a bundle we still surface the VM
so the operator can see + clean it up."""
so the operator can see + clean it up.
The cross-backend caller gates on `has_backend("smolmachines")`
and `has_backend("docker")`, so this module assumes both are
available when called. Both subprocess calls below still
tolerate "command not on PATH" defensively, but the gate is the
intended access pattern."""
from __future__ import annotations
import json
import shutil
import subprocess
from .. import ActiveBottle
from .. import ActiveAgent
from ..docker.bottle_state import read_metadata
from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm
# Smolvm VM names produced by prepare are `claude-bottle-<slug>`,
@@ -29,12 +33,12 @@ from . import smolvm as _smolvm
_VM_NAME_PREFIX = "claude-bottle-"
def enumerate_active() -> list[ActiveBottle]:
"""All currently-running smolmachines-backed bottles. Empty
list when smolvm isn't on PATH or no matching VMs are
running."""
if not _smolvm.is_available():
return []
def enumerate_active() -> list[ActiveAgent]:
"""All currently-running smolmachines-backed agents. Empty
list when no matching VMs are running. Caller is responsible
for gating on `has_backend('smolmachines')` if needed; if
smolvm is missing the `smolvm machine ls` call below returns
nothing silently."""
result = subprocess.run(
["smolvm", "machine", "ls", "--json"],
capture_output=True, text=True, check=False,
@@ -46,7 +50,7 @@ def enumerate_active() -> list[ActiveBottle]:
except json.JSONDecodeError:
return []
services_by_slug = _query_bundle_services()
out: list[ActiveBottle] = []
out: list[ActiveAgent] = []
for m in machines:
name = m.get("name") or ""
state = m.get("state") or ""
@@ -54,7 +58,7 @@ def enumerate_active() -> list[ActiveBottle]:
continue
slug = name[len(_VM_NAME_PREFIX):]
metadata = read_metadata(slug)
out.append(ActiveBottle(
out.append(ActiveAgent(
backend_name="smolmachines",
slug=slug,
agent_name=metadata.agent_name if metadata else "?",
@@ -69,8 +73,17 @@ def _query_bundle_services() -> dict[str, tuple[str, ...]]:
bundle container's `CLAUDE_BOTTLE_SIDECAR_DAEMONS` env var.
Smolmachines bundles all run the PRD-0024 image with the
same daemon set declared via env, so one inspect per bundle
gets us the picture without exec'ing into the container."""
if shutil.which("docker") is None:
gets us the picture without exec'ing into the container.
Returns an empty mapping when the docker backend isn't
available — the bundle services field on each ActiveAgent
just shows up empty, matching the docker backend's "starting"
state."""
# Late import: `has_backend` lives on the backend package's
# __init__, which imports this module transitively. Pulling
# the name in at call time sidesteps the cycle.
from .. import has_backend
if not has_backend("docker"):
return {}
ps = subprocess.run(
["docker", "ps",
+1 -1
View File
@@ -7,7 +7,7 @@ addressed alongside #77).
Each backend's `prepare_cleanup` enumerates its own resources;
docker's `_list_orphan_state_dirs` consults
`enumerate_active_bottles()` for the union of live identities so
`enumerate_active_agents()` for the union of live identities so
state dirs of running smolmachines bottles aren't reaped. State
dirs are shared layout, so docker is the single owner of that
bucket.
+6 -15
View File
@@ -27,9 +27,9 @@ from pathlib import Path
from .. import supervise as _supervise
from ..backend import (
ActiveBottle,
ActiveAgent,
BottleSpec,
enumerate_active_bottles,
enumerate_active_agents,
get_bottle_backend,
known_backend_names,
)
@@ -97,22 +97,13 @@ class QueuedProposal:
queue_dir: Path
# `ActiveAgent` was PRD-0019's docker-specific row type. It now
# aliases the shared `ActiveBottle` dataclass so the dashboard
# and the CLI `list active` both render the same source of truth.
# Field surface stays compatible (slug / agent_name / started_at
# / services) plus a new `backend_name` so dashboard rows can
# show which backend a bottle came from.
ActiveAgent = ActiveBottle
def discover_active_agents() -> list[ActiveBottle]:
"""All currently-running bottles across every backend with
def discover_active_agents() -> list[ActiveAgent]:
"""All currently-running agents across every backend with
their metadata + service set. Returns [] when neither
backend is reachable. Backed by the shared
`enumerate_active_bottles` helper so the CLI's
`enumerate_active_agents` helper so the CLI's
`./cli.py list active` and this dashboard show the same data."""
return enumerate_active_bottles()
return enumerate_active_agents()
+2 -2
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import argparse
import sys
from ..backend import enumerate_active_bottles
from ..backend import enumerate_active_agents
from ..manifest import Manifest
from ._common import PROG, USER_CWD
@@ -23,7 +23,7 @@ def cmd_list(argv: list[str]) -> int:
# `active` enumerates every backend (docker + smolmachines)
# so smolmachines bottles aren't hidden behind the env var.
active = enumerate_active_bottles()
active = enumerate_active_agents()
if not active:
print("no active claude-bottle bottles", file=sys.stderr)
return 0
+68 -10
View File
@@ -1,8 +1,8 @@
"""Unit: backend selection + cross-backend enumeration (issue #77).
`get_bottle_backend(name)` resolves a backend by explicit name,
env var, or default. `enumerate_active_bottles()` walks every
registered backend and concatenates their `ActiveBottle`
env var, or default. `enumerate_active_agents()` walks every
registered backend and concatenates their `ActiveAgent`
listings the CLI and dashboard both go through this so adding
a backend lights it up in both places."""
@@ -14,8 +14,8 @@ from unittest.mock import patch
from claude_bottle import backend as backend_mod
from claude_bottle.backend import (
ActiveBottle,
enumerate_active_bottles,
ActiveAgent,
enumerate_active_agents,
get_bottle_backend,
known_backend_names,
)
@@ -48,25 +48,29 @@ class TestKnownBackendNames(unittest.TestCase):
self.assertEqual(("docker", "smolmachines"), known_backend_names())
class TestEnumerateActiveBottles(unittest.TestCase):
class TestEnumerateActiveAgents(unittest.TestCase):
"""Combines each backend's `enumerate_active`. Each backend's
implementation has its own tests (`test_docker_enumerate_active`,
`test_smolmachines_*`); this just asserts the aggregator stitches
them together."""
def test_concatenates_per_backend(self):
a = ActiveBottle(
a = ActiveAgent(
backend_name="docker", slug="a-1", agent_name="impl",
started_at="", services=("pipelock",),
)
b = ActiveBottle(
b = ActiveAgent(
backend_name="smolmachines", slug="b-2", agent_name="research",
started_at="", services=(),
)
class _FakeBackend:
def __init__(self, items):
def __init__(self, items, available=True):
self._items = items
self._available = available
def is_available(self):
return self._available
def enumerate_active(self):
return self._items
@@ -75,10 +79,13 @@ class TestEnumerateActiveBottles(unittest.TestCase):
backend_mod, "_BACKENDS",
{"docker": _FakeBackend([a]), "smolmachines": _FakeBackend([b])},
):
self.assertEqual([a, b], enumerate_active_bottles())
self.assertEqual([a, b], enumerate_active_agents())
def test_empty_when_no_backends_have_active(self):
class _FakeBackend:
def is_available(self):
return True
def enumerate_active(self):
return []
@@ -86,7 +93,58 @@ class TestEnumerateActiveBottles(unittest.TestCase):
backend_mod, "_BACKENDS",
{"docker": _FakeBackend(), "smolmachines": _FakeBackend()},
):
self.assertEqual([], enumerate_active_bottles())
self.assertEqual([], enumerate_active_agents())
def test_skips_unavailable_backends(self):
# If a backend's runtime isn't installed (smolvm missing on
# a docker-only host, or docker missing on a smolmachines-
# only host), the cross-backend enumerator skips it rather
# than dying — `has_backend` gates the iteration.
present = ActiveAgent(
backend_name="docker", slug="a-1", agent_name="impl",
started_at="", services=(),
)
hidden = ActiveAgent(
backend_name="smolmachines", slug="x", agent_name="x",
started_at="", services=(),
)
class _FakeBackend:
def __init__(self, items, available):
self._items = items
self._available = available
def is_available(self):
return self._available
def enumerate_active(self):
return self._items
with patch.object(
backend_mod, "_BACKENDS",
{
"docker": _FakeBackend([present], available=True),
"smolmachines": _FakeBackend([hidden], available=False),
},
):
self.assertEqual([present], enumerate_active_agents())
class TestHasBackend(unittest.TestCase):
def test_known_backend_consults_is_available(self):
class _FakeBackend:
def is_available(self):
return False
with patch.object(
backend_mod, "_BACKENDS", {"docker": _FakeBackend()},
):
from claude_bottle.backend import has_backend
self.assertFalse(has_backend("docker"))
def test_unknown_backend_returns_false(self):
from claude_bottle.backend import has_backend
self.assertFalse(has_backend("nonexistent"))
if __name__ == "__main__":
+1 -8
View File
@@ -1,11 +1,4 @@
"""Unit: dashboard's row-formatting + selection helpers (PRD 0019).
The active-bottle enumeration tests moved to
`test_docker_enumerate_active.py` (issue #77) — dashboard now
delegates listing to `enumerate_active_bottles` so the parser
and assembly tests live next to the docker backend's
implementation.
"""
"""Unit: dashboard's row-formatting + selection helpers (PRD 0019)."""
from __future__ import annotations
+18 -34
View File
@@ -8,7 +8,7 @@ unit test. Tests split into:
no I/O, deterministic on its input string.
- Integration-shaped tests that monkeypatch the slug list +
services map and read metadata from a fake home, then assert
the assembled `ActiveBottle` shape.
the assembled `ActiveAgent` shape.
The actual `docker ps` invocation is exercised by manual probing
during development and the (real-docker) integration tests; here
@@ -24,21 +24,21 @@ import unittest
from pathlib import Path
from claude_bottle import supervise
from claude_bottle.backend.docker import bottle_state, cleanup
from claude_bottle.backend.docker import bottle_state, enumerate as _enumerate
class TestParseServicesByProject(unittest.TestCase):
def test_empty_input(self):
self.assertEqual({}, cleanup._parse_services_by_project(""))
self.assertEqual({}, _enumerate._parse_services_by_project(""))
def test_one_container(self):
out = cleanup._parse_services_by_project(
out = _enumerate._parse_services_by_project(
"claude-bottle-dev-abc\tegress\n"
)
self.assertEqual({"claude-bottle-dev-abc": {"egress"}}, out)
def test_multiple_services_per_project(self):
out = cleanup._parse_services_by_project(
out = _enumerate._parse_services_by_project(
"claude-bottle-dev-abc\tegress\n"
"claude-bottle-dev-abc\tpipelock\n"
"claude-bottle-dev-abc\tsupervise\n"
@@ -49,7 +49,7 @@ class TestParseServicesByProject(unittest.TestCase):
)
def test_multiple_projects(self):
out = cleanup._parse_services_by_project(
out = _enumerate._parse_services_by_project(
"proj-a\tegress\n"
"proj-b\tpipelock\n"
"proj-a\tsupervise\n"
@@ -62,7 +62,7 @@ class TestParseServicesByProject(unittest.TestCase):
def test_skips_lines_missing_either_field(self):
# Defends against unlabeled containers slipping into the
# output (the filter should prevent it, but be robust).
out = cleanup._parse_services_by_project(
out = _enumerate._parse_services_by_project(
"claude-bottle-dev-abc\tegress\n"
"no-tab-here\n"
"\tmissing-project\n"
@@ -90,28 +90,21 @@ class _FakeHomeMixin:
class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
def setUp(self) -> None:
self._setup_fake_home()
self._orig_slugs = cleanup.list_active_slugs
self._orig_services = cleanup._query_services_by_project
# Skip the docker-availability gate so tests don't need a
# real docker on PATH.
import shutil
self._orig_which = shutil.which
shutil.which = lambda name: "/usr/bin/" + name if name == "docker" else None
self._orig_slugs = _enumerate.list_active_slugs
self._orig_services = _enumerate._query_services_by_project
def tearDown(self) -> None:
cleanup.list_active_slugs = self._orig_slugs
cleanup._query_services_by_project = self._orig_services
import shutil
shutil.which = self._orig_which
_enumerate.list_active_slugs = self._orig_slugs
_enumerate._query_services_by_project = self._orig_services
self._teardown_fake_home()
def _stub(self, slugs: list[str], services_by_project: dict[str, set[str]]) -> None:
cleanup.list_active_slugs = lambda **_: slugs
cleanup._query_services_by_project = lambda: services_by_project
_enumerate.list_active_slugs = lambda **_: slugs
_enumerate._query_services_by_project = lambda: services_by_project
def test_no_active_slugs_returns_empty(self):
self._stub([], {})
self.assertEqual([], cleanup.enumerate_active())
self.assertEqual([], _enumerate.enumerate_active())
def test_assembles_from_metadata_and_services(self):
bottle_state.write_metadata(bottle_state.BottleMetadata(
@@ -126,7 +119,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
["dev-abc"],
{"claude-bottle-dev-abc": {"pipelock", "egress", "supervise"}},
)
active = cleanup.enumerate_active()
active = _enumerate.enumerate_active()
self.assertEqual(1, len(active))
a = active[0]
self.assertEqual("docker", a.backend_name)
@@ -139,7 +132,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
# State dir doesn't exist for this slug — agent_name falls
# back to "?" rather than dropping the row.
self._stub(["mystery-zzz"], {"claude-bottle-mystery-zzz": {"pipelock"}})
active = cleanup.enumerate_active()
active = _enumerate.enumerate_active()
self.assertEqual(1, len(active))
self.assertEqual("?", active[0].agent_name)
self.assertEqual("", active[0].started_at)
@@ -158,7 +151,7 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
compose_project="claude-bottle-warming-up",
))
self._stub(["warming-up"], {})
active = cleanup.enumerate_active()
active = _enumerate.enumerate_active()
self.assertEqual((), active[0].services)
def test_preserves_slug_order(self):
@@ -174,21 +167,12 @@ class TestEnumerateActive(_FakeHomeMixin, unittest.TestCase):
# list_active_slugs returns sorted; preserve that order in
# the output.
self._stub(["a-1", "m-1", "z-1"], {})
active = cleanup.enumerate_active()
active = _enumerate.enumerate_active()
self.assertEqual(
["a-1", "m-1", "z-1"],
[a.slug for a in active],
)
def test_noop_when_docker_missing(self):
# Defensive: list active shouldn't die just because docker
# isn't on PATH (smolmachines bottles are still discoverable
# via the other backend's enumerate).
import shutil
shutil.which = lambda _name: None
self._stub(["some-slug"], {})
self.assertEqual([], cleanup.enumerate_active())
if __name__ == "__main__":
unittest.main()
+5 -4
View File
@@ -1,7 +1,7 @@
"""Unit: smolmachines backend cleanup (`cleanup.py` +
`bottle_cleanup_plan.py`).
Tests mock `subprocess.run` + `shutil.which` so they execute
Tests mock `subprocess.run` + `has_backend` so they execute
without docker / smolvm on PATH. Each cleanup step verifies argv
shape; teardown verifies order (machines bundles networks)."""
@@ -11,6 +11,7 @@ import subprocess
import unittest
from unittest.mock import patch
from claude_bottle import backend as backend_mod
from claude_bottle.backend.smolmachines import cleanup
from claude_bottle.backend.smolmachines.bottle_cleanup_plan import (
SmolmachinesBottleCleanupPlan,
@@ -27,7 +28,7 @@ class TestPrepareCleanup(unittest.TestCase):
def test_empty_when_nothing_running(self):
with patch.object(cleanup, "_smolvm") as smolvm, \
patch.object(cleanup.subprocess, "run") as run, \
patch.object(cleanup.shutil, "which", return_value="/bin/docker"):
patch.object(backend_mod, "has_backend", return_value=True):
smolvm.is_available.return_value = True
run.return_value = _ok(stdout="[]")
plan = cleanup.prepare_cleanup()
@@ -55,7 +56,7 @@ class TestPrepareCleanup(unittest.TestCase):
with patch.object(cleanup, "_smolvm") as smolvm, \
patch.object(cleanup.subprocess, "run", side_effect=fake_run), \
patch.object(cleanup.shutil, "which", return_value="/bin/docker"):
patch.object(backend_mod, "has_backend", return_value=True):
smolvm.is_available.return_value = True
plan = cleanup.prepare_cleanup()
@@ -76,7 +77,7 @@ class TestPrepareCleanup(unittest.TestCase):
def test_no_smolvm_means_no_machines(self):
with patch.object(cleanup, "_smolvm") as smolvm, \
patch.object(cleanup.subprocess, "run", return_value=_ok()), \
patch.object(cleanup.shutil, "which", return_value="/bin/docker"):
patch.object(backend_mod, "has_backend", return_value=True):
smolvm.is_available.return_value = False
plan = cleanup.prepare_cleanup()
self.assertEqual((), plan.machines)