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:
@@ -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", "")),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -70,9 +70,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)
|
||||
|
||||
+17
-14
@@ -640,23 +640,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-<slug>`. 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-<slug>` — 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:
|
||||
@@ -666,11 +662,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 "")
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user