"""Bottle runner: drive bot_bottle to manage a bottle's life. `BottleRunner` is the interface the lifecycle depends on; `ProgrammaticBottleRunner` calls into the bot_bottle Python API directly (no subprocess). The slug returned by `start` is the actual slug minted at launch time — not a post-hoc derivation from the label — so it is authoritative even if bot-bottle's slugification logic changes. `slugify` is retained for `FakeRunner` (tests) and for the label scheme the orchestrator uses to predict collision-free slugs. """ from __future__ import annotations import re from collections.abc import Sequence from typing import Protocol class BottleRunner(Protocol): def start( self, *, agent: str, bottles: Sequence[str], label: str, prompt: str, forge_env: dict[str, str], ) -> str: ... def freeze(self, slug: str) -> None: ... def resume(self, slug: str, prompt: str) -> None: ... def destroy(self, slug: str) -> None: ... _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("-") class ProgrammaticBottleRunner: """Calls into the bot_bottle Python API directly — no subprocess. Imports are deferred to call time so tests can inject a mock into sys.modules['bot_bottle.api'] before calling runner methods. bot_bottle.api is added in the forge-native-integration PR (#318), which merges before this one.""" def start( self, *, agent: str, bottles: Sequence[str], label: str, prompt: str, forge_env: dict[str, str], ) -> str: from bot_bottle import api # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module return api.start_headless( agent, prompt=prompt, bottles=list(bottles) or None, label=label, forge_env=forge_env, ) def freeze(self, slug: str) -> None: from bot_bottle import api # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module api.freeze(slug) def resume(self, slug: str, prompt: str) -> None: from bot_bottle import api # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module api.resume_headless(slug, prompt=prompt) def destroy(self, slug: str) -> None: from bot_bottle import api # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module api.destroy(slug)