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
This commit is contained in:
2026-06-02 14:43:12 +00:00
parent 00cf17de9e
commit 9df3922180
6 changed files with 133 additions and 16 deletions
@@ -105,6 +105,10 @@ class BottleMetadata:
# written before chunk 3 (resume / inspect should fall back to # written before chunk 3 (resume / inspect should fall back to
# deriving from identity in that case). # deriving from identity in that case).
compose_project: str = "" 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: 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)), copy_cwd=bool(raw.get("copy_cwd", False)),
started_at=str(raw.get("started_at", "")), started_at=str(raw.get("started_at", "")),
compose_project=str(raw.get("compose_project", "")), compose_project=str(raw.get("compose_project", "")),
backend=str(raw.get("backend", "")),
) )
+1
View File
@@ -79,6 +79,7 @@ def resolve_plan(
copy_cwd=spec.copy_cwd, copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(), started_at=datetime.now(timezone.utc).isoformat(),
compose_project=f"bot-bottle-{slug}", compose_project=f"bot-bottle-{slug}",
backend="docker",
)) ))
# Clear any leftover preserve marker from a prior capability-block # Clear any leftover preserve marker from a prior capability-block
# so this fresh launch can be cleaned up at session-end unless # so this fresh launch can be cleaned up at session-end unless
+1 -2
View File
@@ -70,9 +70,8 @@ def resolve_plan(
cwd=spec.user_cwd if spec.copy_cwd else "", cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd, copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(), 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="", compose_project="",
backend="smolmachines",
)) ))
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
+13 -10
View File
@@ -640,23 +640,19 @@ def _bottle_for_slug(
) -> tuple["object", str]: ) -> tuple["object", str]:
"""Return `(bottle_handle, prompt_path_hint)` for a re-attach. """Return `(bottle_handle, prompt_path_hint)` for a re-attach.
If the slug is in `bottles` (dashboard-owned), return the stored If the slug is in `bottles` (dashboard-owned), return the stored
handle directly. Otherwise synthesize a `DockerBottle` from the handle directly. Otherwise synthesize a bottle from the persisted
container name `bot-bottle-<slug>`. For synthesized bottles metadata. The backend field in metadata (PRD 0040) selects Docker
the prompt-file path comes from the manifest's agent if we can or smolmachines; unknown or missing metadata defaults to Docker.
resolve it via metadata.json + the loaded manifest; otherwise
the re-attach runs without `--append-system-prompt-file`.
Returns the empty string for prompt_path_hint when we omit the Returns the empty string for prompt_path_hint when we omit the
flag the caller passes None to DockerBottle in that case.""" flag the caller passes None to DockerBottle in that case."""
from ..backend.docker.bottle import DockerBottle from ..backend.docker.bottle import DockerBottle
from ..backend.docker.bottle_state import read_metadata from ..backend.docker.bottle_state import read_metadata
from ..backend.smolmachines.bottle import SmolmachinesBottle
if slug in bottles: if slug in bottles:
_cm, bottle, _identity = bottles[slug] _cm, bottle, _identity = bottles[slug]
return bottle, "" return bottle, ""
# The container hosting the agent's agent process is named machine_name = f"bot-bottle-{slug}"
# `bot-bottle-<slug>` — set by the compose renderer
# (no service suffix on the agent service, by design).
container_name = f"bot-bottle-{slug}"
prompt_path: str | None = None prompt_path: str | None = None
metadata = read_metadata(slug) metadata = read_metadata(slug)
if metadata is not None and manifest is not None: if metadata is not None and manifest is not None:
@@ -666,8 +662,15 @@ def _bottle_for_slug(
"BOT_BOTTLE_CONTAINER_HOME", "/home/node", "BOT_BOTTLE_CONTAINER_HOME", "/home/node",
) )
prompt_path = f"{container_home}/.bot-bottle-prompt.txt" prompt_path = f"{container_home}/.bot-bottle-prompt.txt"
backend = metadata.backend if metadata is not None else ""
if backend == "smolmachines":
synth: object = SmolmachinesBottle(
machine_name,
prompt_path=prompt_path,
)
else:
synth = DockerBottle( synth = DockerBottle(
container=container_name, container=machine_name,
teardown=lambda: None, teardown=lambda: None,
prompt_path_in_container=prompt_path, prompt_path_in_container=prompt_path,
) )
+2
View File
@@ -52,8 +52,10 @@ def cmd_resume(argv: list[str]) -> int:
user_cwd=metadata.cwd or USER_CWD, user_cwd=metadata.cwd or USER_CWD,
identity=metadata.identity, identity=metadata.identity,
) )
backend_name = metadata.backend or None
return _launch_bottle( return _launch_bottle(
spec, spec,
dry_run=args.dry_run, dry_run=args.dry_run,
remote_control=args.remote_control, remote_control=args.remote_control,
backend_name=backend_name,
) )
+107
View File
@@ -216,5 +216,112 @@ class TestBottleMetadata(_FakeHomeMixin, unittest.TestCase):
self.assertEqual("t2", loaded.started_at) 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__": if __name__ == "__main__":
unittest.main() unittest.main()