From 06eed5b236dc8d6dc203e89a597f4af7161b18ae Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 4 Jun 2026 01:04:02 +0000 Subject: [PATCH] docs(research): gitea webhook agent dispatch and PR session continuity 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 --- docs/research/gitea-webhook-agent-dispatch.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/research/gitea-webhook-agent-dispatch.md diff --git a/docs/research/gitea-webhook-agent-dispatch.md b/docs/research/gitea-webhook-agent-dispatch.md new file mode 100644 index 0000000..645b3f1 --- /dev/null +++ b/docs/research/gitea-webhook-agent-dispatch.md @@ -0,0 +1,151 @@ +# 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 "" --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 ` 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//`). It's what `cli.py resume ` 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 ` 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//.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//transcript/.claude/`. When the bottle is relaunched and the transcript remounted, `claude --resume ` 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 "Review PR #N: \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.