"""Bottle runner: drive the bot-bottle CLI to manage a bottle's life. `BottleRunner` is the interface the lifecycle depends on; `SubprocessBottleRunner` shells out to the bot-bottle `cli.py` (`start --headless`, `commit`, `resume --headless`). The subprocess callable is injectable so tests never spawn a process. The slug is derived from the label via `slugify`, matching bot-bottle's container-slug rule; the orchestrator picks labels that embed the issue identity so slugs are unique and collisions never rename them. """ from __future__ import annotations import re import subprocess import sys from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Protocol @dataclass(frozen=True) class RunResult: slug: str exit_code: int class BottleRunner(Protocol): def start( self, *, agent: str, bottles: Sequence[str], label: str, prompt: str, forge_env: dict[str, str], ) -> RunResult: ... def freeze(self, slug: str) -> int: ... def resume(self, slug: str, prompt: str) -> RunResult: ... def destroy(self, slug: str) -> int: ... _SLUG_RE = re.compile(r"[^a-z0-9]+") def slugify(label: str) -> str: """Lowercase, collapse non-alphanumerics to single hyphens, strip leading/trailing hyphens — matches bot-bottle's slug rule.""" return _SLUG_RE.sub("-", label.lower()).strip("-") # A subprocess.run-shaped callable, injectable for tests. RunFn = Callable[[Sequence[str], dict[str, str]], int] def _default_run(argv: Sequence[str], env: dict[str, str]) -> int: return subprocess.run(list(argv), env=env, check=False).returncode class SubprocessBottleRunner: """Shells the bot-bottle CLI. `cli` is the path to `cli.py`; `python` is the interpreter to run it with; `base_env` is the environment the child inherits (the orchestrator's, minus per-run additions).""" def __init__( self, *, cli: str, base_env: dict[str, str], python: str = sys.executable, run: RunFn = _default_run, ) -> None: self._cli = cli self._python = python self._base_env = base_env self._run = run def _argv(self, *args: str) -> list[str]: return [self._python, self._cli, *args] def start( self, *, agent: str, bottles: Sequence[str], label: str, prompt: str, forge_env: dict[str, str], ) -> RunResult: argv = self._argv( "start", agent, "--headless", "--label", label, "--prompt", prompt ) for bottle in bottles: argv += ["--bottle", bottle] code = self._run(argv, {**self._base_env, **forge_env}) return RunResult(slug=slugify(label), exit_code=code) def freeze(self, slug: str) -> int: # bot-bottle's `commit` snapshots a running bottle's state. return self._run(self._argv("commit", slug), self._base_env) def resume(self, slug: str, prompt: str) -> RunResult: code = self._run( self._argv("resume", slug, "--headless", "--prompt", prompt), self._base_env, ) return RunResult(slug=slug, exit_code=code) def destroy(self, slug: str) -> int: # NOTE: bot-bottle `cleanup` currently targets all bottles; a # per-slug teardown command is a known integration follow-up # (tracked in docs/JOURNAL.md). Kept behind this method so the # call site does not change when that lands. return self._run(self._argv("cleanup", slug), self._base_env)