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..9e225ee 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}" + instance_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( + instance_name, + prompt_path=prompt_path, + ) + else: + synth = DockerBottle( + container=instance_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/docs/prds/0040-backend-aware-resume-and-dashboard.md b/docs/prds/0040-backend-aware-resume-and-dashboard.md new file mode 100644 index 0000000..93a7956 --- /dev/null +++ b/docs/prds/0040-backend-aware-resume-and-dashboard.md @@ -0,0 +1,87 @@ +# PRD 0040: Backend-Aware Resume and Dashboard Reattach + +- **Status:** Active +- **Author:** didericis-codex +- **Created:** 2026-06-02 +- **Issue:** #137 + +## Summary + +Persist the backend name in `BottleMetadata` and thread it through `resume` and +dashboard reattach so both flows construct the correct backend bottle without +relying on env overrides or defaulting to Docker. + +## Problem + +`BottleMetadata` records identity, agent, cwd, started_at, and compose project, +but not the backend name. Without it: + +- `cli/resume.py` cannot select the right backend from a preserved state dir + alone; operators must remember to set `BOT_BOTTLE_BACKEND=smolmachines` + separately. +- `cli/dashboard.py` `_bottle_for_slug` constructs a `DockerBottle` for any + externally discovered slug, so reattaching to a live smolmachines agent + from the dashboard sends Docker commands to a smolvm machine. + +## Goals / Success Criteria + +- `BottleMetadata` includes the backend name, written at bottle creation time + for both Docker and smolmachines. +- `cli resume` reads the persisted backend name and constructs the correct + bottle type without requiring an env override. +- Dashboard reattach (`_bottle_for_slug`) reads the persisted backend name and + constructs the correct bottle type. +- Existing Docker bottles without a persisted backend name fall back to Docker + (backward-compatible default). +- Unit tests cover write, read, backward-compatible fallback, and both + resume/reattach code paths. + +## Non-goals + +- No changes to manifest or egress configuration. +- No new CLI flags (backend selection at resume time should be automatic). +- No smolmachines capability-apply implementation (see PRD 0039). + +## Scope + +In scope: + +- `bot_bottle/backend/docker/bottle_state.py` `BottleMetadata` schema and + write path. +- `bot_bottle/backend/docker/bottle.py` and + `bot_bottle/backend/smolmachines/bottle.py` metadata write at creation. +- `bot_bottle/cli/resume.py` backend selection from metadata. +- `bot_bottle/cli/dashboard.py` `_bottle_for_slug` backend selection. +- Unit tests covering the above. + +Out of scope: + +- Migration tooling for existing state dirs. +- Integration tests that exercise full resume across process restarts. + +## Design + +Add a `backend` field to `BottleMetadata` with a default of `"docker"` for +backward compatibility. Both `DockerBottle` and `SmolmachinesBottle` write +their backend name into metadata at creation time. + +`resume` reads the metadata before constructing the bottle object and selects +the appropriate backend class. `_bottle_for_slug` does the same. A helper +function in the metadata module can encapsulate the backend-name-to-class +mapping so the logic is not duplicated. + +## Testing Strategy + +- Unit tests for `BottleMetadata` serialisation with and without the backend + field. +- Unit tests for the backward-compatible default. +- Unit tests for `resume` selecting smolmachines vs Docker from metadata. +- Unit tests for `_bottle_for_slug` selecting smolmachines vs Docker. + +Run: + +- `python3 -m unittest discover -s tests/unit` + +## Open Questions + +None. 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()