docs: update forge PRD — orchestrator split, done signal, org targeting, forge env vars
This commit is contained in:
@@ -8,84 +8,112 @@
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Add a webhook-driven orchestration layer that lets Gitea issues and PR comments
|
Add a webhook-driven orchestration layer that lets Gitea issues and PR comments
|
||||||
drive bot-bottle sessions end-to-end — no operator in the loop for the happy
|
drive bot-bottle sessions end-to-end with no operator in the loop for the happy
|
||||||
path. An issue assigned to the agent user and labelled with a bottle name
|
path. An issue assigned to a member of the configured agent org and labelled
|
||||||
triggers a headless bottle launch; the bottle processes the issue, opens a PR,
|
with an agent name triggers a headless bottle launch; the bottle processes the
|
||||||
and is frozen. Subsequent PR comments rehydrate the bottle with the comment as
|
issue, opens a PR, and posts a done-comment via the Gitea API (through
|
||||||
input. The PR is destroyed when it is closed. Every run emits a provenance
|
cred-proxy) before exiting. The orchestrator detects the done-comment, freezes
|
||||||
footer recording the agent identity, bottle, model, egress activity, and
|
the bottle, and attaches a provenance footer. Subsequent PR comments rehydrate
|
||||||
gitleaks outcome so each PR carries a verifiable audit trail of how it was
|
the frozen bottle. The bottle is destroyed when the PR closes.
|
||||||
produced.
|
|
||||||
|
The separation of concerns across the two layers: bot-bottle owns the headless
|
||||||
|
launch primitives, forge state, Gitea client, and provenance builder.
|
||||||
|
`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
|
## Problem
|
||||||
|
|
||||||
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 loop is fine for exploratory
|
preflight, and type prompts interactively. This blocks "issue → PR" automation
|
||||||
work but blocks "issue → PR" automation: nothing triggers a bottle from a forge
|
and produces no durable audit record of what the agent did. The security model
|
||||||
event, and nothing captures what the agent did in a durable, PR-visible record.
|
already provides the right isolation and egress controls; the missing pieces are
|
||||||
The security model already produces the right isolation and egress controls; the
|
the headless launch primitive that `bot-bottle-orchestrator` can call, the
|
||||||
missing piece is the orchestration layer that closes the loop between forge
|
in-bottle Gitea API access the agent uses to signal completion, and the
|
||||||
events and running bottles, plus a provenance trail that makes the audit story
|
provenance trail that makes the audit story legible to reviewers on every PR.
|
||||||
legible to reviewers.
|
|
||||||
|
|
||||||
## Goals / Success Criteria
|
## Goals / Success Criteria
|
||||||
|
|
||||||
1. `./cli.py forge listen` starts a webhook listener. Gitea delivers issue and
|
1. `./cli.py orchestrate start` and `./cli.py orchestrate resume` are the
|
||||||
PR events to it.
|
non-interactive counterparts to `start` and `resume`. They accept agent,
|
||||||
2. An issue opened with assignee matching the configured agent Gitea username
|
bottle, and prompt via flags rather than TUI pickers, and exit when the
|
||||||
and at least one `bot-bottle:<agent-name>` label launches a headless bottle.
|
agent process exits.
|
||||||
The issue title + body is the initial prompt.
|
2. An issue assigned to a member of the configured org (`FORGE_ORG`, default
|
||||||
3. The bottle runs `claude --dangerously-skip-permissions --no-interactive -p
|
`bot-bottle`) and labelled `bot-bottle:<agent-name>` is the trigger
|
||||||
"<prompt>"` (non-interactive print mode). When it exits, the orchestrator
|
convention. Org membership is verified via the Gitea API at event time.
|
||||||
freezes the bottle and posts a comment with the provenance footer.
|
3. Forge-targeted bottles receive a set of env vars at launch
|
||||||
4. A new comment on the PR associated with the issue rehydrates the bottle with
|
(`FORGE_GITEA_API`, `FORGE_OWNER`, `FORGE_REPO`, `FORGE_ISSUE_NUMBER`) so
|
||||||
`claude ... --continue -p "<comment body>"` and re-freezes on exit.
|
the agent knows where to post its done-comment without hardcoding forge
|
||||||
5. Closing the PR destroys the bottle and cleans up forge state.
|
context in the agent manifest.
|
||||||
6. Every comment the orchestrator posts includes a provenance footer: agent
|
4. The agent's egress policy for forge runs includes `gitea.<host>` with Bearer
|
||||||
name, bottle name(s), model, egress summary, gitleaks pass/fail, start time,
|
auth injected by cred-proxy, enabling direct Gitea API calls from inside the
|
||||||
and duration.
|
bottle.
|
||||||
7. Forge state (issue → slug mapping) survives orchestrator restarts: a new
|
5. The done-comment the agent posts is the done signal. A watchdog timeout
|
||||||
`listen` process picks up in-flight bottles from the forge state directory.
|
(configurable, default 30 min) causes the orchestrator to post the
|
||||||
8. `./cli.py forge status` lists active forge-managed bottles and their
|
done-comment on the agent's behalf if the agent exits without posting one.
|
||||||
associated issue/PR URLs.
|
6. Every orchestrator-posted comment ends with a provenance footer: agent name,
|
||||||
9. Unit tests cover: label parsing, forge state read/write, provenance footer
|
bottle name(s), slug, start time, duration, exit code, gitleaks result, and
|
||||||
rendering, headless launch path (no TUI calls), orchestrator event dispatch.
|
egress summary.
|
||||||
|
7. Forge state (issue → slug, status) is persisted to disk 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
|
||||||
|
read/write, provenance footer rendering, headless launch arg construction,
|
||||||
|
forge env var injection, echo-loop guard.
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
- Webhook signature verification (HMAC-SHA256 of the `X-Gitea-Signature` header).
|
- Webhook signature verification (HMAC-SHA256). Added as a follow-up.
|
||||||
Can be added as a follow-up; the listener accepts all POSTs for now.
|
- The `bot-bottle-orchestrator` binary itself — this PRD covers bot-bottle's
|
||||||
- GitHub or GitLab event support.
|
side of the interface only. The orchestrator is a separate project.
|
||||||
|
- GitHub or GitLab support.
|
||||||
- Multiple simultaneous forge bottles per issue.
|
- Multiple simultaneous forge bottles per issue.
|
||||||
- Automatic retry on agent error exit.
|
- Automatic retry on agent error exit.
|
||||||
- Bottle destruction on issue close (only PR close is in scope; issue close is
|
- Bottle destruction on issue close (PR close only; issue close is ambiguous).
|
||||||
ambiguous — the issue may close before the PR does).
|
- Concurrent multi-issue handling (one blocking run per orchestrator process).
|
||||||
- Auto-discovery of repos to watch; the operator configures the Gitea webhook
|
- A monitoring dashboard (orchestrator-side concern).
|
||||||
URL manually.
|
|
||||||
- Parallelism between the orchestrator and the running bottle (one active
|
|
||||||
bottle per issue at a time; a new comment while the bottle is running is
|
|
||||||
queued by re-freezing after each exit).
|
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
### Label convention
|
### Targeting convention
|
||||||
|
|
||||||
An issue is forge-targeted when **both** of the following are true:
|
An issue is forge-targeted when **both** hold:
|
||||||
|
|
||||||
- Assignee login matches `FORGE_AGENT_USER` env var (default: `didericis-claude`).
|
- 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
|
- At least one label has the prefix `bot-bottle:`. The suffix names the agent
|
||||||
manifest, e.g. `bot-bottle:implementer` → agent `implementer`.
|
manifest, e.g. `bot-bottle:implementer` → agent `implementer`.
|
||||||
|
|
||||||
If the label suffix matches no known agent, the orchestrator posts an error
|
`FORGE_ORG` is read at orchestrate-command startup. It is not embedded in
|
||||||
comment and does nothing.
|
manifests or state files; the orchestrator stamps its value into log output for
|
||||||
|
auditability.
|
||||||
|
|
||||||
Optionally, a second label `bot-bottle-bottle:<bottle-name>` overrides the
|
An optional label `bot-bottle-bottle:<name>` overrides bottle selection. When
|
||||||
bottle selection (analagous to multi-bottle selection in PRD 0066). When absent,
|
absent the agent's default bottle is used.
|
||||||
the agent's default bottle is used.
|
|
||||||
|
|
||||||
### Headless launch — `attach_agent_headless`
|
### `./cli.py orchestrate` — the thin wrapper
|
||||||
|
|
||||||
A new function in `bot_bottle/cli/start.py`:
|
```
|
||||||
|
./cli.py orchestrate start --agent AGENT [--bottle BOTTLE ...] --prompt PROMPT
|
||||||
|
[--label LABEL] [--backend BACKEND]
|
||||||
|
./cli.py orchestrate resume --slug SLUG --prompt PROMPT [--backend BACKEND]
|
||||||
|
./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 resume` is `resume_headless` exposed as a subcommand.
|
||||||
|
|
||||||
|
`orchestrate status` prints the forge state table.
|
||||||
|
|
||||||
|
### Headless primitives
|
||||||
|
|
||||||
|
**`attach_agent_headless`** — new function in `bot_bottle/cli/start.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def attach_agent_headless(
|
def attach_agent_headless(
|
||||||
@@ -96,11 +124,6 @@ def attach_agent_headless(
|
|||||||
agent_provider_template: str = "claude",
|
agent_provider_template: str = "claude",
|
||||||
startup_args: tuple[str, ...] = (),
|
startup_args: tuple[str, ...] = (),
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run the provider CLI inside bottle in non-interactive print mode.
|
|
||||||
|
|
||||||
Blocks until the agent exits; returns the exit code. No tty.
|
|
||||||
resume=True adds --continue so the agent resumes its last session
|
|
||||||
before processing prompt."""
|
|
||||||
runtime = runtime_for(agent_provider_template)
|
runtime = runtime_for(agent_provider_template)
|
||||||
agent_args = list(runtime.bypass_args) # --dangerously-skip-permissions
|
agent_args = list(runtime.bypass_args) # --dangerously-skip-permissions
|
||||||
agent_args.extend(startup_args)
|
agent_args.extend(startup_args)
|
||||||
@@ -111,15 +134,8 @@ def attach_agent_headless(
|
|||||||
return bottle.exec_agent(agent_args, tty=False)
|
return bottle.exec_agent(agent_args, tty=False)
|
||||||
```
|
```
|
||||||
|
|
||||||
The system prompt from the agent's manifest `.md` file is still applied via
|
**`start_headless`** — new function in `bot_bottle/cli/start.py` that mirrors
|
||||||
`--append-system-prompt-file` in `startup_args` (provisioned by
|
`_launch_bottle` without any TUI steps:
|
||||||
`ClaudeAgentProvider.provision_prompt`). The `-p` arg is the user-visible
|
|
||||||
prompt the issue or comment supplies.
|
|
||||||
|
|
||||||
### Headless start — `start_headless`
|
|
||||||
|
|
||||||
A new function in `bot_bottle/cli/start.py` that mirrors `_launch_bottle` but
|
|
||||||
skips all TUI steps:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def start_headless(
|
def start_headless(
|
||||||
@@ -129,42 +145,80 @@ def start_headless(
|
|||||||
bottle_names: tuple[str, ...],
|
bottle_names: tuple[str, ...],
|
||||||
label: str,
|
label: str,
|
||||||
prompt: str,
|
prompt: str,
|
||||||
|
forge_env: dict[str, str] | None = None,
|
||||||
backend_name: str | None = None,
|
backend_name: str | None = None,
|
||||||
) -> tuple[str, int]:
|
) -> tuple[str, int]:
|
||||||
"""Non-interactive bottle launch for forge-driven runs.
|
"""Non-interactive bottle launch. Returns (slug, exit_code)."""
|
||||||
|
|
||||||
Prepares the bottle, runs attach_agent_headless, and freezes on exit.
|
|
||||||
Returns (slug, exit_code). Does not prompt the operator or open a tty.
|
|
||||||
Raises on backend errors."""
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`start_headless`:
|
`forge_env` is merged into the bottle's `guest_env` so the agent receives the
|
||||||
1. Builds a `BottleSpec` with `copy_cwd=False`, `color=""`.
|
forge context as env vars (see below). The caller freezes the bottle after
|
||||||
2. Calls `backend.prepare` directly (no preflight render, no y/N prompt).
|
`start_headless` returns.
|
||||||
3. Enters `backend.launch(plan)` and calls `attach_agent_headless(bottle, prompt=prompt)`.
|
|
||||||
4. Captures session state and returns `(slug, exit_code)`.
|
|
||||||
|
|
||||||
The caller (orchestrator) is responsible for calling
|
**`resume_headless`** — new function in `bot_bottle/cli/resume.py`:
|
||||||
`get_freezer(backend_name).commit_slug(slug)` after the bottle exits.
|
|
||||||
|
|
||||||
### Headless resume — `resume_headless`
|
|
||||||
|
|
||||||
A new function in `bot_bottle/cli/resume.py` that mirrors `cmd_resume` but
|
|
||||||
non-interactively:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def resume_headless(
|
def resume_headless(slug: str, *, prompt: str, backend_name: str | None = None) -> int:
|
||||||
slug: str,
|
|
||||||
*,
|
|
||||||
prompt: str,
|
|
||||||
backend_name: str | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Rehydrate a frozen bottle and run one headless prompt. Returns exit_code."""
|
"""Rehydrate a frozen bottle and run one headless prompt. Returns exit_code."""
|
||||||
```
|
```
|
||||||
|
|
||||||
### Forge state — `bot_bottle/contrib/gitea/forge_state.py`
|
### Forge env vars
|
||||||
|
|
||||||
Per-issue tracking persisted to disk:
|
The orchestrator builds this dict and passes it to `start_headless` as
|
||||||
|
`forge_env`:
|
||||||
|
|
||||||
|
| Var | Example | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `FORGE_GITEA_API` | `https://gitea.dideric.is/api/v1` | Base URL for Gitea API calls |
|
||||||
|
| `FORGE_OWNER` | `didericis` | Repo owner |
|
||||||
|
| `FORGE_REPO` | `bot-bottle` | Repo name |
|
||||||
|
| `FORGE_ISSUE_NUMBER` | `317` | Issue that triggered the run |
|
||||||
|
| `FORGE_PR_NUMBER` | `318` | PR to comment on (empty until PR exists) |
|
||||||
|
|
||||||
|
The agent's system prompt (from the manifest) instructs it to post a comment to
|
||||||
|
`$FORGE_GITEA_API/repos/$FORGE_OWNER/$FORGE_REPO/issues/$FORGE_ISSUE_NUMBER/comments`
|
||||||
|
when it finishes a work unit. The instruction is part of the forge-specific
|
||||||
|
agent prompt, not the base agent manifest, so non-forge runs are unaffected.
|
||||||
|
|
||||||
|
### Gitea egress for forge-targeted bottles
|
||||||
|
|
||||||
|
Forge-targeted bottles get an additional egress route injected by the
|
||||||
|
orchestrator at launch time. This is passed as an extra `EgressRoute` in the
|
||||||
|
`BottleSpec` (or via the forge env and bottle manifest) rather than requiring
|
||||||
|
operators to add it to every agent manifest:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
host: gitea.dideric.is
|
||||||
|
auth:
|
||||||
|
scheme: Bearer
|
||||||
|
token_env: GITEA_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
The cred-proxy injects the token; the agent never sees the raw credential.
|
||||||
|
|
||||||
|
### Done signal and watchdog
|
||||||
|
|
||||||
|
The agent posts a Gitea comment when it finishes a work unit. The orchestrator
|
||||||
|
webhook listener receives the `issue_comment` event and:
|
||||||
|
|
||||||
|
1. Verifies the commenter is a member of `FORGE_ORG`.
|
||||||
|
2. Reads the forge state for `(owner, repo, issue_number)`.
|
||||||
|
3. If `status == "running"`, treats the comment as the done signal: freezes the
|
||||||
|
bottle, appends the provenance footer to the same comment thread, sets
|
||||||
|
`status = "frozen"`.
|
||||||
|
|
||||||
|
**Watchdog**: the orchestrator tracks `last_checkin_at` in forge state. 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 posts the provenance footer comment on behalf of the agent and
|
||||||
|
freezes the bottle.
|
||||||
|
|
||||||
|
Echo-loop guard: comments from members of `FORGE_ORG` that are not the
|
||||||
|
currently-running slug's agent user are still dispatched as resume triggers, not
|
||||||
|
as done signals. The comment-is-done-signal path checks that
|
||||||
|
`comment.user.login == agent_git_user` (read from forge state).
|
||||||
|
|
||||||
|
### Forge state — `bot_bottle/contrib/gitea/forge_state.py`
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.bot-bottle/forge/
|
~/.bot-bottle/forge/
|
||||||
@@ -182,14 +236,16 @@ Schema:
|
|||||||
"agent_name": "implementer",
|
"agent_name": "implementer",
|
||||||
"bottle_names": ["claude"],
|
"bottle_names": ["claude"],
|
||||||
"backend_name": "docker",
|
"backend_name": "docker",
|
||||||
|
"agent_git_user": "didericis-claude",
|
||||||
"issue_number": 17,
|
"issue_number": 17,
|
||||||
"owner": "didericis",
|
"owner": "didericis",
|
||||||
"repo": "bot-bottle",
|
"repo": "bot-bottle",
|
||||||
"status": "frozen"
|
"status": "frozen",
|
||||||
|
"last_checkin_at": "2026-06-29T12:04:12-04:00"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`status` is one of `"running"` | `"frozen"` | `"destroyed"`.
|
`status`: `"running"` | `"frozen"` | `"destroyed"`.
|
||||||
|
|
||||||
Public API:
|
Public API:
|
||||||
|
|
||||||
@@ -200,23 +256,26 @@ def delete_forge_state(owner: str, repo: str, issue_number: int) -> None: ...
|
|||||||
def all_forge_states() -> list[ForgeState]: ...
|
def all_forge_states() -> list[ForgeState]: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
### Provenance — `bot_bottle/contrib/gitea/provenance.py`
|
Writes use atomic rename (`os.replace`) for crash safety.
|
||||||
|
|
||||||
Reads bottle metadata and egress log summary, produces a markdown section:
|
### Provenance — `bot_bottle/contrib/gitea/provenance.py`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def build_provenance_footer(
|
def build_provenance_footer(
|
||||||
slug: str,
|
slug: str,
|
||||||
*,
|
*,
|
||||||
|
agent_name: str,
|
||||||
|
bottle_names: tuple[str, ...],
|
||||||
started_at: str,
|
started_at: str,
|
||||||
finished_at: str,
|
finished_at: str,
|
||||||
exit_code: int,
|
exit_code: int,
|
||||||
|
watchdog_fired: bool = False,
|
||||||
egress_log_path: Path | None = None,
|
egress_log_path: Path | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return a markdown string suitable for appending to a PR/comment body."""
|
"""Return a markdown string for appending to a Gitea comment body."""
|
||||||
```
|
```
|
||||||
|
|
||||||
Output format (collapsed by default via `<details>`):
|
Output (collapsed by default):
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
<details><summary>🔬 Run provenance</summary>
|
<details><summary>🔬 Run provenance</summary>
|
||||||
@@ -230,149 +289,64 @@ Output format (collapsed by default via `<details>`):
|
|||||||
| duration | 4m 12s |
|
| duration | 4m 12s |
|
||||||
| exit | 0 ✓ |
|
| exit | 0 ✓ |
|
||||||
| gitleaks | ✓ no secrets detected |
|
| gitleaks | ✓ no secrets detected |
|
||||||
|
| done signal | agent comment *(or: watchdog — agent did not check in)* |
|
||||||
|
|
||||||
**Egress summary** (deny-by-default; routes allowed: 2)
|
**Egress** (deny-by-default; 3 routes allowed)
|
||||||
- `api.anthropic.com` — Bearer auth
|
- `api.anthropic.com` — Bearer auth
|
||||||
- `gitea.dideric.is` — unauthenticated
|
- `gitea.dideric.is` — Bearer auth
|
||||||
|
- `pypi.org` — unauthenticated
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
```
|
```
|
||||||
|
|
||||||
The egress summary is read from the egress log written by the egress proxy
|
The egress summary is read from `~/.bot-bottle/state/<slug>/egress/`. When
|
||||||
sidecar in `~/.bot-bottle/state/<slug>/egress/`. When unavailable (backend
|
unavailable the section is omitted. `watchdog_fired=True` changes the
|
||||||
has no egress log), the section is omitted rather than erroring.
|
"done signal" row to warn reviewers.
|
||||||
|
|
||||||
### Gitea API client — `bot_bottle/contrib/gitea/client.py`
|
### Gitea client — `bot_bottle/contrib/gitea/client.py`
|
||||||
|
|
||||||
Thin stdlib-only HTTP wrapper used by the orchestrator:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class GiteaClient:
|
class GiteaClient:
|
||||||
def __init__(self, *, api_url: str) -> None: ...
|
def __init__(self, *, api_url: str) -> None: ...
|
||||||
|
def is_org_member(self, org: str, username: str) -> bool: ...
|
||||||
def post_comment(self, owner: str, repo: str, issue_number: int, body: str) -> None: ...
|
def post_comment(self, owner: str, repo: str, issue_number: int, body: str) -> None: ...
|
||||||
def get_pr_for_issue(self, owner: str, repo: str, issue_number: int) -> int | None:
|
def get_pr_for_issue(self, owner: str, repo: str, issue_number: int) -> int | None: ...
|
||||||
"""Return the PR number whose body references issue_number, or None."""
|
def is_pr_open(self, owner: str, repo: str, pr_number: int) -> bool: ...
|
||||||
def close_bottle_is_pr_open(self, owner: str, repo: str, pr_number: int) -> bool: ...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Authentication is not configured in the client — the egress layer injects the
|
Auth is not configured in the client — the egress layer injects the token on
|
||||||
Gitea token on the way out (same pattern as `GiteaDeployKeyProvisioner`).
|
the way out, matching the existing `GiteaDeployKeyProvisioner` pattern.
|
||||||
|
|
||||||
### Orchestrator — `bot_bottle/contrib/gitea/orchestrator.py`
|
### Implementation chunks
|
||||||
|
|
||||||
```python
|
1. **Headless primitives** — `attach_agent_headless` + `start_headless` (with
|
||||||
class ForgeOrchestrator:
|
`forge_env` param) in `cli/start.py`; `resume_headless` in `cli/resume.py`.
|
||||||
def __init__(
|
Tests: no tty, correct arg order, `forge_env` appears in `guest_env`.
|
||||||
self,
|
|
||||||
*,
|
|
||||||
manifest: ManifestIndex,
|
|
||||||
gitea_client: GiteaClient,
|
|
||||||
agent_user: str,
|
|
||||||
backend_name: str | None = None,
|
|
||||||
) -> None: ...
|
|
||||||
|
|
||||||
def on_issue_opened(self, event: dict) -> None: ...
|
2. **Forge state** — `contrib/gitea/forge_state.py`: `ForgeState` dataclass,
|
||||||
def on_issue_comment_created(self, event: dict) -> None: ...
|
read/write/delete/all helpers, atomic rename. Tests: round-trip JSON, missing
|
||||||
def on_pull_request_closed(self, event: dict) -> None: ...
|
file → None, atomic write.
|
||||||
```
|
|
||||||
|
|
||||||
`on_issue_opened`:
|
3. **Gitea client** — `contrib/gitea/client.py`: `is_org_member`,
|
||||||
1. Extract `owner`, `repo`, `issue_number`, `assignees`, `labels`, `title`, `body`.
|
`post_comment`, `get_pr_for_issue`, `is_pr_open`. Tests: mock
|
||||||
2. Verify assignee contains `agent_user`. Bail silently if not.
|
`urllib.request.urlopen`, assert payloads and 404-as-false for membership.
|
||||||
3. Parse `bot-bottle:<agent-name>` label. Post error comment + return if absent or unknown.
|
|
||||||
4. Parse optional `bot-bottle-bottle:<bottle-name>` label; else `bottle_names = ()`.
|
|
||||||
5. Build prompt: `f"Issue #{issue_number}: {title}\n\n{body}"`.
|
|
||||||
6. Call `start_headless(manifest, agent_name=..., bottle_names=..., label=..., prompt=...)`.
|
|
||||||
7. Write forge state (status=`"running"`).
|
|
||||||
8. On bottle exit: `get_freezer(backend).commit_slug(slug)`.
|
|
||||||
9. Update forge state `status="frozen"`, set `pr_number` by querying Gitea for a PR
|
|
||||||
referencing the issue.
|
|
||||||
10. Post provenance comment on the PR (or the issue if no PR found).
|
|
||||||
|
|
||||||
`on_issue_comment_created`:
|
4. **Provenance** — `contrib/gitea/provenance.py`: `build_provenance_footer`.
|
||||||
1. Look up forge state by `(owner, repo, issue_number)`. Skip if not found or destroyed.
|
Tests: required fields present, watchdog row text, egress omitted when log
|
||||||
2. Skip if comment author is `agent_user` (prevents echo loops).
|
absent.
|
||||||
3. Skip if forge state `status == "running"` (already active; queue is out of scope).
|
|
||||||
4. Update forge state `status="running"`.
|
|
||||||
5. Call `resume_headless(slug, prompt=comment_body)`.
|
|
||||||
6. Re-freeze: `get_freezer(backend).commit_slug(slug)`.
|
|
||||||
7. Update forge state `status="frozen"`.
|
|
||||||
8. Post provenance comment.
|
|
||||||
|
|
||||||
`on_pull_request_closed`:
|
5. **`./cli.py orchestrate`** — `cli/orchestrate.py` with `start`, `resume`,
|
||||||
1. Match `pr_number` against all forge states for `(owner, repo)`.
|
`status` subcommands wired into `cli.py`. Tests: arg parsing, `start`
|
||||||
2. Destroy the bottle: call the backend's teardown for `slug` and delete the image.
|
delegates to `start_headless`, `resume` delegates to `resume_headless`.
|
||||||
3. Set `status="destroyed"`.
|
|
||||||
|
|
||||||
### Webhook listener — `bot_bottle/contrib/gitea/webhook_server.py`
|
|
||||||
|
|
||||||
Small `http.server.BaseHTTPRequestHandler` that:
|
|
||||||
- Accepts `POST /webhook`.
|
|
||||||
- Reads the `X-Gitea-Event` header to select the handler.
|
|
||||||
- Deserializes the JSON body and calls the orchestrator's matching `on_*` method.
|
|
||||||
- Returns HTTP 200 for known events, 204 for unknown (no-op).
|
|
||||||
|
|
||||||
Runs in the same thread as the CLI (blocking `serve_forever`). The orchestrator
|
|
||||||
handlers are synchronous; long-running launches block the listener thread for
|
|
||||||
the duration. (Concurrent multi-issue handling is out of scope for the MVP.)
|
|
||||||
|
|
||||||
### CLI — `bot_bottle/cli/forge.py`
|
|
||||||
|
|
||||||
```
|
|
||||||
./cli.py forge listen [--host HOST] [--port PORT] [--agent-user USER]
|
|
||||||
./cli.py forge status
|
|
||||||
```
|
|
||||||
|
|
||||||
`listen` defaults: `--host 0.0.0.0 --port 8765 --agent-user $FORGE_AGENT_USER`.
|
|
||||||
|
|
||||||
`status` prints a table of active forge bottles (slug, issue URL, PR URL, status).
|
|
||||||
|
|
||||||
`forge` is registered in `cli.py` alongside `start`, `resume`, `commit`, etc.
|
|
||||||
|
|
||||||
## Provenance as the product
|
## Provenance as the product
|
||||||
|
|
||||||
Every comment the orchestrator posts ends with the provenance footer. The footer
|
Every orchestrator-posted comment ends with the provenance footer — non-optional
|
||||||
is not optional and not configurable off. This is load-bearing: it is the audit
|
and not configurable off. PRs that land without a footer were not produced by
|
||||||
trail that lets human reviewers verify what the agent did, what credentials it
|
this integration. The `watchdog_fired` flag in the footer flags runs where the
|
||||||
had access to, what it called out to, and whether gitleaks caught anything.
|
agent did not self-report completion, so reviewers know the audit trail may be
|
||||||
PRs that land without a provenance footer were not opened by the forge
|
incomplete.
|
||||||
integration.
|
|
||||||
|
|
||||||
The footer also links back to the bot-bottle repo (anchored to the commit SHA
|
The footer links to the bot-bottle repo pinned to the commit SHA active during
|
||||||
used for the run, not `main`) so the policy that governed the run is pinned in
|
the run (not `main`), so the policy that governed the run is permanently
|
||||||
the PR history.
|
anchored in the PR history.
|
||||||
|
|
||||||
## Implementation chunks
|
|
||||||
|
|
||||||
1. **Headless primitives** — `attach_agent_headless` + `start_headless` in
|
|
||||||
`cli/start.py`; `resume_headless` in `cli/resume.py`. Tests: assert no tty,
|
|
||||||
correct arg construction with and without `resume=True`.
|
|
||||||
|
|
||||||
2. **Forge state** — `contrib/gitea/forge_state.py`: `ForgeState` dataclass,
|
|
||||||
`write_forge_state`, `read_forge_state`, `delete_forge_state`,
|
|
||||||
`all_forge_states`. Tests: round-trip JSON, missing file returns None,
|
|
||||||
concurrent-write safety via atomic rename.
|
|
||||||
|
|
||||||
3. **Gitea client** — `contrib/gitea/client.py`: `post_comment`,
|
|
||||||
`get_pr_for_issue`. Tests: mock `urllib.request.urlopen` and assert payloads.
|
|
||||||
|
|
||||||
4. **Provenance** — `contrib/gitea/provenance.py`: `build_provenance_footer`.
|
|
||||||
Tests: verify footer contains all required fields; verify graceful omission
|
|
||||||
when egress log is absent.
|
|
||||||
|
|
||||||
5. **Orchestrator** — `contrib/gitea/orchestrator.py`: `ForgeOrchestrator`
|
|
||||||
with the three `on_*` handlers. Tests: mock `start_headless`,
|
|
||||||
`resume_headless`, `get_freezer`, `GiteaClient`, `forge_state.*`; assert
|
|
||||||
correct calls for each event path (happy path, unknown label, echo-loop
|
|
||||||
prevention, status=running guard).
|
|
||||||
|
|
||||||
6. **Webhook listener** — `contrib/gitea/webhook_server.py`. Tests: mock
|
|
||||||
orchestrator methods; assert correct dispatch per `X-Gitea-Event` value and
|
|
||||||
correct HTTP status codes.
|
|
||||||
|
|
||||||
7. **CLI wiring** — `cli/forge.py` + registration in `cli.py`. Tests:
|
|
||||||
`cmd_forge_status` tabular output, `cmd_forge_listen` argument parsing.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
None.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user