refactor(orchestrator): swap SubprocessBottleRunner → ProgrammaticBottleRunner
lint / lint (push) Failing after 2m15s
test / unit (pull_request) Successful in 51s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m7s

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:
2026-07-01 19:47:57 +00:00
parent 71699b3ecd
commit d5fb159857
7 changed files with 104 additions and 133 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ from ..contrib.gitea.forge_state import ForgeState, SqliteForgeStateStore
from .config import Config
from .lifecycle import Orchestrator
from .model import RunRecord
from .runner import SubprocessBottleRunner
from .runner import ProgrammaticBottleRunner
from .sidecar import ForgeSidecar, OpLog, drain_done_events
from .watchdog import Watchdog
from .webhook import WebhookServer
@@ -104,7 +104,7 @@ def make_sidecar(
def build(config: Config) -> tuple[WebhookServer, Watchdog, Orchestrator]:
store = BotBottleStateStore(config.db_path)
runner = SubprocessBottleRunner(cli=config.bot_bottle_cli, base_env=dict(os.environ))
runner = ProgrammaticBottleRunner()
membership_forge = make_forge(config, "_", "_")
orchestrator = Orchestrator(
forge=membership_forge,
-2
View File
@@ -26,7 +26,6 @@ class Config:
watchdog_timeout_secs: int
webhook_host: str
webhook_port: int
bot_bottle_cli: str
queue_dir: Path
sidecar_socket: Path
db_path: Path | None
@@ -43,7 +42,6 @@ class Config:
watchdog_timeout_secs=int(e.get("FORGE_WATCHDOG_TIMEOUT", "1800")),
webhook_host=e.get("FORGE_WEBHOOK_HOST", "127.0.0.1"),
webhook_port=int(e.get("FORGE_WEBHOOK_PORT", "8477")),
bot_bottle_cli=e.get("BOT_BOTTLE_CLI", "cli.py"),
queue_dir=Path(e.get("FORGE_QUEUE_DIR", str(default_root / "forge-queue"))),
sidecar_socket=Path(
e.get("FORGE_SIDECAR_SOCKET", str(default_root / "forge-sidecar.sock"))
+2 -2
View File
@@ -110,7 +110,7 @@ class Orchestrator:
def _launch(self, event: IssueAssigned, target: Target) -> None:
label = self._label_for(target.agent_name, event)
bottles = [target.bottle_override] if target.bottle_override else []
result = self._runner.start(
slug = self._runner.start(
agent=target.agent_name,
bottles=bottles,
label=label,
@@ -122,7 +122,7 @@ class Orchestrator:
owner=event.owner,
repo=event.repo,
issue_number=event.issue_number,
slug=result.slug,
slug=slug,
agent_name=target.agent_name,
bottle_names=bottles,
status=STATUS_RUNNING,
+34 -70
View File
@@ -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)