Forge native integration: PRD + forge library layer #318
@@ -40,11 +40,12 @@ bot-bottle's side of that contract.
|
||||
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; the missing pieces are
|
||||
the headless launch primitive that `bot-bottle-orchestrator` can call, 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.
|
||||
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
|
||||
@@ -60,10 +61,12 @@ accepted as the price of those properties.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. `./cli.py orchestrate start` and `./cli.py orchestrate resume` are the
|
||||
non-interactive counterparts to `start` and `resume`. They accept agent,
|
||||
bottle, and prompt via flags rather than TUI pickers, and exit when the
|
||||
agent process exits.
|
||||
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.
|
||||
@@ -138,65 +141,49 @@ absent the agent's default bottle is used.
|
||||
./cli.py orchestrate status
|
||||
```
|
||||
|
||||
`orchestrate start` is `start_headless` exposed as a subcommand. It prepares
|
||||
the bottle non-interactively, launches the agent in print mode, and exits
|
||||
with the agent's exit code. The caller (`bot-bottle-orchestrator`) manages
|
||||
freeze, state, and Gitea comments 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 `resume_headless` exposed as a subcommand.
|
||||
`orchestrate resume` is the shim over the new `resume --headless` (below).
|
||||
|
||||
`orchestrate status` prints the forge state table.
|
||||
|
||||
### Headless primitives
|
||||
### Headless primitives — what exists vs. what's new
|
||||
|
||||
**`attach_agent_headless`** — new function in `bot_bottle/cli/start.py`:
|
||||
Headless **start** already shipped in #315 and this PRD reuses it as-is:
|
||||
|
||||
```python
|
||||
def attach_agent_headless(
|
||||
bottle: Bottle,
|
||||
*,
|
||||
prompt: str,
|
||||
resume: bool = False,
|
||||
agent_provider_template: str = "claude",
|
||||
startup_args: tuple[str, ...] = (),
|
||||
) -> int:
|
||||
runtime = runtime_for(agent_provider_template)
|
||||
agent_args = list(runtime.bypass_args) # --dangerously-skip-permissions
|
||||
agent_args.extend(startup_args)
|
||||
agent_args.append("--no-interactive")
|
||||
if resume:
|
||||
agent_args.extend(runtime.resume_args) # --continue
|
||||
agent_args.extend(["-p", prompt])
|
||||
return bottle.exec_agent(agent_args, tty=False)
|
||||
- `./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.
|
||||
|
||||
**2. `resume --headless`** — new in `bot_bottle/cli/resume.py`, mirroring the
|
||||
`--headless` flag on `start`:
|
||||
|
||||
```
|
||||
./cli.py resume <slug> --headless --prompt TEXT
|
||||
```
|
||||
|
||||
**`start_headless`** — new function in `bot_bottle/cli/start.py` that mirrors
|
||||
`_launch_bottle` without any TUI steps:
|
||||
|
||||
```python
|
||||
def start_headless(
|
||||
manifest: ManifestIndex,
|
||||
*,
|
||||
agent_name: str,
|
||||
bottle_names: tuple[str, ...],
|
||||
label: str,
|
||||
prompt: str,
|
||||
forge_env: dict[str, str] | None = None,
|
||||
backend_name: str | None = None,
|
||||
) -> tuple[str, int]:
|
||||
"""Non-interactive bottle launch. Returns (slug, exit_code)."""
|
||||
```
|
||||
|
||||
`forge_env` carries the forge context and token to the forge sidecar launched
|
||||
alongside the agent (see below); the agent process itself does not receive the
|
||||
token. The caller freezes the bottle after `start_headless` returns.
|
||||
|
||||
**`resume_headless`** — new function in `bot_bottle/cli/resume.py`:
|
||||
|
||||
```python
|
||||
def resume_headless(slug: str, *, prompt: str, backend_name: str | None = None) -> int:
|
||||
"""Rehydrate a frozen bottle and run one headless prompt. Returns exit_code."""
|
||||
```
|
||||
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
|
||||
|
||||
@@ -428,9 +415,12 @@ the `Forge` abstraction.
|
||||
|
||||
### Implementation chunks
|
||||
|
||||
1. **Headless primitives** — `attach_agent_headless` + `start_headless` (with
|
||||
`forge_env` param) in `cli/start.py`; `resume_headless` in `cli/resume.py`.
|
||||
Tests: no tty, correct arg order, `forge_env` appears in `guest_env`.
|
||||
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,
|
||||
read/write/delete/all helpers, atomic rename. Tests: round-trip JSON, missing
|
||||
@@ -455,7 +445,7 @@ the `Forge` abstraction.
|
||||
6. **`./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`.
|
||||
delegates to `start --headless`, `resume` delegates to `resume --headless`.
|
||||
|
||||
## Provenance as the product
|
||||
|
|
||||
|
||||
|
||||
Reference in New Issue
Block a user
Reword this: we will have a provenance api, but we won’t surface it in the pr