feat: expose stable Python API for programmatic bottle orchestration
lint / lint (push) Failing after 2m1s
test / unit (pull_request) Successful in 50s
test / integration (pull_request) Successful in 18s
test / coverage (pull_request) Failing after 1m8s

Add bot_bottle/api.py with four public functions the orchestrator uses:
start_headless, resume_headless, freeze, and destroy. These let a
ProgrammaticBottleRunner call directly into bot_bottle instead of
shelling out to the CLI; call sites in lifecycle.py stay unchanged.

Key changes:
- BottleSpec gains forge_env field for forge sidecar credentials
- _launch_bottle returns (slug, exit_code) instead of int so start_headless
  can return the slug to callers
- All four API functions convert Die and non-zero exits to BottleError
- 27 new unit tests; existing tests updated for the new return type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:11:13 +00:00
parent 42004d37fd
commit 520e6f545d
9 changed files with 560 additions and 11 deletions
+10
View File
@@ -1 +1,11 @@
"""bot-bottle: Python implementation of the agent container launcher."""
from .api import BottleError, destroy, freeze, resume_headless, start_headless
__all__ = [
"BottleError",
"destroy",
"freeze",
"resume_headless",
"start_headless",
]
+258
View File
@@ -0,0 +1,258 @@
"""Public Python API for programmatic bottle orchestration.
Stable surface for bot-bottle-orchestrator (and other Python callers) to
drive bottles without invoking the CLI as a subprocess. Every function
converts ``Die`` and non-zero agent exit codes to ``BottleError`` so
callers use exception handling rather than inspecting return values.
The Protocol the orchestrator's ``BottleRunner`` targets looks like::
class BottleRunner(Protocol):
def start(self, agent: str, *, prompt: str, ...) -> str: ...
def resume(self, slug: str, *, prompt: str) -> None: ...
def freeze(self, slug: str) -> None: ...
def destroy(self, slug: str) -> None: ...
A ``SubprocessBottleRunner`` calls ``./cli.py`` for each operation. A
``ProgrammaticBottleRunner`` calls these functions directly; the Protocol
call sites in ``lifecycle.py`` are unchanged.
"""
from __future__ import annotations
from typing import Sequence
from .backend import BottleSpec
from .backend.freeze import CommitCancelled, get_freezer
from .bottle_state import cleanup_state, clear_preserve_marker, read_metadata
from .cli._common import USER_CWD
from .cli.start import _launch_bottle, _peek_agent_bottle, _uniquify_label_headless
from .log import Die
from .manifest import ManifestError, ManifestIndex
class BottleError(Exception):
"""Raised when a bottle operation fails.
``exit_code`` carries the agent process's exit code when the failure is
a non-zero agent exit; 1 for all other failure modes (missing state,
backend errors, etc.)."""
def __init__(self, message: str, *, exit_code: int = 1) -> None:
super().__init__(message)
self.exit_code = exit_code
def start_headless(
agent_name: str,
*,
prompt: str,
bottles: Sequence[str] | None = None,
label: str | None = None,
color: str | None = None,
backend_name: str | None = None,
copy_cwd: bool = False,
forge_env: dict[str, str] | None = None,
user_cwd: str | None = None,
) -> str:
"""Launch a new bottle headlessly. Returns the bottle slug.
``forge_env`` is passed through to the forge sidecar (not the agent)
when the bottle is forge-targeted; it carries the credentials and
context the sidecar needs to call the forge API.
Raises ``BottleError`` on configuration errors or if the agent exits
non-zero. The returned slug can be passed to ``freeze()``,
``resume_headless()``, or ``destroy()`` for subsequent lifecycle
operations."""
cwd = user_cwd or USER_CWD
try:
manifest = ManifestIndex.resolve(cwd)
manifest.require_agent(agent_name)
except (Die, ManifestError) as exc:
raise BottleError(str(exc)) from exc
if bottles:
bottle_names: tuple[str, ...] = tuple(bottles)
else:
default_bottle = _peek_agent_bottle(manifest, agent_name)
if not default_bottle:
raise BottleError(
f"agent '{agent_name}' has no default bottle; "
f"pass bottles=[...]"
)
bottle_names = (default_bottle,)
spec = BottleSpec(
manifest=manifest,
agent_name=agent_name,
copy_cwd=copy_cwd,
user_cwd=cwd,
label=_uniquify_label_headless(label or agent_name),
color=color or "",
bottle_names=bottle_names,
forge_env=dict(forge_env) if forge_env else {},
)
try:
slug, exit_code = _launch_bottle(
spec,
dry_run=False,
backend_name=backend_name,
assume_yes=True,
headless_prompt_text=prompt,
)
except Die as exc:
raise BottleError(exc.message, exit_code=exc.code) from exc
if exit_code != 0:
raise BottleError(
f"agent exited {exit_code} (slug={slug!r})", exit_code=exit_code
)
return slug
def resume_headless(
slug: str,
*,
prompt: str,
backend_name: str | None = None,
forge_env: dict[str, str] | None = None,
) -> None:
"""Resume a frozen bottle headlessly with ``prompt``.
``forge_env`` re-supplies forge context for the new session (the
sidecar is relaunched alongside the agent on resume).
Raises ``BottleError`` on missing state, backend errors, or non-zero
agent exit."""
metadata = read_metadata(slug)
if metadata is None:
raise BottleError(
f"no state recorded for slug {slug!r}; "
f"check ~/.bot-bottle/state/ or call start_headless() to create a new bottle"
)
try:
manifest = ManifestIndex.resolve(metadata.cwd or USER_CWD)
manifest.require_agent(metadata.agent_name)
except (Die, ManifestError) as exc:
raise BottleError(str(exc)) from exc
spec = BottleSpec(
manifest=manifest,
agent_name=metadata.agent_name,
copy_cwd=metadata.copy_cwd,
user_cwd=metadata.cwd or USER_CWD,
identity=metadata.identity,
bottle_names=tuple(metadata.bottle_names),
forge_env=dict(forge_env) if forge_env else {},
)
try:
_, exit_code = _launch_bottle(
spec,
dry_run=False,
backend_name=backend_name or metadata.backend or None,
assume_yes=True,
headless_prompt_text=prompt,
)
except Die as exc:
raise BottleError(exc.message, exit_code=exc.code) from exc
if exit_code != 0:
raise BottleError(
f"agent exited {exit_code} resuming {slug!r}", exit_code=exit_code
)
def freeze(slug: str, *, backend_name: str | None = None) -> None:
"""Freeze the named bottle to a resumable artifact.
Reads the bottle's backend from its metadata when ``backend_name`` is
not supplied. Raises ``BottleError`` if the freeze fails."""
metadata = read_metadata(slug)
resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker"
try:
get_freezer(resolved_backend).commit_slug(slug)
except CommitCancelled as exc:
raise BottleError(f"freeze cancelled for {slug!r}") from exc
except Die as exc:
raise BottleError(exc.message, exit_code=exc.code) from exc
def destroy(slug: str, *, backend_name: str | None = None) -> None:
"""Destroy the named bottle, removing all resources and state.
Brings down any running resources for ``slug``, then removes the
per-bottle state directory. Idempotent: a slug with no running
resources or no state directory is not an error."""
metadata = read_metadata(slug)
resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker"
try:
if resolved_backend == "docker":
_destroy_docker(slug)
elif resolved_backend == "smolmachines":
_destroy_smolmachines(slug)
# macos-container: the container is torn down inside the launch
# context manager; no persistent VM survives, so nothing extra is
# needed at destroy time beyond the state-dir removal below.
except Die as exc:
raise BottleError(exc.message, exit_code=exc.code) from exc
clear_preserve_marker(slug)
cleanup_state(slug)
# --- backend-specific helpers -----------------------------------------------
def _destroy_docker(slug: str) -> None:
"""Best-effort ``docker compose down`` for a Docker bottle.
No-op when the compose file is absent — the project was already
brought down (normal for a frozen bottle) or was never created."""
from .backend.docker.compose import (
compose_down,
compose_file_path,
compose_project_name,
)
from .bottle_state import bottle_state_dir
state_dir = bottle_state_dir(slug)
compose_file = compose_file_path(state_dir)
if compose_file.exists():
compose_down(compose_project_name(slug), compose_file)
def _destroy_smolmachines(slug: str) -> None:
"""Best-effort stop + delete for a smolmachines bottle.
Both steps are best-effort: a machine that is already gone does not
cause an error; partial failures are logged as warnings."""
import subprocess
from .log import warn
machine = f"bot-bottle-{slug}"
subprocess.run(
["smolvm", "machine", "stop", "--name", machine],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
r = subprocess.run(
["smolvm", "machine", "delete", "-f", machine],
capture_output=True,
text=True,
check=False,
)
if r.returncode != 0:
warn(
f"smolvm machine delete -f {machine!r} failed "
f"(may already be gone): {(r.stderr or '').strip()}"
)
__all__ = [
"BottleError",
"destroy",
"freeze",
"resume_headless",
"start_headless",
]
+6 -1
View File
@@ -37,7 +37,7 @@ import shlex
import sys
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar
@@ -75,6 +75,11 @@ class BottleSpec:
# Ordered bottle names selected at launch (issue #269). When non-empty
# they are merged in order and replace the agent's `bottle:` field.
bottle_names: tuple[str, ...] = ()
# Forge sidecar env vars (PRD forge-native-integration, chunk 1).
# Passed by the orchestrator at launch time; the forge sidecar reads
# them to connect to Gitea. Empty for non-forge runs. The agent
# process itself does not receive these.
forge_env: dict[str, str] = field(default_factory=dict)
@dataclass(frozen=True)
+2 -1
View File
@@ -74,10 +74,11 @@ def cmd_resume(argv: list[str]) -> int:
bottle_names=tuple(metadata.bottle_names),
)
backend_name = metadata.backend or None
return _launch_bottle(
_, rc = _launch_bottle(
spec,
dry_run=args.dry_run,
backend_name=backend_name,
assume_yes=args.headless,
headless_prompt_text=args.prompt or "",
)
return rc
+13 -5
View File
@@ -144,11 +144,12 @@ def cmd_start(argv: list[str]) -> int:
color=color,
bottle_names=bottle_names,
)
return _launch_bottle(
_, rc = _launch_bottle(
spec,
dry_run=dry_run,
backend_name=backend_name,
)
return rc
# --- Headless launch -----------------------------------------------------
@@ -203,13 +204,14 @@ def _start_headless(
color=args.color or "",
bottle_names=bottle_names,
)
return _launch_bottle(
_, rc = _launch_bottle(
spec,
dry_run=dry_run,
backend_name=backend_name,
assume_yes=True,
headless_prompt_text=prompt,
)
return rc
def _uniquify_label_headless(label: str) -> str:
@@ -497,11 +499,16 @@ def _launch_bottle(
backend_name: str | None = None,
assume_yes: bool = False,
headless_prompt_text: str = "",
) -> int:
) -> tuple[str, int]:
"""Shared launch core for `start` and `resume`. Builds the plan,
prints / dry-runs / prompts as appropriate, brings the bottle up,
attaches claude, and prints the resume hint on session end.
Returns ``(slug, exit_code)`` where ``slug`` is the bottle identity
(empty string when the launch was aborted before a slug was minted)
and ``exit_code`` is the agent process's exit code (0 on clean exit
or when launch was aborted before the agent ran).
`assume_yes` skips the interactive y/N confirmation (headless /
orchestrator launches), where there is no human at the prompt.
@@ -510,6 +517,7 @@ def _launch_bottle(
agent receives the initial task without interactive input."""
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
identity = ""
exit_code = 0
try:
plan, identity = prepare_with_preflight(
spec,
@@ -520,7 +528,7 @@ def _launch_bottle(
backend_name=backend_name,
)
if plan is None:
return 0
return identity, 0
backend = get_bottle_backend(backend_name)
with backend.launch(plan) as bottle:
@@ -547,7 +555,7 @@ def _launch_bottle(
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
if agent_provider_template == "claude":
capture_claude_session_state(identity, exit_code)
return 0
return identity, exit_code
finally:
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
# sources under state/<slug>/. If we never reached the
+267
View File
@@ -0,0 +1,267 @@
"""Unit: bot_bottle public Python API (bot_bottle/__init__.py surface).
Covers start_headless, resume_headless, freeze, and destroy — the four
operations the bot-bottle-orchestrator's ProgrammaticBottleRunner uses.
All I/O is stubbed so no container is created.
"""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from bot_bottle import BottleError, destroy, freeze, resume_headless, start_headless
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _make_manifest(agent_name: str = "implementer", bottle_name: str = "claude"):
manifest = MagicMock()
manifest.agents = {agent_name: MagicMock(bottle=bottle_name)}
manifest.all_agent_names = [agent_name]
manifest.all_bottle_names = [bottle_name]
manifest.home_md = None # eager mode — _peek_agent_bottle uses agents dict
manifest.require_agent = MagicMock(return_value=None)
return manifest
def _metadata(
slug: str = "implementer-abc12",
agent_name: str = "implementer",
backend: str = "docker",
):
md = MagicMock()
md.identity = slug
md.agent_name = agent_name
md.cwd = "/repo"
md.copy_cwd = False
md.bottle_names = ["claude"]
md.backend = backend
return md
# ---------------------------------------------------------------------------
# start_headless
# ---------------------------------------------------------------------------
class TestStartHeadless(unittest.TestCase):
def setUp(self) -> None:
self._manifest = _make_manifest()
patch("bot_bottle.api.ManifestIndex.resolve", return_value=self._manifest).start()
self._launch = patch(
"bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0)
).start()
patch(
"bot_bottle.api._uniquify_label_headless", side_effect=lambda lbl: lbl
).start()
self.addCleanup(patch.stopall)
def _spec(self):
self._launch.assert_called_once()
return self._launch.call_args[0][0]
def test_returns_slug_on_success(self):
slug = start_headless("implementer", prompt="Do it")
self.assertEqual("implementer-abc12", slug)
def test_passes_assume_yes_and_prompt(self):
start_headless("implementer", prompt="Do it")
kwargs = self._launch.call_args[1]
self.assertTrue(kwargs["assume_yes"])
self.assertEqual("Do it", kwargs["headless_prompt_text"])
def test_explicit_bottles_forwarded(self):
start_headless("implementer", prompt="Do it", bottles=["dev", "claude"])
self.assertEqual(("dev", "claude"), self._spec().bottle_names)
def test_default_bottle_resolved_from_manifest(self):
start_headless("implementer", prompt="Do it")
self.assertEqual(("claude",), self._spec().bottle_names)
def test_forge_env_on_spec(self):
env = {"FORGE_GITEA_API": "https://gitea.example.com/api/v1", "FORGE_OWNER": "acme"}
start_headless("implementer", prompt="Do it", forge_env=env)
self.assertEqual(env, self._spec().forge_env)
def test_no_forge_env_defaults_to_empty_dict(self):
start_headless("implementer", prompt="Do it")
self.assertEqual({}, self._spec().forge_env)
def test_nonzero_exit_raises_bottle_error(self):
self._launch.return_value = ("implementer-abc12", 1)
with self.assertRaises(BottleError) as ctx:
start_headless("implementer", prompt="Do it")
self.assertEqual(1, ctx.exception.exit_code)
def test_no_default_bottle_raises_bottle_error(self):
manifest = _make_manifest(bottle_name="")
with patch("bot_bottle.api.ManifestIndex.resolve", return_value=manifest):
with self.assertRaises(BottleError):
start_headless("implementer", prompt="Do it")
self._launch.assert_not_called()
def test_backend_name_forwarded(self):
start_headless("implementer", prompt="Do it", backend_name="docker")
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
def test_label_forwarded_to_spec(self):
start_headless("implementer", prompt="Do it", label="nightly")
self.assertEqual("nightly", self._spec().label)
def test_color_forwarded_to_spec(self):
start_headless("implementer", prompt="Do it", color="green")
self.assertEqual("green", self._spec().color)
# ---------------------------------------------------------------------------
# resume_headless
# ---------------------------------------------------------------------------
class TestResumeHeadless(unittest.TestCase):
def setUp(self) -> None:
self._md = _metadata()
patch("bot_bottle.api.read_metadata", return_value=self._md).start()
manifest = _make_manifest()
patch("bot_bottle.api.ManifestIndex.resolve", return_value=manifest).start()
self._launch = patch(
"bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0)
).start()
self.addCleanup(patch.stopall)
def _spec(self):
self._launch.assert_called_once()
return self._launch.call_args[0][0]
def test_passes_assume_yes_and_prompt(self):
resume_headless("implementer-abc12", prompt="Address review")
kwargs = self._launch.call_args[1]
self.assertTrue(kwargs["assume_yes"])
self.assertEqual("Address review", kwargs["headless_prompt_text"])
def test_identity_set_on_spec(self):
resume_headless("implementer-abc12", prompt="Prompt")
self.assertEqual("implementer-abc12", self._spec().identity)
def test_forge_env_on_spec(self):
env = {"FORGE_ISSUE_NUMBER": "42"}
resume_headless("implementer-abc12", prompt="Prompt", forge_env=env)
self.assertEqual(env, self._spec().forge_env)
def test_missing_state_raises_bottle_error(self):
with patch("bot_bottle.api.read_metadata", return_value=None):
with self.assertRaises(BottleError):
resume_headless("no-such-abc12", prompt="Prompt")
self._launch.assert_not_called()
def test_nonzero_exit_raises_bottle_error(self):
self._launch.return_value = ("implementer-abc12", 2)
with self.assertRaises(BottleError) as ctx:
resume_headless("implementer-abc12", prompt="Prompt")
self.assertEqual(2, ctx.exception.exit_code)
def test_backend_from_metadata_when_not_supplied(self):
resume_headless("implementer-abc12", prompt="Prompt")
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
def test_explicit_backend_overrides_metadata(self):
resume_headless(
"implementer-abc12", prompt="Prompt", backend_name="smolmachines"
)
self.assertEqual("smolmachines", self._launch.call_args[1]["backend_name"])
# ---------------------------------------------------------------------------
# freeze
# ---------------------------------------------------------------------------
class TestFreeze(unittest.TestCase):
def setUp(self) -> None:
patch("bot_bottle.api.read_metadata", return_value=_metadata()).start()
self._freezer = MagicMock()
self._get_freezer = patch(
"bot_bottle.api.get_freezer", return_value=self._freezer
).start()
self.addCleanup(patch.stopall)
def test_calls_commit_slug(self):
freeze("implementer-abc12")
self._freezer.commit_slug.assert_called_once_with("implementer-abc12")
def test_backend_from_metadata_when_not_supplied(self):
freeze("implementer-abc12")
self._get_freezer.assert_called_once_with("docker")
def test_explicit_backend_used(self):
freeze("implementer-abc12", backend_name="smolmachines")
self._get_freezer.assert_called_once_with("smolmachines")
def test_commit_cancelled_raises_bottle_error(self):
from bot_bottle.backend.freeze import CommitCancelled
self._freezer.commit_slug.side_effect = CommitCancelled("declined")
with self.assertRaises(BottleError):
freeze("implementer-abc12")
# ---------------------------------------------------------------------------
# destroy
# ---------------------------------------------------------------------------
class TestDestroy(unittest.TestCase):
def setUp(self) -> None:
patch("bot_bottle.api.read_metadata", return_value=_metadata()).start()
self._dd = patch("bot_bottle.api._destroy_docker").start()
patch("bot_bottle.api.clear_preserve_marker").start()
self._cleanup = patch("bot_bottle.api.cleanup_state").start()
self.addCleanup(patch.stopall)
def test_docker_backend_calls_destroy_docker(self):
destroy("implementer-abc12")
self._dd.assert_called_once_with("implementer-abc12")
def test_state_dir_always_cleaned(self):
destroy("implementer-abc12")
self._cleanup.assert_called_once_with("implementer-abc12")
def test_smolmachines_backend_calls_destroy_smolmachines(self):
patch(
"bot_bottle.api.read_metadata",
return_value=_metadata(backend="smolmachines"),
).start()
ds = patch("bot_bottle.api._destroy_smolmachines").start()
destroy("implementer-abc12")
ds.assert_called_once_with("implementer-abc12")
self._dd.assert_not_called()
def test_missing_metadata_defaults_to_docker(self):
patch("bot_bottle.api.read_metadata", return_value=None).start()
destroy("no-state-abc12")
self._dd.assert_called_once_with("no-state-abc12")
def test_explicit_backend_overrides_metadata(self):
ds = patch("bot_bottle.api._destroy_smolmachines").start()
destroy("implementer-abc12", backend_name="smolmachines")
ds.assert_called_once_with("implementer-abc12")
self._dd.assert_not_called()
# ---------------------------------------------------------------------------
# public surface exported from bot_bottle.__init__
# ---------------------------------------------------------------------------
class TestPublicSurface(unittest.TestCase):
def test_importable_from_package(self):
import bot_bottle
for name in ("BottleError", "start_headless", "resume_headless", "freeze", "destroy"):
self.assertTrue(hasattr(bot_bottle, name), f"missing: {name}")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -29,7 +29,7 @@ def _metadata():
class ResumeHeadlessTest(unittest.TestCase):
def setUp(self) -> None:
self._launch = patch.object(
resume_mod, "_launch_bottle", return_value=0
resume_mod, "_launch_bottle", return_value=("implementer-abc12", 0)
).start()
patch.object(
resume_mod, "read_metadata", return_value=_metadata()
+1 -1
View File
@@ -56,7 +56,7 @@ class TestCmdStartHeadless(unittest.TestCase):
return_value=self._manifest,
).start()
self._launch_mock = patch(
"bot_bottle.cli.start._launch_bottle", return_value=0
"bot_bottle.cli.start._launch_bottle", return_value=("", 0)
).start()
# No bottles running by default → no label collision.
patch(
+2 -2
View File
@@ -45,7 +45,7 @@ class TestCmdStartSelector(unittest.TestCase):
self._launch_patch = patch(
"bot_bottle.cli.start._launch_bottle",
return_value=0,
return_value=("", 0),
)
self._launch_mock = self._launch_patch.start()
@@ -211,7 +211,7 @@ class TestCmdStartLabelCollision(unittest.TestCase):
self._manifest = _make_manifest(["researcher"], ["claude"])
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start()
self._launch_mock = patch(
"bot_bottle.cli.start._launch_bottle", return_value=0,
"bot_bottle.cli.start._launch_bottle", return_value=("", 0),
).start()
# Stub the bottle picker to always return a selection.
patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start()