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
+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