Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83cb64378c |
@@ -1,11 +1 @@
|
||||
"""bot-bottle: Python implementation of the agent container launcher."""
|
||||
|
||||
from .api import BottleError, destroy, freeze, resume_headless, start_headless
|
||||
|
||||
__all__ = [
|
||||
"BottleError",
|
||||
"destroy",
|
||||
"freeze",
|
||||
"resume_headless",
|
||||
"start_headless",
|
||||
]
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"""Public Python API for programmatic bottle orchestration.
|
||||
|
||||
Stable surface for bot-bottle-orchestrator (and other Python callers) to
|
||||
drive bottles without invoking the CLI as a subprocess. Every function
|
||||
converts ``Die`` and non-zero agent exit codes to ``BottleError`` so
|
||||
callers use exception handling rather than inspecting return values.
|
||||
|
||||
The Protocol the orchestrator's ``BottleRunner`` targets looks like::
|
||||
|
||||
class BottleRunner(Protocol):
|
||||
def start(self, agent: str, *, prompt: str, ...) -> str: ...
|
||||
def resume(self, slug: str, *, prompt: str) -> None: ...
|
||||
def freeze(self, slug: str) -> None: ...
|
||||
def destroy(self, slug: str) -> None: ...
|
||||
|
||||
A ``SubprocessBottleRunner`` calls ``./cli.py`` for each operation. A
|
||||
``ProgrammaticBottleRunner`` calls these functions directly; the Protocol
|
||||
call sites in ``lifecycle.py`` are unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, cast
|
||||
|
||||
from .backend import BottleSpec
|
||||
from .backend.freeze import CommitCancelled, get_freezer
|
||||
from .bottle_state import cleanup_state, clear_preserve_marker, read_metadata
|
||||
from .cli._common import USER_CWD
|
||||
from .cli.start import _launch_bottle, _peek_agent_bottle, _uniquify_label_headless
|
||||
from .log import Die
|
||||
from .manifest import ManifestError, ManifestIndex
|
||||
|
||||
|
||||
class BottleError(Exception):
|
||||
"""Raised when a bottle operation fails.
|
||||
|
||||
``exit_code`` carries the agent process's exit code when the failure is
|
||||
a non-zero agent exit; 1 for all other failure modes (missing state,
|
||||
backend errors, etc.)."""
|
||||
|
||||
def __init__(self, message: str, *, exit_code: int = 1) -> None:
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
def start_headless(
|
||||
agent_name: str,
|
||||
*,
|
||||
prompt: str,
|
||||
bottles: Sequence[str] | None = None,
|
||||
label: str | None = None,
|
||||
color: str | None = None,
|
||||
backend_name: str | None = None,
|
||||
copy_cwd: bool = False,
|
||||
forge_env: dict[str, str] | None = None,
|
||||
user_cwd: str | None = None,
|
||||
) -> str:
|
||||
"""Launch a new bottle headlessly. Returns the bottle slug.
|
||||
|
||||
``forge_env`` is passed through to the forge sidecar (not the agent)
|
||||
when the bottle is forge-targeted; it carries the credentials and
|
||||
context the sidecar needs to call the forge API.
|
||||
|
||||
Raises ``BottleError`` on configuration errors or if the agent exits
|
||||
non-zero. The returned slug can be passed to ``freeze()``,
|
||||
``resume_headless()``, or ``destroy()`` for subsequent lifecycle
|
||||
operations."""
|
||||
cwd = user_cwd or USER_CWD
|
||||
try:
|
||||
manifest = ManifestIndex.resolve(cwd)
|
||||
manifest.require_agent(agent_name)
|
||||
except (Die, ManifestError) as exc:
|
||||
raise BottleError(str(exc)) from exc
|
||||
|
||||
if bottles:
|
||||
bottle_names: tuple[str, ...] = tuple(bottles)
|
||||
else:
|
||||
default_bottle = _peek_agent_bottle(manifest, agent_name)
|
||||
if not default_bottle:
|
||||
raise BottleError(
|
||||
f"agent '{agent_name}' has no default bottle; "
|
||||
f"pass bottles=[...]"
|
||||
)
|
||||
bottle_names = (default_bottle,)
|
||||
|
||||
spec = BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name=agent_name,
|
||||
copy_cwd=copy_cwd,
|
||||
user_cwd=cwd,
|
||||
label=_uniquify_label_headless(label or agent_name),
|
||||
color=color or "",
|
||||
bottle_names=bottle_names,
|
||||
forge_env=dict(forge_env) if forge_env else {},
|
||||
)
|
||||
try:
|
||||
slug, exit_code = _launch_bottle(
|
||||
spec,
|
||||
dry_run=False,
|
||||
backend_name=backend_name,
|
||||
assume_yes=True,
|
||||
headless_prompt_text=prompt,
|
||||
)
|
||||
except Die as exc:
|
||||
raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc
|
||||
if exit_code != 0:
|
||||
raise BottleError(
|
||||
f"agent exited {exit_code} (slug={slug!r})", exit_code=exit_code
|
||||
)
|
||||
return slug
|
||||
|
||||
|
||||
def resume_headless(
|
||||
slug: str,
|
||||
*,
|
||||
prompt: str,
|
||||
backend_name: str | None = None,
|
||||
forge_env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Resume a frozen bottle headlessly with ``prompt``.
|
||||
|
||||
``forge_env`` re-supplies forge context for the new session (the
|
||||
sidecar is relaunched alongside the agent on resume).
|
||||
|
||||
Raises ``BottleError`` on missing state, backend errors, or non-zero
|
||||
agent exit."""
|
||||
metadata = read_metadata(slug)
|
||||
if metadata is None:
|
||||
raise BottleError(
|
||||
f"no state recorded for slug {slug!r}; "
|
||||
f"check ~/.bot-bottle/state/ or call start_headless() to create a new bottle"
|
||||
)
|
||||
|
||||
try:
|
||||
manifest = ManifestIndex.resolve(metadata.cwd or USER_CWD)
|
||||
manifest.require_agent(metadata.agent_name)
|
||||
except (Die, ManifestError) as exc:
|
||||
raise BottleError(str(exc)) from exc
|
||||
|
||||
spec = BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name=metadata.agent_name,
|
||||
copy_cwd=metadata.copy_cwd,
|
||||
user_cwd=metadata.cwd or USER_CWD,
|
||||
identity=metadata.identity,
|
||||
bottle_names=tuple(metadata.bottle_names),
|
||||
forge_env=dict(forge_env) if forge_env else {},
|
||||
)
|
||||
try:
|
||||
_, exit_code = _launch_bottle(
|
||||
spec,
|
||||
dry_run=False,
|
||||
backend_name=backend_name or metadata.backend or None,
|
||||
assume_yes=True,
|
||||
headless_prompt_text=prompt,
|
||||
)
|
||||
except Die as exc:
|
||||
raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc
|
||||
if exit_code != 0:
|
||||
raise BottleError(
|
||||
f"agent exited {exit_code} resuming {slug!r}", exit_code=exit_code
|
||||
)
|
||||
|
||||
|
||||
def freeze(slug: str, *, backend_name: str | None = None) -> None:
|
||||
"""Freeze the named bottle to a resumable artifact.
|
||||
|
||||
Reads the bottle's backend from its metadata when ``backend_name`` is
|
||||
not supplied. Raises ``BottleError`` if the freeze fails."""
|
||||
metadata = read_metadata(slug)
|
||||
resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker"
|
||||
try:
|
||||
get_freezer(resolved_backend).commit_slug(slug)
|
||||
except CommitCancelled as exc:
|
||||
raise BottleError(f"freeze cancelled for {slug!r}") from exc
|
||||
except Die as exc:
|
||||
raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc
|
||||
|
||||
|
||||
def destroy(slug: str, *, backend_name: str | None = None) -> None:
|
||||
"""Destroy the named bottle, removing all resources and state.
|
||||
|
||||
Brings down any running resources for ``slug``, then removes the
|
||||
per-bottle state directory. Idempotent: a slug with no running
|
||||
resources or no state directory is not an error."""
|
||||
metadata = read_metadata(slug)
|
||||
resolved_backend = backend_name or (metadata.backend if metadata else "") or "docker"
|
||||
try:
|
||||
if resolved_backend == "docker":
|
||||
_destroy_docker(slug)
|
||||
elif resolved_backend == "smolmachines":
|
||||
_destroy_smolmachines(slug)
|
||||
# macos-container: the container is torn down inside the launch
|
||||
# context manager; no persistent VM survives, so nothing extra is
|
||||
# needed at destroy time beyond the state-dir removal below.
|
||||
except Die as exc:
|
||||
raise BottleError(exc.message, exit_code=cast(int, exc.code)) from exc
|
||||
clear_preserve_marker(slug)
|
||||
cleanup_state(slug)
|
||||
|
||||
|
||||
# --- backend-specific helpers -----------------------------------------------
|
||||
|
||||
|
||||
def _destroy_docker(slug: str) -> None:
|
||||
"""Best-effort ``docker compose down`` for a Docker bottle.
|
||||
|
||||
No-op when the compose file is absent — the project was already
|
||||
brought down (normal for a frozen bottle) or was never created."""
|
||||
from .backend.docker.compose import (
|
||||
compose_down,
|
||||
compose_file_path,
|
||||
compose_project_name,
|
||||
)
|
||||
from .bottle_state import bottle_state_dir
|
||||
|
||||
state_dir = bottle_state_dir(slug)
|
||||
compose_file = compose_file_path(state_dir)
|
||||
if compose_file.exists():
|
||||
compose_down(compose_project_name(slug), compose_file)
|
||||
|
||||
|
||||
def _destroy_smolmachines(slug: str) -> None:
|
||||
"""Best-effort stop + delete for a smolmachines bottle.
|
||||
|
||||
Both steps are best-effort: a machine that is already gone does not
|
||||
cause an error; partial failures are logged as warnings."""
|
||||
import subprocess
|
||||
|
||||
from .log import warn
|
||||
|
||||
machine = f"bot-bottle-{slug}"
|
||||
subprocess.run(
|
||||
["smolvm", "machine", "stop", "--name", machine],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
r = subprocess.run(
|
||||
["smolvm", "machine", "delete", "-f", machine],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
warn(
|
||||
f"smolvm machine delete -f {machine!r} failed "
|
||||
f"(may already be gone): {(r.stderr or '').strip()}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BottleError",
|
||||
"destroy",
|
||||
"freeze",
|
||||
"resume_headless",
|
||||
"start_headless",
|
||||
]
|
||||
@@ -37,7 +37,7 @@ import shlex
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, Sequence, TypeVar
|
||||
|
||||
@@ -75,11 +75,6 @@ class BottleSpec:
|
||||
# Ordered bottle names selected at launch (issue #269). When non-empty
|
||||
# they are merged in order and replace the agent's `bottle:` field.
|
||||
bottle_names: tuple[str, ...] = ()
|
||||
# Forge sidecar env vars (PRD forge-native-integration, chunk 1).
|
||||
# Passed by the orchestrator at launch time; the forge sidecar reads
|
||||
# them to connect to Gitea. Empty for non-forge runs. The agent
|
||||
# process itself does not receive these.
|
||||
forge_env: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -74,11 +74,10 @@ def cmd_resume(argv: list[str]) -> int:
|
||||
bottle_names=tuple(metadata.bottle_names),
|
||||
)
|
||||
backend_name = metadata.backend or None
|
||||
_, rc = _launch_bottle(
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=args.dry_run,
|
||||
backend_name=backend_name,
|
||||
assume_yes=args.headless,
|
||||
headless_prompt_text=args.prompt or "",
|
||||
)
|
||||
return rc
|
||||
|
||||
+5
-13
@@ -144,12 +144,11 @@ def cmd_start(argv: list[str]) -> int:
|
||||
color=color,
|
||||
bottle_names=bottle_names,
|
||||
)
|
||||
_, rc = _launch_bottle(
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=dry_run,
|
||||
backend_name=backend_name,
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
# --- Headless launch -----------------------------------------------------
|
||||
@@ -204,14 +203,13 @@ def _start_headless(
|
||||
color=args.color or "",
|
||||
bottle_names=bottle_names,
|
||||
)
|
||||
_, rc = _launch_bottle(
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=dry_run,
|
||||
backend_name=backend_name,
|
||||
assume_yes=True,
|
||||
headless_prompt_text=prompt,
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
def _uniquify_label_headless(label: str) -> str:
|
||||
@@ -499,16 +497,11 @@ def _launch_bottle(
|
||||
backend_name: str | None = None,
|
||||
assume_yes: bool = False,
|
||||
headless_prompt_text: str = "",
|
||||
) -> tuple[str, int]:
|
||||
) -> int:
|
||||
"""Shared launch core for `start` and `resume`. Builds the plan,
|
||||
prints / dry-runs / prompts as appropriate, brings the bottle up,
|
||||
attaches claude, and prints the resume hint on session end.
|
||||
|
||||
Returns ``(slug, exit_code)`` where ``slug`` is the bottle identity
|
||||
(empty string when the launch was aborted before a slug was minted)
|
||||
and ``exit_code`` is the agent process's exit code (0 on clean exit
|
||||
or when launch was aborted before the agent ran).
|
||||
|
||||
`assume_yes` skips the interactive y/N confirmation (headless /
|
||||
orchestrator launches), where there is no human at the prompt.
|
||||
|
||||
@@ -517,7 +510,6 @@ def _launch_bottle(
|
||||
agent receives the initial task without interactive input."""
|
||||
stage_dir = Path(tempfile.mkdtemp(prefix="bot-bottle-stage."))
|
||||
identity = ""
|
||||
exit_code = 0
|
||||
try:
|
||||
plan, identity = prepare_with_preflight(
|
||||
spec,
|
||||
@@ -528,7 +520,7 @@ def _launch_bottle(
|
||||
backend_name=backend_name,
|
||||
)
|
||||
if plan is None:
|
||||
return identity, 0
|
||||
return 0
|
||||
|
||||
backend = get_bottle_backend(backend_name)
|
||||
with backend.launch(plan) as bottle:
|
||||
@@ -555,7 +547,7 @@ def _launch_bottle(
|
||||
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
|
||||
if agent_provider_template == "claude":
|
||||
capture_claude_session_state(identity, exit_code)
|
||||
return identity, exit_code
|
||||
return 0
|
||||
finally:
|
||||
# PRD 0018 chunk 2: prepare now writes the bottle's bind-mount
|
||||
# sources under state/<slug>/. If we never reached the
|
||||
|
||||
@@ -7,118 +7,43 @@
|
||||
|
||||
## Summary
|
||||
|
||||
Add a webhook-driven orchestration layer that lets Gitea issues and PR comments
|
||||
drive bot-bottle sessions end-to-end with no operator in the loop for the happy
|
||||
path. An issue assigned to a member of the configured agent org and labelled
|
||||
with an agent name triggers a headless bottle launch; the bottle processes the
|
||||
issue, opens a PR, and interacts with the forge through a **forge sidecar** —
|
||||
the agent never touches the Gitea API or its credentials directly. The agent
|
||||
calls `signal_done(status, summary)` on the sidecar when a work unit is
|
||||
complete; the sidecar relays that to the orchestrator over a queue dir (the same
|
||||
pattern as the supervise sidecar), so completion is an unambiguous in-band
|
||||
signal rather than a comment the orchestrator has to parse. The orchestrator
|
||||
freezes the bottle. Subsequent PR comments rehydrate the frozen bottle. The
|
||||
bottle is destroyed when the PR closes.
|
||||
Add a webhook-driven orchestration layer that lets Gitea issues and PR comments drive bot-bottle sessions end-to-end with no operator in the loop for the happy path. An issue assigned to a member of the configured agent org and labelled with an agent name triggers a headless bottle launch; the bottle processes the issue, opens a PR, and interacts with the forge through a **forge sidecar** — the agent never touches the Gitea API or its credentials directly. The agent calls `signal_done(status, summary)` on the sidecar when a work unit is complete; the sidecar relays that to the orchestrator over a queue dir (the same pattern as the supervise sidecar), so completion is an unambiguous in-band signal rather than a comment the orchestrator has to parse. The orchestrator freezes the bottle. Subsequent PR comments rehydrate the frozen bottle. The bottle is destroyed when the PR closes.
|
||||
|
||||
The forge sidecar is backed by a `Forge` abstract class with per-provider
|
||||
implementations (Gitea first), so the agent's prompts and the sidecar protocol
|
||||
stay forge-agnostic. The sidecar logs forge operations semantically ("read PR
|
||||
description", "posted comment", "signalled done"), giving richer provenance than
|
||||
post-hoc egress-byte parsing, and enforces a **read-anywhere / write-scoped**
|
||||
permission model: the agent may read for context but may only write to the
|
||||
issue and PRs it was assigned.
|
||||
The forge sidecar is backed by a `Forge` abstract class with per-provider implementations (Gitea first), so the agent's prompts and the sidecar protocol stay forge-agnostic. The sidecar logs forge operations semantically ("read PR description", "posted comment", "signalled done"), giving richer provenance than post-hoc egress-byte parsing, and enforces a **read-anywhere / write-scoped** permission model: the agent may read for context but may only write to the issue and PRs it was assigned.
|
||||
|
||||
Run provenance is exposed through a **provenance API** (the sidecar's structured
|
||||
operation log plus the run's metadata), not posted back into the forge. We do
|
||||
not surface a provenance footer in the PR — the audit record lives behind the
|
||||
API where it can be retained and queried, rather than as an editable comment.
|
||||
Run provenance is exposed through a **provenance API** (the sidecar's structured operation log plus the run's metadata), not posted back into the forge. We do not surface a provenance footer in the PR — the audit record lives behind the API where it can be retained and queried, rather than as an editable comment.
|
||||
|
||||
The separation of concerns across the two layers: bot-bottle owns the headless
|
||||
launch primitives, the forge sidecar + `Forge` abstraction, and forge state.
|
||||
`bot-bottle-orchestrator` (separate binary) owns the webhook listener, bottle
|
||||
lifecycle loop, and monitoring dashboard; it calls into bot-bottle via
|
||||
`./cli.py orchestrate`, a thin wrapper command. This PRD covers bot-bottle's
|
||||
side of that contract.
|
||||
The separation of concerns across the two layers: bot-bottle owns the headless launch primitives, the forge sidecar + `Forge` abstraction, and forge state. `bot-bottle-orchestrator` (separate binary) owns the webhook listener, bottle lifecycle loop, and monitoring dashboard; it calls into bot-bottle via `./cli.py orchestrate`, a thin wrapper command. This PRD covers bot-bottle's side of that contract.
|
||||
|
||||
## Problem
|
||||
|
||||
Today an operator must open the TUI, select an agent and bottle, confirm the
|
||||
preflight, and type prompts interactively. This blocks "issue → PR" automation
|
||||
and produces no durable audit record of what the agent did. The security model
|
||||
already provides the right isolation and egress controls, and `start --headless`
|
||||
(#315) already gives `bot-bottle-orchestrator` a non-interactive launch path.
|
||||
The missing pieces are a headless `resume` counterpart for rehydrating frozen
|
||||
bottles, a forge-interaction surface the agent uses to read context, post
|
||||
comments, and signal completion, and the provenance trail that makes the audit
|
||||
story legible to reviewers on every PR.
|
||||
Today an operator must open the TUI, select an agent and bottle, confirm the preflight, and type prompts interactively. This blocks "issue → PR" automation and produces no durable audit record of what the agent did. The security model already provides the right isolation and egress controls, and `start --headless` (#315) already gives `bot-bottle-orchestrator` a non-interactive launch path. The missing pieces are a headless `resume` counterpart for rehydrating frozen bottles, a forge-interaction surface the agent uses to read context, post comments, and signal completion, and the provenance trail that makes the audit story legible to reviewers on every PR.
|
||||
|
||||
That forge-interaction surface could be built two ways: (2) give the agent the
|
||||
Gitea API directly with cred-proxy injecting the token, or (3) put a forge
|
||||
sidecar between the agent and the forge. This PRD takes **option 3**. The
|
||||
deciding factors: a sidecar `signal_done` call is an unambiguous completion
|
||||
signal where comment-parsing is a correctness risk that surfaces in production;
|
||||
the sidecar produces a semantic audit trail rather than HTTP bytes, which is
|
||||
load-bearing for provenance (the stated product priority); and the sidecar can
|
||||
enforce scope tighter than repo-wide API-key permissions, reducing blast radius
|
||||
for a prompt-injected agent. The costs — a second sidecar process per forge run,
|
||||
a new failure mode if it crashes, and per-forge implementation cost — are
|
||||
accepted as the price of those properties.
|
||||
That forge-interaction surface could be built two ways: (2) give the agent the Gitea API directly with cred-proxy injecting the token, or (3) put a forge sidecar between the agent and the forge. This PRD takes **option 3**. The deciding factors: a sidecar `signal_done` call is an unambiguous completion signal where comment-parsing is a correctness risk that surfaces in production; the sidecar produces a semantic audit trail rather than HTTP bytes, which is load-bearing for provenance (the stated product priority); and the sidecar can enforce scope tighter than repo-wide API-key permissions, reducing blast radius for a prompt-injected agent. The costs — a second sidecar process per forge run, a new failure mode if it crashes, and per-forge implementation cost — are accepted as the price of those properties.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. Headless launch already exists: `./cli.py start <agent> --headless --prompt`
|
||||
(#315) runs non-interactively with no TUI selectors or y/N preflight. This
|
||||
PRD builds on it rather than re-introducing it. The remaining gap is a
|
||||
matching headless `resume` path (`./cli.py resume --headless`), since
|
||||
rehydrating a frozen bottle for a new prompt is required by the freeze /
|
||||
rehydrate loop and `resume` has no non-interactive entry point today.
|
||||
2. An issue assigned to a member of the configured org (`FORGE_ORG`, default
|
||||
`bot-bottle`) and labelled `bot-bottle:<agent-name>` is the trigger
|
||||
convention. Org membership is verified via the Gitea API at event time.
|
||||
3. Forge-targeted bottles run a **forge sidecar** that exposes a small,
|
||||
forge-agnostic API (comment/issue/PR CRUD plus `signal_done`) over the same
|
||||
queue-dir + HTTP/JSON-RPC machinery as the supervise sidecar. The agent calls
|
||||
the sidecar; it never sees the forge token or forge-specific endpoints.
|
||||
4. The sidecar is backed by a `Forge` abstract class. Gitea is the first
|
||||
concrete implementation; adding a forge means a new subclass, not changes to
|
||||
the agent prompt or sidecar protocol. The sidecar enforces a read-anywhere /
|
||||
write-scoped model: writes are limited to the assigned issue and its PRs;
|
||||
reads are unrestricted for context.
|
||||
5. The agent calls `signal_done(status, summary)` on the sidecar when a work
|
||||
unit is complete; the sidecar relays it to the orchestrator over a queue dir.
|
||||
This is the done signal — no comment parsing. A watchdog timeout
|
||||
(configurable, default 30 min) causes the orchestrator to treat the run as
|
||||
done-without-self-report if the agent exits without signalling.
|
||||
6. Run provenance (agent name, bottle name(s), slug, timing, exit code,
|
||||
gitleaks result, egress summary, and the sidecar's semantic operation log)
|
||||
is available through a provenance API. It is **not** surfaced as a PR footer
|
||||
or any other forge comment.
|
||||
7. Forge state (issue → slug, status) is persisted in a local SQLite database
|
||||
under `~/.bot-bottle/` and survives orchestrator restarts.
|
||||
8. `./cli.py orchestrate status` lists active forge-managed bottles and their
|
||||
issue/PR URLs.
|
||||
9. Unit tests cover: label parsing, org-membership check path, forge state
|
||||
store CRUD (SQLite), headless launch arg construction, forge env var
|
||||
injection, sidecar request dispatch through the `Forge` abstraction,
|
||||
write-scope enforcement (reject writes outside the assigned issue/PRs), and
|
||||
`signal_done` queue relay.
|
||||
1. Headless launch already exists: `./cli.py start <agent> --headless --prompt` (#315) runs non-interactively with no TUI selectors or y/N preflight. This PRD builds on it rather than re-introducing it. The remaining gap is a matching headless `resume` path (`./cli.py resume --headless`), since rehydrating a frozen bottle for a new prompt is required by the freeze / rehydrate loop and `resume` has no non-interactive entry point today.
|
||||
2. An issue assigned to a member of the configured org (`FORGE_ORG`, default `bot-bottle`) and labelled `bot-bottle:<agent-name>` is the trigger convention. Org membership is verified via the Gitea API at event time.
|
||||
3. Forge-targeted bottles run a **forge sidecar** that exposes a small, forge-agnostic API (comment/issue/PR CRUD plus `signal_done`) over the same queue-dir + HTTP/JSON-RPC machinery as the supervise sidecar. The agent calls the sidecar; it never sees the forge token or forge-specific endpoints.
|
||||
4. The sidecar is backed by a `Forge` abstract class. Gitea is the first concrete implementation; adding a forge means a new subclass, not changes to the agent prompt or sidecar protocol. The sidecar enforces a read-anywhere / write-scoped model: writes are limited to the assigned issue and its PRs; reads are unrestricted for context.
|
||||
5. The agent calls `signal_done(status, summary)` on the sidecar when a work unit is complete; the sidecar relays it to the orchestrator over a queue dir. This is the done signal — no comment parsing. A watchdog timeout (configurable, default 30 min) causes the orchestrator to treat the run as done-without-self-report if the agent exits without signalling.
|
||||
6. Run provenance (agent name, bottle name(s), slug, timing, exit code, gitleaks result, egress summary, and the sidecar's semantic operation log) is available through a provenance API. It is **not** surfaced as a PR footer or any other forge comment.
|
||||
7. Forge state (issue → slug, status) is persisted in a local SQLite database under `~/.bot-bottle/` and survives orchestrator restarts.
|
||||
8. `./cli.py orchestrate status` lists active forge-managed bottles and their issue/PR URLs.
|
||||
9. Unit tests cover: label parsing, org-membership check path, forge state store CRUD (SQLite), headless launch arg construction, forge env var injection, sidecar request dispatch through the `Forge` abstraction, write-scope enforcement (reject writes outside the assigned issue/PRs), and `signal_done` queue relay.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Webhook signature verification (HMAC-SHA256). Added as a follow-up.
|
||||
- The `bot-bottle-orchestrator` binary itself — this PRD covers bot-bottle's
|
||||
side of the interface only. The orchestrator is a separate project.
|
||||
- The `bot-bottle-orchestrator` binary itself — this PRD covers bot-bottle's side of the interface only. The orchestrator is a separate project.
|
||||
- GitHub or GitLab support.
|
||||
- Multiple simultaneous forge bottles per issue.
|
||||
- Automatic retry on agent error exit.
|
||||
- Bottle destruction on issue close (PR close only; issue close is ambiguous).
|
||||
- Concurrent multi-issue handling (one blocking run per orchestrator process).
|
||||
- A monitoring dashboard (orchestrator-side concern).
|
||||
- Folding `DeployKeyProvisioner` into the `Forge` abstraction. Deploy-key
|
||||
provisioning runs at bottle-provision time on the host; the forge sidecar runs
|
||||
inside the bottle at agent time. The two have different lifecycles and actors,
|
||||
so coupling them into one class is deferred to a follow-up. This PRD only
|
||||
shares the Gitea HTTP client between them.
|
||||
- Folding `DeployKeyProvisioner` into the `Forge` abstraction. Deploy-key provisioning runs at bottle-provision time on the host; the forge sidecar runs inside the bottle at agent time. The two have different lifecycles and actors, so coupling them into one class is deferred to a follow-up. This PRD only shares the Gitea HTTP client between them.
|
||||
|
||||
## Design
|
||||
|
||||
@@ -126,17 +51,12 @@ accepted as the price of those properties.
|
||||
|
||||
An issue is forge-targeted when **both** hold:
|
||||
|
||||
- At least one assignee is a member of the Gitea org named by `FORGE_ORG`
|
||||
(default `bot-bottle`). Checked via `GET /api/v1/orgs/{org}/members/{user}`.
|
||||
- At least one label has the prefix `bot-bottle:`. The suffix names the agent
|
||||
manifest, e.g. `bot-bottle:implementer` → agent `implementer`.
|
||||
- At least one assignee is a member of the Gitea org named by `FORGE_ORG` (default `bot-bottle`). Checked via `GET /api/v1/orgs/{org}/members/{user}`.
|
||||
- At least one label has the prefix `bot-bottle:`. The suffix names the agent manifest, e.g. `bot-bottle:implementer` → agent `implementer`.
|
||||
|
||||
`FORGE_ORG` is read at orchestrate-command startup. It is not embedded in
|
||||
manifests or state files; the orchestrator stamps its value into log output for
|
||||
auditability.
|
||||
`FORGE_ORG` is read at orchestrate-command startup. It is not embedded in manifests or state files; the orchestrator stamps its value into log output for auditability.
|
||||
|
||||
An optional label `bot-bottle-bottle:<name>` overrides bottle selection. When
|
||||
absent the agent's default bottle is used.
|
||||
An optional label `bot-bottle-bottle:<name>` overrides bottle selection. When absent the agent's default bottle is used.
|
||||
|
||||
### `./cli.py orchestrate` — the thin wrapper
|
||||
|
||||
@@ -147,11 +67,7 @@ absent the agent's default bottle is used.
|
||||
./cli.py orchestrate status
|
||||
```
|
||||
|
||||
`orchestrate start` is a thin shim over the already-shipped `start --headless`
|
||||
(#315): it forwards agent / bottle / label / prompt and adds the forge-specific
|
||||
wiring (`forge_env`, sidecar launch). It does not re-implement headless launch.
|
||||
The caller (`bot-bottle-orchestrator`) manages freeze, state, and the forge
|
||||
sidecar's done signal around it.
|
||||
`orchestrate start` is a thin shim over the already-shipped `start --headless` (#315): it forwards agent / bottle / label / prompt and adds the forge-specific wiring (`forge_env`, sidecar launch). It does not re-implement headless launch. The caller (`bot-bottle-orchestrator`) manages freeze, state, and the forge sidecar's done signal around it.
|
||||
|
||||
`orchestrate resume` is the shim over the new `resume --headless` (below).
|
||||
|
||||
@@ -161,51 +77,26 @@ sidecar's done signal around it.
|
||||
|
||||
Headless **start** already shipped in #315 and this PRD reuses it as-is:
|
||||
|
||||
- `./cli.py start <agent> --headless --prompt TEXT` — no TUI selectors, no y/N
|
||||
preflight. Internally `_start_headless()` calls the shared `_launch_bottle()`
|
||||
with `assume_yes=True` and `headless_prompt_text=prompt`.
|
||||
- The prompt is delivered through `AgentProvider.headless_prompt(prompt)` —
|
||||
claude `-p`, codex positional, pi `-p`. The orchestrator does **not** hand-roll
|
||||
agent args; it relies on this provider abstraction. (An earlier draft proposed
|
||||
`start_headless` / `attach_agent_headless` helpers that constructed
|
||||
`--no-interactive`/`-p` directly — those are dropped as redundant with, and
|
||||
divergent from, what #315 merged.)
|
||||
- `./cli.py start <agent> --headless --prompt TEXT` — no TUI selectors, no y/N preflight. Internally `_start_headless()` calls the shared `_launch_bottle()` with `assume_yes=True` and `headless_prompt_text=prompt`.
|
||||
- The prompt is delivered through `AgentProvider.headless_prompt(prompt)` — claude `-p`, codex positional, pi `-p`. The orchestrator does **not** hand-roll agent args; it relies on this provider abstraction. (An earlier draft proposed `start_headless` / `attach_agent_headless` helpers that constructed `--no-interactive`/`-p` directly — those are dropped as redundant with, and divergent from, what #315 merged.)
|
||||
|
||||
Two additions are needed on top of #315:
|
||||
|
||||
**1. A `forge_env` hook on the headless launch path.** The orchestrator needs to
|
||||
pass forge context + token through to the forge sidecar launched alongside the
|
||||
agent. This is a parameter threaded into `_launch_bottle` (the same core
|
||||
`start --headless` already uses), not a parallel launch function. The agent
|
||||
process itself does not receive the token.
|
||||
**1. A `forge_env` hook on the headless launch path.** The orchestrator needs to pass forge context + token through to the forge sidecar launched alongside the agent. This is a parameter threaded into `_launch_bottle` (the same core `start --headless` already uses), not a parallel launch function. The agent process itself does not receive the token.
|
||||
|
||||
**2. `resume --headless`** — new in `bot_bottle/cli/resume.py`, mirroring the
|
||||
`--headless` flag on `start`:
|
||||
**2. `resume --headless`** — new in `bot_bottle/cli/resume.py`, mirroring the `--headless` flag on `start`:
|
||||
|
||||
```
|
||||
./cli.py resume <slug> --headless --prompt TEXT
|
||||
```
|
||||
|
||||
It rehydrates a frozen bottle and runs one headless prompt via the same
|
||||
`assume_yes` + `headless_prompt` path, returning the agent's exit code. `resume`
|
||||
has no non-interactive entry point today, so this is genuinely new work rather
|
||||
than a rename of an existing helper.
|
||||
It rehydrates a frozen bottle and runs one headless prompt via the same `assume_yes` + `headless_prompt` path, returning the agent's exit code. `resume` has no non-interactive entry point today, so this is genuinely new work rather than a rename of an existing helper.
|
||||
|
||||
### Forge sidecar
|
||||
|
||||
Forge-targeted bottles run a forge sidecar alongside the agent, mirroring the
|
||||
supervise sidecar: a per-bottle process that exposes an HTTP/JSON-RPC endpoint
|
||||
over a Unix socket and relays events to the orchestrator through a queue dir.
|
||||
The agent calls the sidecar; the sidecar holds the forge token and makes the
|
||||
actual forge API calls. The agent never receives the credential and never sees a
|
||||
forge-specific endpoint — swapping Gitea for another forge does not change the
|
||||
agent prompt or the sidecar protocol.
|
||||
Forge-targeted bottles run a forge sidecar alongside the agent, mirroring the supervise sidecar: a per-bottle process that exposes an HTTP/JSON-RPC endpoint over a Unix socket and relays events to the orchestrator through a queue dir. The agent calls the sidecar; the sidecar holds the forge token and makes the actual forge API calls. The agent never receives the credential and never sees a forge-specific endpoint — swapping Gitea for another forge does not change the agent prompt or the sidecar protocol.
|
||||
|
||||
The sidecar is configured at launch from the forge context (owner, repo, issue,
|
||||
PR) and the token, supplied by the orchestrator — not baked into the agent
|
||||
manifest. Because the sidecar owns the token, forge traffic does not need a
|
||||
cred-proxy egress route on the agent; the agent's egress policy is unchanged by
|
||||
forge targeting.
|
||||
The sidecar is configured at launch from the forge context (owner, repo, issue, PR) and the token, supplied by the orchestrator — not baked into the agent manifest. Because the sidecar owns the token, forge traffic does not need a cred-proxy egress route on the agent; the agent's egress policy is unchanged by forge targeting.
|
||||
|
||||
**Sidecar protocol** (forge-agnostic; each method maps to a `Forge` call):
|
||||
|
||||
@@ -218,25 +109,15 @@ forge targeting.
|
||||
| `update_description(number, body)` | write-scoped | Edit the assigned issue/PR body |
|
||||
| `signal_done(status, summary)` | — | Relay completion to the orchestrator |
|
||||
|
||||
Issues and PRs are distinct domain objects (`Issue` vs `PullRequest`) read
|
||||
through distinct methods; a PR carries merge state an issue does not.
|
||||
Issues and PRs are distinct domain objects (`Issue` vs `PullRequest`) read through distinct methods; a PR carries merge state an issue does not.
|
||||
|
||||
**Scope enforcement** is read-anywhere / write-scoped: read methods accept any
|
||||
issue/PR number for context; write methods are rejected unless the target is the
|
||||
assigned issue or one of its PRs. This is tighter than Gitea's repo-wide API-key
|
||||
permissions and bounds the blast radius of a prompt-injected agent. Rejections
|
||||
are logged semantically (operation, target, reason) so the audit trail records
|
||||
attempted out-of-scope writes, not just allowed ones.
|
||||
**Scope enforcement** is read-anywhere / write-scoped: read methods accept any issue/PR number for context; write methods are rejected unless the target is the assigned issue or one of its PRs. This is tighter than Gitea's repo-wide API-key permissions and bounds the blast radius of a prompt-injected agent. Rejections are logged semantically (operation, target, reason) so the audit trail records attempted out-of-scope writes, not just allowed ones.
|
||||
|
||||
**Semantic audit**: every sidecar call is logged as a structured operation
|
||||
("read PR #318 description", "posted comment to #317", "signalled done:
|
||||
success") rather than as opaque HTTP bytes. This log feeds provenance directly,
|
||||
with no post-hoc egress-log parsing.
|
||||
**Semantic audit**: every sidecar call is logged as a structured operation ("read PR #318 description", "posted comment to #317", "signalled done: success") rather than as opaque HTTP bytes. This log feeds provenance directly, with no post-hoc egress-log parsing.
|
||||
|
||||
### `Forge` abstraction — `bot_bottle/contrib/forge/`
|
||||
|
||||
The sidecar dispatches to a `Forge` abstract class. Each provider implements the
|
||||
operations behind the sidecar protocol:
|
||||
The sidecar dispatches to a `Forge` abstract class. Each provider implements the operations behind the sidecar protocol:
|
||||
|
||||
```python
|
||||
class Forge(abc.ABC):
|
||||
@@ -258,26 +139,15 @@ class Forge(abc.ABC):
|
||||
def is_pr_open(self, number: int) -> bool: ...
|
||||
```
|
||||
|
||||
`Issue` and `PullRequest` are separate frozen dataclasses — a PR adds `merged`.
|
||||
`ScopedForge` wraps a concrete `Forge` to enforce the read-anywhere /
|
||||
write-scoped model (`post_comment` / `update_description` raise `ForgeScopeError`
|
||||
outside the assigned issue and PRs).
|
||||
`Issue` and `PullRequest` are separate frozen dataclasses — a PR adds `merged`. `ScopedForge` wraps a concrete `Forge` to enforce the read-anywhere / write-scoped model (`post_comment` / `update_description` raise `ForgeScopeError` outside the assigned issue and PRs).
|
||||
|
||||
`GiteaForge` is the first and only concrete implementation in this PRD. It wraps
|
||||
the Gitea HTTP client (below). Adding GitHub or GitLab later is a new subclass;
|
||||
the sidecar, protocol, and agent prompt are untouched.
|
||||
`GiteaForge` is the first and only concrete implementation in this PRD. It wraps the Gitea HTTP client (below). Adding GitHub or GitLab later is a new subclass; the sidecar, protocol, and agent prompt are untouched.
|
||||
|
||||
> **Deferred:** `DeployKeyProvisioner` is *not* folded into `Forge` here.
|
||||
> Deploy-key provisioning runs on the host at provision time; the sidecar runs
|
||||
> in the bottle at agent time. They have different lifecycles and actors, so a
|
||||
> shared abstract base would couple two unrelated auth contexts. For now they
|
||||
> only share the Gitea HTTP client; a later PRD can revisit unification.
|
||||
> **Deferred:** `DeployKeyProvisioner` is *not* folded into `Forge` here. Deploy-key provisioning runs on the host at provision time; the sidecar runs in the bottle at agent time. They have different lifecycles and actors, so a shared abstract base would couple two unrelated auth contexts. For now they only share the Gitea HTTP client; a later PRD can revisit unification.
|
||||
|
||||
### Forge env vars
|
||||
|
||||
The orchestrator passes forge context to the **sidecar** (not the agent) at
|
||||
launch. The agent does not need owner/repo/issue env vars to construct API
|
||||
calls, since it only names issue/PR numbers to the sidecar:
|
||||
The orchestrator passes forge context to the **sidecar** (not the agent) at launch. The agent does not need owner/repo/issue env vars to construct API calls, since it only names issue/PR numbers to the sidecar:
|
||||
|
||||
| Var | Example | Purpose |
|
||||
|---|---|---|
|
||||
@@ -287,49 +157,26 @@ calls, since it only names issue/PR numbers to the sidecar:
|
||||
| `FORGE_ISSUE_NUMBER` | `317` | Assigned issue (defines write scope) |
|
||||
| `FORGE_PR_NUMBER` | `318` | Assigned PR (empty until PR exists) |
|
||||
|
||||
The agent's forge-specific prompt instructs it to call `signal_done` on the
|
||||
sidecar when a work unit is complete, and to use the sidecar for any
|
||||
comment/description writes. The instruction is forge-agnostic and is part of the
|
||||
forge prompt overlay, not the base agent manifest, so non-forge runs are
|
||||
unaffected.
|
||||
The agent's forge-specific prompt instructs it to call `signal_done` on the sidecar when a work unit is complete, and to use the sidecar for any comment/description writes. The instruction is forge-agnostic and is part of the forge prompt overlay, not the base agent manifest, so non-forge runs are unaffected.
|
||||
|
||||
### Done signal and watchdog
|
||||
|
||||
The agent calls `signal_done(status, summary)` on the sidecar when it finishes a
|
||||
work unit. The sidecar writes the event to its queue dir; the orchestrator reads
|
||||
it and:
|
||||
The agent calls `signal_done(status, summary)` on the sidecar when it finishes a work unit. The sidecar writes the event to its queue dir; the orchestrator reads it and:
|
||||
|
||||
1. Reads the forge state for `(owner, repo, issue_number)`.
|
||||
2. If `status == "running"`, treats the event as the done signal: freezes the
|
||||
bottle and sets `status = "frozen"`. Provenance is recorded via the
|
||||
provenance API — no comment is posted to the forge.
|
||||
2. If `status == "running"`, treats the event as the done signal: freezes the bottle and sets `status = "frozen"`. Provenance is recorded via the provenance API — no comment is posted to the forge.
|
||||
|
||||
Because completion is an explicit `signal_done` call, the orchestrator does not
|
||||
parse comment text to detect "done", and intermediate comments the agent posts
|
||||
mid-run cannot be mistaken for completion.
|
||||
Because completion is an explicit `signal_done` call, the orchestrator does not parse comment text to detect "done", and intermediate comments the agent posts mid-run cannot be mistaken for completion.
|
||||
|
||||
**Watchdog**: the orchestrator tracks `last_checkin_at` in forge state, updated
|
||||
on each sidecar event. A background thread wakes every minute. If
|
||||
`now - last_checkin_at > FORGE_WATCHDOG_TIMEOUT` (default 30 min, configurable
|
||||
via env) and `status == "running"`, the orchestrator treats the run as
|
||||
done-without-self-report and freezes the bottle, flagging the run as incomplete
|
||||
in the provenance record.
|
||||
**Watchdog**: the orchestrator tracks `last_checkin_at` in forge state, updated on each sidecar event. A background thread wakes every minute. If `now - last_checkin_at > FORGE_WATCHDOG_TIMEOUT` (default 30 min, configurable via env) and `status == "running"`, the orchestrator treats the run as done-without-self-report and freezes the bottle, flagging the run as incomplete in the provenance record.
|
||||
|
||||
**Sidecar-death failure mode**: if the forge sidecar crashes mid-run the agent
|
||||
loses forge access while the bottle is otherwise healthy. The orchestrator
|
||||
detects a dead sidecar (socket/queue gone) the same way it detects a stalled
|
||||
agent and falls back to the watchdog path.
|
||||
**Sidecar-death failure mode**: if the forge sidecar crashes mid-run the agent loses forge access while the bottle is otherwise healthy. The orchestrator detects a dead sidecar (socket/queue gone) the same way it detects a stalled agent and falls back to the watchdog path.
|
||||
|
||||
### Forge state — `bot_bottle/contrib/gitea/forge_state.py`
|
||||
|
||||
State is stored in a local SQLite database at `~/.bot-bottle/bot-bottle.db`.
|
||||
Access goes through a thin CRUD interface, `ForgeStateStore`, so the storage
|
||||
location/engine can be swapped without touching callers. `SqliteForgeStateStore`
|
||||
is the first implementation.
|
||||
State is stored in a local SQLite database at `~/.bot-bottle/bot-bottle.db`. Access goes through a thin CRUD interface, `ForgeStateStore`, so the storage location/engine can be swapped without touching callers. `SqliteForgeStateStore` is the first implementation.
|
||||
|
||||
The `forge_state` table is keyed by `(owner, repo, issue_number)` and carries:
|
||||
`slug`, `agent_name`, `bottle_names` (JSON), `backend_name`, `agent_git_user`,
|
||||
`pr_number` (nullable), `status`, `last_checkin_at`.
|
||||
The `forge_state` table is keyed by `(owner, repo, issue_number)` and carries: `slug`, `agent_name`, `bottle_names` (JSON), `backend_name`, `agent_git_user`, `pr_number` (nullable), `status`, `last_checkin_at`.
|
||||
|
||||
`status`: `"running"` | `"frozen"` | `"destroyed"`.
|
||||
|
||||
@@ -346,34 +193,19 @@ class SqliteForgeStateStore(ForgeStateStore):
|
||||
def __init__(self, db_path: Path | None = None) -> None: ...
|
||||
```
|
||||
|
||||
`upsert` uses `INSERT OR REPLACE` so a re-run for the same issue overwrites in
|
||||
place. The schema is created on first open.
|
||||
`upsert` uses `INSERT OR REPLACE` so a re-run for the same issue overwrites in place. The schema is created on first open.
|
||||
|
||||
### Provenance API
|
||||
|
||||
Run provenance — agent, bottle(s), slug, timing, exit code, gitleaks result,
|
||||
egress summary, watchdog-fired flag, and the sidecar's semantic operation log —
|
||||
is exposed through a **provenance API**, not posted into the forge. There is no
|
||||
provenance footer or run-summary comment.
|
||||
Run provenance — agent, bottle(s), slug, timing, exit code, gitleaks result, egress summary, watchdog-fired flag, and the sidecar's semantic operation log — is exposed through a **provenance API**, not posted into the forge. There is no provenance footer or run-summary comment.
|
||||
|
||||
The rationale (per the monetization positioning): a PR comment is mutable by any
|
||||
maintainer, unsigned, and per-PR, so it is worthless as an audit record and
|
||||
invites false trust. The authoritative record therefore lives behind the API,
|
||||
where it can be retained, queried, and (eventually) signed. Whether any
|
||||
projection of it ever appears in the forge is a separate, out-of-scope decision;
|
||||
this PR does not build one.
|
||||
The rationale (per the monetization positioning): a PR comment is mutable by any maintainer, unsigned, and per-PR, so it is worthless as an audit record and invites false trust. The authoritative record therefore lives behind the API, where it can be retained, queried, and (eventually) signed. Whether any projection of it ever appears in the forge is a separate, out-of-scope decision; this PR does not build one.
|
||||
|
||||
The API surface itself (schema, transport, signing, retention) is **out of scope
|
||||
for this PRD** and belongs with the orchestrator / control-plane work. bot-bottle
|
||||
here only produces the raw material: the sidecar's semantic operation log and the
|
||||
run metadata the orchestrator collects.
|
||||
The API surface itself (schema, transport, signing, retention) is **out of scope for this PRD** and belongs with the orchestrator / control-plane work. bot-bottle here only produces the raw material: the sidecar's semantic operation log and the run metadata the orchestrator collects.
|
||||
|
||||
### Gitea HTTP client — `bot_bottle/contrib/gitea/client.py`
|
||||
|
||||
`GiteaForge` (and the existing `GiteaDeployKeyProvisioner`) share one thin HTTP
|
||||
client. Unlike the option-2 design, the token is held by the sidecar process and
|
||||
passed to the client directly — there is no agent-side cred-proxy route to
|
||||
inject it, because the agent never makes forge calls.
|
||||
`GiteaForge` (and the existing `GiteaDeployKeyProvisioner`) share one thin HTTP client. Unlike the option-2 design, the token is held by the sidecar process and passed to the client directly — there is no agent-side cred-proxy route to inject it, because the agent never makes forge calls.
|
||||
|
||||
```python
|
||||
class GiteaClient:
|
||||
@@ -386,54 +218,22 @@ class GiteaClient:
|
||||
def get_pull(self, number: int) -> dict: ...
|
||||
```
|
||||
|
||||
`GiteaForge` adapts this client to the `Forge` surface (mapping raw JSON to
|
||||
`Issue` / `PullRequest` / `Comment`). Sharing only the HTTP client (not an
|
||||
abstract base) is the deliberate boundary between the sidecar and the deploy-key
|
||||
provisioner — see the deferral note under the `Forge` abstraction.
|
||||
`GiteaForge` adapts this client to the `Forge` surface (mapping raw JSON to `Issue` / `PullRequest` / `Comment`). Sharing only the HTTP client (not an abstract base) is the deliberate boundary between the sidecar and the deploy-key provisioner — see the deferral note under the `Forge` abstraction.
|
||||
|
||||
### Implementation chunks
|
||||
|
||||
1. **Headless additions on top of #315** — thread a `forge_env` parameter into
|
||||
the existing `_launch_bottle` core (the one `start --headless` already uses);
|
||||
add a `--headless` path to `cli/resume.py` reusing `assume_yes` +
|
||||
`headless_prompt`. No new `start_headless`/`attach_agent_headless` helpers.
|
||||
Tests: `forge_env` reaches the sidecar/`guest_env`; `resume --headless` skips
|
||||
the TUI and y/N preflight and returns the agent exit code.
|
||||
1. **Headless additions on top of #315** — thread a `forge_env` parameter into the existing `_launch_bottle` core (the one `start --headless` already uses); add a `--headless` path to `cli/resume.py` reusing `assume_yes` + `headless_prompt`. No new `start_headless`/`attach_agent_headless` helpers. Tests: `forge_env` reaches the sidecar/`guest_env`; `resume --headless` skips the TUI and y/N preflight and returns the agent exit code.
|
||||
|
||||
2. **Forge state** — `contrib/gitea/forge_state.py`: `ForgeState` dataclass,
|
||||
`ForgeStateStore` CRUD interface, `SqliteForgeStateStore`. Tests: round-trip,
|
||||
missing → None, `INSERT OR REPLACE` upsert, delete idempotent, `all()`
|
||||
ordering, persistence across store instances.
|
||||
2. **Forge state** — `contrib/gitea/forge_state.py`: `ForgeState` dataclass, `ForgeStateStore` CRUD interface, `SqliteForgeStateStore`. Tests: round-trip, missing → None, `INSERT OR REPLACE` upsert, delete idempotent, `all()` ordering, persistence across store instances.
|
||||
|
||||
3. **`Forge` abstraction + Gitea client** — `contrib/forge/base.py` (`Forge`
|
||||
ABC, `ScopedForge`, `Issue` / `PullRequest` / `Comment`) and
|
||||
`contrib/gitea/client.py` + `GiteaForge`: `is_org_member`, `read_issue`,
|
||||
`read_pr`, `read_comments`, `post_comment`, `update_description`,
|
||||
`get_pr_for_issue`, `is_pr_open`. Tests: mock `urllib.request.urlopen`,
|
||||
assert payloads and 404-as-false for membership; `ScopedForge` write-scope
|
||||
enforcement.
|
||||
3. **`Forge` abstraction + Gitea client** — `contrib/forge/base.py` (`Forge` ABC, `ScopedForge`, `Issue` / `PullRequest` / `Comment`) and `contrib/gitea/client.py` + `GiteaForge`: `is_org_member`, `read_issue`, `read_pr`, `read_comments`, `post_comment`, `update_description`, `get_pr_for_issue`, `is_pr_open`. Tests: mock `urllib.request.urlopen`, assert payloads and 404-as-false for membership; `ScopedForge` write-scope enforcement.
|
||||
|
||||
4. **Forge sidecar** — sidecar process exposing the protocol over a Unix socket,
|
||||
queue-dir relay, write-scope enforcement, semantic op log, `signal_done`.
|
||||
Reuses the supervise sidecar bundle machinery. Tests: dispatch each method to
|
||||
the `Forge`, reject out-of-scope writes, `signal_done` writes a queue event,
|
||||
scope-rejection is logged.
|
||||
4. **Forge sidecar** — sidecar process exposing the protocol over a Unix socket, queue-dir relay, write-scope enforcement, semantic op log, `signal_done`. Reuses the supervise sidecar bundle machinery. Tests: dispatch each method to the `Forge`, reject out-of-scope writes, `signal_done` writes a queue event, scope-rejection is logged.
|
||||
|
||||
5. **`./cli.py orchestrate`** — `cli/orchestrate.py` with `start`, `resume`,
|
||||
`status` subcommands wired into `cli.py`; `start` launches the forge sidecar
|
||||
alongside the agent for forge-targeted runs. Tests: arg parsing, `start`
|
||||
delegates to `start --headless`, `resume` delegates to `resume --headless`.
|
||||
5. **`./cli.py orchestrate`** — `cli/orchestrate.py` with `start`, `resume`, `status` subcommands wired into `cli.py`; `start` launches the forge sidecar alongside the agent for forge-targeted runs. Tests: arg parsing, `start` delegates to `start --headless`, `resume` delegates to `resume --headless`.
|
||||
|
||||
## Provenance
|
||||
|
||||
Run provenance is captured (sidecar semantic operation log + run metadata) and
|
||||
exposed through a provenance API. It is deliberately **not** surfaced in the
|
||||
forge — no footer, no run-summary comment. A mutable, unsigned PR comment is not
|
||||
an audit record; the authoritative record lives behind the API where it can be
|
||||
retained and signed. The `watchdog_fired` flag marks runs where the agent did
|
||||
not self-report completion so consumers of the API know the record may be
|
||||
incomplete.
|
||||
Run provenance is captured (sidecar semantic operation log + run metadata) and exposed through a provenance API. It is deliberately **not** surfaced in the forge — no footer, no run-summary comment. A mutable, unsigned PR comment is not an audit record; the authoritative record lives behind the API where it can be retained and signed. The `watchdog_fired` flag marks runs where the agent did not self-report completion so consumers of the API know the record may be incomplete.
|
||||
|
||||
The provenance API's schema, transport, signing, and retention are out of scope
|
||||
for this PRD (control-plane work); bot-bottle here produces the raw material
|
||||
only.
|
||||
The provenance API's schema, transport, signing, and retention are out of scope for this PRD (control-plane work); bot-bottle here produces the raw material only.
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
"""Unit: bot_bottle public Python API (bot_bottle/__init__.py surface).
|
||||
|
||||
Covers start_headless, resume_headless, freeze, and destroy — the four
|
||||
operations the bot-bottle-orchestrator's ProgrammaticBottleRunner uses.
|
||||
All I/O is stubbed so no container is created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle import BottleError, destroy, freeze, resume_headless, start_headless
|
||||
from bot_bottle.log import Die
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_manifest(agent_name: str = "implementer", bottle_name: str = "claude"):
|
||||
manifest = MagicMock()
|
||||
manifest.agents = {agent_name: MagicMock(bottle=bottle_name)}
|
||||
manifest.all_agent_names = [agent_name]
|
||||
manifest.all_bottle_names = [bottle_name]
|
||||
manifest.home_md = None # eager mode — _peek_agent_bottle uses agents dict
|
||||
manifest.require_agent = MagicMock(return_value=None)
|
||||
return manifest
|
||||
|
||||
|
||||
def _metadata(
|
||||
slug: str = "implementer-abc12",
|
||||
agent_name: str = "implementer",
|
||||
backend: str = "docker",
|
||||
):
|
||||
md = MagicMock()
|
||||
md.identity = slug
|
||||
md.agent_name = agent_name
|
||||
md.cwd = "/repo"
|
||||
md.copy_cwd = False
|
||||
md.bottle_names = ["claude"]
|
||||
md.backend = backend
|
||||
return md
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start_headless
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStartHeadless(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._manifest = _make_manifest()
|
||||
patch("bot_bottle.api.ManifestIndex.resolve", return_value=self._manifest).start()
|
||||
self._launch = patch(
|
||||
"bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0)
|
||||
).start()
|
||||
patch(
|
||||
"bot_bottle.api._uniquify_label_headless", side_effect=str
|
||||
).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _spec(self):
|
||||
self._launch.assert_called_once()
|
||||
return self._launch.call_args[0][0]
|
||||
|
||||
def test_returns_slug_on_success(self):
|
||||
slug = start_headless("implementer", prompt="Do it")
|
||||
self.assertEqual("implementer-abc12", slug)
|
||||
|
||||
def test_passes_assume_yes_and_prompt(self):
|
||||
start_headless("implementer", prompt="Do it")
|
||||
kwargs = self._launch.call_args[1]
|
||||
self.assertTrue(kwargs["assume_yes"])
|
||||
self.assertEqual("Do it", kwargs["headless_prompt_text"])
|
||||
|
||||
def test_explicit_bottles_forwarded(self):
|
||||
start_headless("implementer", prompt="Do it", bottles=["dev", "claude"])
|
||||
self.assertEqual(("dev", "claude"), self._spec().bottle_names)
|
||||
|
||||
def test_default_bottle_resolved_from_manifest(self):
|
||||
start_headless("implementer", prompt="Do it")
|
||||
self.assertEqual(("claude",), self._spec().bottle_names)
|
||||
|
||||
def test_forge_env_on_spec(self):
|
||||
env = {"FORGE_GITEA_API": "https://gitea.example.com/api/v1", "FORGE_OWNER": "acme"}
|
||||
start_headless("implementer", prompt="Do it", forge_env=env)
|
||||
self.assertEqual(env, self._spec().forge_env)
|
||||
|
||||
def test_no_forge_env_defaults_to_empty_dict(self):
|
||||
start_headless("implementer", prompt="Do it")
|
||||
self.assertEqual({}, self._spec().forge_env)
|
||||
|
||||
def test_nonzero_exit_raises_bottle_error(self):
|
||||
self._launch.return_value = ("implementer-abc12", 1)
|
||||
with self.assertRaises(BottleError) as ctx:
|
||||
start_headless("implementer", prompt="Do it")
|
||||
self.assertEqual(1, ctx.exception.exit_code)
|
||||
|
||||
def test_no_default_bottle_raises_bottle_error(self):
|
||||
manifest = _make_manifest(bottle_name="")
|
||||
with patch("bot_bottle.api.ManifestIndex.resolve", return_value=manifest):
|
||||
with self.assertRaises(BottleError):
|
||||
start_headless("implementer", prompt="Do it")
|
||||
self._launch.assert_not_called()
|
||||
|
||||
def test_manifest_error_in_resolve_raises_bottle_error(self):
|
||||
from bot_bottle.manifest import ManifestError
|
||||
patch(
|
||||
"bot_bottle.api.ManifestIndex.resolve", side_effect=ManifestError("bad")
|
||||
).start()
|
||||
with self.assertRaises(BottleError):
|
||||
start_headless("implementer", prompt="Do it")
|
||||
self._launch.assert_not_called()
|
||||
|
||||
def test_die_from_launch_bottle_raises_bottle_error(self):
|
||||
self._launch.side_effect = Die(3, "backend exploded")
|
||||
with self.assertRaises(BottleError) as ctx:
|
||||
start_headless("implementer", prompt="Do it")
|
||||
self.assertEqual(3, ctx.exception.exit_code)
|
||||
|
||||
def test_backend_name_forwarded(self):
|
||||
start_headless("implementer", prompt="Do it", backend_name="docker")
|
||||
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
|
||||
|
||||
def test_label_forwarded_to_spec(self):
|
||||
start_headless("implementer", prompt="Do it", label="nightly")
|
||||
self.assertEqual("nightly", self._spec().label)
|
||||
|
||||
def test_color_forwarded_to_spec(self):
|
||||
start_headless("implementer", prompt="Do it", color="green")
|
||||
self.assertEqual("green", self._spec().color)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resume_headless
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResumeHeadless(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._md = _metadata()
|
||||
patch("bot_bottle.api.read_metadata", return_value=self._md).start()
|
||||
manifest = _make_manifest()
|
||||
patch("bot_bottle.api.ManifestIndex.resolve", return_value=manifest).start()
|
||||
self._launch = patch(
|
||||
"bot_bottle.api._launch_bottle", return_value=("implementer-abc12", 0)
|
||||
).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _spec(self):
|
||||
self._launch.assert_called_once()
|
||||
return self._launch.call_args[0][0]
|
||||
|
||||
def test_passes_assume_yes_and_prompt(self):
|
||||
resume_headless("implementer-abc12", prompt="Address review")
|
||||
kwargs = self._launch.call_args[1]
|
||||
self.assertTrue(kwargs["assume_yes"])
|
||||
self.assertEqual("Address review", kwargs["headless_prompt_text"])
|
||||
|
||||
def test_identity_set_on_spec(self):
|
||||
resume_headless("implementer-abc12", prompt="Prompt")
|
||||
self.assertEqual("implementer-abc12", self._spec().identity)
|
||||
|
||||
def test_forge_env_on_spec(self):
|
||||
env = {"FORGE_ISSUE_NUMBER": "42"}
|
||||
resume_headless("implementer-abc12", prompt="Prompt", forge_env=env)
|
||||
self.assertEqual(env, self._spec().forge_env)
|
||||
|
||||
def test_missing_state_raises_bottle_error(self):
|
||||
with patch("bot_bottle.api.read_metadata", return_value=None):
|
||||
with self.assertRaises(BottleError):
|
||||
resume_headless("no-such-abc12", prompt="Prompt")
|
||||
self._launch.assert_not_called()
|
||||
|
||||
def test_nonzero_exit_raises_bottle_error(self):
|
||||
self._launch.return_value = ("implementer-abc12", 2)
|
||||
with self.assertRaises(BottleError) as ctx:
|
||||
resume_headless("implementer-abc12", prompt="Prompt")
|
||||
self.assertEqual(2, ctx.exception.exit_code)
|
||||
|
||||
def test_manifest_error_in_resolve_raises_bottle_error(self):
|
||||
from bot_bottle.manifest import ManifestError
|
||||
patch(
|
||||
"bot_bottle.api.ManifestIndex.resolve", side_effect=ManifestError("bad")
|
||||
).start()
|
||||
with self.assertRaises(BottleError):
|
||||
resume_headless("implementer-abc12", prompt="Prompt")
|
||||
self._launch.assert_not_called()
|
||||
|
||||
def test_die_from_launch_bottle_raises_bottle_error(self):
|
||||
self._launch.side_effect = Die(5, "resume failed")
|
||||
with self.assertRaises(BottleError) as ctx:
|
||||
resume_headless("implementer-abc12", prompt="Prompt")
|
||||
self.assertEqual(5, ctx.exception.exit_code)
|
||||
|
||||
def test_backend_from_metadata_when_not_supplied(self):
|
||||
resume_headless("implementer-abc12", prompt="Prompt")
|
||||
self.assertEqual("docker", self._launch.call_args[1]["backend_name"])
|
||||
|
||||
def test_explicit_backend_overrides_metadata(self):
|
||||
resume_headless(
|
||||
"implementer-abc12", prompt="Prompt", backend_name="smolmachines"
|
||||
)
|
||||
self.assertEqual("smolmachines", self._launch.call_args[1]["backend_name"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# freeze
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFreeze(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
patch("bot_bottle.api.read_metadata", return_value=_metadata()).start()
|
||||
self._freezer = MagicMock()
|
||||
self._get_freezer = patch(
|
||||
"bot_bottle.api.get_freezer", return_value=self._freezer
|
||||
).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def test_calls_commit_slug(self):
|
||||
freeze("implementer-abc12")
|
||||
self._freezer.commit_slug.assert_called_once_with("implementer-abc12")
|
||||
|
||||
def test_backend_from_metadata_when_not_supplied(self):
|
||||
freeze("implementer-abc12")
|
||||
self._get_freezer.assert_called_once_with("docker")
|
||||
|
||||
def test_explicit_backend_used(self):
|
||||
freeze("implementer-abc12", backend_name="smolmachines")
|
||||
self._get_freezer.assert_called_once_with("smolmachines")
|
||||
|
||||
def test_commit_cancelled_raises_bottle_error(self):
|
||||
from bot_bottle.backend.freeze import CommitCancelled
|
||||
self._freezer.commit_slug.side_effect = CommitCancelled("declined")
|
||||
with self.assertRaises(BottleError):
|
||||
freeze("implementer-abc12")
|
||||
|
||||
def test_die_from_freezer_raises_bottle_error(self):
|
||||
self._freezer.commit_slug.side_effect = Die(2, "commit exploded")
|
||||
with self.assertRaises(BottleError) as ctx:
|
||||
freeze("implementer-abc12")
|
||||
self.assertEqual(2, ctx.exception.exit_code)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# destroy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDestroy(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
patch("bot_bottle.api.read_metadata", return_value=_metadata()).start()
|
||||
self._dd = patch("bot_bottle.api._destroy_docker").start()
|
||||
patch("bot_bottle.api.clear_preserve_marker").start()
|
||||
self._cleanup = patch("bot_bottle.api.cleanup_state").start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def test_docker_backend_calls_destroy_docker(self):
|
||||
destroy("implementer-abc12")
|
||||
self._dd.assert_called_once_with("implementer-abc12")
|
||||
|
||||
def test_state_dir_always_cleaned(self):
|
||||
destroy("implementer-abc12")
|
||||
self._cleanup.assert_called_once_with("implementer-abc12")
|
||||
|
||||
def test_smolmachines_backend_calls_destroy_smolmachines(self):
|
||||
patch(
|
||||
"bot_bottle.api.read_metadata",
|
||||
return_value=_metadata(backend="smolmachines"),
|
||||
).start()
|
||||
ds = patch("bot_bottle.api._destroy_smolmachines").start()
|
||||
destroy("implementer-abc12")
|
||||
ds.assert_called_once_with("implementer-abc12")
|
||||
self._dd.assert_not_called()
|
||||
|
||||
def test_other_backend_skips_docker_and_smolmachines(self):
|
||||
patch(
|
||||
"bot_bottle.api.read_metadata",
|
||||
return_value=_metadata(backend="macos-container"),
|
||||
).start()
|
||||
ds = patch("bot_bottle.api._destroy_smolmachines").start()
|
||||
destroy("implementer-abc12")
|
||||
self._dd.assert_not_called()
|
||||
ds.assert_not_called()
|
||||
self._cleanup.assert_called_once_with("implementer-abc12")
|
||||
|
||||
def test_missing_metadata_defaults_to_docker(self):
|
||||
patch("bot_bottle.api.read_metadata", return_value=None).start()
|
||||
destroy("no-state-abc12")
|
||||
self._dd.assert_called_once_with("no-state-abc12")
|
||||
|
||||
def test_explicit_backend_overrides_metadata(self):
|
||||
ds = patch("bot_bottle.api._destroy_smolmachines").start()
|
||||
destroy("implementer-abc12", backend_name="smolmachines")
|
||||
ds.assert_called_once_with("implementer-abc12")
|
||||
self._dd.assert_not_called()
|
||||
|
||||
def test_die_from_backend_raises_bottle_error(self):
|
||||
self._dd.side_effect = Die(1, "compose failed")
|
||||
with self.assertRaises(BottleError):
|
||||
destroy("implementer-abc12")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _destroy_docker (helper)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDestroyDocker(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._compose_down = patch(
|
||||
"bot_bottle.backend.docker.compose.compose_down"
|
||||
).start()
|
||||
self._compose_project_name = patch(
|
||||
"bot_bottle.backend.docker.compose.compose_project_name",
|
||||
return_value="bb-proj",
|
||||
).start()
|
||||
self._state_dir = patch(
|
||||
"bot_bottle.bottle_state.bottle_state_dir",
|
||||
return_value=Path("/fake/state"),
|
||||
).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _run(self, exists: bool) -> None:
|
||||
fake_file = MagicMock()
|
||||
fake_file.exists.return_value = exists
|
||||
with patch(
|
||||
"bot_bottle.backend.docker.compose.compose_file_path",
|
||||
return_value=fake_file,
|
||||
):
|
||||
from bot_bottle.api import _destroy_docker
|
||||
_destroy_docker("slug-1")
|
||||
|
||||
def test_calls_compose_down_when_file_exists(self) -> None:
|
||||
self._run(exists=True)
|
||||
self._compose_down.assert_called_once()
|
||||
|
||||
def test_noop_when_compose_file_absent(self) -> None:
|
||||
self._run(exists=False)
|
||||
self._compose_down.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _destroy_smolmachines (helper)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDestroySmolmachines(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._run = patch("subprocess.run").start()
|
||||
self._run.return_value = MagicMock(returncode=0, stderr="", stdout="")
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def _call(self) -> None:
|
||||
from bot_bottle.api import _destroy_smolmachines
|
||||
_destroy_smolmachines("slug-7")
|
||||
|
||||
def test_issues_stop_then_delete(self) -> None:
|
||||
self._call()
|
||||
self.assertEqual(2, self._run.call_count)
|
||||
first_argv = self._run.call_args_list[0][0][0]
|
||||
self.assertIn("stop", first_argv)
|
||||
second_argv = self._run.call_args_list[1][0][0]
|
||||
self.assertIn("delete", second_argv)
|
||||
|
||||
def test_nonzero_delete_does_not_raise(self) -> None:
|
||||
self._run.side_effect = [
|
||||
MagicMock(returncode=0),
|
||||
MagicMock(returncode=1, stderr="not found", stdout=""),
|
||||
]
|
||||
self._call() # must not raise
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# public surface exported from bot_bottle.__init__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPublicSurface(unittest.TestCase):
|
||||
def test_importable_from_package(self):
|
||||
import bot_bottle
|
||||
for name in ("BottleError", "start_headless", "resume_headless", "freeze", "destroy"):
|
||||
self.assertTrue(hasattr(bot_bottle, name), f"missing: {name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -29,7 +29,7 @@ def _metadata():
|
||||
class ResumeHeadlessTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._launch = patch.object(
|
||||
resume_mod, "_launch_bottle", return_value=("implementer-abc12", 0)
|
||||
resume_mod, "_launch_bottle", return_value=0
|
||||
).start()
|
||||
patch.object(
|
||||
resume_mod, "read_metadata", return_value=_metadata()
|
||||
|
||||
@@ -56,7 +56,7 @@ class TestCmdStartHeadless(unittest.TestCase):
|
||||
return_value=self._manifest,
|
||||
).start()
|
||||
self._launch_mock = patch(
|
||||
"bot_bottle.cli.start._launch_bottle", return_value=("", 0)
|
||||
"bot_bottle.cli.start._launch_bottle", return_value=0
|
||||
).start()
|
||||
# No bottles running by default → no label collision.
|
||||
patch(
|
||||
|
||||
@@ -45,7 +45,7 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
|
||||
self._launch_patch = patch(
|
||||
"bot_bottle.cli.start._launch_bottle",
|
||||
return_value=("", 0),
|
||||
return_value=0,
|
||||
)
|
||||
self._launch_mock = self._launch_patch.start()
|
||||
|
||||
@@ -211,7 +211,7 @@ class TestCmdStartLabelCollision(unittest.TestCase):
|
||||
self._manifest = _make_manifest(["researcher"], ["claude"])
|
||||
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start()
|
||||
self._launch_mock = patch(
|
||||
"bot_bottle.cli.start._launch_bottle", return_value=("", 0),
|
||||
"bot_bottle.cli.start._launch_bottle", return_value=0,
|
||||
).start()
|
||||
# Stub the bottle picker to always return a selection.
|
||||
patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start()
|
||||
|
||||
Reference in New Issue
Block a user