refactor(bottles): two-phase cleanup parallel to prepare/launch
test / run tests/run_tests.py (pull_request) Successful in 13s

cmd_cleanup used to only sweep running containers via `docker ps`,
missing stopped pipelock sidecars and orphaned networks entirely. On
my host the new version surfaced ~10 stranded networks left behind by
SIGKILLed sessions — the kind of thing the old command implied it was
handling.

New shape, symmetric with start:
- BottleCleanupPlan (abstract, in bottles/__init__.py) with `print` +
  `empty` abstract members.
- DockerBottleCleanupPlan (concrete, in bottles/docker.py) carrying
  the resolved tuples of containers and networks.
- BottlePlatform gains abstract prepare_cleanup() + cleanup(plan).
  DockerBottlePlatform implements both:
    - prepare_cleanup: docker ps -a + docker network ls, both
      filtered to ^claude-bottle-, sorted for stable output.
    - cleanup: docker rm -f containers first (they hold the network
      attachment), then docker network rm.
- cmd_cleanup is now ~25 lines: prepare → print → y/N → cleanup.
This commit is contained in:
2026-05-10 23:14:54 -04:00
parent 4a45c267f3
commit 18d29fc23f
3 changed files with 137 additions and 25 deletions
+35 -1
View File
@@ -1,7 +1,7 @@
"""Per-platform bottle factories.
A bottle is a running, isolated environment with claude inside. Each
platform exposes two functions:
platform exposes four methods:
prepare(spec, stage_dir=...) -> BottlePlan
Resolves names, validates host-side prerequisites, and writes
@@ -12,6 +12,13 @@ platform exposes two functions:
Brings up the container (or VM, or remote machine), provisions
it, yields a Bottle handle, and tears everything down on exit.
prepare_cleanup() -> BottleCleanupPlan
Enumerates orphaned resources left behind by previous bottles
(containers, networks, ...). Idempotent; no side effects.
cleanup(plan) -> None
Actually removes everything described by the cleanup plan.
Selection is driven by CLAUDE_BOTTLE_PLATFORM (default "docker"). Per
PRD 0003 the manifest does not carry a platform field; the host
environment picks.
@@ -58,6 +65,23 @@ class BottlePlan(ABC):
"""Render the y/N preflight summary to stderr."""
@dataclass(frozen=True)
class BottleCleanupPlan(ABC):
"""Base output of a platform's prepare_cleanup step. Concrete
subclasses (e.g. DockerBottleCleanupPlan) carry platform-specific
lists of resources to be removed and implement `print` + `empty`."""
@abstractmethod
def print(self) -> None:
"""Render the cleanup y/N summary to stderr."""
@property
@abstractmethod
def empty(self) -> bool:
"""True iff there is nothing to clean up; the CLI uses this to
short-circuit before showing the y/N."""
class Bottle(Protocol):
"""Handle to a running bottle. Yielded by a platform's launch step.
@@ -89,6 +113,15 @@ class BottlePlatform(ABC):
def launch(self, plan: BottlePlan) -> AbstractContextManager[Bottle]:
"""Build/run the bottle and yield a handle; tear down on exit."""
@abstractmethod
def prepare_cleanup(self) -> BottleCleanupPlan:
"""Enumerate orphaned resources from previous bottles. No side
effects; safe to call before the y/N."""
@abstractmethod
def cleanup(self, plan: BottleCleanupPlan) -> None:
"""Remove everything described by the cleanup plan."""
# Import concrete platform classes AFTER the base types are defined, so
# each platform module can pull BottleSpec / BottlePlan / BottlePlatform
@@ -114,6 +147,7 @@ def get_bottle_platform() -> BottlePlatform:
__all__ = [
"Bottle",
"BottleCleanupPlan",
"BottlePlan",
"BottlePlatform",
"BottleSpec",