PRD 0040: backend-aware resume and dashboard reattach #143

Merged
didericis merged 4 commits from prd-0040-backend-aware-resume-and-dashboard into main 2026-06-02 11:23:08 -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
# 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", "")),
)
+1
View File
@@ -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
+1 -2
View File
@@ -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)
+17 -14
View File
@@ -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-<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."""
didericis marked this conversation as resolved Outdated
Outdated
Review

Why the rename? Seems a bit silly: I know smolmachines aren't technically containers, but container seems more generic than "machine"... is there a more generic word we can use?

Why the rename? Seems a bit silly: I know smolmachines aren't technically containers, but container seems more generic than "machine"... is there a more generic word we can use?
Outdated
Review

Fair point. How about instance_name? Both Docker containers and smolmachines VMs are instances of the agent runtime, so that term is accurate for both backends without implying either one specifically.

Line 655 for reference.

Fair point. How about `instance_name`? Both Docker containers and smolmachines VMs are instances of the agent runtime, so that term is accurate for both backends without implying either one specifically. Line 655 for reference.
Outdated
Review

sure

sure
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}"
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 "")
+2
View File
@@ -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,
)
@@ -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)
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()