refactor(cleanup): compose-ls driven, plus orphan state-dir reaping
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m4s

PRD 0018 chunk 4. `claude-bottle cleanup` now derives its work
from `docker compose ls --all --format json`, filtered to projects
whose name starts with `claude-bottle-`. Per project: one `compose
down --volumes` removes the containers + the compose-managed
networks atomically.

The plan also enumerates three fallback buckets:

  - Stray containers — `claude-bottle-*` containers with no
    `com.docker.compose.project` label (left over from pre-compose
    code paths). Cleared via `docker rm -f`.
  - Stray networks — `claude-bottle-*` networks with no compose
    project label. Cleared via `docker network rm`.
  - Orphan state dirs — per-bottle `~/.claude-bottle/state/<id>/`
    dirs with no live project AND no `.preserve` marker. The
    `.preserve` marker (capability-block or auto-preserve-on-crash)
    explicitly opts-out of reaping; manual `rm -rf` is the only
    path for preserved state.

cli/cleanup.py collapses to a single y/N prompt — backend.prepare_cleanup
returns everything in one plan, backend.cleanup processes everything,
no more double-prompt for state. The CLI-side state-dir enumeration
+ `_state_summary` flags from PR #25 are gone; the backend's
orphan-detection rules subsume them.
This commit is contained in:
2026-05-25 23:41:23 -04:00
parent a515efb6b4
commit 9f2498397f
5 changed files with 349 additions and 249 deletions
@@ -1,8 +1,21 @@
"""DockerBottleCleanupPlan — concrete subclass of BottleCleanupPlan.
Holds the tuples of container and network names that
DockerBottleBackend.cleanup will remove. The y/N preflight reads
these via `print`; the CLI short-circuits via `empty`.
PRD 0018 chunk 4: cleanup is centered on compose projects. `docker
compose ls` is the source of truth for what's running; the plan
carries the projects to `compose down`, plus three fallback buckets
for legacy / orphan resources:
- stray_containers: pre-compose `claude-bottle-*` containers not
attached to any compose project. Cleared via `docker rm -f`.
- stray_networks: same idea for networks. Cleared via
`docker network rm`.
- orphan_state_dirs: per-bottle state dirs under
~/.claude-bottle/state/ that have no live compose project AND
no `.preserve` marker. Reaped via `shutil.rmtree`.
Compose-managed networks are removed by `compose down --volumes`,
so they don't appear in stray_networks for a normal project — only
truly leftover ones.
"""
from __future__ import annotations
@@ -17,20 +30,30 @@ from .. import BottleCleanupPlan
@dataclass(frozen=True)
class DockerBottleCleanupPlan(BottleCleanupPlan):
"""Resources DockerBottleBackend.cleanup will remove. Produced by
`prepare_cleanup` from a snapshot of `docker ps -a` + `docker
network ls`; sorted so the y/N output is stable."""
`prepare_cleanup`; sorted so the y/N output is stable."""
containers: tuple[str, ...]
networks: tuple[str, ...]
projects: tuple[str, ...]
stray_containers: tuple[str, ...]
stray_networks: tuple[str, ...]
orphan_state_dirs: tuple[str, ...]
@property
def empty(self) -> bool:
return not self.containers and not self.networks
return (
not self.projects
and not self.stray_containers
and not self.stray_networks
and not self.orphan_state_dirs
)
def print(self) -> None:
print(file=sys.stderr)
for name in self.containers:
info(f"container: {name}")
for name in self.networks:
info(f"network: {name}")
for name in self.projects:
info(f"compose project: {name}")
for name in self.stray_containers:
info(f"stray container: {name}")
for name in self.stray_networks:
info(f"stray network: {name}")
for name in self.orphan_state_dirs:
info(f"orphan state: {name}")
print(file=sys.stderr)
+192 -65
View File
@@ -1,76 +1,180 @@
"""Cleanup + active-listing for the Docker bottle backend.
`prepare_cleanup` enumerates orphaned `claude-bottle-` containers and
networks; `cleanup` removes them. `list_active` queries the same
namespace for ad-hoc inspection. All three share a single concern:
acting on resources whose names start with `claude-bottle-`.
PRD 0018 chunk 4: cleanup is centered on `docker compose ls`.
Pre-compose code paths could leave bare containers / networks
without a compose project; those still show up via the prefix
scan, just as a fallback bucket alongside the project list.
`prepare_cleanup` enumerates:
- Live compose projects whose name starts with `claude-bottle-`.
- `claude-bottle-*` containers that aren't part of any compose
project (legacy orphans).
- `claude-bottle-*` networks that aren't tied to a compose
project (legacy orphans; compose-managed networks come down
with `compose down --volumes` and don't appear here).
- State dirs under ~/.claude-bottle/state/<identity>/ with no
live compose project AND no `.preserve` marker.
`cleanup` removes everything in the plan.
`list_active` queries the same compose project namespace and prints
each project's services for ad-hoc inspection.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
from ...log import info
from ... import supervise as _supervise
from ...log import info, warn
from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_state import bottle_state_dir, is_preserved
_PROJECT_PREFIX = "claude-bottle-"
def _list_compose_projects() -> list[str]:
"""Return the names of all currently-known compose projects
(running OR stopped) whose name starts with `claude-bottle-`.
`docker compose ls --all` reports both up + exited states."""
result = subprocess.run(
["docker", "compose", "ls", "--all", "--format", "json"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
warn(f"docker compose ls failed: {result.stderr.strip()}")
return []
try:
projects = json.loads(result.stdout or "[]")
except json.JSONDecodeError as e:
warn(f"docker compose ls returned malformed JSON: {e}")
return []
names: list[str] = []
for p in projects:
if not isinstance(p, dict):
continue
name = str(p.get("Name", ""))
if name.startswith(_PROJECT_PREFIX):
names.append(name)
return sorted(set(names))
def _list_prefixed_containers() -> list[str]:
"""All claude-bottle-prefixed containers, running or stopped."""
result = subprocess.run(
["docker", "ps", "-a",
"--filter", f"name=^{_PROJECT_PREFIX}",
"--format", "{{.Names}}\t{{.Label \"com.docker.compose.project\"}}"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
warn(f"docker ps failed: {result.stderr.strip()}")
return []
out: list[str] = []
for line in (result.stdout or "").splitlines():
if not line:
continue
name, _, project = line.partition("\t")
# Stray = no compose label. Compose-managed containers carry
# `com.docker.compose.project=<name>`; we'll reap those via
# `compose down`, not via container rm.
if not project:
out.append(name)
return sorted(set(out))
def _list_prefixed_networks() -> list[str]:
"""All claude-bottle-prefixed networks not currently attached
to a compose project. Compose-managed networks have a
`com.docker.compose.project` label; bare ones (from pre-compose
code paths) don't."""
result = subprocess.run(
["docker", "network", "ls",
"--filter", f"name={_PROJECT_PREFIX}",
"--format", "{{.Name}}\t{{.Label \"com.docker.compose.project\"}}"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
warn(f"docker network ls failed: {result.stderr.strip()}")
return []
out: list[str] = []
for line in (result.stdout or "").splitlines():
if not line:
continue
name, _, project = line.partition("\t")
if not project:
out.append(name)
return sorted(set(out))
def _list_orphan_state_dirs(live_projects: set[str]) -> list[str]:
"""State identities whose compose project isn't running and
that don't have a `.preserve` marker. `.preserve` means the
user (or an auto-preserve-on-crash) wants the state kept for
`resume`."""
state_root = _supervise.claude_bottle_root() / "state"
if not state_root.is_dir():
return []
orphans: list[str] = []
for child in sorted(state_root.iterdir()):
if not child.is_dir():
continue
identity = child.name
project = f"{_PROJECT_PREFIX}{identity}"
if project in live_projects:
continue
if is_preserved(identity):
continue
orphans.append(identity)
return orphans
def prepare_cleanup() -> DockerBottleCleanupPlan:
"""Enumerate all claude-bottle-prefixed containers (running or
stopped) and networks. No removals — caller confirms first."""
"""Enumerate everything cleanup will touch. No removals."""
docker_mod.require_docker()
# `docker ps -a --filter name=...` uses regex matching; anchor at
# the start so we don't pick up containers that merely contain
# "claude-bottle-" mid-name.
cr = subprocess.run(
[
"docker", "ps", "-a",
"--filter", "name=^claude-bottle-",
"--format", "{{.Names}}",
],
capture_output=True,
text=True,
check=True,
projects = _list_compose_projects()
project_set = set(projects)
return DockerBottleCleanupPlan(
projects=tuple(projects),
stray_containers=tuple(_list_prefixed_containers()),
stray_networks=tuple(_list_prefixed_networks()),
orphan_state_dirs=tuple(_list_orphan_state_dirs(project_set)),
)
containers = tuple(sorted(
line for line in (cr.stdout or "").splitlines() if line
))
# `docker network ls --filter name=...` uses substring matching.
# "claude-bottle-" is specific enough that false positives are
# not a concern.
nr = subprocess.run(
[
"docker", "network", "ls",
"--filter", "name=claude-bottle-",
"--format", "{{.Name}}",
],
capture_output=True,
text=True,
check=True,
)
networks = tuple(sorted(
line for line in (nr.stdout or "").splitlines() if line
))
return DockerBottleCleanupPlan(containers=containers, networks=networks)
def cleanup(plan: DockerBottleCleanupPlan) -> None:
"""Remove the containers and networks listed in the plan.
Containers first; networks would refuse to delete while containers
are still attached."""
for name in plan.containers:
info(f"removing container {name}")
"""Remove everything in the plan. Projects first (whose `compose
down` reaps their containers + networks atomically), then stray
legacy resources, then orphan state dirs."""
for project in plan.projects:
info(f"docker compose down ({project})")
result = subprocess.run(
["docker", "compose", "-p", project, "down", "--volumes"],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
warn(
f"compose down failed for {project}: "
f"{result.stderr.strip()}"
)
for name in plan.stray_containers:
info(f"removing stray container {name}")
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
for name in plan.networks:
info(f"removing network {name}")
for name in plan.stray_networks:
info(f"removing stray network {name}")
subprocess.run(
["docker", "network", "rm", name],
stdout=subprocess.DEVNULL,
@@ -78,27 +182,50 @@ def cleanup(plan: DockerBottleCleanupPlan) -> None:
check=False,
)
for identity in plan.orphan_state_dirs:
path = bottle_state_dir(identity)
info(f"removing orphan state dir {path}")
try:
shutil.rmtree(path, ignore_errors=True)
except OSError as e:
warn(f"failed to remove {path}: {e}")
def list_active() -> None:
"""Print all running claude-bottle containers (name + status).
Prints a single-line banner if there are none."""
"""Print every active claude-bottle compose project + its
services. Empty banner when there are none."""
docker_mod.require_docker()
projects = _list_compose_projects()
# Filter to projects with at least one running container — `compose ls`
# already filters by default to active projects unless `--all` was
# set; double-check by querying status.
result = subprocess.run(
[
"docker", "ps",
"--filter", "name=^claude-bottle-",
"--format", "{{.Names}}\t{{.Status}}",
],
capture_output=True,
text=True,
check=True,
["docker", "compose", "ls", "--format", "json"],
capture_output=True, text=True, check=False,
)
containers = (result.stdout or "").strip()
if not containers:
info("no active claude-bottle containers")
running_names: set[str] = set()
if result.returncode == 0:
try:
data = json.loads(result.stdout or "[]")
running_names = {
str(p.get("Name", "")) for p in data if isinstance(p, dict)
}
except json.JSONDecodeError:
pass
active = [p for p in projects if p in running_names]
if not active:
info("no active claude-bottle compose projects")
return
print()
for line in containers.splitlines():
name, _, status = line.partition("\t")
info(f"container: {name} status: {status}")
for project in active:
info(f"compose project: {project}")
ps = subprocess.run(
["docker", "compose", "-p", project, "ps", "--format",
"{{.Service}}\t{{.Name}}\t{{.Status}}"],
capture_output=True, text=True, check=False,
)
for line in (ps.stdout or "").splitlines():
service, _, rest = line.partition("\t")
name, _, status = rest.partition("\t")
info(f" {service:12s} {name} ({status})")
print()
+20 -70
View File
@@ -1,19 +1,22 @@
"""cleanup: stop and remove all orphaned claude-bottle resources
(containers + networks) left behind by previous bottles, plus
optionally the per-bottle state dirs under ~/.claude-bottle/state/.
"""cleanup: stop and remove all orphaned claude-bottle resources.
State cleanup is prompted separately from container cleanup because
the trade-off is different: containers + networks are pure debris,
but a state dir may carry a resumable bottle (capability-block
rebuild + transcript snapshot) the operator still wants."""
PRD 0018 chunk 4: backend's prepare_cleanup carries everything in
one plan — live compose projects (whose `compose down` removes
containers + networks atomically), legacy stray containers/networks
that aren't in any project, and orphan state dirs (per-bottle
state with no live project AND no `.preserve` marker). One prompt,
one cleanup call.
State dirs with `.preserve` are intentionally never touched — they
hold capability-block rebuilds or crash snapshots the operator may
want to `resume`. Manual `rm -rf ~/.claude-bottle/state/<identity>`
is the path for those.
"""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
from .. import supervise as _supervise
from ..backend import get_bottle_backend
from ..log import info
from ._common import read_tty_line
@@ -22,74 +25,21 @@ from ._common import read_tty_line
def cmd_cleanup(_argv: list[str]) -> int:
backend = get_bottle_backend()
plan = backend.prepare_cleanup()
state_dirs = _enumerate_state_dirs()
if plan.empty and not state_dirs:
if plan.empty:
info("no claude-bottle resources to clean up")
return 0
if not plan.empty:
plan.print()
if _prompt_yes("remove all of the above?"):
backend.cleanup(plan)
info("containers + networks: cleaned")
else:
info("containers + networks: skipped")
if state_dirs:
_print_state(state_dirs)
if _prompt_yes(
"remove per-bottle state? (loses resumable bottles)",
):
for d in state_dirs:
shutil.rmtree(d, ignore_errors=True)
info(f"state: removed {len(state_dirs)} dir(s)")
else:
info("state: skipped")
plan.print()
if not _prompt_yes("remove all of the above?"):
info("cleanup: skipped")
return 0
backend.cleanup(plan)
info("cleanup: done")
return 0
# --- State enumeration + display ------------------------------------------
def _enumerate_state_dirs() -> list[Path]:
"""All per-bottle state dirs under ~/.claude-bottle/state/.
Sorted for stable preflight output."""
state_root = _supervise.claude_bottle_root() / "state"
if not state_root.is_dir():
return []
return sorted(p for p in state_root.iterdir() if p.is_dir())
def _state_summary(path: Path) -> str:
"""One-line description suitable for the cleanup prompt. Calls
out resumability so the operator can decide whether removing it
loses anything they care about."""
flags: list[str] = []
if (path / "metadata.json").is_file():
flags.append("resumable")
else:
flags.append("no metadata.json (orphan)")
if (path / "Dockerfile").is_file():
flags.append("rebuilt Dockerfile")
if (path / "transcript").is_dir():
flags.append("transcript snapshot")
if (path / ".preserve").is_file():
flags.append("preserve marker")
return f"state: {path.name} ({', '.join(flags)})"
def _print_state(dirs: list[Path]) -> None:
print(file=sys.stderr)
for d in dirs:
info(_state_summary(d))
print(file=sys.stderr)
# --- Prompt ----------------------------------------------------------------
def _prompt_yes(message: str) -> bool:
sys.stderr.write(f"claude-bottle: {message} [y/N] ")
sys.stderr.flush()
-102
View File
@@ -1,102 +0,0 @@
"""Unit: cli/cleanup.py state-dir enumeration + summary.
The end-to-end cleanup-with-prompt flow is exercised manually;
here we cover the state-dir display logic so a regression in the
resumable / orphan / rebuild flags surfaces in unit CI."""
import tempfile
import unittest
from pathlib import Path
from claude_bottle import supervise
from claude_bottle.backend.docker import bottle_state
from claude_bottle.cli.cleanup import _enumerate_state_dirs, _state_summary
class _FakeHomeMixin:
def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cli-cleanup-test.")
original = supervise.claude_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".claude-bottle"
supervise.claude_bottle_root = fake_root # type: ignore[assignment]
self._restore = lambda: setattr(supervise, "claude_bottle_root", original)
def _teardown_fake_home(self):
self._restore()
self._tmp.cleanup()
class TestEnumerateStateDirs(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def test_empty_when_no_state_root(self):
self.assertEqual([], _enumerate_state_dirs())
def test_lists_each_identity_dir(self):
bottle_state.write_per_bottle_dockerfile("dev-aaa", "FROM x\n")
bottle_state.write_per_bottle_dockerfile("api-bbb", "FROM y\n")
dirs = _enumerate_state_dirs()
self.assertEqual(
["api-bbb", "dev-aaa"],
[p.name for p in dirs],
)
def test_sorted_for_stable_preflight(self):
for name in ("z", "a", "m"):
bottle_state.write_per_bottle_dockerfile(name, "FROM x\n")
names = [p.name for p in _enumerate_state_dirs()]
self.assertEqual(["a", "m", "z"], names)
class TestStateSummary(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def _path(self, name: str) -> Path:
return bottle_state.bottle_state_dir(name)
def test_orphan_state_dir(self):
# Only a Dockerfile, no metadata.json — the "api / dev" shape
# that comes from pre-identity-fix code.
bottle_state.write_per_bottle_dockerfile("orphan", "FROM old\n")
s = _state_summary(self._path("orphan"))
self.assertIn("orphan", s)
self.assertIn("no metadata.json", s)
self.assertIn("rebuilt Dockerfile", s)
def test_resumable_state_dir(self):
bottle_state.write_metadata(bottle_state.BottleMetadata(
identity="dev-aaa", agent_name="dev",
cwd="/proj/A", copy_cwd=True, started_at="t",
))
s = _state_summary(self._path("dev-aaa"))
self.assertIn("resumable", s)
self.assertNotIn("rebuilt Dockerfile", s)
def test_resumable_with_capability_rebuild_and_preserve_marker(self):
bottle_state.write_metadata(bottle_state.BottleMetadata(
identity="dev-bbb", agent_name="dev",
cwd="", copy_cwd=False, started_at="t",
))
bottle_state.write_per_bottle_dockerfile("dev-bbb", "FROM rebuilt\n")
bottle_state.transcript_snapshot_dir("dev-bbb").mkdir(parents=True)
bottle_state.mark_preserved("dev-bbb")
s = _state_summary(self._path("dev-bbb"))
self.assertIn("resumable", s)
self.assertIn("rebuilt Dockerfile", s)
self.assertIn("transcript snapshot", s)
self.assertIn("preserve marker", s)
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
"""Unit: backend/docker/cleanup orphan-state-dir detection.
PRD 0018 chunk 4. The orphan-state-dir rule has three categories:
- LIVE: a compose project with the matching name is up → keep
- PRESERVED: state dir carries `.preserve` → keep (resume target)
- ORPHAN: neither → reap
These are the cases the test exercises. The compose-project +
container/network enumeration is left to the integration tests
because it requires a real docker daemon."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from claude_bottle import supervise
from claude_bottle.backend.docker import bottle_state
from claude_bottle.backend.docker.cleanup import _list_orphan_state_dirs
class _FakeHomeMixin:
def _setup_fake_home(self) -> None:
self._tmp = tempfile.TemporaryDirectory(prefix="docker-cleanup-test.")
original = supervise.claude_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".claude-bottle"
supervise.claude_bottle_root = fake_root # type: ignore[assignment]
self._restore = lambda: setattr(supervise, "claude_bottle_root", original)
def _teardown_fake_home(self) -> None:
self._restore()
self._tmp.cleanup()
class TestOrphanStateDirs(_FakeHomeMixin, unittest.TestCase):
def setUp(self) -> None:
self._setup_fake_home()
def tearDown(self) -> None:
self._teardown_fake_home()
def test_no_state_root_returns_empty(self):
self.assertEqual([], _list_orphan_state_dirs(set()))
def test_state_dir_with_no_live_no_preserve_is_orphan(self):
# Just touch the dir; no metadata, no preserve marker — the
# exact orphan shape.
bottle_state.write_per_bottle_dockerfile("solo-aaa", "FROM x\n")
self.assertEqual(
["solo-aaa"],
_list_orphan_state_dirs(set()),
)
def test_live_project_skips_dir(self):
# Live project means the bottle is currently running under
# compose — never reap.
bottle_state.write_per_bottle_dockerfile("live-bbb", "FROM x\n")
self.assertEqual(
[],
_list_orphan_state_dirs({"claude-bottle-live-bbb"}),
)
def test_preserve_marker_skips_dir(self):
# Preserve marker = capability-block or crash auto-preserve;
# the user explicitly wanted this dir kept for `resume`.
bottle_state.write_per_bottle_dockerfile("kept-ccc", "FROM x\n")
bottle_state.mark_preserved("kept-ccc")
self.assertEqual(
[],
_list_orphan_state_dirs(set()),
)
def test_preserve_overrides_no_live_project(self):
# Even without a live project, a preserve marker keeps it.
bottle_state.write_per_bottle_dockerfile("kept-ddd", "FROM x\n")
bottle_state.mark_preserved("kept-ddd")
self.assertEqual([], _list_orphan_state_dirs(set()))
def test_mixed_set_categorized_correctly(self):
bottle_state.write_per_bottle_dockerfile("orphan-eee", "FROM x\n")
bottle_state.write_per_bottle_dockerfile("live-fff", "FROM y\n")
bottle_state.write_per_bottle_dockerfile("kept-ggg", "FROM z\n")
bottle_state.mark_preserved("kept-ggg")
result = _list_orphan_state_dirs({"claude-bottle-live-fff"})
self.assertEqual(["orphan-eee"], result)
def test_sorted_output(self):
for name in ("zzz-1", "aaa-1", "mmm-1"):
bottle_state.write_per_bottle_dockerfile(name, "FROM x\n")
self.assertEqual(
["aaa-1", "mmm-1", "zzz-1"],
_list_orphan_state_dirs(set()),
)
if __name__ == "__main__":
unittest.main()