refactor(orchestrator): swap SubprocessBottleRunner → ProgrammaticBottleRunner
BottleRunner Protocol tightened: start() → str, freeze/resume/destroy → None. RunResult removed. lifecycle.py unpacks the slug directly. FakeRunner and test_runner updated to match. Config.bot_bottle_cli dropped (nothing uses it). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +1,22 @@
|
||||
"""Bottle runner: drive the bot-bottle CLI to manage a bottle's life.
|
||||
"""Bottle runner: drive bot_bottle 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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
`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
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Sequence
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunResult:
|
||||
slug: str
|
||||
exit_code: int
|
||||
|
||||
|
||||
class BottleRunner(Protocol):
|
||||
def start(
|
||||
self,
|
||||
@@ -35,13 +26,13 @@ class BottleRunner(Protocol):
|
||||
label: str,
|
||||
prompt: str,
|
||||
forge_env: dict[str, str],
|
||||
) -> RunResult: ...
|
||||
) -> str: ...
|
||||
|
||||
def freeze(self, slug: str) -> int: ...
|
||||
def freeze(self, slug: str) -> None: ...
|
||||
|
||||
def resume(self, slug: str, prompt: str) -> RunResult: ...
|
||||
def resume(self, slug: str, prompt: str) -> None: ...
|
||||
|
||||
def destroy(self, slug: str) -> int: ...
|
||||
def destroy(self, slug: str) -> None: ...
|
||||
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
@@ -53,34 +44,12 @@ def slugify(label: str) -> str:
|
||||
return _SLUG_RE.sub("-", label.lower()).strip("-")
|
||||
|
||||
|
||||
# A subprocess.run-shaped callable, injectable for tests.
|
||||
RunFn = Callable[[Sequence[str], dict[str, str]], int]
|
||||
class ProgrammaticBottleRunner:
|
||||
"""Calls into the bot_bottle Python API directly — no subprocess.
|
||||
|
||||
|
||||
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]
|
||||
Imports are deferred to call time so this module can be imported
|
||||
before `bot_bottle.api` is available (e.g. in isolated test runs
|
||||
that mock the API surface)."""
|
||||
|
||||
def start(
|
||||
self,
|
||||
@@ -90,29 +59,24 @@ class SubprocessBottleRunner:
|
||||
label: str,
|
||||
prompt: str,
|
||||
forge_env: dict[str, str],
|
||||
) -> RunResult:
|
||||
argv = self._argv(
|
||||
"start", agent, "--headless", "--label", label, "--prompt", prompt
|
||||
) -> str:
|
||||
import bot_bottle.api as api
|
||||
return api.start_headless(
|
||||
agent,
|
||||
prompt=prompt,
|
||||
bottles=list(bottles) or None,
|
||||
label=label,
|
||||
forge_env=forge_env,
|
||||
)
|
||||
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 freeze(self, slug: str) -> None:
|
||||
import bot_bottle.api as api
|
||||
api.freeze(slug)
|
||||
|
||||
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 resume(self, slug: str, prompt: str) -> None:
|
||||
import bot_bottle.api as api
|
||||
api.resume_headless(slug, prompt=prompt)
|
||||
|
||||
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)
|
||||
def destroy(self, slug: str) -> None:
|
||||
import bot_bottle.api as api
|
||||
api.destroy(slug)
|
||||
|
||||
Reference in New Issue
Block a user