314dc03b0d
Moves the orchestrator into bot_bottle/orchestrator/ so one install gets everything. Entry point is now `python -m bot_bottle.orchestrator run`. - Add bot_bottle/orchestrator/ with all 14 modules (verbatim move; internal imports were already relative, so no changes inside orchestrator modules) - Rewrite bootstrap.py: remove the lazy bot_bottle import guard, use direct relative imports from ..contrib.* - Add bot_bottle/contrib/forge/base.py: ScopedForge (read-anywhere / write-scoped) - Add bot_bottle/contrib/gitea/client.py: GiteaClient + GiteaForge (urllib.request only) - Add bot_bottle/contrib/gitea/forge_state.py: ForgeState + SqliteForgeStateStore - Add tests/unit/orchestrator/ (82 tests: 63 migrated + 19 new for contrib modules) Closes #321
119 lines
3.5 KiB
Python
119 lines
3.5 KiB
Python
"""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)
|