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>
10 KiB
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) 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:
- Starts the container for a named agent.
- Runs
claude -p "<task>" --output-format json --dangerously-skip-permissionsinside it (no tty, no session picker). - Captures stdout as JSON, extracts
session_id. - Blocks until Claude exits, then tears down.
The host-dispatch-to-container-agents 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:
- Validates the HMAC signature.
- Extracts
pull_request.numberandX-Gitea-Event/action. - Looks up whether a bottle already exists for this PR number.
- Spawns or resumes accordingly (see next section).
- 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). 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:
- Non-interactive run mode (the
cli.py rungap already identified in host-dispatch research). - Passing
--resume <session_id>explicitly rather than--continue. - A PR-keyed registry to connect PR numbers to bottle identities and session IDs.
- 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.