06eed5b236
Research note covering how to spawn bot-bottle agents from Gitea webhook events and reuse the same session (bottle identity + Claude session ID) across an entire PR lifecycle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
152 lines
10 KiB
Markdown
152 lines
10 KiB
Markdown
# Gitea Webhook Agent Dispatch
|
|
|
|
## Question
|
|
|
|
How should bot-bottle spawn and manage agents in response to Gitea PR events — and how do we reuse the same agent (with its full session context) across every event in a PR's lifecycle?
|
|
|
|
## Summary
|
|
|
|
A lightweight webhook receiver maps Gitea PR events to `cli.py` invocations. Spawning is straightforward: the existing work on non-interactive run mode (see [host-dispatch-to-container-agents.md](host-dispatch-to-container-agents.md)) is the missing piece. Session continuity is harder: it requires tracking two identifiers per open PR — the **bottle identity** (bot-bottle's slug for the container state dir) and the **Claude session ID** (the UUID Claude writes to its JSONL transcript). The transcript snapshot mechanism already used by capability-block is the right foundation; it just needs a non-interactive path and a PR-keyed store.
|
|
|
|
## Gitea Webhook Events for PR Lifecycle
|
|
|
|
Gitea fires `X-Gitea-Event: pull_request` (with an `action` field) for most PR state changes. The payload always includes `pull_request.number`, which is the stable key for correlating events to a running agent.
|
|
|
|
| `X-Gitea-Event` value | Relevant `action` values | When it fires |
|
|
|---|---|---|
|
|
| `pull_request` | `opened`, `reopened`, `closed`, `synchronized` | PR created, closed, or pushed to |
|
|
| `pull_request_comment` | `created`, `edited` | Timeline comment posted |
|
|
| `pull_request_review_approved` | — | Review submitted with approval |
|
|
| `pull_request_review_rejected` | — | Review submitted requesting changes |
|
|
| `pull_request_review_comment` | — | Inline code review comment |
|
|
| `pull_request_sync` | — | New commits pushed to the PR branch |
|
|
|
|
`pull_request` with `action: synchronized` and `pull_request_sync` both fire on push; they carry the same information but are separate subscriptions in the webhook config UI. Subscribe to `pull_request` and `pull_request_review` (the umbrella) plus `pull_request_comment` to cover the full lifecycle.
|
|
|
|
The webhook receiver validates the `X-Gitea-Signature-256` HMAC header (SHA-256 of the raw body, keyed by the configured secret) before dispatching.
|
|
|
|
## Spawning an Agent From a Webhook
|
|
|
|
### What we need from bot-bottle
|
|
|
|
The current `cli.py start` is interactive — it prompts y/N and attaches a tty. A webhook handler needs a non-interactive mode that:
|
|
|
|
1. Starts the container for a named agent.
|
|
2. Runs `claude -p "<task>" --output-format json --dangerously-skip-permissions` inside it (no tty, no session picker).
|
|
3. Captures stdout as JSON, extracts `session_id`.
|
|
4. Blocks until Claude exits, then tears down.
|
|
|
|
The [host-dispatch-to-container-agents](host-dispatch-to-container-agents.md) research proposes `cli.py run <agent> <task>` for exactly this. That command is the prerequisite for everything below. It should return the Claude JSON output so callers can extract `session_id`.
|
|
|
|
### Webhook receiver sketch
|
|
|
|
The receiver is a small HTTP service (Flask, FastAPI, or a Go net/http handler) running alongside bot-bottle on the host. It:
|
|
|
|
1. Validates the HMAC signature.
|
|
2. Extracts `pull_request.number` and `X-Gitea-Event` / `action`.
|
|
3. Looks up whether a bottle already exists for this PR number.
|
|
4. Spawns or resumes accordingly (see next section).
|
|
5. Optionally posts a comment back to the PR via Gitea API once Claude finishes.
|
|
|
|
The receiver does not need to be async or queue-based for a single-repo bot, but should at minimum serialize events for the same PR number (a per-PR lock) to avoid two concurrent sessions clobbering each other's transcript.
|
|
|
|
## Reusing the Same Agent Across a PR
|
|
|
|
This is the harder problem. Two separate identities need to be tracked and connected:
|
|
|
|
### Identity 1: bottle identity (bot-bottle slug)
|
|
|
|
The slug is the per-bottle state directory name (`~/.bot-bottle/state/<slug>/`). It's what `cli.py resume <slug>` uses to relaunch a container and mount the preserved state — including the transcript snapshot. This already works for the capability-block flow.
|
|
|
|
### Identity 2: Claude session ID
|
|
|
|
Claude Code's `--output-format json` response includes a `session_id` UUID. Passing `--resume <session_id>` on a subsequent non-interactive run makes Claude continue from exactly that conversation, with full memory of prior tool calls. `--continue` (which maps to `resume_args` in `agent_provider.py`) only picks up the *most recent* session in the project directory — unsafe when multiple sessions may be running concurrently.
|
|
|
|
The session JSONL lives at `~/.claude/projects/<encoded-cwd>/<session_id>.jsonl` inside the container guest. The transcript snapshot (`snapshot_transcript(slug)` in `capability_apply.py`) copies all of `~/.claude` out of the container before teardown, so the JSONL is preserved in `~/.bot-bottle/state/<slug>/transcript/.claude/`. When the bottle is relaunched and the transcript remounted, `claude --resume <session_id>` can find the JSONL at the right path.
|
|
|
|
### Per-PR session registry
|
|
|
|
The receiver needs a small persistent map:
|
|
|
|
```
|
|
PR number → { bottle_identity: str, claude_session_id: str, agent_name: str }
|
|
```
|
|
|
|
The simplest implementation is a JSON file at `~/.bot-bottle/pr-sessions.json`, written after each successful first-run and updated with each resume. A sqlite database is better if concurrent multi-repo support is needed.
|
|
|
|
### Full lifecycle flow
|
|
|
|
```
|
|
PR opened
|
|
→ webhook: action=opened
|
|
→ no entry in pr-sessions.json
|
|
→ cli.py run <agent> "Review PR #N: <title>\n<diff URL>"
|
|
→ starts container, runs claude -p ... --output-format json
|
|
→ on success: captures session_id from JSON output
|
|
→ snapshot_transcript(slug)
|
|
→ tears down container
|
|
→ write pr-sessions.json: { pr: N, slug: <slug>, session_id: <uuid> }
|
|
|
|
PR gets new commit
|
|
→ webhook: action=synchronized OR pull_request_sync
|
|
→ look up pr-sessions.json: found slug + session_id
|
|
→ cli.py run-resume <slug> --claude-session <session_id> "New commits pushed. Review the diff."
|
|
→ relaunches container with transcript snapshot mounted
|
|
→ runs claude -p ... --resume <session_id> --output-format json
|
|
→ captures new session_id (same or rotated)
|
|
→ snapshot_transcript(slug) again
|
|
→ update pr-sessions.json with latest session_id
|
|
|
|
Comment @-mentions bot
|
|
→ webhook: pull_request_comment, action=created
|
|
→ extract comment body, check for bot mention
|
|
→ same resume flow as above with comment as the prompt
|
|
|
|
PR closed / merged
|
|
→ webhook: action=closed
|
|
→ cli.py cleanup <slug> (or equivalent)
|
|
→ remove from pr-sessions.json
|
|
```
|
|
|
|
### What needs to be built
|
|
|
|
| Piece | Status | Notes |
|
|
|---|---|---|
|
|
| `cli.py run <agent> <task>` | Missing | Non-interactive start; see host-dispatch research |
|
|
| `cli.py run-resume <slug> --claude-session <id> <task>` | Missing | Like `resume` but non-interactive, passes `--resume <id>` to claude |
|
|
| `snapshot_transcript` on clean exit | Exists (PRD 0012) | Already called from `start.py`'s session-end path |
|
|
| Transcript remount on resume | Exists | `bottle_state.py::transcript_snapshot_dir` → docker cp in on launch |
|
|
| PR session registry | Missing | Needs to be designed; `~/.bot-bottle/pr-sessions.json` is the simplest start |
|
|
| Webhook receiver service | Missing | New service; needs to be a declared bottle or run as a host process |
|
|
|
|
## Known Rough Edges
|
|
|
|
**Session ID is not available from within the session.** The ID is only in the `--output-format json` result, readable after the process exits. There is no env var or hook that exposes it mid-session ([upstream issue #44607](https://github.com/anthropics/claude-code/issues/44607)). For the webhook bot this is fine — the outer receiver reads it from the subprocess result.
|
|
|
|
**`--continue` vs `--resume <id>`:** The existing `resume_args = ("--continue",)` in `agent_provider.py` picks up the *most recent* session. For an interactive single-user resume this is fine. For a webhook bot that may have multiple open PRs, it is not safe — two PRs' transcripts would collide if they share a project directory encoding. Use `--resume <session_id>` explicitly.
|
|
|
|
**Project directory encoding.** Claude stores sessions keyed by the absolute cwd, encoded as a path. Inside the container the cwd is always `/home/node` or a subdir. As long as every run for the same PR uses the same cwd, `--resume <session_id>` will find the right JSONL. The cwd should be pinned per PR entry in the session registry.
|
|
|
|
**Concurrent events for the same PR.** If two webhooks arrive close together (e.g., push + CI comment), the receiver must serialize them. A per-PR asyncio lock or a simple file lock on the session registry entry is enough.
|
|
|
|
**Context window growth.** Each resume appends to the same session. A PR with many round trips will eventually hit the context limit. Mitigation options: start a fresh Claude session (new `cli.py run`) periodically and carry forward a summary; or rely on Claude's built-in compaction. The session registry could include a turn count to trigger rotation.
|
|
|
|
**Webhook delivery ordering.** Gitea does not guarantee ordered delivery or exactly-once delivery. The receiver should be idempotent (same PR event processed twice should not create two bottles) and should ignore events for closed PRs.
|
|
|
|
## Relationship to Existing Bot-Bottle Infrastructure
|
|
|
|
The transcript snapshot + bottle identity system (PRD 0012, `capability_apply.py`) was designed for the capability-block flow: an operator-triggered resume after a security event. The webhook flow is the same mechanism on a faster loop driven by Gitea events instead of operator action. The implementation delta is:
|
|
|
|
1. Non-interactive run mode (the `cli.py run` gap already identified in host-dispatch research).
|
|
2. Passing `--resume <session_id>` explicitly rather than `--continue`.
|
|
3. A PR-keyed registry to connect PR numbers to bottle identities and session IDs.
|
|
4. A webhook receiver to drive the loop.
|
|
|
|
These are additive changes that sit on top of the existing transcript preservation machinery without altering it.
|
|
|
|
## Recommendation
|
|
|
|
Start with the non-interactive run mode (`cli.py run`) since everything else depends on it. Once that exists, the webhook receiver and session registry are straightforward glue. The receiver should run as a host process (not inside a bottle) since it needs to call `cli.py` and manage the session registry file. Serialize per-PR to avoid concurrency bugs. Use `--resume <session_id>` (not `--continue`) for all resume paths.
|
|
|
|
The PR session registry is deliberately minimal to start — a JSON file is fine. If multi-repo or multi-agent scenarios appear, migrating to sqlite is a one-file change.
|