feat(supervise): start orchestrator on demand via backend-agnostic bring-up

Add BottleBackend.ensure_orchestrator() -> str: the backend-agnostic
entry point that brings up the per-host orchestrator + shared gateway
(idempotent) and returns its host-reachable control-plane URL. Docker
starts the orchestrator + gateway containers (OrchestratorService);
firecracker boots the infra VM; macos-container dies with a pointer
(no orchestrator). Previously bring-up was reachable only through each
backend's consolidated_launch, with no shared handle.

Wire it into `bot-bottle supervise`: supervise is often the first thing
an operator runs, before any bottle has booted the control plane, so
`_resolve_orchestrator_url` now starts the selected backend's
orchestrator on demand when discovery finds nothing, instead of failing
with "launch a bottle first".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoEZHDjv84ChoZbozQERhJ
This commit is contained in:
2026-07-16 21:52:11 -04:00
parent 265119d601
commit 2641ab70fd
6 changed files with 105 additions and 2 deletions
+12
View File
@@ -511,6 +511,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
del plan del plan
return "" return ""
def ensure_orchestrator(self) -> str:
"""Bring up this backend's per-host orchestrator + shared gateway
(idempotent) and return the host-reachable control-plane URL.
This is the backend-agnostic bring-up entry point: `launch` calls
it as part of starting a bottle, and operator tools (`supervise`)
call it to start the control plane on demand when none is running
yet. Docker starts the orchestrator + gateway containers;
firecracker boots the infra VM. Backends with no orchestrator
(macos-container) die with a pointer — the default here."""
die(f"backend {self.name!r} has no orchestrator control plane")
@abstractmethod @abstractmethod
def prepare_cleanup(self) -> CleanupT: def prepare_cleanup(self) -> CleanupT:
"""Enumerate orphaned resources from previous bottles. No side """Enumerate orphaned resources from previous bottles. No side
+4
View File
@@ -105,6 +105,10 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle yield bottle
def ensure_orchestrator(self) -> str:
from ...orchestrator.lifecycle import OrchestratorService
return OrchestratorService().ensure_running()
def supervise_mcp_url(self, plan: DockerBottlePlan) -> str: def supervise_mcp_url(self, plan: DockerBottlePlan) -> str:
"""Docker bottles reach the supervise daemon via the """Docker bottles reach the supervise daemon via the
compose-network alias `supervise:9100`. No per-bottle URL compose-network alias `supervise:9100`. No per-bottle URL
@@ -110,3 +110,7 @@ class FirecrackerBottleBackend(
def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str: def supervise_mcp_url(self, plan: FirecrackerBottlePlan) -> str:
return plan.agent_supervise_url return plan.agent_supervise_url
def ensure_orchestrator(self) -> str:
from . import infra_vm
return infra_vm.ensure_running().control_plane_url
+24 -2
View File
@@ -65,10 +65,26 @@ ApplyError = (OrchestratorClientError,)
_client_instance: OrchestratorClient | None = None _client_instance: OrchestratorClient | None = None
def _resolve_orchestrator_url() -> str:
"""URL of the running orchestrator control plane, starting one on demand.
Supervise is often the first thing an operator runs — before any bottle
has booted the control plane. So when discovery finds nothing, bring up
the selected backend's orchestrator + gateway (idempotent) rather than
failing with "launch a bottle first"."""
try:
return discover_orchestrator_url()
except OrchestratorClientError:
from ..backend import get_bottle_backend
backend = get_bottle_backend()
info(f"no orchestrator control plane running; starting one ({backend.name})…")
return backend.ensure_orchestrator()
def _client() -> OrchestratorClient: def _client() -> OrchestratorClient:
global _client_instance # noqa: PLW0603 — CLI-session singleton global _client_instance # noqa: PLW0603 — CLI-session singleton
if _client_instance is None: if _client_instance is None:
_client_instance = OrchestratorClient(discover_orchestrator_url()) _client_instance = OrchestratorClient(_resolve_orchestrator_url())
return _client_instance return _client_instance
@@ -204,12 +220,18 @@ def cmd_supervise(argv: list[str]) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Establish the orchestrator connection up front so a missing control # Establish the orchestrator connection up front so a missing control
# plane is a clean one-line error, not a curses crash mid-loop. # plane is a clean one-line error, not a curses crash mid-loop. This also
# starts the orchestrator on demand when none is running (see `_client`).
try: try:
_client() _client()
except OrchestratorClientError as e: except OrchestratorClientError as e:
error(str(e)) error(str(e))
return 1 return 1
except Die as e:
# Backend has no orchestrator to start (e.g. macos-container).
if e.message:
error(e.message)
return e.code if isinstance(e.code, int) else 1
if args.once: if args.once:
return _list_once() return _list_once()
+35
View File
@@ -244,5 +244,40 @@ class TestHasBackend(unittest.TestCase):
self.assertFalse(has_backend("nonexistent")) self.assertFalse(has_backend("nonexistent"))
class TestEnsureOrchestrator(unittest.TestCase):
"""The backend-agnostic orchestrator bring-up entry point. Docker starts
the orchestrator + gateway containers; firecracker boots the infra VM;
backends without one (macos-container) die with a pointer."""
def test_docker_delegates_to_orchestrator_service(self):
b = get_bottle_backend("docker")
with patch(
"bot_bottle.orchestrator.lifecycle.OrchestratorService"
) as service_cls:
service_cls.return_value.ensure_running.return_value = (
"http://127.0.0.1:8099"
)
url = b.ensure_orchestrator()
self.assertEqual(url, "http://127.0.0.1:8099")
service_cls.return_value.ensure_running.assert_called_once_with()
def test_firecracker_delegates_to_infra_vm(self):
b = get_bottle_backend("firecracker")
with patch(
"bot_bottle.backend.firecracker.infra_vm.ensure_running"
) as ensure_running:
ensure_running.return_value.control_plane_url = (
"http://10.243.255.1:8099"
)
url = b.ensure_orchestrator()
self.assertEqual(url, "http://10.243.255.1:8099")
def test_macos_default_dies(self):
from bot_bottle.log import Die
b = get_bottle_backend("macos-container")
with self.assertRaises(Die):
b.ensure_orchestrator()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+26
View File
@@ -176,5 +176,31 @@ class TestEditInEditor(unittest.TestCase):
os.environ["EDITOR"] = original_editor os.environ["EDITOR"] = original_editor
class TestResolveOrchestratorUrl(unittest.TestCase):
"""`_resolve_orchestrator_url` starts the backend orchestrator on demand
when discovery finds nothing — supervise is often the first thing run."""
def test_returns_discovered_url_without_starting(self) -> None:
with patch.object(
supervise_cli, "discover_orchestrator_url",
return_value="http://127.0.0.1:8099",
), patch("bot_bottle.backend.get_bottle_backend") as get_backend:
url = supervise_cli._resolve_orchestrator_url()
self.assertEqual(url, "http://127.0.0.1:8099")
get_backend.assert_not_called() # nothing to start; discovery won
def test_starts_backend_orchestrator_when_none_running(self) -> None:
backend = MagicMock()
backend.name = "firecracker"
backend.ensure_orchestrator.return_value = "http://10.243.255.1:8099"
with patch.object(
supervise_cli, "discover_orchestrator_url",
side_effect=supervise_cli.OrchestratorClientError("none"),
), patch("bot_bottle.backend.get_bottle_backend", return_value=backend):
url = supervise_cli._resolve_orchestrator_url()
self.assertEqual(url, "http://10.243.255.1:8099")
backend.ensure_orchestrator.assert_called_once_with()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()