docs(prd): reconcile headless primitives with shipped start --headless

#315 already merged `start --headless` (assume_yes on _launch_bottle +
AgentProvider.headless_prompt). The PRD's proposed start_headless /
attach_agent_headless helpers were redundant with it, and the latter
diverged by hand-rolling --no-interactive/-p instead of using the
headless_prompt provider abstraction. Drop them.

Scope the remaining headless work to what's actually new: a forge_env
hook threaded into the existing _launch_bottle core, and a `resume
--headless` path (resume has no non-interactive entry point today).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WL77TgFxKbs3cidGMG9dz7
This commit is contained in:
2026-06-30 17:46:59 -04:00
parent ebad90bfa9
commit 4cb106b48d
+54 -64
View File
@@ -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 Today an operator must open the TUI, select an agent and bottle, confirm the
preflight, and type prompts interactively. This blocks "issue → PR" automation preflight, and type prompts interactively. This blocks "issue → PR" automation
and produces no durable audit record of what the agent did. The security model 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 already provides the right isolation and egress controls, and `start --headless`
the headless launch primitive that `bot-bottle-orchestrator` can call, a (#315) already gives `bot-bottle-orchestrator` a non-interactive launch path.
forge-interaction surface the agent uses to read context, post comments, and The missing pieces are a headless `resume` counterpart for rehydrating frozen
signal completion, and the provenance trail that makes the audit story legible bottles, a forge-interaction surface the agent uses to read context, post
to reviewers on every PR. 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 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 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 ## Goals / Success Criteria
1. `./cli.py orchestrate start` and `./cli.py orchestrate resume` are the 1. Headless launch already exists: `./cli.py start <agent> --headless --prompt`
non-interactive counterparts to `start` and `resume`. They accept agent, (#315) runs non-interactively with no TUI selectors or y/N preflight. This
bottle, and prompt via flags rather than TUI pickers, and exit when the PRD builds on it rather than re-introducing it. The remaining gap is a
agent process exits. 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 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 `bot-bottle`) and labelled `bot-bottle:<agent-name>` is the trigger
convention. Org membership is verified via the Gitea API at event time. 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 ./cli.py orchestrate status
``` ```
`orchestrate start` is `start_headless` exposed as a subcommand. It prepares `orchestrate start` is a thin shim over the already-shipped `start --headless`
the bottle non-interactively, launches the agent in print mode, and exits (#315): it forwards agent / bottle / label / prompt and adds the forge-specific
with the agent's exit code. The caller (`bot-bottle-orchestrator`) manages wiring (`forge_env`, sidecar launch). It does not re-implement headless launch.
freeze, state, and Gitea comments around it. 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. `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 - `./cli.py start <agent> --headless --prompt TEXT` — no TUI selectors, no y/N
def attach_agent_headless( preflight. Internally `_start_headless()` calls the shared `_launch_bottle()`
bottle: Bottle, with `assume_yes=True` and `headless_prompt_text=prompt`.
*, - The prompt is delivered through `AgentProvider.headless_prompt(prompt)`
prompt: str, claude `-p`, codex positional, pi `-p`. The orchestrator does **not** hand-roll
resume: bool = False, agent args; it relies on this provider abstraction. (An earlier draft proposed
agent_provider_template: str = "claude", `start_headless` / `attach_agent_headless` helpers that constructed
startup_args: tuple[str, ...] = (), `--no-interactive`/`-p` directly — those are dropped as redundant with, and
) -> int: divergent from, what #315 merged.)
runtime = runtime_for(agent_provider_template)
agent_args = list(runtime.bypass_args) # --dangerously-skip-permissions Two additions are needed on top of #315:
agent_args.extend(startup_args)
agent_args.append("--no-interactive") **1. A `forge_env` hook on the headless launch path.** The orchestrator needs to
if resume: pass forge context + token through to the forge sidecar launched alongside the
agent_args.extend(runtime.resume_args) # --continue agent. This is a parameter threaded into `_launch_bottle` (the same core
agent_args.extend(["-p", prompt]) `start --headless` already uses), not a parallel launch function. The agent
return bottle.exec_agent(agent_args, tty=False) 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 It rehydrates a frozen bottle and runs one headless prompt via the same
`_launch_bottle` without any TUI steps: `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
```python than a rename of an existing helper.
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."""
```
### Forge sidecar ### Forge sidecar
@@ -428,9 +415,12 @@ the `Forge` abstraction.
### Implementation chunks ### Implementation chunks
1. **Headless primitives**`attach_agent_headless` + `start_headless` (with 1. **Headless additions on top of #315** — thread a `forge_env` parameter into
`forge_env` param) in `cli/start.py`; `resume_headless` in `cli/resume.py`. the existing `_launch_bottle` core (the one `start --headless` already uses);
Tests: no tty, correct arg order, `forge_env` appears in `guest_env`. 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, 2. **Forge state**`contrib/gitea/forge_state.py`: `ForgeState` dataclass,
read/write/delete/all helpers, atomic rename. Tests: round-trip JSON, missing 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`, 6. **`./cli.py orchestrate`** — `cli/orchestrate.py` with `start`, `resume`,
`status` subcommands wired into `cli.py`; `start` launches the forge sidecar `status` subcommands wired into `cli.py`; `start` launches the forge sidecar
alongside the agent for forge-targeted runs. Tests: arg parsing, `start` 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 ## Provenance as the product