Compare commits

...

4 Commits

Author SHA1 Message Date
didericis-claude 83c45d772a refactor: rename machine_name to instance_name in _bottle_for_slug
test / unit (pull_request) Successful in 32s
test / integration (pull_request) Successful in 42s
2026-06-02 15:01:41 +00:00
didericis-claude 1c3b4cca08 docs: mark PRD 0040 Active
test / unit (pull_request) Successful in 42s
test / integration (pull_request) Successful in 1m3s
2026-06-02 14:43:23 +00:00
didericis-claude 9df3922180 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
2026-06-02 14:43:12 +00:00
didericis-claude 00cf17de9e docs: add PRD 0040
test / unit (pull_request) Successful in 41s
test / integration (pull_request) Successful in 57s
2026-06-02 10:28:16 -04:00
7 changed files with 220 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 instance_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(
instance_name,
prompt_path=prompt_path,
)
else:
synth = DockerBottle( synth = DockerBottle(
container=container_name, container=instance_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,
) )
@@ -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.
+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()