From a3d9ac9605eaeb585051bcafc9c1584b2622bfeb Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 2 Jun 2026 14:43:12 +0000 Subject: [PATCH] feat: persist backend in BottleMetadata; use it in resume and dashboard reattach (PRD 0040) BottleMetadata gains a backend field (default ""). Docker prepare writes "docker"; smolmachines prepare writes "smolmachines". read_metadata deserialises it with "" as the backward-compatible default. resume now passes metadata.backend to _launch_bottle so a preserved smolmachines bottle is resumed on the right backend without requiring BOT_BOTTLE_BACKEND to be set manually. _bottle_for_slug now reads metadata.backend and constructs a SmolmachinesBottle for smolmachines slugs instead of always defaulting to DockerBottle. No-metadata slugs still fall back to Docker. Closes #137 --- bot_bottle/backend/docker/bottle_state.py | 5 + bot_bottle/backend/docker/prepare.py | 1 + bot_bottle/backend/smolmachines/prepare.py | 3 +- bot_bottle/cli/dashboard.py | 31 +++--- bot_bottle/cli/resume.py | 2 + tests/unit/test_bottle_state.py | 107 +++++++++++++++++++++ 6 files changed, 133 insertions(+), 16 deletions(-) diff --git a/bot_bottle/backend/docker/bottle_state.py b/bot_bottle/backend/docker/bottle_state.py index 89e525f..d258974 100644 --- a/bot_bottle/backend/docker/bottle_state.py +++ b/bot_bottle/backend/docker/bottle_state.py @@ -105,6 +105,10 @@ class BottleMetadata: # written before chunk 3 (resume / inspect should fall back to # deriving from identity in that case). compose_project: str = "" + # PRD 0040: backend name ("docker" or "smolmachines"). Empty string + # for state dirs written before PRD 0040; callers default to "docker" + # for backward compatibility. + backend: str = "" def metadata_path(identity: str) -> Path: @@ -138,6 +142,7 @@ def read_metadata(identity: str) -> BottleMetadata | None: copy_cwd=bool(raw.get("copy_cwd", False)), started_at=str(raw.get("started_at", "")), compose_project=str(raw.get("compose_project", "")), + backend=str(raw.get("backend", "")), ) diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index 3ce9c83..20de072 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -79,6 +79,7 @@ def resolve_plan( copy_cwd=spec.copy_cwd, started_at=datetime.now(timezone.utc).isoformat(), compose_project=f"bot-bottle-{slug}", + backend="docker", )) # Clear any leftover preserve marker from a prior capability-block # so this fresh launch can be cleaned up at session-end unless diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py index 8294076..3f29c0c 100644 --- a/bot_bottle/backend/smolmachines/prepare.py +++ b/bot_bottle/backend/smolmachines/prepare.py @@ -71,9 +71,8 @@ def resolve_plan( cwd=spec.user_cwd if spec.copy_cwd else "", copy_cwd=spec.copy_cwd, started_at=datetime.now(timezone.utc).isoformat(), - # No compose project for smolmachines bottles; chunk 4 - # will give dashboard discovery a backend-specific path. compose_project="", + backend="smolmachines", )) subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) diff --git a/bot_bottle/cli/dashboard.py b/bot_bottle/cli/dashboard.py index 5c09d2d..df17964 100644 --- a/bot_bottle/cli/dashboard.py +++ b/bot_bottle/cli/dashboard.py @@ -647,23 +647,19 @@ def _bottle_for_slug( ) -> tuple["object", str]: """Return `(bottle_handle, prompt_path_hint)` for a re-attach. If the slug is in `bottles` (dashboard-owned), return the stored - handle directly. Otherwise synthesize a `DockerBottle` from the - container name `bot-bottle-`. For synthesized bottles - the prompt-file path comes from the manifest's agent if we can - resolve it via metadata.json + the loaded manifest; otherwise - the re-attach runs without `--append-system-prompt-file`. + handle directly. Otherwise synthesize a bottle from the persisted + metadata. The backend field in metadata (PRD 0040) selects Docker + or smolmachines; unknown or missing metadata defaults to Docker. Returns the empty string for prompt_path_hint when we omit the flag — the caller passes None to DockerBottle in that case.""" from ..backend.docker.bottle import DockerBottle from ..backend.docker.bottle_state import read_metadata + from ..backend.smolmachines.bottle import SmolmachinesBottle if slug in bottles: _cm, bottle, _identity = bottles[slug] return bottle, "" - # The container hosting the agent's agent process is named - # `bot-bottle-` — set by the compose renderer - # (no service suffix on the agent service, by design). - container_name = f"bot-bottle-{slug}" + machine_name = f"bot-bottle-{slug}" prompt_path: str | None = None metadata = read_metadata(slug) if metadata is not None and manifest is not None: @@ -673,11 +669,18 @@ def _bottle_for_slug( "BOT_BOTTLE_CONTAINER_HOME", "/home/node", ) prompt_path = f"{container_home}/.bot-bottle-prompt.txt" - synth = DockerBottle( - container=container_name, - teardown=lambda: None, - prompt_path_in_container=prompt_path, - ) + backend = metadata.backend if metadata is not None else "" + if backend == "smolmachines": + synth: object = SmolmachinesBottle( + machine_name, + prompt_path=prompt_path, + ) + else: + synth = DockerBottle( + container=machine_name, + teardown=lambda: None, + prompt_path_in_container=prompt_path, + ) return synth, (prompt_path or "") diff --git a/bot_bottle/cli/resume.py b/bot_bottle/cli/resume.py index 2ce5ccd..55661a3 100644 --- a/bot_bottle/cli/resume.py +++ b/bot_bottle/cli/resume.py @@ -52,8 +52,10 @@ def cmd_resume(argv: list[str]) -> int: user_cwd=metadata.cwd or USER_CWD, identity=metadata.identity, ) + backend_name = metadata.backend or None return _launch_bottle( spec, dry_run=args.dry_run, remote_control=args.remote_control, + backend_name=backend_name, ) diff --git a/tests/unit/test_bottle_state.py b/tests/unit/test_bottle_state.py index 4386976..d0c032c 100644 --- a/tests/unit/test_bottle_state.py +++ b/tests/unit/test_bottle_state.py @@ -216,5 +216,112 @@ class TestBottleMetadata(_FakeHomeMixin, unittest.TestCase): self.assertEqual("t2", loaded.started_at) +class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase): + """PRD 0040: backend field is persisted and read back.""" + + def setUp(self): + self._setup_fake_home() + + def tearDown(self): + self._teardown_fake_home() + + def test_backend_field_roundtrips_docker(self): + meta = BottleMetadata( + identity="dev-b1", + agent_name="dev", + cwd="", + copy_cwd=False, + started_at="2026-06-02T00:00:00+00:00", + compose_project="bot-bottle-dev-b1", + backend="docker", + ) + write_metadata(meta) + loaded = read_metadata("dev-b1") + self.assertIsNotNone(loaded) + assert loaded is not None + self.assertEqual("docker", loaded.backend) + + def test_backend_field_roundtrips_smolmachines(self): + meta = BottleMetadata( + identity="dev-b2", + agent_name="dev", + cwd="", + copy_cwd=False, + started_at="2026-06-02T00:00:00+00:00", + compose_project="", + backend="smolmachines", + ) + write_metadata(meta) + loaded = read_metadata("dev-b2") + self.assertIsNotNone(loaded) + assert loaded is not None + self.assertEqual("smolmachines", loaded.backend) + + def test_missing_backend_field_defaults_to_empty(self): + # Old state dirs written before PRD 0040 have no backend key. + import json + from bot_bottle.backend.docker import bottle_state as bs + path = bs.metadata_path("dev-b3") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({ + "identity": "dev-b3", + "agent_name": "dev", + "cwd": "", + "copy_cwd": False, + "started_at": "2026-06-02T00:00:00+00:00", + "compose_project": "bot-bottle-dev-b3", + })) + loaded = read_metadata("dev-b3") + self.assertIsNotNone(loaded) + assert loaded is not None + self.assertEqual("", loaded.backend) + + +class TestBottleForSlugBackend(_FakeHomeMixin, unittest.TestCase): + """PRD 0040: _bottle_for_slug constructs the right bottle type.""" + + def setUp(self): + self._setup_fake_home() + + def tearDown(self): + self._teardown_fake_home() + + def test_docker_metadata_returns_docker_bottle(self): + from bot_bottle.backend.docker.bottle import DockerBottle + from bot_bottle.cli.dashboard import _bottle_for_slug + write_metadata(BottleMetadata( + identity="dev-d1", + agent_name="dev", + cwd="", + copy_cwd=False, + started_at="2026-06-02T00:00:00+00:00", + compose_project="bot-bottle-dev-d1", + backend="docker", + )) + bottle, _ = _bottle_for_slug("dev-d1", {}, None) + self.assertIsInstance(bottle, DockerBottle) + + def test_smolmachines_metadata_returns_smolmachines_bottle(self): + from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle + from bot_bottle.cli.dashboard import _bottle_for_slug + write_metadata(BottleMetadata( + identity="dev-s1", + agent_name="dev", + cwd="", + copy_cwd=False, + started_at="2026-06-02T00:00:00+00:00", + compose_project="", + backend="smolmachines", + )) + bottle, _ = _bottle_for_slug("dev-s1", {}, None) + self.assertIsInstance(bottle, SmolmachinesBottle) + + def test_no_metadata_defaults_to_docker_bottle(self): + from bot_bottle.backend.docker.bottle import DockerBottle + from bot_bottle.cli.dashboard import _bottle_for_slug + bottle, _ = _bottle_for_slug("unknown-slug", {}, None) + self.assertIsInstance(bottle, DockerBottle) + + if __name__ == "__main__": unittest.main()