e6cafa39e0
Cast Die.code (_ExitCode) to int before passing to BottleError — Die always holds an int but SystemExit.code is typed as str|int|None in typeshed. Replace untyped lambda with str() as identity for _uniquify_label_headless mock (fixes reportUnknownLambdaType). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
8.7 KiB
Python
259 lines
8.7 KiB
Python
"""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, cast
|
|
|
|
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=cast(int, 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=cast(int, 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=cast(int, 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=cast(int, 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",
|
|
]
|