diff --git a/.gitignore b/.gitignore index ac569bf1..44c9f3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ yarn-error.log* # Build outputs dist/ build/ +!docs/build/ *.tsbuildinfo # MkDocs build output diff --git a/.prettierignore b/.prettierignore index 7dba69f5..e6428257 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,4 @@ bun.lockb .github/agents/ .github/prompts/ .specify/ +docs/index.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md deleted file mode 100644 index 7d36f625..00000000 --- a/docs/ARCHITECTURE.md +++ /dev/null @@ -1,152 +0,0 @@ -# Architecture - -A single HTTP server process receives GitHub webhook events, acknowledges within 10 seconds, and asynchronously hands each event to a daemon for execution. Every event walks the same path: verify → route → classify → enqueue → daemon claims the job → run the pipeline → finalise the tracking comment. - -## Request flow - -```mermaid -flowchart TD - GH["GitHub webhook
POST /api/github/webhooks"]:::entry - VERIFY["Verify HMAC-SHA256"]:::guard - ACK["200 OK within 10 seconds"]:::ack - ROUTE["Router
idempotency + allowlist + concurrency"]:::guard - TR["Haiku triage
binary heavy classifier"]:::decide - QUEUE["Orchestrator job queue
Valkey list"]:::store - SCALE{{"Scale-up decision
heavy OR queue ≥ threshold
AND no persistent slots?
and cooldown elapsed?"}}:::fork - SPAWN["K8s API
create bare Pod
DAEMON_EPHEMERAL=true"]:::decide - FLEET["Daemon fleet
persistent + ephemeral
WebSocket connections"]:::target - PIPE["runPipeline
clone → prompt → Claude Agent SDK"]:::work - FIN["Finalise tracking comment
success, error, or cost summary"]:::done - - GH --> VERIFY --> ACK - ACK -. async .-> ROUTE - ROUTE --> TR - TR --> QUEUE - QUEUE --> SCALE - SCALE -->|yes| SPAWN - SPAWN --> FLEET - SCALE -->|no, or cooldown active| FLEET - QUEUE -->|JobOffer| FLEET - FLEET --> PIPE - PIPE --> FIN - - classDef entry fill:#0b5cad,stroke:#083e74,color:#ffffff - classDef guard fill:#164a3a,stroke:#0d2c24,color:#ffffff - classDef ack fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff - classDef decide fill:#8a5a00,stroke:#5c3d00,color:#ffffff - classDef fork fill:#6a2080,stroke:#451454,color:#ffffff - classDef target fill:#114a82,stroke:#0a2f56,color:#ffffff - classDef work fill:#4a2e7a,stroke:#311f50,color:#ffffff - classDef store fill:#5c3d00,stroke:#3d2900,color:#ffffff - classDef done fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff -``` - -## Key concepts - -- **Async processing.** The webhook handler must respond within 10 seconds, so the router fires `processRequest` with `fire-and-forget` semantics after the 200 OK is queued. Every box downstream of `ACK` runs after the HTTP response is already on the wire. -- **Idempotency is two-layered.** The fast path is an in-memory `Map` keyed by the `X-GitHub-Delivery` header — cheap, but lost on restart. The durable path (`isAlreadyProcessed` in `src/core/tracking-comment.ts`) scans GitHub issue/PR comments for the hidden delivery marker embedded in the tracking comment, so duplicate deliveries are still detected across pod restarts, OOM kills, and crash loops — this works **without** `DATABASE_URL`. `DATABASE_URL` is only required to persist execution/dispatch history across restarts; it is not what provides durable idempotency. -- **One request, one clone.** Each delivery clones the repo into a unique temp directory under `CLONE_BASE_DIR` **on the daemon host**. Claude operates on local files via `cwd`. The directory is removed after the run regardless of outcome. -- **The webhook server never runs the pipeline.** Only daemons execute `runPipeline`. The webhook server is the orchestrator — it enqueues jobs and optionally spawns additional ephemeral daemons. -- **Every orchestrator runs a queue worker.** `src/orchestrator/queue-worker.ts` polls `queue:jobs` via `LMOVE` into a per-instance processing list (`queue:processing:{instanceId}`), offers the job to a locally-connected daemon, and atomically re-queues it to the head when no local daemon can take it. Multi-orchestrator HA: `LMOVE` grants exactly-once claim across instances, `selectDaemon` inherently restricts to daemons whose WebSocket is held by this process, so the offer/accept round-trip stays in-process and `pendingOffers` never needs shared state. Crash recovery is handled by each orchestrator draining its own processing list at startup, and by a cross-instance reaper (`src/orchestrator/valkey-cleanup.ts`) that drains processing lists owned by instances whose `orchestrator:{id}:alive` liveness key (`src/orchestrator/instance-liveness.ts`) has expired. -- **MCP servers.** Tracking-comment updates, inline PR reviews, and (optionally) Context7 library docs are exposed as MCP servers the agent can call. Git changes are made via the Bash tool against the cloned repo, not through a dedicated MCP server. - -## Dispatch Flow - -Dispatch collapsed to a single target: `daemon`. Every job is claimed by some daemon in the fleet over WebSocket. The only question the router answers is **which reason put the job there** — and whether an ephemeral daemon needs to be spawned so the fleet has capacity. - -### Single target, four reasons - -Canonical source: `src/shared/dispatch-types.ts`. - -- `DispatchTarget` = `"daemon"` (singleton — kept as a field for DB/log stability). -- `DispatchReason` is one of: - -| Reason | When the router sets it | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- | -| `persistent-daemon` | Routed to an existing persistent daemon. The default, hot path. | -| `ephemeral-daemon-triage` | Triage flagged the job as heavy → orchestrator spawned an ephemeral daemon Pod to claim it. | -| `ephemeral-daemon-overflow` | Queue length ≥ `EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` → orchestrator spawned an ephemeral daemon Pod to drain overflow. | -| `ephemeral-spawn-failed` | Spawn was required but the K8s API call failed. Job is rejected with a tracking-comment infra error. | - -### Scale-up model - -The fleet is two-tiered: - -- **Persistent daemons** are long-lived and deployed out-of-band (Helm, kubectl, plain `docker run`). They set `DAEMON_EPHEMERAL` unset or `false` and stay connected to the orchestrator indefinitely. -- **Ephemeral daemons** are bare Pods spawned on demand by the orchestrator via the Kubernetes API. They run the same daemon image with `DAEMON_EPHEMERAL=true`, connect over WebSocket, claim one or more jobs, and exit after `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` of idle. - -On every event, the orchestrator evaluates a scale-up rule: - -1. **Triage.** A single-turn Haiku call returns `{ heavy, confidence, rationale }`. `heavy = true` is one scale-up trigger. -2. **Overflow.** If the job queue length is `≥ EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` and the persistent pool has no free slots, that's the other trigger. -3. **Cooldown.** Scale-ups are rate-limited by `EPHEMERAL_DAEMON_SPAWN_COOLDOWN_MS`. During cooldown, heavy/overflow signals do **not** spawn — the job falls back to `persistent-daemon` routing and waits for persistent capacity. -4. **Spawn.** When both a trigger fires and cooldown has elapsed, the orchestrator calls the K8s API to create a bare Pod running the daemon image with `DAEMON_EPHEMERAL=true`. Only a true K8s API failure yields `ephemeral-spawn-failed`; the job is then rejected with a tracking-comment infra error. - -The newly-spawned ephemeral daemon connects via WebSocket, is registered into the fleet with `isEphemeral: true`, claims the job from the queue, runs it, then drains and exits after the idle timeout. - -### Why each request was routed the way it was - -Every dispatch decision writes a `dispatch_reason` log field and, when `DATABASE_URL` is configured, an `executions` row. The four canonical values are listed above and in [Observability](OBSERVABILITY.md). - -## Directory layout - -| Directory | Responsibility | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -| `src/webhook/` | Event routing (`router.ts`) and per-event handlers (`events/`, one file per event type). | -| `src/core/` | The pipeline: context → fetch → format → prompt → checkout → execute → finalise. `pipeline.ts` is the single execution path (daemon-side). | -| `src/ai/` | Provider-agnostic LLM client (Anthropic + Bedrock) used by triage. | -| `src/orchestrator/` | WebSocket server, daemon registry, job queue, job dispatcher, triage, ephemeral-daemon scaler. Embedded in the webhook server process. | -| `src/daemon/` | Standalone worker process (persistent or ephemeral). WebSocket client that accepts job offers and runs `src/core/pipeline.ts`. | -| `src/k8s/` | Ephemeral daemon Pod spawner (`ephemeral-daemon-spawner.ts`). | -| `src/mcp/` | MCP server registry. Add new servers here. | -| `src/db/` | Postgres layer. Migration runner, connection singleton, observability queries. Active only when `DATABASE_URL` is set. | -| `src/shared/` | Types shared between server and daemon (WebSocket messages, dispatch enums). | -| `src/utils/` | Retry, sanitisation, circuit breaker. | - -## PR shepherding reactor + continuation flow - -The new `bot:ship` lifecycle (flag-gated; see [SHIP.md](SHIP.md)) is event-driven rather than long-running. A trigger creates a `ship_intents` row and persists a `ship_continuations` row with `wake_at`. Two paths advance the session: - -```mermaid -flowchart LR - Trigger["bot:ship trigger
literal / NL / label"]:::input - SessRunner["session-runner.ts"]:::core - Intent["ship_intents row"]:::store - Cont["ship_continuations row
wake_at"]:::store - - WebhookEvt["PR/check/review
webhook event"]:::input - Reactor["webhook-reactor.fanOut"]:::core - TickleSet["Valkey ship:tickle
sorted set"]:::store - - Cron["tickle-scheduler
polls every CRON_TICKLE_INTERVAL_MS"]:::core - Reentry["session-runner re-entry
continuation loop"]:::core - Terminal["terminal status
+ tracking comment"]:::output - - Trigger --> SessRunner - SessRunner --> Intent - SessRunner --> Cont - - WebhookEvt --> Reactor - Reactor --> Cont - Reactor --> TickleSet - - Cron --> TickleSet - TickleSet --> Reentry - Reentry --> Intent - Reentry --> Terminal - - classDef input fill:#1f6feb,stroke:#0b3d99,color:#ffffff - classDef core fill:#8957e5,stroke:#4c2889,color:#ffffff - classDef store fill:#0e8a16,stroke:#063d09,color:#ffffff - classDef output fill:#cf222e,stroke:#85090e,color:#ffffff -``` - -The reactor (`fanOut`) writes `wake_at = now()` and `ZADD ship:tickle 0 ` so the next cron tick (typically under 30s) re-enters the runner. This keeps daemon slots free between iterations and gives the bot crash-restart safety: on boot, `tickle-scheduler` reconciles missed wakes from Postgres into Valkey before the periodic timer's first tick. - -## Further reading - -- [Bot Workflows](BOT-WORKFLOWS.md) — registry-driven `bot:*` label + `@chrisleekr-bot` comment dispatch, the composite ship cascade, and how to add a new workflow. Source of truth: `src/workflows/registry.ts`. -- [Configuration](CONFIGURATION.md) — every environment variable the app reads. -- [Daemon](DAEMON.md) — persistent vs ephemeral daemon lifecycle and K8s deployment. -- [Deployment](DEPLOYMENT.md) — Docker build args, health probes, resource sizing. -- [Extending](EXTENDING.md) — add webhook handlers and MCP servers. diff --git a/docs/BOT-WORKFLOWS.md b/docs/BOT-WORKFLOWS.md deleted file mode 100644 index 98d2afa5..00000000 --- a/docs/BOT-WORKFLOWS.md +++ /dev/null @@ -1,312 +0,0 @@ -# Bot Workflows - -The bot ships with a small, registry-driven set of workflows. Each workflow is triggered by applying a `bot:*` label or by posting a comment that mentions `@chrisleekr-bot` — both paths resolve to the same `workflow_runs` row via `src/workflows/dispatcher.ts`. - -This page is the canonical reference for each workflow, the dispatch flow, and how to add a new one. Keep it in sync with the registry (see [Extending](#extending-adding-a-new-workflow)). - -## Design principle: one verb, one artifact - -Every workflow is named after the senior-engineer move it performs and produces a single Markdown deliverable that becomes the body of the tracking comment. The verbs map 1:1 to the loop a senior dev runs on every issue / PR. - -| Workflow | Senior-dev verb | Artifact | Side effects | -| ----------- | --------------------------------------------------- | ----------------------------------- | ----------------------------------------------- | -| `triage` | "Is this actionable? Reproduce if it claims a bug." | `TRIAGE.md` + `TRIAGE_VERDICT.json` | none | -| `plan` | "What's the approach?" | `PLAN.md` | none | -| `implement` | "Write the code, open the PR." | `IMPLEMENT.md` | new branch, commits, PR | -| `review` | "Find bugs in the diff before declaring done." | `REVIEW.md` | inline review comments via `pulls.createReview` | -| `resolve` | "Fix CI, answer reviewer feedback." | `RESOLVE.md` | new commits on head branch, replies to comments | -| `ship` | composite — runs all five end-to-end | rolled-up `state.stepRuns` | as above, per step | - -The `review` and `resolve` verbs are deliberately separate. `review` proactively reads the diff and posts findings; `resolve` reactively answers existing review feedback and fixes failing CI. Conflating them ("just one workflow that handles a PR") was the design mistake the 2026-04-25 rename corrected — the old `review` workflow only did resolution work and never actually reviewed code. - -## Dispatch flow - -```mermaid -flowchart TD - classDef label fill:#1e3a8a,color:#ffffff,stroke:#1e3a8a - classDef comment fill:#0f766e,color:#ffffff,stroke:#0f766e - classDef shared fill:#4b5563,color:#ffffff,stroke:#4b5563 - classDef run fill:#6d28d9,color:#ffffff,stroke:#6d28d9 - classDef terminal fill:#065f46,color:#ffffff,stroke:#065f46 - - LabelEvt["issues.labeled or pull_request.labeled"]:::label - CommentEvt["issue_comment or review_comment with @chrisleekr-bot"]:::comment - LabelRoute["dispatchByLabel
registry.getByLabel"]:::label - IntentRoute["dispatchByIntent
intent-classifier.classify"]:::comment - - Checks["context check → label mutex →
requiresPrior check → idempotent insert"]:::shared - Enqueue["enqueueJob → workflow_runs row"]:::run - Daemon["daemon executeWorkflowRun
runs handler in registry.getByName"]:::run - Cascade["orchestrator.onStepComplete
success → next child or parent terminal
failed → parent flipped failed"]:::terminal - - LabelEvt --> LabelRoute - CommentEvt --> IntentRoute - LabelRoute --> Checks - IntentRoute --> Checks - Checks --> Enqueue - Enqueue --> Daemon - Daemon --> Cascade - Cascade -. composite only .-> Enqueue -``` - -Composite workflows like `ship` insert a child row per step. When the child completes, `orchestrator.onStepComplete` locks the parent row, advances `state.currentStepIndex`, and either enqueues the next step or flips the parent terminal. See `specs/20260421-181205-bot-workflows/contracts/handoff-protocol.md` for the transaction invariants. - -## Workflows - -### triage - -- **Label**: `bot:triage` -- **Accepted context**: `issue` -- **Inputs**: issue title + body, plus a fresh clone of the repo (the handler runs the Claude Agent SDK with `Read`/`Grep`/`Glob`/`Bash`/`Write` against the working tree). -- **Method**: the agent classifies the issue (bug / feature / refactor / docs / unclear). For **bug-class** issues the agent must establish either a reproduction or a structural proof before declaring valid — there is no turn cap. Three evidence paths are accepted: (1) **code inspection** with `file:line` citations for structural defects (module-scoped state, missing constraint, race window across an `await`, unguarded shared resource); (2) a **runtime test** that exercises the claim (`bun test`, `bun run typecheck`, `/tmp` scratch, CLI invocation); (3) an **invariant test** that pins down the property the fix will rely on (e.g. "N concurrent callers → exactly 1 succeeds", "operation idempotent under retry") — preferred over synthetic race repros, since it survives the fix as a regression guard. Before declaring `reproduced=null` the agent must walk the harness ladder (unit → mocked unit → integration with `bun run dev:deps` Postgres+Valkey → multi-process docker-compose) and name the highest rung tried in `details`. "Race condition we can't trigger" alone is not a valid escape hatch — races almost always have an invariant test. -- **Outputs**: - - `state.valid` — boolean verdict - - `state.confidence` — 0-1 float from the agent's self-assessment - - `state.summary` — the agent's verdict rationale; length is uncapped (the agent writes as much as the verdict honestly requires). Embedded into the failed-cascade `reason` line when `valid === false`. - - `state.recommendedNext` ∈ {`plan`, `stop`} - - `state.evidence` — array of `{file, line?, note?}` cites - - `state.reproduction` — `{ attempted: boolean, reproduced: boolean | null, details: string }`. `attempted=false` means non-bug class. `reproduced=true` covers runtime-test pass, invariant-test pass, OR structural-defect inspection backed by `file:line` citations. `reproduced=null` with `attempted=true` is permitted only after the harness ladder has been walked AND an invariant test has been ruled out; `details` must name the highest rung tried and explain why the next rung wouldn't help. The agent never lies about reproduction status. `details` is bounded by a 50 000-char sanity cap (Zod schema) — the prompt softly asks the agent to aim for ≤2000 chars and expand only when evidence demands it, but a verdict whose `details` runs longer is accepted rather than discarded so paid SDK runs are never wasted on a length check. - - `state.report` — full `TRIAGE.md` markdown with sections: Verdict, What was inspected, **Reproduction** (commands run + output + conclusion), Findings, Reasoning, Recommended next step. Embedded verbatim into the tracking comment. -- **Stop conditions**: - - Agent writes both `TRIAGE.md` and `TRIAGE_VERDICT.json`; verdict JSON validates against the Zod schema (which now requires `reproduction`). - - When `valid === false`, the handler returns `failed` so the `bot:ship` cascade halts at this step (no plan/implement/review/resolve). - - Missing markdown, malformed JSON, or an SDK error all map to `failed` with a specific `reason`. -- **Example trigger**: add label `bot:triage`, or comment "`@chrisleekr-bot triage this`" - -### plan - -- **Label**: `bot:plan` -- **Accepted context**: `issue` -- **Requires prior**: a successful `triage` run on the same issue **with `state.valid === true`** (the dispatcher's `requiresPrior: 'triage'` gate plus the triage handler's own `valid=false → failed` return together enforce this). -- **Inputs**: issue body + triage state -- **Outputs**: `state.plan` — a `PLAN.md` markdown string written by a multi-turn agent session over a clone of the repo, captured via `runPipeline({ captureFiles: ["PLAN.md"] })` before workspace cleanup. Plus `costUsd` / `turns` / `durationMs` metadata. The full `PLAN.md` body is embedded verbatim into the tracking comment. -- **Stop conditions**: agent writes `PLAN.md`; pipeline reports success or failure. No turn cap (`AGENT_MAX_TURNS` defaults to unset; `DEFAULT_MAXTURNS` also unset). -- **Example trigger**: `@chrisleekr-bot plan this out` - -### implement - -- **Label**: `bot:implement` -- **Accepted context**: `issue` -- **Requires prior**: a successful `plan` run -- **Inputs**: issue body + saved plan markdown (carried forward as the prompt trigger body) -- **Outputs**: `state.pr_number`, `state.pr_url`, `state.branch`, `state.report` (full `IMPLEMENT.md` body — Summary / Files changed / Commits / Tests run / Verification), `state.costUsd`, `state.turns`. The agent is asked to write `IMPLEMENT.md` before finishing; the handler captures it pre-cleanup and embeds it in the tracking comment. -- **PR detection**: `findRecentOpenedPr` filters on `pr.user?.type === "Bot"` plus `created_at >= since - 5s`. It deliberately does **not** match on a hard-coded slug — dev installs publish as `chrisleekr-bot-dev[bot]` and prod as `chrisleekr-bot[bot]`, so a slug check produces false negatives. -- **Stop conditions**: pipeline pushes a branch and opens a PR, OR pipeline fails. If the pipeline reports success but `findRecentOpenedPr` returns null, the handler fails with `"implement completed but no PR was found"`. The handler does NOT poll CI or reviewer state — that is `resolve`'s job, after `review` runs. -- **Example trigger**: `@chrisleekr-bot implement this` - -### review - -- **Label**: `bot:review` -- **Accepted context**: `pr` -- **Inputs**: PR title + body, full PR diff via `git diff origin/...HEAD` against a fresh clone, plus branch-staleness diagnostics (commits behind base, fork status). -- **Method**: the agent operates as a senior engineer — reads every changed file in full, cross-references with the rest of the repo for callers/tests/related code, runs `bun test` / `bun run typecheck` / `bun run lint` when uncertain, and only posts findings it can defend with evidence. Each finding has a severity prefix: - - `[blocker]` — must fix before merge (correctness, security) - - `[major]` — should fix before merge (likely bug, missing test) - - `[minor]` — nice to fix (readability) - - `[nit]` — taste, optional -- **Outputs**: `state.head_sha`, `state.changed_files`, `state.additions`, `state.deletions`, `state.branch_state` (`{ commits_behind_base, commits_ahead_of_base, is_fork }`), `state.report` (full `REVIEW.md` — Summary / What was checked / Findings / Reasoning), `state.findings` (`{ blocker, major, minor, nit, total }` — counted by `countFindings()` from the severity tags the agent prompt mandates; `total` excludes `nit`), `state.costUsd`, `state.turns`. The agent posts each finding as a separate inline comment via `mcp__github_inline_comment__create_inline_comment` (one MCP call per finding) — never as a single bundled review POST. This guarantees each finding lands on the right line with its own resolvable thread, instead of one wall-of-text comment at the top of the PR. -- **Progress visibility**: the handler seeds the tracking comment (via `setState` + `tryReserveTrackingCommentId`) before invoking the pipeline and threads the reserved id into `RunPipelineOverrides.trackingCommentId`. The agent updates the same comment via `mcp__github_comment__update_claude_comment` at five `[update tracking comment]` checkpoints in the prompt — branch refresh, file walk, finding count, posting findings, final REVIEW.md — so the user sees live progress instead of waiting blind for a final report. Without the seed/handoff, `pipeline.ts` would create-and-finalize its own comment and the handler's mid-run updates would target the wrong id; with it, the handler owns the comment lifecycle end-to-end. -- **No-findings case**: the agent MUST still post a top-level review body listing exactly what was checked (files read in full, classes of issue scanned, tests run) and why no issues were flagged. Silence looks indistinguishable from "didn't actually look." -- **Branch refresh**: if the PR head is behind base AND not on a fork, the agent rebases onto base, resolves conflicts honestly (reads the surrounding code, runs typecheck + tests, doesn't take ours/theirs blindly), and force-pushes with `--force-with-lease`. Fork PRs get a comment asking the contributor to rebase. See `src/workflows/handlers/branch-refresh.ts`. -- **Stop conditions**: agent writes `REVIEW.md`; pipeline reports success. **Push policy**: the only acceptable push from `review` is `git push --force-with-lease` after a clean rebase onto base (same diff, fresh head SHA — see Branch refresh above). The handler never creates commits with code changes (those belong to `implement` / `resolve`), never calls `pulls.merge`, and never posts an `APPROVE` or `REQUEST_CHANGES` review (FR-017 — those are human prerogatives). -- **Example trigger**: add label `bot:review` on the PR, or comment "`@chrisleekr-bot review this PR`" - -### resolve - -- **Label**: `bot:resolve` -- **Accepted context**: `pr` -- **Inputs**: PR title, failing check names, count of open top-level review comments, branch-staleness diagnostics. -- **Method**: classify each open reviewer comment (Valid / Partially Valid / Invalid / Needs Clarification), fix valid ones with new commits, reply to all four classes appropriately. Fix failing CI when there is a clear root cause. Refresh the branch first if it's stale (same logic as `review`). -- **Outputs**: `state.failing_checks`, `state.top_level_comments`, `state.branch_state`, `state.report` (full `RESOLVE.md` body — Summary / CI status / Review comments / Commits pushed / Outstanding), `state.costUsd`, `state.turns`. The agent is asked to write `RESOLVE.md` before finishing; the handler captures it pre-cleanup and embeds it in the tracking comment. -- **Progress visibility**: same seed/handoff pattern as `review` — the handler `setState`s a "Resolve starting" message before the pipeline, hands the reserved tracking-comment id to `RunPipelineOverrides.trackingCommentId`, and the agent posts `[update tracking comment]` checkpoints at branch-refresh, CI-fix, comment-classification, and final RESOLVE.md steps. Reviewer-thread replies are posted via `gh api repos///pulls//comments//replies -X POST` (the bot's `gh` and `git` calls authenticate via `GH_TOKEN` / `GITHUB_TOKEN` injected from the GitHub App installation token by `buildProviderEnv` in `src/core/executor.ts`). -- **Stop conditions** (from `src/workflows/handlers/resolve.ts`): - - `FIX_ATTEMPTS_CAP = 3` — max consecutive CI-fix attempts per PR - - `POLL_WAIT_SECS_CAP = 900` — 15-minute reviewer-patience window - - The handler NEVER calls `octokit.rest.pulls.merge` (FR-017) — merging is a human action. -- **Example trigger**: add label `bot:resolve` on the PR, or comment "`@chrisleekr-bot fix the CI failures`" / "`@chrisleekr-bot address the review comments`" - -### ship (composite) - -- **Label**: `bot:ship` -- **Accepted context**: `issue` -- **Steps**: `triage → plan → implement → review → resolve` (the bot writes the code, then reviews its own work, then resolves anything the review surfaced — closes the senior-dev loop end-to-end) -- **Cascade target retargeting** (`src/workflows/orchestrator.ts` `deriveChildTarget`): when the next step's registry context is `pr` but the parent's target is an `issue`, the orchestrator switches the inserted child row's `target_type/number` to the PR. The PR number is read from the just-completed child's state (typical hand-off from `implement`, which writes `state.pr_number`) and persisted on the parent's `state.pr_number` so subsequent loop-back inserts can rediscover it without re-reading a child row. If neither child nor parent state carries `pr_number`, the parent fails with a clear reason instead of silently inheriting the issue target. -- **Bounded review/resolve loop** (post-implement): controlled by `REVIEW_RESOLVE_MAX_ITERATIONS` (default `2`, range 1–5). - - Each completed `review` child increments `parent.state.review_iterations` and records its findings count on `parent.state.last_review_findings`. - - **After review-N**: if `N ≥ 2` AND `findings.total === 0` → parent succeeds early ("review found no issues after N iterations") — skip resolve, nothing left to fix. - - **After resolve-N**: if `N < cap` → insert another review at `steps.indexOf("review")` (the natural cascade picks resolve up again at the next index). Else → parent succeeds; if `last_review_findings > 0` the terminal message recommends manual re-review since resolve-N's fixes were never re-validated. - - Findings come from `review.ts`'s `countFindings()`, which counts the severity tags (`[blocker]/[major]/[minor]/[nit]`) the prompt mandates. `total` excludes `nit` so taste-level comments never keep the loop spinning. -- **PR body**: `implement` instructs the agent to read `.github/PULL_REQUEST_TEMPLATE/bot-implement.md`, fill every section based on actual work (Summary / Changes / Files changed / Commits / Tests run / Verification), and pass it via `gh pr create --body-file …`. This keeps bot PRs structurally consistent and prevents `gh` from auto-falling-back to the human PR template. -- **Outputs**: rolled-up `state.stepRuns` plus terminal status on the parent row. When the loop ran, `state.review_iterations` and `state.last_review_findings` are persisted; when retargeting kicked in, `state.pr_number` is too. -- **Resume semantics** (`src/workflows/handlers/ship.ts`): - - `bot:ship` is re-applicable on a target whose prior parent row is **terminal** (the partial unique index only blocks in-flight parents). - - Per-step staleness rules: - - `triage` — fresh iff succeeded AND `state.valid === true` AND `state.recommendedNext === 'plan'` (an invalid verdict halts the cascade rather than poisoning future ship runs) - - `plan` — fresh iff succeeded AND created after the last triage success - - `implement` — fresh iff succeeded AND the recorded PR is still open - - `review` — always stale (the bot self-reviews on every ship iteration; cheap relative to letting a stale review stand) - - `resolve` — always stale - - The first stale step becomes `startIndex`; prior-step run ids are carried forward in `state.stepRuns`. -- **Cost note**: every ship pays for at least two `review` and one `resolve` agent run (the loop's lower bound). With `REVIEW_RESOLVE_MAX_ITERATIONS=2`, the worst case is 2 review + 2 resolve. Per project direction (2026-04-25), accuracy beats cost — closing the loop justifies the extra spend. -- **Example trigger**: add label `bot:ship`, or comment "`@chrisleekr-bot ship this`" - -### ship (PR shepherding lifecycle, `ship_intents`) - -A separate, newer lifecycle layered on top of the composite handler. The probe-verdict ladder and continuation-loop architecture remain **flag-gated** behind `SHIP_USE_PROBE_VERDICT` and `SHIP_USE_CONTINUATION_LOOP` (default off; the composite path above is unchanged when these flags are unset). The three trigger surfaces — literal, natural-language, and label — are permanent v1 and require no flag. See [`docs/SHIP.md`](SHIP.md) for the operator-facing summary. - -- **State**: rows in `ship_intents` (status: `active` | `paused` | `merged_externally` | `ready_awaiting_human_merge` | `deadline_exceeded` | `human_took_over` | `aborted_by_user` | `pr_closed`). Wake events queued in Valkey `ship:tickle`. Cancellation flag at `ship:cancel:{intent_id}`. -- **Three trigger surfaces (FR-027)** — all functionally equivalent, normalised to a single `CanonicalCommand`: - 1. **Literal**: `bot:ship` (or `bot:ship --deadline 2h`) PR comment. Deterministic regex parser. Permanent surface. - 2. **Natural language**: `@chrisleekr-bot ship this please`. Mention-prefix-gated NL classifier (FR-025a) — zero LLM cost on comments without the mention. Bedrock single-turn classification. Permanent surface. - 3. **Label**: apply `bot:ship` (or `bot:ship/deadline=2h`). Bot self-removes the label after acting (FR-026a). Re-application is the supported re-trigger mechanism. -- **Lifecycle commands** (same three surfaces): `bot:stop` / `bot:resume` / `bot:abort-ship`. -- **Reactor (T023-T027)**: `pull_request.{synchronize,closed}`, `pull_request_review.submitted`, `pull_request_review_comment.{created,edited,deleted}`, `check_run.completed`, `check_suite.completed` early-wake any active intent on the affected PR via Valkey `ZADD ship:tickle 0 `. Reactor on `synchronize` from a non-bot pusher transitions to terminal `human_took_over` + `manual-push-detected` (FR-010). Reactor on `pull_request.closed` transitions to `merged_externally` or `pr_closed`. -- **Probe verdict ladder** (`src/workflows/ship/verdict.ts`): `human_took_over` > `behind_base` > `failing_checks` > `pending_checks` > `mergeable_pending` > `changes_requested` > `open_threads` > `ready`. The `mergeable=null` backoff schedule is bounded by `MERGEABLE_NULL_BACKOFF_MS_LIST`. -- **Terminal `ready` action** (FR-019): `markPullRequestReadyForReview` GraphQL mutation if PR `isDraft === true`, then update tracking comment to terminal state, then transition `ship_intents.status = 'ready_awaiting_human_merge'`. Failure of step 1 does NOT block 2 / 3 (logged + surfaced in the tracking comment). The bot **never** calls `gh pr merge` (FR-008, T046b static guard). -- **MCP server**: `resolve-review-thread` exposes the `resolveReviewThread` GraphQL mutation as a single tool the resolve handler can call — bound to one PR at construction; refuses cross-PR thread ids. -- **Rollout** (research.md R8): three flags default off; enable for one-week soak; follow-up PR removes flags + dead code paths. - -## User-facing surfaces - -Each workflow run produces two GitHub-visible signals: a **tracking comment** (the bot's working/result body) and a **reaction set** on the user's trigger comment. - -### Tracking comments - -- `triage`, `plan`, and `implement` post an **up-front "starting…" comment** as soon as they fetch the issue title, before the (multi-minute) agent run. The terminal `setState` call rewrites the same comment with the verdict / plan / PR link. Skipping the up-front write would leave the user staring at an empty issue while the daemon worked. -- `review` and `resolve` already post upfront; behaviour unchanged. -- For composite parents (`ship`), the tracking comment is rendered as a **verbose composite**: the parent's narrative followed by one `### ` block per child step, each linking back to the child's own tracking comment via deep `#issuecomment-` anchors. The composite refresh is triggered automatically by `tracking-mirror.setState` whenever a child run writes — the cascade walks `parent_run_id` and re-renders the parent's body so the user always sees the latest child status on the surface they're already watching. - -### Trigger-comment reactions - -Comment-driven workflows stack four GitHub reactions on the user's trigger comment so the lifecycle is visible without scrolling: - -| Stage | Reaction | Where it fires | -| ------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------ | -| Trigger detected, before classifier | 👀 `eyes` | `src/webhook/events/issue-comment.ts`, `review-comment.ts` (after allowlist) | -| Job dispatched to a daemon | 🚀 `rocket` | `src/workflows/dispatcher.ts` (after `enqueueJob`) | -| Workflow succeeded | 🎉 `hooray` | `src/daemon/workflow-executor.ts` for atomic runs; `src/workflows/orchestrator.ts` for composite parents (cascade) | -| Workflow failed (handler error, daemon disconnect, OOM) | 😕 `confused` | `workflow-executor.ts`, `orchestrator.ts`, `src/orchestrator/connection-handler.ts` (orphan path) | - -GitHub reactions are additive — the combined set is the audit trail. Label-triggered runs (`bot:ship` via label apply) skip reactions silently because no comment exists to react on. Reaction failures (e.g., missing `reactions:write` scope) are logged at warn level and swallowed; they never block a workflow. - -### Failure surface on daemon disconnect - -When a daemon dies abruptly (OOM, pod eviction, network partition), `connection-handler.cleanupAfterDisconnect` walks every in-flight `workflow_runs` row owned by that daemon, finds the topmost ancestor (so a child step failure shows up on the parent's surface), and: - -1. Updates the ancestor's tracking comment with an `❌ Daemon disconnected (likely OOM)` message and resume instructions. -2. Adds 😕 `confused` to the user's trigger comment. - -This closes the silent-failure window that previously left users staring at a stale "starting…" comment after an OOM. The liveness reaper still flips the `workflow_runs.status` to `failed`; the cleanup path only owns the user-visible surface. - -### Re-trigger / resume - -Re-triggering `ship` (re-applying the `bot:ship` label or re-commenting the intent) walks the prior runs via `computeStartIndex` in `src/workflows/handlers/ship.ts`: succeeded `triage`/`plan` rows are reused, succeeded `implement` is reused only while its PR is still open, and `review`/`resolve` always re-run. A failed `implement` row from a prior crash means resume picks up at `implement` — the row is not "succeeded" so `isFresh` returns false and the step is re-queued. - -## Comment intent classifier - -Comments that mention `@chrisleekr-bot` are routed through `src/workflows/intent-classifier.ts`, which returns `{ workflow, confidence, rationale }` using a single-turn Haiku call. Rules: - -- `confidence < INTENT_CONFIDENCE_THRESHOLD` (default `0.75`) → the dispatcher posts a short clarification reply (FR-009) instead of dispatching. -- `workflow === "unsupported"` → refusal reply (FR-010). -- `workflow ∈ registry` → same dispatch as the label path. - -The classifier prompt distinguishes `review` (proactive code review — find bugs, post inline findings) from `resolve` (reactive — fix CI, answer feedback). Comments like "review this PR" / "do a code review" / "check for issues" map to `review`; comments like "fix CI" / "address the comments" / "respond to the feedback" map to `resolve`. The split mirrors the verb-per-workflow design. - -The classifier treats the comment body as untrusted input: it wraps the body in an opaque `` delimiter, strips prompt-like control tokens, and rejects any model output that doesn't validate against a closed-enum Zod schema (T037a). - -Override the threshold per environment: - -```text -INTENT_CONFIDENCE_THRESHOLD=0.60 # looser — more dispatches, more clarifications skipped -INTENT_CONFIDENCE_THRESHOLD=0.90 # stricter — only very confident asks dispatch -``` - -## Branch refresh (`review` and `resolve`) - -Both PR-side workflows compute branch staleness via `octokit.rest.repos.compareCommitsWithBasehead` before dispatching the agent prompt. The result is injected as a "Branch state" section in the prompt with one of three directives: - -- **Up-to-date** — one-line no-op so the agent doesn't waste turns probing. -- **Same-repo behind base** — rebase onto base, resolve conflicts (reading the surrounding code; running typecheck + the affected tests; never taking ours/theirs blindly), then `git push --force-with-lease`. After push, head SHA changes — the agent must re-fetch any cached state. -- **Fork PR behind base** — the bot's installation token can't push to a fork's branch, so the agent posts a top-level PR comment asking the contributor to rebase, then proceeds against the stale head and flags affected findings in the final report. - -Always-rebase semantics (per project direction 2026-04-25): if the branch is outdated, refresh first regardless of conflict risk. The cost of resolving conflicts well is far smaller than the cost of reviewing/resolving against stale code. - -## Extending: adding a new workflow - -1. **Add a handler**. Create `src/workflows/handlers/.ts` exporting `handler: WorkflowHandler` — the handler takes a `WorkflowRunContext` and returns a `HandlerResult` (`succeeded` | `failed` | `handed-off`). Capture exactly one Markdown artifact (`.md`) so the tracking comment is self-documenting. -2. **Register it**. Append one `RegistryEntry` to `rawRegistry` in `src/workflows/registry.ts` — name, label (`bot:`), accepted context, optional `requiresPrior`, optional `steps` for composite workflows, and the handler reference. The Zod schema validates at module load, so a mistyped entry fails the process at boot (FR-023/024). -3. **Document it**. Add a section here matching the template used for the six built-ins above — verb, accepted context, inputs, method, outputs, stop conditions, example trigger. The doc-sync rule in `CLAUDE.md` makes this mandatory for any PR touching `src/workflows/`. -4. **Test it**. Add at least one unit test under `test/workflows/handlers/.test.ts` covering the happy path plus one failure mode. Integration via `test/workflows/dispatcher.test.ts` is automatic — if the registry entry is valid, dispatch works. -5. **Update the classifier prompt**. If the new workflow should be reachable via comments, extend the system prompt in `src/workflows/intent-classifier.ts` (the enum is driven by the registry, but the prompt narrative needs to mention the new workflow with at least three fixture comments in `test/workflows/fixtures/intent-comments.json`). - -See the source of truth: - -- Registry: `src/workflows/registry.ts` -- Dispatcher: `src/workflows/dispatcher.ts` -- Orchestrator (composite cascade): `src/workflows/orchestrator.ts` -- Branch-refresh helper: `src/workflows/handlers/branch-refresh.ts` -- Hand-off protocol: `specs/20260421-181205-bot-workflows/contracts/handoff-protocol.md` - -## Bridge architecture: ship-iteration → daemon `workflow_runs` - -The `bot:ship` shepherding loop does **not** own its own daemon-execution path. It bridges onto the existing `workflow_runs` pipeline so a single executor surface clones, runs the Agent SDK, and pushes — no parallel implementation to drift between. - -The bridge has three moving parts: - -1. **Iteration handler** (`src/workflows/ship/iteration.ts`) — converts a non-ready probe verdict into one `workflow_runs` row carrying `state.shipIntentId`, plus an enqueued `kind: "workflow-run"` job. -2. **Orchestrator cascade hook** (`src/workflows/orchestrator.ts` `onStepComplete` early-wake) — on every workflow-run completion reads `state.shipIntentId` and `ZADD ship:tickle 0 ` if the intent is non-terminal. -3. **Tickle scheduler** (`src/workflows/ship/tickle-scheduler.ts`, booted from `src/app.ts`) — fires `resumeShipIntent` for every due intent so the next iteration re-enters via the same bridge. - -```mermaid -flowchart LR - subgraph WEBHOOK["Webhook server (orchestrator process)"] - direction LR - cmd["@chrisleekr-bot ship comment"] --> trig[trigger-router] - trig --> SR["session-runner.runShipFromCommand"] - SR --> probe[probe
verdict] - probe -->|"ready"| terminal["terminal:ready"] - probe -->|"non-ready"| iter["iteration.runIteration"] - iter --> wr[("workflow_runs row
state.shipIntentId")] - iter --> q[("queue:jobs
kind=workflow-run")] - end - - q --> daemon[Daemon process] - daemon --> exec[Daemon job executor] - exec --> agent[Agent SDK
+ Octokit push] - agent --> done["markSucceeded(runId)"] - done --> cascade["orchestrator.onStepComplete
maybeEarlyWakeShipIntent"] - cascade --> tickle[("ship:tickle ZSET
score=0")] - - subgraph SCHED["Tickle scheduler (orchestrator process)"] - direction LR - timer["setInterval"] --> due["ZRANGEBYSCORE 0 now"] - due --> resume["resumeShipIntent(intent_id)"] - resume --> SR2["session-runner.resumeShipIntent"] - end - - tickle --> due - SR2 -.->|next iteration| iter - - classDef ship fill:#1f4d8c,stroke:#0a1f3d,color:#fff - classDef daemon fill:#7a3b1f,stroke:#3d1d0e,color:#fff - classDef store fill:#2d5d4a,stroke:#143025,color:#fff - iter:::ship - SR:::ship - SR2:::ship - resume:::ship - exec:::daemon - agent:::daemon - wr:::store - q:::store - tickle:::store -``` - -### Why bridge instead of duplicate - -The existing `src/core/pipeline.ts` already does clone + Agent SDK + push + cleanup. Reimplementing this inside `src/workflows/ship/` would duplicate ~300 lines of clone/temp-dir/cleanup logic and create two parallel agent-execution paths, doubling the surface area for security and resource-leak bugs (research.md Q5). - -### Scoped commands (`bot:rebase`, `bot:fix-thread`, `bot:explain-thread`, `bot:open-pr`) - -Each scoped command gets its own per-executor `JobKind` and its own daemon-side executor under `src/daemon/scoped-*-executor.ts`. The orchestrator emits a `scoped-job-offer` (server→daemon); the daemon evaluates capacity, accepts via `job:accept`, executes, then reports `scoped-job-completion` (daemon→server). The executor posts the user-facing reply directly via the installation token — the orchestrator-side bridge in `connection-handler.ts` finalizes the execution row and emits telemetry but does not re-post the comment. - -`scoped-rebase` is deterministic git only (no Agent SDK). The other three are scaffolded; the multi-turn Agent SDK invocation lands as a follow-up. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md deleted file mode 100644 index ca03652b..00000000 --- a/docs/CONFIGURATION.md +++ /dev/null @@ -1,144 +0,0 @@ -# Configuration - -Every environment variable the app reads at startup, grouped by concern. The authoritative source is `src/config.ts` — all values are validated via Zod at boot and the process exits if a required variable is missing or malformed. - -Columns: **Default** lists the fallback applied when the variable is unset (blank means "no default, must be set when required"). **Required when** describes the runtime condition under which the variable is mandatory. - -## GitHub App credentials - -Server mode only. If `ORCHESTRATOR_URL` is set, the process runs in daemon mode and these are not required. - -| Variable | Default | Required when | Notes | -| ------------------------ | ------- | ------------- | ------------------------------------------------------------------ | -| `GITHUB_APP_ID` | — | Server mode | Numeric App ID from the GitHub App settings page. | -| `GITHUB_APP_PRIVATE_KEY` | — | Server mode | Full PEM, base64-encoded or raw. Used to mint installation tokens. | -| `GITHUB_WEBHOOK_SECRET` | — | Server mode | HMAC-SHA256 secret configured in the GitHub App settings. | - -## AI provider - -| Variable | Default | Required when | Notes | -| ---------------------------- | ------------------------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------- | -| `CLAUDE_PROVIDER` | `anthropic` | — | `anthropic` or `bedrock`. | -| `CLAUDE_MODEL` | `claude-opus-4-7` (anthropic); — (bedrock) | Bedrock | Bedrock requires an explicit Bedrock model ID. | -| `ANTHROPIC_API_KEY` | — | Anthropic, unless `CLAUDE_CODE_OAUTH_TOKEN` is set | Console pay-as-you-go. Safe for multi-tenant deploys. | -| `CLAUDE_CODE_OAUTH_TOKEN` | — | Anthropic, unless `ANTHROPIC_API_KEY` is set | Max/Pro subscription token (`sk-ant-oat…`). Requires `ALLOWED_OWNERS`. | -| `AWS_REGION` | — | Bedrock | Resolved by the AWS SDK credential chain. | -| `AWS_PROFILE` | — | Optional (bedrock) | Local SSO profile for dev. | -| `AWS_ACCESS_KEY_ID` | — | Optional (bedrock) | Long-lived credential pair. Prefer profile or OIDC. | -| `AWS_SECRET_ACCESS_KEY` | — | Optional (bedrock) | Paired with `AWS_ACCESS_KEY_ID`. | -| `AWS_SESSION_TOKEN` | — | Optional (bedrock) | For temporary credentials. | -| `AWS_BEARER_TOKEN_BEDROCK` | — | Optional (bedrock, CI) | Set automatically by `aws-actions/configure-aws-credentials` OIDC. | -| `ANTHROPIC_BEDROCK_BASE_URL` | — | Optional (bedrock) | Override the Bedrock runtime endpoint (VPC endpoint / proxy). | -| `ALLOWED_OWNERS` | unset | OAuth token path | Comma-separated allowlist. Required when using `CLAUDE_CODE_OAUTH_TOKEN`. | - -## Runtime - -| Variable | Default | Notes | -| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `PORT` | `3000` | HTTP webhook listener. | -| `LOG_LEVEL` | `info` | Pino level: `fatal`, `error`, `warn`, `info`, `debug`, `trace`. `debug` surfaces full webhook payloads. | -| `NODE_ENV` | `production` | `production`, `development`, or `test`. | -| `TRIGGER_PHRASE` | `@chrisleekr-bot` | Mention text that triggers the bot. Must match the App's bot login. | -| `MAX_CONCURRENT_REQUESTS` | `3` | Ceiling on simultaneous Claude executions per process. | -| `AGENT_TIMEOUT_MS` | `3600000` | Wall-clock budget for one agent execution (default 60 min). Lower it only when you trust the job is bounded. | -| `AGENT_MAX_TURNS` | unset | Optional Claude SDK turn cap. Unset = no cap (the agent runs to completion). Overrides `DEFAULT_MAXTURNS`. | -| `CLAUDE_CODE_PATH` | resolved from `node_modules` | Absolute path to the Claude Code CLI `cli.js`. Set when globally installed. | -| `CLONE_BASE_DIR` | `/tmp/bot-workspaces` | Parent directory for per-delivery clones. | -| `CLONE_DEPTH` | `50` | Shallow-clone depth. Increase for deeply-diverged PRs. | -| `CONTEXT7_API_KEY` | unset | Lifts Context7 MCP rate limiting. No other effect. | - -## Dispatch - -Dispatch collapsed to a single target (`daemon`). The router decides only **which reason** a job lands there and whether to spawn an ephemeral daemon — see [Architecture → Dispatch Flow](ARCHITECTURE.md#dispatch-flow). - -## Ephemeral daemons (Kubernetes scale-up) - -Used when the orchestrator needs to add daemon capacity on demand. Spawned Pods run the same daemon image with `DAEMON_EPHEMERAL=true` and exit after idle. - -| Variable | Default | Notes | -| ---------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `DAEMON_EPHEMERAL` | `false` | Set to `true` on ephemeral daemon Pods (injected by the spawner). Controls idle-exit behaviour on the daemon. | -| `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` | `120000` | Ephemeral daemon exits after this much idle time (no active job). | -| `EPHEMERAL_DAEMON_SPAWN_COOLDOWN_MS` | `30000` | Minimum time between ephemeral spawns (orchestrator side). During cooldown, heavy/overflow signals fall back to `persistent-daemon`. | -| `EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` | `3` | Queue length that triggers an `ephemeral-daemon-overflow` spawn. | -| `EPHEMERAL_DAEMON_NAMESPACE` | `default` | Kubernetes namespace for spawned ephemeral Pods. The orchestrator ServiceAccount needs `create/get/delete` on `pods` here. | -| `KUBECONFIG` | auto (in-cluster) | Kubernetes client config path. The client auto-detects in-cluster via `KUBERNETES_SERVICE_HOST`. | - -The orchestrator also expects a pre-existing `daemon-secrets` K8s Secret in `EPHEMERAL_DAEMON_NAMESPACE`, mounted into the spawned Pod via `envFrom: secretRef: daemon-secrets`. See [DAEMON.md](DAEMON.md) and [DEPLOYMENT.md](DEPLOYMENT.md) for the full Pod spec and RBAC. - -## Data layer - -Required whenever the orchestrator role is active (i.e. the webhook server process, which always runs the orchestrator). - -| Variable | Default | Notes | -| -------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `VALKEY_URL` | — | Backs the daemon job queue, in-flight set, and the ephemeral-spawn cooldown. | -| `DATABASE_URL` | — | Postgres connection for `executions` and `triage_results`. Unset disables durable observability and telemetry aggregates. Durable idempotency itself comes from GitHub tracking comments and works without Postgres. | - -## Orchestrator and daemon - -| Variable | Default | Notes | -| ------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `WS_PORT` | `3002` | Orchestrator WebSocket listener. Bound only in server mode. Must differ from `PORT`. | -| `ORCHESTRATOR_URL` | — | Presence flips the process from server mode to **daemon** mode. Must be `ws://` or `wss://`. | -| `DAEMON_AUTH_TOKEN` | — | Shared secret for the daemon ⇄ orchestrator handshake. Required on both orchestrator and daemon processes. | -| `HEARTBEAT_INTERVAL_MS` | `30000` | Daemon → orchestrator ping cadence. | -| `HEARTBEAT_TIMEOUT_MS` | `90000` | Eviction threshold. Keep `≥ 2 × HEARTBEAT_INTERVAL_MS` to tolerate a dropped packet. | -| `STALE_EXECUTION_THRESHOLD_MS` | `3600000` | How long a `running` execution may sit before the watcher marks it failed. Set `≥ AGENT_TIMEOUT_MS`. | -| `DAEMON_DRAIN_TIMEOUT_MS` | `300000` | Post-SIGTERM window to finish in-flight work. Raise to `≥ AGENT_TIMEOUT_MS` if you want zero mid-run kills. | -| `JOB_MAX_RETRIES` | `3` | Retries for transient daemon dispatch failures only. Isolated-job ignores this. | -| `OFFER_TIMEOUT_MS` | `5000` | How long the orchestrator waits for a daemon to claim an offer before falling through. | -| `QUEUE_WORKER_BACKOFF_MAX_MS` | `5000` | Upper bound on the queue-worker's sleep between retries when no locally-connected daemon can take a job. | -| `LIVENESS_REAPER_INTERVAL_MS` | `30000` | Cadence of the heartbeat-based reaper that fails `workflow_runs` rows whose owning orchestrator/daemon stops heartbeating in Valkey, and flips abandoned `daemons` rows to `inactive`. Min 20000 (orchestrator heartbeat refresh). | -| `DAEMON_UPDATE_STRATEGY` | `exit` | `exit`, `pull`, or `notify`. Advisory hint reported in the update response. | -| `DAEMON_UPDATE_DELAY_MS` | `0` | Delay before graceful shutdown after an update signal. | -| `DAEMON_MEMORY_FLOOR_MB` | `512` | Minimum free memory the orchestrator requires before dispatching. | -| `DAEMON_DISK_FLOOR_MB` | `1024` | Minimum free disk the orchestrator requires before dispatching. | - -## Triage - -| Variable | Default | Notes | -| ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `TRIAGE_ENABLED` | `true` | Kill-switch. When `false`, triage returns `heavy=false` and the job routes to `persistent-daemon`. | -| `TRIAGE_MODEL` | `haiku-3-5` | Alias resolved at runtime. Affects triage cost and latency only. | -| `TRIAGE_CONFIDENCE_THRESHOLD` | `1.0` | Below this, triage is treated as sub-threshold and the job routes to `persistent-daemon`. | -| `TRIAGE_MAX_TOKENS` | `256` | Cap on the JSON response. Values above ~100 are wasted budget. | -| `TRIAGE_TIMEOUT_MS` | `5000` | Per-call wall clock. Beyond this, the circuit-breaker counter increments. | -| `DEFAULT_MAXTURNS` | unset | Optional process-wide turn cap. Unset = no cap; agent runs end-to-end. Set only if ops needs a hard ceiling. `AGENT_MAX_TURNS` overrides when both are set. | -| `INTENT_CONFIDENCE_THRESHOLD` | `0.75` | Range `[0, 1]`. Below this, a `@chrisleekr-bot` comment is treated as ambiguous and the dispatcher posts a clarification request instead of dispatching a workflow. See [bot workflows](BOT-WORKFLOWS.md#comment-intent-classifier). | - -See [Triage](TRIAGE.md) for the binary `heavy` signal, circuit breaker, and the six fallback reasons that appear in logs. - -## PR Shepherding (`bot:ship` lifecycle, ship_intents) - -The new shepherding lifecycle (intents tracked in `ship_intents`) is gated by feature flags so it can be rolled out incrementally per `research.md` R8. - -| Variable | Default | Notes | -| --------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MAX_WALL_CLOCK_PER_SHIP_RUN` | `4h` | Hard ceiling on a single intent's wall-clock budget. Accepts integer ms or duration suffix (`4h` / `30m` / `90s`). Per-invocation `--deadline` is clamped to this ceiling. | -| `MAX_SHIP_ITERATIONS` | `50` | Iteration cap (FR-012). Checked at the start of each iteration; firing transitions the intent to terminal `human_took_over` + `BlockerCategory='iteration-cap'`. | -| `CRON_TICKLE_INTERVAL_MS` | `30000` | How often the cron tickle scans `ship:tickle` for due intents. Lower = faster wake but more Valkey traffic. | -| `MERGEABLE_NULL_BACKOFF_MS_LIST` | `500,1500,4500` | Comma-separated bounded backoff schedule used by `runProbe` when `mergeable=null`. Per FR-021, exhaustion yields `mergeable_pending` and the session yields rather than spinning. | -| `REVIEW_BARRIER_SAFETY_MARGIN_MS` | `1200000` | (20 min) FR-023: minimum elapsed time since last bot push before the bot may declare `ready` without a non-bot review on the current head SHA. | -| `FIX_ATTEMPTS_PER_SIGNATURE_CAP` | `3` | FR-013: maximum attempts per failure signature within a single intent. Cap firing terminates the intent with `BlockerCategory='flake-cap'`. | -| `SHIP_FORBIDDEN_TARGET_BRANCHES` | empty | Comma-separated branch names (e.g., `main,production,release`) that the bot refuses to shepherd PRs against. Per FR-015 refusal case 4. | -| `SHIP_USE_PROBE_VERDICT` | `false` | Rollout flag — when `true`, terminal-readiness uses the new GraphQL probe verdict ladder. Default off keeps the legacy in-process loop in charge. | -| `SHIP_USE_CONTINUATION_LOOP` | `false` | Rollout flag — when `true`, the iteration loop exits after each phase and re-enters via the cron tickle (Valkey `ship:tickle`). Restart-safe and slot-friendly. | - -The natural-language and label trigger surfaces (FR-025/025a/026/026a/027) are permanent — there is no flag gating them. After a one-week soak with the two probe/continuation flags set to `true` and clean operation observed, a follow-up PR removes those two flags and the legacy code paths (research.md R8 cutover plan, T071). - -## Composite ship workflow - -| Variable | Default | Notes | -| ------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `REVIEW_RESOLVE_MAX_ITERATIONS` | `2` | Range `1–5`. Caps the post-implement review/resolve loop inside `bot:ship`. Each iteration is one `review` run. A clean review (`findings.total === 0`) after at least 2 iterations short-circuits the loop; if the cap is reached with non-zero findings, ship marks succeeded but recommends manual re-review. Set to `1` to disable looping (run review and resolve once each, never short-circuit). | - -See [bot workflows: ship (composite)](BOT-WORKFLOWS.md#ship-composite) for the full loop semantics and retargeting rules. - -## Mode matrix — what's required when - -| Role | Required | -| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Orchestrator (webhook server) | GitHub App credentials, one AI provider credential, `VALKEY_URL`, `DATABASE_URL`, `DAEMON_AUTH_TOKEN`. | -| Ephemeral-daemon scale-up | K8s API access + RBAC on `pods` in `EPHEMERAL_DAEMON_NAMESPACE`, `daemon-secrets` Secret. | -| Daemon process (`ORCHESTRATOR_URL` set) | `DAEMON_AUTH_TOKEN` and one AI provider credential (`ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` / Bedrock env). GitHub App credentials and data-layer URLs are NOT required. | diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md deleted file mode 100644 index 93f570d5..00000000 --- a/docs/CONTRIBUTING.md +++ /dev/null @@ -1,5 +0,0 @@ -# Contributing - -The contributor guide lives at the repo root so it renders on the GitHub code view. See [CONTRIBUTING.md on GitHub](https://github.com/chrisleekr/github-app-playground/blob/main/CONTRIBUTING.md) for local setup, testing, linting, and commit message conventions. - -If your PR touches code that the operator-facing docs describe, update the matching page under `docs/` in the same change — see the `## Documentation` section of [CLAUDE.md](https://github.com/chrisleekr/github-app-playground/blob/main/CLAUDE.md). diff --git a/docs/DAEMON.md b/docs/DAEMON.md deleted file mode 100644 index 2033b158..00000000 --- a/docs/DAEMON.md +++ /dev/null @@ -1,147 +0,0 @@ -# Daemon - -A daemon is a standalone worker process that connects to the orchestrator over WebSocket, accepts job offers, and runs each job through `src/core/pipeline.ts`. The webhook server never runs the pipeline in-process — every execution happens on a daemon. - -## Persistent vs Ephemeral - -There are two daemon types. Always qualify which one you mean — plain "daemon" is ambiguous. The union of both types connected at a given moment is the **daemon fleet**. - -| Type | How it starts | Lifetime | `DAEMON_EPHEMERAL` | -| --------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------ | -| **Persistent daemon** | Deployed out-of-band (Helm, kubectl, `docker run`, systemd, etc.) | Long-lived — stays connected until SIGTERM or eviction. | unset or `false` | -| **Ephemeral daemon** | Spawned on demand by the orchestrator as a bare Pod via the K8s API. | Exits after `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` (default 120s) of no active job. | `true` | - -Only persistent daemons count towards the "persistent pool free slots" the orchestrator uses to decide whether an overflow spawn is warranted. Ephemeral daemons exist specifically to drain the current surge and then disappear. - -## When to use it - -- Persistent daemons handle the default, hot path. Run one (or more) in your cluster as a baseline so the common case does not pay a Pod-spawn latency. -- Ephemeral daemons kick in when triage flags a job as heavy, or when the job queue piles up — see `ephemeral-daemon-triage` and `ephemeral-daemon-overflow` in [Observability](OBSERVABILITY.md). They let you scale to zero on idle without losing bursty capacity. - -## How it runs - -Setting `ORCHESTRATOR_URL` to a `ws://` or `wss://` address flips the process into daemon mode. In that mode: - -- GitHub App credentials are not required. The daemon does not bind a webhook listener. -- The daemon advertises capabilities (platform, free memory/disk relative to `DAEMON_MEMORY_FLOOR_MB` / `DAEMON_DISK_FLOOR_MB`) on every heartbeat. It also advertises `isEphemeral` and `maxConcurrentJobs` on `daemon:register` so the orchestrator can compute persistent-pool free slots correctly. -- The orchestrator sends an offer when a job comes in. The daemon accepts or declines; accepted work runs through the shared pipeline (`src/core/pipeline.ts`). -- On SIGTERM, the daemon refuses new offers and drains in-flight work up to `DAEMON_DRAIN_TIMEOUT_MS` before exiting. -- On spot/preemption signals (AWS Spot interruption, GCP preemption), the daemon begins draining early so the orchestrator can reroute pending offers. -- When `DAEMON_EPHEMERAL=true`, the daemon additionally exits after `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` with no active job — persistent daemons never idle-exit. - -## Operational knobs - -| Variable | Default | Notes | -| ---------------------------------- | -------- | -------------------------------------------------------------------------------- | -| `ORCHESTRATOR_URL` | — | Required. `wss://` in production; `ws://` emits a warning. | -| `DAEMON_AUTH_TOKEN` | — | Shared secret with the orchestrator. | -| `DAEMON_EPHEMERAL` | `false` | `true` on ephemeral daemon Pods (injected by the spawner). Enables idle-exit. | -| `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` | `120000` | Ephemeral daemons exit after this idle window. | -| `HEARTBEAT_INTERVAL_MS` | `30000` | Ping cadence. | -| `HEARTBEAT_TIMEOUT_MS` | `90000` | Orchestrator eviction threshold. Keep `≥ 2 × HEARTBEAT_INTERVAL_MS`. | -| `DAEMON_DRAIN_TIMEOUT_MS` | `300000` | Post-SIGTERM grace. Raise to `≥ AGENT_TIMEOUT_MS` to guarantee no mid-run kills. | -| `DAEMON_MEMORY_FLOOR_MB` | `512` | Below this, the orchestrator skips the daemon on dispatch. | -| `DAEMON_DISK_FLOOR_MB` | `1024` | Same, for free disk. | -| `OFFER_TIMEOUT_MS` | `5000` | How long the orchestrator waits for a claim before falling through. | - -See [Configuration](CONFIGURATION.md#orchestrator-and-daemon) for the rest. - -## Concurrency - -A daemon process handles up to `maxConcurrentJobs` jobs at a time (advertised on register). Scale horizontally by running multiple persistent daemon pods, and let the orchestrator add ephemeral daemons for bursts. - ---- - -## Kubernetes deployment - -### Persistent daemon Deployment (example) - -Run persistent daemons as a regular Deployment, scaled to N replicas. They connect outbound to the orchestrator and need no inbound ports. - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: github-app-playground-daemon - namespace: default -spec: - replicas: 2 - selector: - matchLabels: - app: github-app-playground-daemon - template: - metadata: - labels: - app: github-app-playground-daemon - spec: - terminationGracePeriodSeconds: 300 - containers: - - name: daemon - image: chrisleekr/github-app-playground:latest-daemon - envFrom: - - secretRef: - name: daemon-secrets - env: - - name: ORCHESTRATOR_URL - value: "wss://orchestrator.example.internal:3002" - - name: CLONE_BASE_DIR - value: "/workspaces" - volumeMounts: - - name: bot-workspaces - mountPath: /workspaces - volumes: - - name: bot-workspaces - emptyDir: - sizeLimit: 5Gi -``` - -`terminationGracePeriodSeconds` should match or exceed `DAEMON_DRAIN_TIMEOUT_MS` so SIGTERM has time to drain in-flight work before SIGKILL. - -### Ephemeral daemon RBAC (orchestrator side) - -The orchestrator's ServiceAccount needs permission to spawn bare Pods in `EPHEMERAL_DAEMON_NAMESPACE`: - -```yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: github-app-playground-ephemeral-spawner - namespace: default -rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["create", "get", "delete"] -``` - -Bind it to the orchestrator pod's ServiceAccount via a `RoleBinding`. Without these verbs, every scale-up attempt yields `dispatch_reason=ephemeral-spawn-failed` and the affected job is rejected with a tracking-comment infra error. - -### `daemon-secrets` Secret - -Spawned ephemeral Pods get their configuration via `envFrom: secretRef: daemon-secrets`. Create this Secret once in `EPHEMERAL_DAEMON_NAMESPACE` with at minimum: - -- `DAEMON_AUTH_TOKEN` — daemon ⇄ orchestrator WebSocket handshake. **Only source**: the Secret. The spawner does not inline this into the Pod spec so it cannot leak via `kubectl get pod -o yaml` or the Pod audit log. -- `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` (and `ALLOWED_OWNERS`) or Bedrock `AWS_*` vars — AI provider credentials. -- `VALKEY_URL`, `DATABASE_URL` — data layer. - -`ORCHESTRATOR_URL` is provided by the spawner inline (derived from the orchestrator's own `ORCHESTRATOR_PUBLIC_URL`), so it does not need to live in the Secret. - -GitHub App credentials are NOT needed on daemons — the orchestrator mints installation tokens and passes them per-job. - -### Ephemeral Pod security posture - -The spawner hardens every ephemeral Pod as follows (see `src/k8s/ephemeral-daemon-spawner.ts`): - -- `automountServiceAccountToken: false` — the daemon never calls the K8s API itself, so no ServiceAccount token is mounted. An agent subprocess running untrusted repo code cannot use it. -- `securityContext: { runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000, seccompProfile: RuntimeDefault }` at Pod scope. -- Container-scope: `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`. -- `restartPolicy: Never` and `activeDeadlineSeconds: 3600` provide a hard K8s-enforced ceiling if the idle-exit loop wedges. - -### Key constraints - -- `AGENT_TIMEOUT_MS` must stay below the GitHub installation-token TTL (3600s) so the daemon cannot outlive its credentials. -- `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` should be longer than typical heartbeat cadence so a short lull between back-to-back jobs does not cause the daemon to exit. -- Match `terminationGracePeriodSeconds` on the persistent daemon Deployment to `DAEMON_DRAIN_TIMEOUT_MS`. - -## Implementation references - -`src/daemon/main.ts`, `src/orchestrator/ws-server.ts`, `src/orchestrator/ephemeral-daemon-scaler.ts`, `src/k8s/ephemeral-daemon-spawner.ts`, `src/core/pipeline.ts`, `src/core/tracking-comment.ts`. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md deleted file mode 100644 index 2406caeb..00000000 --- a/docs/DEPLOYMENT.md +++ /dev/null @@ -1,345 +0,0 @@ -# Deployment - -This guide covers building and running the bot in production. The repository -ships **two container images** — an orchestrator and a daemon — that are built -from separate Dockerfiles but share a byte-identical base layer. - ---- - -## Image topology - -| Image | Dockerfile | Role | Needs outbound network | -| -------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| `orchestrator` | `Dockerfile.orchestrator` | Webhook server, WebSocket daemon registry, triage classifier, ephemeral daemon spawner | GitHub API, Anthropic/Bedrock, Postgres, Valkey, K8s API | -| `daemon` | `Dockerfile.daemon` | Fat worker image with real toolchains (kubectl, helm, terraform, aws, gcloud, docker CLI, go, rust, etc.) | Orchestrator WebSocket (outbound), GitHub API, Anthropic | - -The two images intentionally diverge after a shared base because their cost and -attack surface are very different: the orchestrator stays slim (no docker CLI, -no third-party toolchains), while the daemon bakes in the tools Claude agents -are allowed to shell out to. The shared prefix — stages `base`, `development`, -`deps` — is enforced byte-identical by -`scripts/check-dockerfile-base-sync.ts` (runs in CI) between the -`# --- SHARED-BASE-BEGIN ---` and `# --- SHARED-BASE-END ---` markers. - -### Shared base stages - -Both Dockerfiles start with the same three stages: - -| Stage | Base | Purpose | -| ------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `base` | `oven/bun:1.3.12` | Installs Node.js 20 (for Claude Code CLI), npm 11, `curl`, `git`, `@anthropic-ai/claude-code@2.1.114` globally, plus targeted openssl CVE upgrades | -| `development` | `base` | `bun install` (all deps) + `bun run build` → bundles `dist/` (app, daemon main, MCP stdio servers) | -| `deps` | `base` | `bun install --production --ignore-scripts` (runtime deps only; skips husky) | - -### Orchestrator-specific stages - -`Dockerfile.orchestrator` adds one stage on top of `deps`: - -| Stage | Base | Purpose | -| ------------ | ------ | ----------------------------------------------------------------------------------- | -| `production` | `base` | Copies `dist/`, production `node_modules/`, and `src/db/migrations/`; runs as `bun` | - -### Daemon-specific stages - -`Dockerfile.daemon` adds two stages on top of `deps`: - -| Stage | Base | Purpose | -| -------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `daemon-tools` | `base` | Installs kubectl, helm, terraform, kustomize, k9s, stern, argocd, flux, tflint, yq, aws-cli, gcloud, docker CLI, go, rust, poetry, gh, azure-cli, and bakes `daemon-capabilities.static.json` for fast startup | -| `production` | `daemon-tools` | Copies `dist/` and production `node_modules/`; runs as `bun` | - -Tool versions in `daemon-tools` are parameterised by `ARG` (e.g. -`KUBECTL_VERSION`, `HELM_VERSION`) and bumped together by -Renovate/Dependabot. The Trivy gate in CI blocks CVE regressions. - ---- - -## Build - -```bash -# Orchestrator only -bun run docker:build:orchestrator # → chrisleekr/github-app-playground:local-orchestrator - -# Daemon only (slow — installs toolchains) -bun run docker:build:daemon # → chrisleekr/github-app-playground:local-daemon - -# Both (convenience) -bun run docker:build -``` - -The scripts expand to `docker build -f -t ... . --progress=plain` -(see `package.json`). There is no default `Dockerfile` in the repo — always -pass `-f`. - -### Build arguments - -Common to both images: - -| Argument | Default | Purpose | -| ----------------- | ------------- | ----------------------------------------------------------- | -| `PACKAGE_VERSION` | `untagged` | Stored as Docker label `com.chrisleekr.bot.package-version` | -| `GIT_HASH` | `unspecified` | Stored as Docker label `com.chrisleekr.bot.git-hash` | - -Daemon-only (toggle toolchain cost): - -| Argument | Default | Purpose | -| ---------------- | ----------- | ------------------------------------------------------ | -| `TARGETARCH` | from buildx | Selects `amd64` / `arm64` asset URLs | -| `INSTALL_GCLOUD` | `true` | Skip the ~500 MB Google Cloud SDK install if `false` | -| `INSTALL_LANGS` | `go rust` | Space-separated list of language toolchains to bake in | - -```bash -# Example: orchestrator with version metadata -docker build -f Dockerfile.orchestrator \ - --build-arg PACKAGE_VERSION=$(bun -e "console.log(require('./package.json').version)") \ - --build-arg GIT_HASH=$(git rev-parse --short HEAD) \ - -t chrisleekr/github-app-playground:$(git rev-parse --short HEAD)-orchestrator \ - . -``` - -### Image contents (production) - -**Both images copy:** - -- `/app/dist/` — bundled app, MCP stdio servers, and (daemon only) `dist/daemon/main.js`. Produced by `bun run build` in the `development` stage. -- `/app/package.json` — for runtime version lookups. -- `/app/node_modules/` — runtime-only deps from the `deps` stage. -- `/app/src/db/migrations/` — SQL files, not bundled (orchestrator only; daemon also copies them because it may run migrations). -- `ENV CLAUDE_CODE_PATH=/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js` — pinned path to the globally-installed Claude Code CLI, because the Agent SDK otherwise looks for `{cwd}/dist/cli.js`. - -**Only the daemon copies:** `/app/daemon-capabilities.static.json` (pre-computed tool discovery manifest consumed by `src/daemon/tool-discovery.ts`). - -**Neither image copies:** `src/` sources, `tsconfig.json`, devDependencies, -or the `scripts/` directory. All MCP servers run from the bundled `dist/mcp/servers/*.js`, not source. - ---- - -## Run - -### Orchestrator - -```bash -docker run \ - --env-file .env \ - -p 3000:3000 \ - -p 3002:3002 \ - chrisleekr/github-app-playground:local-orchestrator -``` - -- `3000` — HTTP: webhook listener, `/healthz`, `/readyz`. -- `3002` — WebSocket: daemon registry (`WS_PORT`, default `3002`, see - `src/orchestrator/ws-server.ts`). Only expose this on networks the daemons - will connect from. - -Shortcut: `bun run docker:run` (also mounts `~/.aws` read-only for local Bedrock testing). - -### Daemon - -```bash -docker run \ - --env-file .env \ - -e ORCHESTRATOR_URL=ws://orchestrator-host:3002 \ - -e DAEMON_AUTH_TOKEN=... \ - -v $HOME/.aws:/home/bun/.aws:ro \ - chrisleekr/github-app-playground:local-daemon -``` - -The daemon does **not** expose any HTTP port and does **not** need GitHub App -credentials — the orchestrator mints installation tokens and hands them off -per job. See [DAEMON.md](DAEMON.md) for the full lifecycle and auth contract. - -Shortcut: `bun run docker:run:daemon` (connects back to -`ws://host.docker.internal:3002` for the local-dev `docker:run` orchestrator). - ---- - -## Health and readiness probes - -> These endpoints exist on the **orchestrator image only**. The daemon has no -> HTTP listener; its liveness is tracked in the orchestrator's in-memory daemon -> registry via the WebSocket heartbeat. - -| Endpoint | Method | Success | Failure | Purpose | -| ---------- | ------ | ----------- | ------------------- | ---------------------------------------------------- | -| `/healthz` | `GET` | `200 ok` | — | Liveness: process is alive (no external deps) | -| `/readyz` | `GET` | `200 ready` | `503 shutting down` | Readiness: accept traffic (flips `false` on SIGTERM) | - -See `src/app.ts:99-104`. On `SIGTERM`, the server immediately returns `503` on -`/readyz` so the load balancer stops routing new requests while in-flight work -drains. - -### Docker HEALTHCHECK (orchestrator) - -`Dockerfile.orchestrator` ships with: - -```dockerfile -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:3000/healthz || exit 1 -``` - -Honoured by Docker Compose, ECS, Nomad, Swarm. Kubernetes ignores Docker -`HEALTHCHECK` and uses the probe spec below. `curl` is installed in the `base` -stage specifically for this — do not remove. - -### Kubernetes probes (orchestrator) - -```yaml -livenessProbe: - httpGet: - path: /healthz - port: 3000 - initialDelaySeconds: 5 - periodSeconds: 10 - -readinessProbe: - httpGet: - path: /readyz - port: 3000 - initialDelaySeconds: 5 - periodSeconds: 5 -``` - -For the **daemon**, replace HTTP probes with an `exec` probe that checks the -daemon is still connected to the orchestrator — see -[DAEMON.md](DAEMON.md) for a working example. - ---- - -## Graceful shutdown - -The orchestrator handles `SIGTERM` and `SIGINT`: - -1. Flips `/readyz` to `503` (load balancer stops routing). -2. Calls `server.close()` — waits for in-flight HTTP requests to finish. -3. MCP stdio child processes exit via their own `finally` blocks. -4. **Force-exits after 290 seconds** if shutdown has not completed (`src/app.ts:360`). - -Set `terminationGracePeriodSeconds: 300` on the Pod so SIGKILL lands 10 seconds -after the force-exit fires: - -```yaml -spec: - terminationGracePeriodSeconds: 300 -``` - -The daemon has its own drain contract driven by `DAEMON_DRAIN_TIMEOUT_MS` — it -finishes the job it is currently executing, rejects new offers, then -disconnects. Match `terminationGracePeriodSeconds` to `DAEMON_DRAIN_TIMEOUT_MS` -on the daemon's Pod spec (see [DAEMON.md](DAEMON.md)). - ---- - -## Resource recommendations - -### Orchestrator sizing - -I/O-bound — network calls to GitHub and the LLM provider, WebSocket fan-out, -SQL writes, occasional K8s API calls to spawn ephemeral daemons. The orchestrator never runs the pipeline itself, so 1 GB is typically enough. - -| `MAX_CONCURRENT_REQUESTS` | Memory limit | CPU | -| ------------------------- | ------------ | -------- | -| 1 | 1 GB | 1 vCPU | -| 3 (default) | 2 GB | 1–2 vCPU | -| 5 | 3 GB | 2 vCPU | - -### Daemon sizing - -Dominated by whatever Claude runs inside it — `kubectl`, `terraform plan`, -`aws cli`, `docker build`. Start with: - -| Concurrent jobs | Memory limit | CPU | -| ------------------- | ------------ | -------- | -| 1 | 2 GB | 1–2 vCPU | -| 3 (typical default) | 4 GB | 2–4 vCPU | - -Set concurrency via `DAEMON_MAX_CONCURRENT_JOBS`. The image itself is ~2 GB -unpacked — plan for the node. The same sizing applies to ephemeral daemon -Pods — the spawner uses the same image. - -### Disk - -Each job clones the target repository to `CLONE_BASE_DIR` (default -`/tmp/bot-workspaces`) with `git clone --depth=${CLONE_DEPTH}` (default `50`, -see `src/core/checkout.ts:59`). The clone directory is removed in the -pipeline's `finally` block. - -Peak disk = `average_repo_size × concurrent_jobs`. For large monorepos, mount -a dedicated volume: - -```yaml -volumes: - - name: bot-workspaces - emptyDir: - sizeLimit: 5Gi -containers: - - name: github-app-playground - env: - - name: CLONE_BASE_DIR - value: /workspaces - volumeMounts: - - name: bot-workspaces - mountPath: /workspaces -``` - ---- - -## Environment variables - -The full schema lives in [CONFIGURATION.md](CONFIGURATION.md). Production -defaults worth double-checking: - -| Variable | Production recommendation | -| ------------------------- | ---------------------------------------------------------------------------------------------- | -| `NODE_ENV` | `production` (the `development` stage bakes this in at build time; keep it set at runtime too) | -| `LOG_LEVEL` | `info` — `debug` is very verbose and exposes webhook payloads | -| `MAX_CONCURRENT_REQUESTS` | Start at `3`; tune against memory limits and LLM budget | -| `CLONE_BASE_DIR` | Override if `/tmp` is on a small or shared filesystem | -| `PORT` | `3000` (must match `containerPort` and probe paths) | -| `WS_PORT` | `3002` (orchestrator WebSocket; keep behind cluster network) | - ---- - -## Ephemeral-daemon K8s requirements - -If you want the orchestrator to spawn ephemeral daemon Pods on demand, two -cluster-side prerequisites must be in place in `EPHEMERAL_DAEMON_NAMESPACE`: - -### Orchestrator RBAC - -The orchestrator's ServiceAccount needs `create`, `get`, and `delete` on -`pods` in `EPHEMERAL_DAEMON_NAMESPACE`: - -```yaml -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: github-app-playground-ephemeral-spawner - # Role lives in the namespace where ephemeral daemon Pods will be created. - namespace: ${EPHEMERAL_DAEMON_NAMESPACE} -rules: - - apiGroups: [""] - resources: ["pods"] - verbs: ["create", "get", "delete"] ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: github-app-playground-ephemeral-spawner - # Must match the Role namespace above. - namespace: ${EPHEMERAL_DAEMON_NAMESPACE} -subjects: - - kind: ServiceAccount - name: github-app-playground - # Namespace where the orchestrator ServiceAccount actually lives. - namespace: ${ORCHESTRATOR_NAMESPACE} -roleRef: - kind: Role - name: github-app-playground-ephemeral-spawner - apiGroup: rbac.authorization.k8s.io -``` - -Without these verbs, every scale-up attempt yields `dispatch_reason=ephemeral-spawn-failed` and the job is rejected with a tracking-comment infra error. - -### `daemon-secrets` Secret - -Spawned ephemeral daemon Pods receive their configuration via `envFrom: secretRef: daemon-secrets`. Create this Secret once in `EPHEMERAL_DAEMON_NAMESPACE` with only the daemon runtime values it needs — `DAEMON_AUTH_TOKEN`, Claude provider keys, and the daemon-side data-layer URLs (`DATABASE_URL`, `VALKEY_URL`). Do **not** copy GitHub App private-key material into this Secret: the orchestrator mints installation tokens and hands them to the daemon per job, so expanding the blast radius to every ephemeral Pod is unnecessary. See [DAEMON.md](DAEMON.md) for the full key list. diff --git a/docs/EXTENDING.md b/docs/EXTENDING.md deleted file mode 100644 index c64ecf21..00000000 --- a/docs/EXTENDING.md +++ /dev/null @@ -1,247 +0,0 @@ -# Extending the Bot - -This guide explains how to add new webhook event handlers and MCP servers. -The codebase is designed so that both extension points follow a consistent pattern -with minimal boilerplate. - ---- - -## Adding a New Webhook Event Handler - -### When to add one - -Add a new handler when you want the bot to react to a GitHub event that is not yet -handled — for example, `pull_request.closed`, `issue.opened`, or `push`. - -### Step 1 — Subscribe to the event in the GitHub App settings - -The GitHub App must be subscribed to the event before GitHub will deliver it. - -1. Go to **Settings > Developer settings > GitHub Apps > your app > Permissions & events**. -2. Under **Subscribe to events**, check the event you want to handle. -3. Click **Save changes**. - -### Step 2 — Create a handler file in `src/webhook/events/` - -Each handler file exports a single function that receives `(octokit, payload, deliveryId)`. -Use an existing handler as a template. - -**Template — event that triggers the bot:** - -```typescript -// src/webhook/events/my-event.ts -import type { MyEvent } from "@octokit/webhooks-types"; -import type { Octokit } from "octokit"; - -import { parseMyEvent } from "../../core/context"; // add a parser (see Step 3) -import { containsTrigger } from "../../core/trigger"; -import { logger } from "../../logger"; -import { processRequest } from "../router"; - -export function handleMyEvent(octokit: Octokit, payload: MyEvent, deliveryId: string): void { - // Filter to the specific action(s) you care about - if (payload.action !== "created") return; - - // Skip bot comments to avoid self-triggering loops - if (payload.comment.user.type === "Bot") return; - - // Only proceed when the trigger phrase is present - if (!containsTrigger(payload.comment.body)) return; - - logger.info( - { deliveryId, owner: payload.repository.owner.login, repo: payload.repository.name }, - "Trigger detected in my_event", - ); - - const ctx = parseMyEvent(payload, octokit, deliveryId); - - // Fire-and-forget — webhook must respond within 10 s - processRequest(ctx).catch((err) => { - ctx.log.error({ err }, "Async processing failed for my_event"); - }); -} -``` - -**Template — event that only logs (placeholder):** - -See `src/webhook/events/pull-request.ts` for the minimal placeholder pattern used -for events that are subscribed but not yet fully implemented. - -### Step 3 — Add a context parser in `src/core/context.ts` - -`processRequest()` requires a `BotContext`. Add a `parse*` function that maps the -raw webhook payload to the `BotContext` interface defined in `src/types.ts`. - -The existing `parseIssueCommentEvent` and `parseReviewCommentEvent` functions in -`src/core/context.ts` show the expected field mapping. - -### Step 4 — Register the handler in `src/app.ts` - -```typescript -// src/app.ts -import { handleMyEvent } from "./webhook/events/my-event"; - -// Inside the file, alongside the other registrations: -app.webhooks.on("my_event.created", ({ octokit, payload, id }) => { - handleMyEvent(octokit, payload as unknown as MyEvent, id); -}); -``` - -The `@octokit/webhooks-types` package provides TypeScript types for every GitHub -webhook payload. Import the matching type for your event. - -### Step 5 — Add tests - -Add a test file at `test/webhook/events/my-event.test.ts`. Use the existing test -files as a template. - ---- - -## Adding a New MCP Server - -### When to add one - -Add a new MCP server when you want to give Claude access to a new tool — for -example, a tool that posts to Slack, queries a database, or calls an external API. - -### Transport types - -| Type | When to use | Example | -| ------- | --------------------------------------------------------------- | ------------- | -| `stdio` | Local process; needs per-request secrets (tokens, IDs) | `comment.ts` | -| `http` | Remote service with a stable URL; no per-request process needed | `context7.ts` | - -### Option A — stdio server (local process) - -A stdio server is spawned as a child process per request. The registry passes -environment variables that carry per-request context (tokens, IDs). - -#### 1. Create the server file in `src/mcp/servers/` - -```typescript -// src/mcp/servers/my-server.ts -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; - -// Read env vars injected by the registry -const MY_VAR = process.env["MY_VAR"]; -if (!MY_VAR) { - console.error("Error: MY_VAR is required"); - process.exit(1); -} - -const server = new McpServer({ name: "My Server", version: "1.0.0" }); - -server.tool( - "my_tool", - "Description Claude sees when deciding whether to use this tool", - { input: z.string().describe("Tool input") }, - async ({ input }) => { - // ... implementation ... - return { content: [{ type: "text" as const, text: "result" }] }; - }, -); - -async function runServer(): Promise { - const transport = new StdioServerTransport(); - await server.connect(transport); - process.on("exit", () => { - void server.close(); - }); -} - -void runServer().catch(console.error); -``` - -#### 2. Register the server definition in `src/mcp/registry.ts` - -```typescript -// src/mcp/registry.ts -function myServerDef(sharedEnv: Record): McpServerDef { - return { - type: "stdio", - command: "bun", - args: ["run", "src/mcp/servers/my-server.ts"], - env: { ...sharedEnv, MY_VAR: "value" }, - }; -} - -// Inside resolveMcpServers(): -servers["my_server"] = myServerDef(sharedEnv); -``` - -The `sharedEnv` object already carries `GITHUB_TOKEN`, `REPO_OWNER`, `REPO_NAME`, -and `GITHUB_EVENT_NAME`. Spread it and add your own variables. - -#### 3. Add the server source to the Docker production stage - -stdio servers run as source files via `bun run src/mcp/servers/*.ts`. -The `Dockerfile` already copies the entire `src/mcp/` directory to the production -image, so new server files under `src/mcp/servers/` are included automatically. -No Dockerfile change is needed. - ---- - -### Option B — HTTP server (remote) - -An HTTP server is not spawned as a process — the Agent SDK connects to its URL -directly. Use this for external services like Context7. - -#### 1. Create a factory function in `src/mcp/servers/` - -```typescript -// src/mcp/servers/my-remote.ts -import type { McpServerDef } from "../../types"; - -export function myRemoteServer(): McpServerDef { - return { - type: "http", - url: "https://my-service.example.com/mcp", - headers: { Authorization: `Bearer ${process.env["MY_API_KEY"]}` }, - }; -} -``` - -#### 2. Register in `src/mcp/registry.ts` - -```typescript -import { myRemoteServer } from "./servers/my-remote"; - -// Inside resolveMcpServers(), conditionally when credentials are present: -if (config.myApiKey) { - servers["my_remote"] = myRemoteServer(); -} -``` - -#### 3. Add the env var to `src/config.ts` - -Follow the existing `context7ApiKey` pattern: add an optional Zod field, read the -environment variable in `loadConfig()`, and document it in `docs/SETUP.md`. - ---- - -## Existing servers - -| Server name | Transport | Purpose | Opt-in | -| ----------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `comment_update` | stdio | Writes/updates GitHub PR/issue comments owned by the bot. | Always on. | -| `inline_comments` | stdio | Posts inline review comments and replies on PR diffs. | Always on. | -| `resolve_review_thread` | stdio | Resolves a single PR review thread the bot has just replied to. Bound to one `(owner, repo, pullNumber)` per server instance. | Wired by `src/workflows/handlers/resolve.ts` for the resolve iteration only — not added to a session's allowed-tools by default. | -| `daemon_capabilities` | stdio | Reports the executing daemon's local environment (CPU, memory, language toolchain) to the agent. | Always on for daemon-run workflows. | -| `context7` (HTTP) | http | Fetches library documentation snippets via Upstash Context7. Requires `CONTEXT7_API_KEY`. | Auto-skipped when API key absent. | - ---- - -## Reference — Key Interfaces - -The `McpServerDef` type in `src/types.ts` defines both transport shapes: - -```typescript -type McpServerDef = - | { type: "stdio"; command: string; args: string[]; env?: Record } - | { type: "http"; url: string; headers?: Record }; -``` - -`BotContext` in `src/types.ts` defines the fields available to event handlers and -the processing pipeline (owner, repo, entityNumber, isPR, eventName, etc.). diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md deleted file mode 100644 index 50379dfc..00000000 --- a/docs/OBSERVABILITY.md +++ /dev/null @@ -1,108 +0,0 @@ -# Observability - -Structured JSON logs via [pino](https://getpino.io) are the primary signal. Every dispatch decision and every pipeline step carries a `deliveryId` so you can reconstruct a request end-to-end from a single log query. When `DATABASE_URL` is configured, the same information is also persisted to the `executions` and `triage_results` tables for aggregate reporting. - -## Log fields - -| Field | What it means | -| ------------------------ | ----------------------------------------------------------------------------------------------------- | -| `deliveryId` | `X-GitHub-Delivery` header — stable across every log line for a single webhook. | -| `event` | GitHub event name (`pull_request`, `issue_comment`, …). | -| `repo` | `owner/name` of the triggering repo. | -| `dispatch_target` | Always `daemon` (singleton — kept as a field for DB/log stability). | -| `dispatch_reason` | Why the job landed where it did. See below. | -| `isEphemeral` | Present on daemon-originating log lines. `true` if emitted by an ephemeral daemon, `false` otherwise. | -| `triage_fallback_reason` | Only present on triage fallbacks — one of the six values in [Triage](TRIAGE.md#fallback-reasons). | -| `confidence` | Triage confidence (0–1), only when the decision came from triage. | -| `heavy` | Triage binary signal (`true`/`false`) — only on triage-success. | -| `rationale` | Free-text rationale from the triage LLM. Only on triage-success. | -| `cost_usd` | Agent-reported total cost from the SDK. Present on completed executions. | -| `workflowRunId` | UUID of the `workflow_runs` row — stable per bot workflow run. See [Bot Workflows](BOT-WORKFLOWS.md). | -| `workflowName` | Workflow name (`triage`, `plan`, `implement`, `review`, `ship`). Emitted by dispatcher and handlers. | -| `ship_duration_ms` | Composite `ship` wall-clock duration measured from parent enqueue to terminal status. | -| `intentWorkflow` | Intent-classifier verdict for comment triggers (includes `clarify`/`unsupported`). | -| `intentConfidence` | Intent-classifier confidence (0–1). Dispatcher compares to `INTENT_CONFIDENCE_THRESHOLD`. | - -## Ship-workflow log fields (FR-016) - -The `bot:ship` lifecycle emits structured pino log lines validated against the canonical Zod schema in `src/workflows/ship/log-fields.ts`. The schema is consumed by every emitter (probe, intent transitions, reactor fan-out) so field names and types do not drift between modules. - -| Field | Type | When present | -| --------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `event` | string (e.g. `ship.intent.transition`, `ship.probe.run`, `ship.reactor.fanout`) | Always | -| `intent_id` | UUID | Always | -| `pr` | `{owner, repo, number, installation_id}` | Always | -| `iteration_n` | non-negative int | Always (0 on pre-iteration events) | -| `phase` | `probe` \| `fix` \| `reply` \| `wait` \| `terminal` | Iteration events | -| `from_status` / `to_status` | `SessionStatus` | Transition events only | -| `terminal_blocker_category` | `BlockerCategory` | Terminal `human_took_over` transitions | -| `non_readiness_reason` | `NonReadinessReason` | Probe events with non-ready verdict | -| `trigger_surface` | `literal` \| `nl` \| `label` | Session-start events only (FR-027) | -| `principal_login` | string | Session-start events only | -| `spent_usd_cents` | non-negative integer | Always — cumulative session spend (cents, NOT float, to avoid binary-fp drift in aggregations) | -| `wall_clock_ms` | non-negative integer | Always — cumulative session wall-clock | -| `delta_usd_cents` | non-negative integer | Per-event spend (iteration events only) | -| `delta_ms` | non-negative integer | Per-event wall-clock duration | - -**Querying example** (Datadog / Loki): - -```text -event:"ship.intent.transition" to_status:"human_took_over" terminal_blocker_category:"flake-cap" -| count by pr.repo -``` - -The schema is the **source of truth**. Adding or renaming a field requires updating `src/workflows/ship/log-fields.ts`; the co-located `log-fields.test.ts` round-trips a sample through the schema and rejects unknown / mistyped fields, so silent drift fails CI. - -### Iteration / tickle / scoped event keys (FR-018) - -Every ship-iteration-wiring emitter draws its `event` value from the typed `SHIP_LOG_EVENTS` constant in `src/workflows/ship/log-fields.ts`. A typo is therefore a compile error, and operators can grep for these literals deterministically. - -| Event key | Where it fires | What it indicates | -| ------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `ship.iteration.enqueued` | `iteration.runIteration` after `enqueueJob` | A non-ready verdict bridged into the daemon `workflow_runs` pipeline. One row per iteration. | -| `ship.iteration.terminal_cap` | `iteration.runIteration` cap check | The intent hit `MAX_SHIP_ITERATIONS` and was transitioned to `deadline_exceeded` with `iteration-cap` blocker. | -| `ship.iteration.terminal_deadline` | `iteration.runIteration` deadline check | The intent's `deadline_at` elapsed; transitioned to `deadline_exceeded`. | -| `ship.tickle.started` | `app.ts` boot, after `tickleScheduler.start()` | The cron tickle scheduler is now scanning `ship:tickle`. Quickstart S0 pre-flight asserts on this line. | -| `ship.tickle.due` | `orchestrator.onStepComplete` early-wake **OR** `session-runner.resumeShipIntent` | An intent is being re-entered. `source` field discriminates `workflow_run_completion` vs scheduler. | -| `ship.tickle.skip_terminal` | `orchestrator.onStepComplete` early-wake | The hook found a `shipIntentId` but the intent is already terminal (or missing); ZADD was skipped. | -| `ship.scoped..enqueued` | `dispatch-scoped.ts` after `enqueueJob` | A scoped command (`rebase` / `fix_thread` / `explain_thread` / `open_pr`) was enqueued for daemon dispatch. | -| `ship.scoped..daemon.completed` | `connection-handler.handleScopedJobCompletion` (orchestrator) **AND** the executor itself | Daemon reported successful completion. The bridge logs at this key on `status === "succeeded"`. | -| `ship.scoped..daemon.failed` | Same handler / executor | Daemon reported `halted` or `failed`. `reason` field carries the structured halt reason. | - -`` ∈ `rebase`, `fix_thread`, `explain_thread`, `open_pr`. The literal strings live as nested const properties on `SHIP_LOG_EVENTS` so a Datadog search like `event:"ship.scoped.rebase.daemon.completed"` is guaranteed to match the emitter. - -## Dispatch reasons - -Canonical source: `src/shared/dispatch-types.ts`. Four values, all landing on `dispatch_target=daemon`. - -| Reason | When the router sets it | -| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `persistent-daemon` | Routed to an existing persistent daemon. The default, hot path. Also used on cooldown — when a scale-up was warranted but blocked by the cooldown window. | -| `ephemeral-daemon-triage` | Triage returned `heavy=true` and an ephemeral daemon Pod was spawned to claim the job. | -| `ephemeral-daemon-overflow` | Queue length ≥ `EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` **and** the persistent pool is saturated (zero free slots); a spawn drains the overflow. | -| `ephemeral-spawn-failed` | A spawn was required but the K8s API call failed. The job is rejected with a tracking-comment infra error. | - -## Aggregate reporting - -When `DATABASE_URL` is set, helpers in `src/db/queries/dispatch-stats.ts` expose the most operator-relevant aggregates. Call them from an internal admin endpoint, a scheduled job, or `bun repl`: - -| Helper | Returns | -| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `eventsPerTarget(days)` | Count of executions grouped by `dispatch_target`. Post-collapse this is always a single `daemon` row — useful only as a liveness counter; query `dispatch_reason` directly for the per-reason split. | -| `triageRate(days)` | Share of events whose `dispatch_reason` is `ephemeral-daemon-triage` (i.e. triage drove an ephemeral spawn) vs. all events. | -| `avgConfidenceAndFallback(days)` | Mean triage confidence plus fallback counts by reason. | -| `triageSpend(days)` | Cumulative `cost_usd` for triage-reached executions. | - -## Alerts worth having - -- **Triage error rate**. `parse-error` + `llm-error` + `timeout` + `circuit-open` above a sustained threshold (e.g. 10% over 15 minutes) signals provider trouble or a regression. -- **Ephemeral spawn failures**. Any `dispatch_reason=ephemeral-spawn-failed` points at RBAC, quota, or control-plane issues. The affected request fails with a tracking-comment infra error. -- **Heartbeat drift**. Daemons missing heartbeats past `HEARTBEAT_TIMEOUT_MS` get evicted — sustained eviction points at network or resource-floor issues. -- **OOM / crash loops**. Standard infra alerts. The durable idempotency check means a restart won't replay a processed event, but a crash loop still blocks new ones. - -## Health probes - -| Path | Purpose | -| ---------- | -------------------------------------------------------------------------------- | -| `/healthz` | Liveness. Returns 200 once the HTTP server is bound. | -| `/readyz` | Readiness. Returns 200 once config is validated and the data layer is reachable. | diff --git a/docs/SETUP.md b/docs/SETUP.md deleted file mode 100644 index bc34bf53..00000000 --- a/docs/SETUP.md +++ /dev/null @@ -1,523 +0,0 @@ -# GitHub App Setup Guide - -Step-by-step guide for registering, configuring, and deploying the `@chrisleekr-bot` GitHub App. - -## Prerequisites - -### Required for all environments - -| Tool | Version | Purpose | -| -------------------------------------------------- | ------- | ---------------------------------------------------------------------- | -| [Bun](https://bun.sh) | ≥ 1.3.8 | Runtime and package manager (`engines.bun` in `package.json`) | -| [Git](https://git-scm.com) | any | Repository checkout during agent execution | -| GitHub account | — | Admin access to the target org or personal account | -| Publicly reachable HTTPS URL | — | Webhook endpoint (`https://github.chrislee.local/api/github/webhooks`) | -| [Anthropic API key](https://console.anthropic.com) | — | Required when `CLAUDE_PROVIDER=anthropic` (default) | - -### Required for Amazon Bedrock only - -| Tool / Permission | Purpose | -| ------------------------------------------------------------- | ----------------------------------------------------------------- | -| AWS account with Bedrock access | Hosting Claude via AWS | -| `AWS_REGION` set to a region with Bedrock enabled | e.g. `us-east-1` | -| One of: AWS SSO profile, IAM access keys, OIDC token, or IRSA | Authentication (see [Section 7](#7-amazon-bedrock-configuration)) | - ---- - -## 1. Create the GitHub App - -This section walks through every field in the GitHub App registration form in the exact order it appears in the GitHub UI. Complete all steps before clicking **Create GitHub App** at the end. - -### 1.1 Navigate to the registration form - -**For a personal account:** - -1. Click your **profile picture** in the top-right corner of any GitHub page -2. Click **Settings** -3. In the left sidebar, scroll to the bottom and click **Developer settings** -4. In the left sidebar, click **GitHub Apps** -5. Click **New GitHub App** - -**For an organization:** - -1. Click your **profile picture** → **Your organizations** -2. Click **Settings** to the right of the target organization -3. In the left sidebar, click **Developer settings** -4. In the left sidebar, click **GitHub Apps** -5. Click **New GitHub App** - -Direct link: [https://github.com/settings/apps/new](https://github.com/settings/apps/new) - -> A user or organization can register up to 100 GitHub Apps. There is no limit to how many apps can be installed on an account. -> Source: [Registering a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) - ---- - -### 1.2 Basic Information - -Fill in the first three fields at the top of the form: - -#### GitHub App name - -``` -chrisleekr-bot -``` - -- Must be **globally unique** across all of GitHub — no two apps can share a name regardless of owner. -- Maximum 34 characters. -- The name is converted to lowercase with spaces replaced by `-` and special characters removed before it is displayed in the GitHub UI (e.g. `My App` → `my-app`). This slugified form is what users will see when the bot takes an action. -- You **cannot** use the same name as an existing GitHub account unless it is your own user or organization name. - -> If the name is already taken, GitHub will show a validation error when you try to submit the form. Add a suffix (e.g. `-bot`, `-dev`) to make it unique. - -#### Homepage URL - -``` -https://github.com/chrisleekr/github-app-playground -``` - -- Required. Must be a valid, fully-qualified URL (`https://...`). -- If you do not have a dedicated website, use your repository URL. GitHub uses this field when displaying app details to users who encounter the app during installation. - -#### Description _(optional)_ - -``` -AI-powered code review bot — responds to @chrisleekr-bot mentions on PRs and issues. -``` - -- Shown to users on the app installation page. -- Keep it short and informative. Helps installers understand what permissions the app will request. - ---- - -### 1.3 Webhook Configuration - -Webhooks are how GitHub pushes events to your server. This section appears **before** the Permissions section in the form. - -#### Active - -- **Check this box.** It enables webhook delivery. -- Without it, GitHub will not send any events to your server, and the bot will never be triggered. - -#### Webhook URL - -``` -https://github.chrislee.local/api/github/webhooks -``` - -- The URL GitHub will POST events to. Must be publicly reachable over HTTPS. -- The path `/api/github` is set by the `pathPrefix` option in `createNodeMiddleware` inside `src/app.ts`. Do not change the path unless you also update the source code. -- **During local development**, use a tunnelling tool to expose `localhost:3000`: - - ```bash - # Option A — ngrok (https://ngrok.com), wrapped by the repo script - bun run dev:ngrok - # Paste the generated https://....ngrok.io URL here - - # Option B — smee.io (https://smee.io) - smee --url https://smee.io/ --path /api/github/webhooks --port 3000 - # Paste the https://smee.io/ URL here - ``` - -#### SSL verification - -- Leave **Enable SSL verification** checked (the default). -- GitHub strongly recommends SSL verification. It confirms the webhook URL's TLS certificate is valid before delivering events. -- Only disable this if you are using a self-signed certificate in a controlled environment. - -#### Webhook secret - -Generate a random secret: - -```bash -openssl rand -hex 32 -``` - -- Copy the output and paste it into this field. -- **Save this value** — you will need it as `GITHUB_WEBHOOK_SECRET` in your `.env` file. -- GitHub uses this secret to sign every webhook payload with HMAC-SHA256. The app verifies the signature via `createNodeMiddleware` from `octokit` before processing any event. -- Without a secret, anyone who knows your webhook URL could send forged events to your server. - -> See [Validating webhook deliveries](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) for how the HMAC-SHA256 signature verification works. - ---- - -### 1.4 Identifying and Authorizing Users (OAuth) - -This app uses **installation tokens** (server-to-server authentication) only. It never acts on behalf of an individual GitHub user. Leave all OAuth fields at their defaults. - -#### Callback URL - -- Leave **empty**. -- This field is only needed when your app generates user access tokens via the OAuth web flow. This app does not do that. - -#### Request user authorization (OAuth) during installation - -- Leave **unchecked**. -- Checking this would redirect every person who installs the app through an OAuth consent screen. This app never needs a user access token. - -#### Enable Device Flow - -- Leave **unchecked**. -- Device flow is used to generate user access tokens for CLI tools. Not applicable here. - -#### Expire user authorization tokens - -- Leave at default (checked, i.e. tokens expire). -- This field has no effect since user authorization is not used. - -> For background on installation tokens vs user access tokens, see [About authentication with a GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app). - ---- - -### 1.5 Post Installation - -#### Setup URL _(optional)_ - -- Leave **empty**. -- This URL is where GitHub redirects users after they install the app, if you need to show a post-install configuration page. Not required for this app. - -#### Redirect on update - -- Leave **unchecked**. -- Only relevant if you use a Setup URL and want to re-run setup whenever a user modifies the installation (e.g. adds/removes repositories). - ---- - -### 1.6 Permissions - -Under **Permissions & events**, expand the **Repository permissions** section and set the following. Leave everything else at **No access**. - -#### Repository permissions - -| Permission | Setting | Why | -| ----------------- | ------------ | ------------------------------------------------------------------- | -| **Actions** | Read-only | The app can read actions to the repository | -| **Contents** | Read & Write | The app clones the repo and can push commits via the git CLI | -| **Issues** | Read & Write | Read issue body and comments; post bot replies as issue comments | -| **Pull requests** | Read & Write | Read PR diff and context; post review comments and general comments | -| **Metadata** | Read-only | **Auto-granted** — required for all GitHub Apps, cannot be removed | -| **Workflows** | Read & Write | The app can read/write workflows to the repository | - -> **Principle of least privilege**: Only request permissions your app actually uses. Requesting unnecessary permissions increases the blast radius if credentials are compromised. See [Choosing permissions for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/setting-up-a-github-app/choosing-permissions-for-a-github-app). - -#### Organization permissions - -- Leave all at **No access**. Not needed. - -#### Account permissions - -- Leave all at **No access**. Not needed. - ---- - -### 1.7 Subscribe to Events - -After you set permissions, the **Subscribe to events** section becomes available and lists only events that match the permissions you granted. Check all of these: - -| Event checkbox in GitHub UI | Action(s) handled | Handler file | -| -------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------- | -| **Issue comments** | `issue_comment.created` | `src/webhook/events/issue-comment.ts` | -| **Issues** | `issues.labeled` / `issues.unlabeled` | `src/webhook/events/issues.ts` | -| **Pull requests** | `pull_request.opened` / `.labeled` / `.synchronize` / `.closed` | `src/webhook/events/pull-request.ts` | -| **Pull request reviews** | `pull_request_review.submitted` | `src/webhook/events/review.ts` | -| **Pull request review comments** | `pull_request_review_comment.created` / `.edited` / `.deleted` | `src/webhook/events/review-comment.ts` | -| **Pull request review threads** | `pull_request_review_thread.resolved` / `.unresolved` | `src/webhook/events/review-thread.ts` | -| **Check runs** | `check_run.completed` (powers the `bot:ship` reactor — early-wakes active intents on CI completion) | `src/webhook/events/check-run.ts` | -| **Check suites** | `check_suite.completed` | `src/webhook/events/check-suite.ts` | - -The PR shepherding reactor uses the new `synchronize`, `closed`, `edited`, `deleted`, `check_run`, and `check_suite` subscriptions to early-wake active sessions. The literal/NL/label trigger surfaces are all permanent v1 features and require no flag gating. Existing `bot:ship` (composite) operation does not require the new wake subscriptions. - -The bot also recognises four GitHub labels on PRs (FR-026): `bot:ship`, `bot:stop`, `bot:resume`, `bot:abort-ship`, plus the suffix-overridden variants `bot:ship/deadline=2h` etc. The bot self-removes the label after acting (FR-026a) — re-application is the supported re-trigger mechanism. - -> **Note:** GitHub does **not** emit a `pull_request_review_thread.created` action. The only valid actions for this event are `resolved` and `unresolved`, confirmed by `PullRequestReviewThreadResolvedEvent` and `PullRequestReviewThreadUnresolvedEvent` in `@octokit/webhooks-types`. Both actions route to the same handler. -> -> `pull-request.ts` is now an active handler — it dispatches `opened`, `labeled`, `synchronize`, and `closed` actions (the latter two via `fireReactor` to early-wake any active `bot:ship` session). `review.ts` likewise fires the reactor on `pull_request_review.submitted`. `review-thread.ts` remains a placeholder — `resolved`/`unresolved` actions are logged but take no further action; wire `processRequest()` inside it when needed. - -Leave all other events **unchecked**. Every subscribed event that your server does not handle still generates an HTTP POST to your webhook URL, wastes bandwidth, and creates noise in the **Advanced** delivery log. - -> Full list of available webhook events: [Webhook events and payloads](https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads) - ---- - -### 1.8 Where Can This GitHub App Be Installed? - -This is the last section before the submit button: - -| Option | When to use | -| ------------------------ | -------------------------------------------------------------------- | -| **Only on this account** | Personal or single-org use — recommended for private deployments | -| **Any account** | You plan to share the app publicly with other users or organizations | - -For a private bot deployment, select **Only on this account**. - ---- - -### 1.9 Submit the Form - -Click **Create GitHub App**. - -GitHub will: - -1. Register the app and assign it a unique **App ID** -2. Redirect you to the app's **General settings** page -3. Show a green confirmation banner - -You are now on the app settings page. **Do not close this tab** — the next steps require values from this page. - ---- - -## 2. Generate a Private Key - -The private key is used to sign JWT tokens that authenticate the app to the GitHub API. Without it, the app cannot generate installation tokens. - -### Step-by-step - -1. On the app's **General settings** page (where you landed after creation), scroll down to the **Private keys** section near the bottom of the page. -2. Click **Generate a private key**. -3. GitHub generates an RSA-2048 key pair, keeps the public key, and immediately downloads the private key as a `.pem` file to your computer (e.g. `chrisleekr-bot.2026-02-18.private-key.pem`). -4. Move the file somewhere secure — a password manager, a secrets vault, or an encrypted disk. **Never commit this file to Git.** - -### What the private key looks like - -```text ------BEGIN RSA PRIVATE KEY----- - ------END RSA PRIVATE KEY----- -``` - -The entire content of the file — including the `-----BEGIN` and `-----END` header/footer lines — must be stored as the `GITHUB_APP_PRIVATE_KEY` environment variable. When setting this in a single-line environment file, replace each newline with a literal `\n`: - -```bash -# Single-line format for .env files (replace with the real content) -GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n\n-----END RSA PRIVATE KEY-----\n" -``` - -Or keep the file on disk and read it at runtime: - -```bash -# Shell — expand to a single line with \n escape sequences -export GITHUB_APP_PRIVATE_KEY="$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' chrisleekr-bot.private-key.pem)" -``` - -> See [Managing private keys for GitHub Apps](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps) for key rotation and revocation procedures. - ---- - -## 3. Note the App ID - -Still on the app's **General settings** page: - -1. Scroll to the very top, to the **About** section. -2. Find the **App ID** field — it is a short integer, e.g. `123456`. -3. Save this value — you will need it as `GITHUB_APP_ID` in your `.env` file. - -The App ID is also visible in the URL when you are on the app settings page: - -``` -https://github.com/settings/apps/chrisleekr-bot - ^^^^^^^^^^^^^^^^ — this is your app slug, not the ID -``` - -The numeric ID is only shown in the **About** section or returned by the API (`GET /app`). - ---- - -## 4. Install the App on Repositories - -The app is now registered but not yet installed on any repository. A GitHub App only receives webhook events and can only access resources for repositories where it is installed. - -### Step-by-step - -1. In the left sidebar of your app settings, click **Install App**. -2. You will see a list of accounts (your personal account and any organizations you own or manage). Click **Install** next to the account where you want the bot to be active. -3. A confirmation screen asks which repositories to grant access to: - - | Option | Effect | - | ---------------------------- | ------------------------------------------------------------------ | - | **All repositories** | App can access every current and future repository on this account | - | **Only select repositories** | App is limited to repositories you explicitly choose (recommended) | - -4. If you chose **Only select repositories**, use the search box to find and select each target repository. -5. Click **Install**. - -GitHub will redirect you to the installation's settings page. The URL contains the **Installation ID** — save it if you need it for debugging (it is not required for normal operation; `octokit` resolves it automatically from the webhook payload). - -> After installation, the bot will respond to `@chrisleekr-bot` mentions **only** in the repositories where the app is installed. Mentions in other repositories are silently ignored. - -### Verify installation - -Go to any installed repository and check **Settings > GitHub Apps**. You should see `chrisleekr-bot` listed with the access level you granted. - ---- - -## 5. Local Development - -### Install dependencies - -```bash -bun install -``` - -### Copy and fill in environment variables - -```bash -cp .env.example .env -# Edit .env with your values — see Section 9 for the full variable reference -``` - -### Run in development mode (watch) - -```bash -bun run dev -``` - -This starts the HTTP server on `PORT` (default `3000`) and restarts on file changes. - -### Run tests - -```bash -bun test # run once -bun run test:watch # re-run on file changes -bun run test:coverage # with coverage report -``` - -### Other useful commands - -```bash -bun run check # Unified quality gate: typecheck + lint + format + test -bun run typecheck # TypeScript strict type check (no emit) -bun run lint # ESLint check -bun run lint:fix # ESLint auto-fix -bun run format # Prettier format check -bun run format:fix # Prettier auto-fix -``` - -`bun run check` is the single command to run before opening a pull request. - -### Expose the local server for webhook delivery - -GitHub must reach your webhook URL over the internet. During development, use a tunnelling tool such as [ngrok](https://ngrok.com) or [smee.io](https://smee.io): - -```bash -# Example with ngrok (wrapped by the repo script) -bun run dev:ngrok -# Copy the forwarding URL and paste it into the GitHub App webhook settings -``` - ---- - -## 6. Configure Environment Variables - -Copy `.env.example` and fill in the values: - -```bash -cp .env.example .env -``` - -All variables are validated at startup by `zod` in `src/config.ts`. The process exits immediately with a clear error message if any required variable is missing or invalid. - -### GitHub App credentials (always required) - -| Variable | Source | -| ------------------------ | ----------------------------------------------------------------------------------- | -| `GITHUB_APP_ID` | App settings page, **About** section | -| `GITHUB_APP_PRIVATE_KEY` | Full contents of the downloaded `.pem` file (including `-----BEGIN/END-----` lines) | -| `GITHUB_WEBHOOK_SECRET` | The value you generated with `openssl rand -hex 32` during registration | - -### AI provider, Anthropic, and Bedrock variables - -These are documented in the canonical env reference — see [Configuration → AI provider](CONFIGURATION.md#ai-provider). For Bedrock, follow [Section 7](#7-amazon-bedrock-configuration) below for the credential commands; the variable schema lives in `CONFIGURATION.md`. - -Quick provenance: - -- `ANTHROPIC_API_KEY` — generate at [console.anthropic.com](https://console.anthropic.com). -- `CLAUDE_CODE_OAUTH_TOKEN` — run `claude setup-token` (Max/Pro subscription path; requires `ALLOWED_OWNERS`). -- `AWS_*` — see Section 7. - -### Optional and runtime variables - -See [Configuration → Runtime](CONFIGURATION.md#runtime) for `PORT`, `LOG_LEVEL`, `NODE_ENV`, `MAX_CONCURRENT_REQUESTS`, `CLONE_BASE_DIR`, `TRIGGER_PHRASE`, `CONTEXT7_API_KEY`, and the rest. The schema in `src/config.ts` is authoritative. - ---- - -## 7. Amazon Bedrock Configuration - -Set `CLAUDE_PROVIDER=bedrock` and `CLAUDE_MODEL=` (e.g. `us.anthropic.claude-sonnet-4-6`). -`AWS_REGION` is also required. Then choose **one** credential method: - -### Credential method 1 — Local dev (AWS SSO profile) - -```bash -# Authenticate with SSO first -aws sso login --profile default # or your custom profile name - -# Set the profile in .env -AWS_PROFILE=default -``` - -The Claude Code subprocess inherits the profile via the AWS SDK credential chain automatically. - -### Credential method 2 — Explicit access keys (CI/CD or non-SSO) - -```bash -AWS_ACCESS_KEY_ID=AKIA... -AWS_SECRET_ACCESS_KEY=... -AWS_SESSION_TOKEN=... # only for temporary/assumed-role credentials -``` - -### Credential method 3 — OIDC bearer token (GitHub Actions) - -Use [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) in your workflow, then set: - -```bash -AWS_BEARER_TOKEN_BEDROCK= -``` - -### Bedrock IAM policy - -The IAM role or user needs at minimum: - -```json -{ - "Effect": "Allow", - "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"], - "Resource": "arn:aws:bedrock:::foundation-model/anthropic.claude-*" -} -``` - -See [Amazon Bedrock identity-based policy examples](https://docs.aws.amazon.com/bedrock/latest/userguide/security_iam_id-based-policy-examples.html). - ---- - -## 8. Verify the Setup - -### Test webhook delivery - -1. Go to **Settings > Developer settings > GitHub Apps > your app > Advanced** -2. Click **Redeliver** on a recent delivery, or trigger a new one by posting a comment -3. Check the app logs for a successful signature verification message - -### End-to-end test - -1. Open an issue or PR in a repository where the app is installed -2. Post a comment: `@chrisleekr-bot what does this repo do?` -3. The bot creates a tracking comment and begins replying - -### Troubleshooting - -| Symptom | Check | -| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| Webhook returns 401/403 | `GITHUB_WEBHOOK_SECRET` must exactly match the value set in app settings (no trailing newline) | -| Bot does not respond | Confirm the app is installed on the target repository; check that the subscribed events match the comment type | -| `GITHUB_APP_PRIVATE_KEY` error | Set the full `.pem` contents including `-----BEGIN RSA PRIVATE KEY-----` / `-----END RSA PRIVATE KEY-----` lines | -| `ERR_OSSL_BAD_END_LINE` | The single-line `"...\n..."` form (literal backslash+n) is normalized automatically in v1.2.0+ — upgrade if you see this on older deployments | -| Webhook timeout (> 10 s on GitHub) | Processing runs async after `200 OK`; check pod logs for errors — GitHub may show timeout but work proceeds | -| `ANTHROPIC_API_KEY is required` | `CLAUDE_PROVIDER` defaults to `anthropic`; set `ANTHROPIC_API_KEY` or switch to `bedrock` | -| `AWS_REGION is required` | Set `AWS_REGION` when `CLAUDE_PROVIDER=bedrock` | -| `CLAUDE_MODEL is required` | Bedrock uses a different model ID format; set e.g. `CLAUDE_MODEL=us.anthropic.claude-sonnet-4-6` | -| Bedrock `UnrecognizedClientException` | AWS credentials are missing or expired; verify the credential method in use (see Section 8) | -| Pod OOM killed | Reduce `MAX_CONCURRENT_REQUESTS` or increase the memory available to the process | -| `/readyz` returns 503 | Server is shutting down (SIGTERM received); a restart is in progress — normal during graceful shutdown | -| Clone directory full | `CLONE_BASE_DIR` is out of disk space; reduce `MAX_CONCURRENT_REQUESTS` or free up disk | -| Context7 server not active | `CONTEXT7_API_KEY` is empty or unset; without a key the Context7 MCP server is disabled automatically | diff --git a/docs/SHIP.md b/docs/SHIP.md deleted file mode 100644 index 80035199..00000000 --- a/docs/SHIP.md +++ /dev/null @@ -1,41 +0,0 @@ -# `bot:ship` — PR shepherding to merge-ready - -The shepherding lifecycle takes an open PR from "needs work" to "ready for human merge" — driving CI fixes, replying to review threads, and resolving them, until the merge-readiness probe says the PR is clean. The bot **never** merges; the final merge action remains with a human (FR-008). - -## How to invoke - -There are three functionally equivalent surfaces (FR-027). All three produce the same canonical command and the same downstream behaviour; only the `surface` field on the canonical record differs (it appears in logs for observability, FR-016). - -| Surface | Example | Notes | -| ------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| Literal | `bot:ship` _(or `bot:ship --deadline 2h`)_ | Deterministic regex parser. The legacy surface; available without any feature flag. | -| Natural | `@chrisleekr-bot ship this please` | Mention-prefix-gated NL classifier (FR-025a). Zero LLM cost on comments without the mention. | -| Label | Apply the `bot:ship` label _(or `bot:ship/deadline=2h`)_ | Bot self-removes the label after acting (FR-026a). Re-application is the supported re-trigger mechanism. | - -All three surfaces — literal, natural-language, and label — are permanent v1 features. The natural-language path costs nothing on comments without the `TRIGGER_PHRASE` mention (FR-025a gate runs before the LLM call). - -The four recognised verbs (each available across all three surfaces): `ship`, `stop`, `resume`, `abort-ship`. See `contracts/bot-commands.md` for full syntax. - -## How to monitor - -Each session writes a single canonical tracking comment marked with ``. The body shows current phase, last action, next queued action, iteration count, USD spent, deadline, and (on terminal) the blocker category. A maintainer glancing at the PR sees exactly where the bot is and whether they need to act. - -For Day-2 SQL queries (active sessions, terminal-state distribution, USD spend by intent), see the in-repo file `specs/20260427-201332-pr-shepherding-merge-ready/quickstart.md` §"Day-2 ops". - -## How to abort - -Three ways, all equivalent: - -| Surface | Example | -| ------- | -------------------------------- | -| Literal | `bot:abort-ship` | -| Natural | `@chrisleekr-bot abort the bot` | -| Label | Apply the `bot:abort-ship` label | - -Abort sets a Valkey cancellation flag, waits ≤2 s for the next cooperative checkpoint, then force-transitions the intent to `aborted_by_user`. After abort, the bot performs zero further mutating actions on the PR (FR-009 / SC-005). - -For a recoverable pause, use `bot:stop` (and later `bot:resume`) instead. A stopped session preserves its continuation row; the deadline keeps counting down while paused. - -## Rollout flags - -The remaining v1 rollout flags are `SHIP_USE_PROBE_VERDICT` (probe-verdict ladder vs. legacy in-process review/resolve loop) and `SHIP_USE_CONTINUATION_LOOP` (cron-tickle re-entry vs. in-process loop). Both default off; flip on after the corresponding soak per research.md R8. diff --git a/docs/TRIAGE.md b/docs/TRIAGE.md deleted file mode 100644 index a0ee6191..00000000 --- a/docs/TRIAGE.md +++ /dev/null @@ -1,57 +0,0 @@ -# AI Triage - -Triage is a binary `heavy` classifier. It runs on every event (subject to the kill-switch and circuit breaker) and answers one question: should this job prefer an ephemeral daemon? - -## When it runs - -1. The event hits the router. -2. The orchestrator calls a single-turn Haiku classifier. -3. On success, the returned `heavy` boolean feeds into the [scale-up rule](ARCHITECTURE.md#scale-up-model): `heavy=true` is one of the two triggers that can cause the orchestrator to spawn an ephemeral daemon Pod. `heavy=false` routes the job to `persistent-daemon`. - -## What the call returns - -A small JSON object: `{ heavy: boolean, confidence: number, rationale: string }`. There is no `complexity` field and no `maxTurns` mapping — `maxTurns` always comes from `config.defaultMaxTurns` regardless of the triage outcome. - -## Confidence threshold - -At or above `TRIAGE_CONFIDENCE_THRESHOLD`, `heavy` is accepted as-is. Below it, the router treats the signal as `heavy=false` — the job routes to `persistent-daemon` and the log line carries `triage_fallback_reason=sub-threshold`. The day-one default is `1.0` so only perfectly confident results are accepted. - -## Circuit breaker - -Triage wraps the LLM call in a circuit breaker (see `src/orchestrator/triage.ts`). Consecutive failures trip the breaker; while it is open, the triage function short-circuits to `heavy=false` and emits `triage_fallback_reason=circuit-open`. The breaker re-closes after a cooldown. - -## Fallback reasons - -Six distinct reasons cause triage to fall back to `heavy=false` (i.e. route to `persistent-daemon`): - -| Reason | Trigger | -| --------------- | ----------------------------------------------------------------------- | -| `disabled` | `TRIAGE_ENABLED=false` — triage short-circuits without calling the LLM. | -| `circuit-open` | The circuit breaker tripped after consecutive failures. | -| `timeout` | The call exceeded `TRIAGE_TIMEOUT_MS`. | -| `llm-error` | The provider returned an error. | -| `parse-error` | The JSON response could not be validated against the expected schema. | -| `sub-threshold` | Parsed successfully but `confidence < TRIAGE_CONFIDENCE_THRESHOLD`. | - -All six appear in Pino logs as `triage_fallback_reason`. Canonical values live in `src/orchestrator/triage.ts`. - -## Cost implications - -Every event attempts triage; when `TRIAGE_ENABLED=false` or the breaker is open the call short-circuits _before_ hitting Haiku, so those paths are free. When the call proceeds, one Haiku invocation is the dominant marginal cost on a busy install. Mitigations: - -- `TRIAGE_CONFIDENCE_THRESHOLD` defaults to `1.0` (strictest) — _lower_ it toward `0.8`–`0.9` to accept more heavy verdicts; _raising_ it above `1.0` is not supported and would gate out every response. The compute cost is unchanged either way (the Haiku call still happens); the knob only controls whether the result routes an ephemeral spawn. -- Flip `TRIAGE_ENABLED=false` during a provider incident to suppress spend without redeploying. While disabled, every event is treated as `heavy=false`. -- Keep `TRIAGE_MAX_TOKENS` low (the response schema is ~40 tokens). - -## Tuning knobs - -| Variable | Default | When to change | -| ----------------------------- | ----------- | ------------------------------------------------------------------------ | -| `TRIAGE_ENABLED` | `true` | Incident kill-switch. | -| `TRIAGE_MODEL` | `haiku-3-5` | Experiment with newer Haiku aliases for latency. | -| `TRIAGE_CONFIDENCE_THRESHOLD` | `1.0` | Relax to `0.8`–`0.9` once you're confident the classifier is calibrated. | -| `TRIAGE_MAX_TOKENS` | `256` | Only raise if the rationale is being truncated. | -| `TRIAGE_TIMEOUT_MS` | `5000` | Raise if provider latency is consistently above 5s. | -| `DEFAULT_MAXTURNS` | `30` | Agent turn cap for every execution. | - -Full variable descriptions live in [Configuration](CONFIGURATION.md#triage). diff --git a/docs/build/architecture.md b/docs/build/architecture.md new file mode 100644 index 00000000..060bed85 --- /dev/null +++ b/docs/build/architecture.md @@ -0,0 +1,176 @@ +# Architecture + +A single HTTP server process receives GitHub webhook events, acknowledges within ten seconds, and asynchronously hands each event to a daemon for execution. Every event walks the same path: verify → route → classify → enqueue → daemon claims the job → run the pipeline → finalise the tracking comment. + +## Request flow + +```mermaid +flowchart TD + GH["GitHub webhook
POST /api/github/webhooks"]:::entry + VERIFY["Verify HMAC-SHA256"]:::guard + ACK["200 OK within 10 seconds"]:::ack + ROUTE["Router
idempotency + allowlist + concurrency"]:::guard + TR["Haiku triage
binary heavy classifier"]:::decide + QUEUE["Orchestrator job queue
Valkey list"]:::store + SCALE{{"Scale-up decision
heavy OR queue >= threshold
AND no persistent slots
AND cooldown elapsed"}}:::fork + SPAWN["K8s API
create bare Pod
DAEMON_EPHEMERAL=true"]:::decide + FLEET["Daemon fleet
persistent + ephemeral
WebSocket connections"]:::target + PIPE["runPipeline
clone + prompt + Claude Agent SDK"]:::work + FIN["Finalise tracking comment
success, error, or cost summary"]:::done + + GH --> VERIFY --> ACK + ACK -. async .-> ROUTE + ROUTE --> TR + TR --> QUEUE + QUEUE --> SCALE + SCALE -->|yes| SPAWN + SPAWN --> FLEET + SCALE -->|no, or cooldown active| FLEET + QUEUE -->|JobOffer| FLEET + FLEET --> PIPE + PIPE --> FIN + + classDef entry fill:#0b5cad,stroke:#083e74,color:#ffffff + classDef guard fill:#164a3a,stroke:#0d2c24,color:#ffffff + classDef ack fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff + classDef decide fill:#8a5a00,stroke:#5c3d00,color:#ffffff + classDef fork fill:#6a2080,stroke:#451454,color:#ffffff + classDef target fill:#114a82,stroke:#0a2f56,color:#ffffff + classDef work fill:#4a2e7a,stroke:#311f50,color:#ffffff + classDef store fill:#5c3d00,stroke:#3d2900,color:#ffffff + classDef done fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff +``` + +## Key concepts + +- **Async processing.** The webhook handler responds within ten seconds, so the router fires `processRequest` with fire-and-forget semantics after the 200 OK is queued. Every box downstream of `ACK` runs after the HTTP response is on the wire. +- **Two-layer idempotency.** The fast path is an in-memory `Map` keyed by `X-GitHub-Delivery`. The durable path (`isAlreadyProcessed` in `src/core/tracking-comment.ts`) scans GitHub issue/PR comments for the hidden delivery marker the bot embeds in the tracking comment, so duplicate deliveries are detected across pod restarts, OOM kills, and crash loops — this works **without** `DATABASE_URL`. `DATABASE_URL` is required only to persist execution / dispatch history across restarts. +- **One request, one clone.** Each delivery clones the repo into a unique temp directory under `CLONE_BASE_DIR` **on the daemon host**. Claude operates on local files via `cwd`. The directory is removed in the pipeline's `finally` block regardless of outcome. +- **The webhook server never runs the pipeline.** Only daemons execute `runPipeline`. The webhook server is the orchestrator — it enqueues jobs and optionally spawns ephemeral daemons. +- **Every orchestrator runs a queue worker.** `src/orchestrator/queue-worker.ts` polls `queue:jobs` via `LMOVE` into a per-instance processing list (`queue:processing:{instanceId}`), offers the job to a locally-connected daemon, and atomically re-queues it to the head when no local daemon can take it. Multi-orchestrator HA: `LMOVE` grants exactly-once claim across instances; the offer/accept round-trip stays in-process. Crash recovery is handled by each orchestrator draining its own processing list at startup, plus a cross-instance reaper (`src/orchestrator/valkey-cleanup.ts`) draining processing lists owned by instances whose `orchestrator:{id}:alive` liveness key has expired. +- **MCP servers.** Tracking-comment updates, inline PR reviews, scoped review-thread resolves, daemon-capability reports, repo-memory, and (optionally) Context7 library docs are exposed as MCP servers the agent can call. Git changes are made via the Bash tool against the cloned repo, not through a dedicated MCP server. + +## Dispatch flow + +Dispatch collapsed to a single target — `daemon` — in migration `004_collapse_dispatch_to_daemon.sql`. Every job is claimed by some daemon in the fleet over WebSocket. The router decides only the **reason** the job lands there and whether to spawn an ephemeral daemon. + +### Single target, four reasons + +Canonical source: `src/shared/dispatch-types.ts`. + +- `DispatchTarget` = `"daemon"` (singleton — kept as a field for DB/log stability). +- `DispatchReason` is one of: + +| Reason | When the router sets it | +| --------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `persistent-daemon` | Routed to an existing persistent daemon. The default, hot path. | +| `ephemeral-daemon-triage` | Triage flagged the job heavy → orchestrator spawned an ephemeral daemon Pod. | +| `ephemeral-daemon-overflow` | Queue length ≥ `EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` and persistent pool saturated → spawn drains overflow. | +| `ephemeral-spawn-failed` | Spawn was required but the K8s API call failed. Job rejected with a tracking-comment infra error. | + +### Scale-up model + +The fleet is two-tiered — see [`../operate/runbooks/daemon-fleet.md`](../operate/runbooks/daemon-fleet.md) for the operational view. The decision rule: + +1. **Triage.** Single-turn Haiku call returns `{heavy, confidence, rationale}`. `heavy=true` is one trigger. +2. **Overflow.** `queue_length ≥ EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` **and** persistent free slots = 0 is the other trigger. +3. **Cooldown.** Spawns are rate-limited by `EPHEMERAL_DAEMON_SPAWN_COOLDOWN_MS`. During cooldown, heavy/overflow signals do **not** spawn — the job falls back to `persistent-daemon` and waits. +4. **Spawn.** When both a trigger fires and cooldown has elapsed, the orchestrator calls the K8s API to create a bare Pod with `DAEMON_EPHEMERAL=true`. Only a true K8s API failure yields `ephemeral-spawn-failed`. + +The newly-spawned ephemeral daemon connects via WebSocket, registers with `isEphemeral: true`, claims the job, runs it, then drains and exits after `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS`. + +## WebSocket protocol + +Schema in `src/shared/ws-messages.ts` (Zod discriminated union). Validation failures close the WebSocket with `POLICY_VIOLATION`. Every message has an envelope with `id` (UUID) and `timestamp` (ms). + +### Server → Daemon + +| Type | Purpose | +| ------------------------ | ---------------------------------------------------------------------------------------------------------- | +| `daemon:registered` | Handshake response after `daemon:register`; carries `heartbeatIntervalMs`, `offerTimeoutMs`, `maxRetries`. | +| `heartbeat:ping` | Periodic liveness ping. | +| `job:offer` | Offer a workflow-run job. | +| `scoped-job-offer` | Offer a scoped job (`scoped-rebase`, `scoped-fix-thread`, `scoped-explain-thread`, `scoped-open-pr`). | +| `job:payload` | Full `BotContext` plus overrides (maxTurns, allowedTools, trackingCommentId). Sent after accept. | +| `job:cancel` | Abort a running job. | +| `daemon:update-required` | Daemon version mismatch; force exit. | + +### Daemon → Server + +| Type | Purpose | +| ---------------------------- | -------------------------------------------------------------------------------------------------- | +| `daemon:register` | Initial registration with capabilities, resources, `isEphemeral`, `protocolVersion`, `appVersion`. | +| `heartbeat:pong` | Refresh TTL; carries `activeJobs`, current resources. | +| `job:accept` | Claim an offered job. | +| `job:reject` | Decline with reason (`scoped-kind-unsupported`, `resource-insufficient`, …). | +| `job:status` | Mid-run progress. | +| `job:result` | Workflow-run completion with `ExecutionResult` fields. | +| `scoped-job:completion` | Scoped job result with kind-specific fields. | +| `daemon:draining` | Graceful shutdown initiated. | +| `daemon:update-acknowledged` | Ack for `daemon:update-required`. | +| `error` | Generic error envelope. | + +## PR shepherding bridge + +The `bot:ship` lifecycle does **not** own a separate daemon-execution path. It bridges onto the existing `workflow_runs` pipeline so a single executor surface clones, runs the Agent SDK, and pushes — no parallel implementation to drift between. + +```mermaid +flowchart LR + Trigger["bot:ship trigger
literal / NL / label"]:::input + SR["session-runner.ts"]:::core + Intent[("ship_intents row")]:::store + Cont[("ship_continuations row
wake_at")]:::store + Iter["iteration.runIteration"]:::core + WR[("workflow_runs row
state.shipIntentId")]:::store + Q[("queue:jobs
kind=workflow-run")]:::store + + Daemon["Daemon process"]:::work + Exec["src/core/pipeline.ts"]:::work + Done["markSucceeded(runId)"]:::work + Cascade["orchestrator.onStepComplete
maybeEarlyWakeShipIntent"]:::core + Tickle[("ship:tickle ZSET
score=0")]:::store + + Timer["tickle-scheduler
setInterval"]:::core + Due["ZRANGEBYSCORE 0 now"]:::core + Resume["session-runner.resumeShipIntent"]:::core + + Trigger --> SR --> Intent + SR --> Cont + SR --> Iter --> WR + Iter --> Q + Q --> Daemon --> Exec --> Done --> Cascade --> Tickle + Timer --> Due --> Resume + Tickle --> Due + Resume -. next iteration .-> Iter + + classDef input fill:#1f6feb,stroke:#0b3d99,color:#ffffff + classDef core fill:#8957e5,stroke:#4c2889,color:#ffffff + classDef work fill:#7a3b1f,stroke:#3d1d0e,color:#ffffff + classDef store fill:#0e8a16,stroke:#063d09,color:#ffffff +``` + +The reactor (`fanOut`) writes `wake_at = now()` and `ZADD ship:tickle 0 ` so the next cron tick (typically under 30 s) re-enters the runner. This keeps daemon slots free between iterations and gives the bot crash-restart safety: on boot, `tickle-scheduler` reconciles missed wakes from Postgres into Valkey before the periodic timer's first tick. + +## Directory layout + +| Directory | Responsibility | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `src/webhook/` | Event routing (`router.ts`) and per-event handlers (`events/`, one file per event type). | +| `src/core/` | Pipeline: context → fetch → format → prompt → checkout → execute → finalise. `pipeline.ts` is the single execution path (daemon-side). | +| `src/ai/` | Provider-agnostic LLM client (Anthropic + Bedrock) used by triage and the intent / NL classifiers. | +| `src/orchestrator/` | WebSocket server, daemon registry, job queue, dispatcher, triage, ephemeral-daemon scaler. Embedded in the webhook server process. | +| `src/daemon/` | Standalone worker process (persistent or ephemeral). WebSocket client that accepts offers and runs `pipeline.ts`. | +| `src/k8s/` | Ephemeral daemon Pod spawner. | +| `src/mcp/` | MCP server registry. | +| `src/workflows/` | Registry, dispatcher, composite cascade, ship lifecycle (`ship/`), per-workflow handlers (`handlers/`). | +| `src/db/` | Postgres layer. Migrations, connection singleton, observability queries. Active when `DATABASE_URL` is set. | +| `src/shared/` | Types shared between server and daemon (WebSocket messages, dispatch enums). | +| `src/utils/` | Retry, sanitisation, circuit breaker. | + +## Further reading + +- [Workflows](../use/workflows/index.md) — registry-driven `bot:*` commands. Source of truth: `src/workflows/registry.ts`. +- [`bot:ship` lifecycle](../use/workflows/ship.md) — verdict ladder, status state machine. +- [Daemon fleet runbook](../operate/runbooks/daemon-fleet.md) — persistent vs ephemeral, scaling, K8s. +- [Configuration](../operate/configuration.md) — every environment variable. +- [Extending](extending.md) — add a workflow or MCP server. diff --git a/docs/build/contributing.md b/docs/build/contributing.md new file mode 100644 index 00000000..5a9b3e36 --- /dev/null +++ b/docs/build/contributing.md @@ -0,0 +1,66 @@ +# Contributing + +Short version: open a PR, keep `bun run check` green, update the matching docs page. + +## Branching + +| Branch | Purpose | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `main` | Always green. Push triggers `ci.yml` only — production releases are manual. | +| Feature branches | Any name **other** than `main` and `v*`. Triggers `dev-release.yml` (CI → dev semantic-release → multi-arch image build). | + +## Commit format + +`commitlint` with `@commitlint/config-conventional`. Allowed types: + +```text +feat, fix, docs, style, refactor, test, chore, perf, build, ci, revert, localize, bump +``` + +Example: `feat(ship): wire ship iteration loop, tickle scheduler, and four scoped executors`. + +## Pre-commit gate + +`.husky/pre-commit` runs: + +1. `gitleaks protect --staged` — secret scan (hard exit if `gitleaks` is missing; install with your package manager). +2. `bunx lint-staged` — Prettier and ESLint on staged files. + +`.husky/commit-msg` runs `commitlint` on the message itself. + +## What to run before opening a PR + +```bash +bun run check +``` + +That single command runs typecheck, lint, format, the no-destructive-action guard, the docs-sync check, and tests. Failing any of those fails CI. + +For a broader smoke test: + +```bash +bun run audit:ci # severity-gated dependency audit +bun run docs:build # strict docs build (catches broken internal links) +``` + +## PRs that touch `src/workflows/**` + +The `check:docs-sync` script blocks any PR touching workflow source without a matching update under `docs/use/workflows/`. The intent is that the workflow tree on the docs site never lies about what the registered workflows actually do. + +Test files (`*.test.ts`) and inline markdown (`*.md` under `src/workflows/`) are exempt. + +## PRs that change configuration + +If you add or rename an environment variable in `src/config.ts`, update [`../operate/configuration.md`](../operate/configuration.md) in the same PR. The full doc-sync mapping is in [`conventions.md`](conventions.md#documentation-discipline). + +## Where to file issues + +Use the GitHub issue tracker on the repo. The bot also responds to mentions on issues — `@chrisleekr-bot triage this` is a perfectly valid first move (see [`../use/workflows/triage.md`](../use/workflows/triage.md)). + +## Running the docs site locally + +```bash +bun run docs:install # one-time, installs MkDocs Material +bun run docs:serve # http://localhost:8000 with live reload +bun run docs:build # strict build (CI also runs this) +``` diff --git a/docs/build/conventions.md b/docs/build/conventions.md new file mode 100644 index 00000000..81040c59 --- /dev/null +++ b/docs/build/conventions.md @@ -0,0 +1,109 @@ +# Code conventions + +The repo enforces conventions through tooling, not docs — every rule below is checked by `bun run check`. This page is a tour of what's wired so you know what to expect. + +## Runtime and language + +- **Runtime.** Bun for the application; Node.js 20 for the Claude Code CLI subprocess. Both are installed in the Docker `base` stage. +- **Bun version.** Pinned in `.tool-versions` (`bun 1.3.13`). All workflows use `oven-sh/setup-bun@v2` with `bun-version-file: .tool-versions`. +- **TypeScript.** Strict mode plus the strictest flags: `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`, `useUnknownInCatchVariables`, `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, `noImplicitOverride`, `noPropertyAccessFromIndexSignature`. Module resolution is `bundler` — imports do not need `.js` extensions. + +## ESLint + +`eslint.config.mjs` is the flat config: + +- **Preset.** `@eslint/js` recommended + `typescript-eslint:strictTypeChecked` + `stylisticTypeChecked`. +- **Plugins.** `eslint-plugin-security`, `eslint-plugin-simple-import-sort`, `prettier`. +- **Notable rules.** + - `@typescript-eslint/strict-boolean-expressions: error` + - `@typescript-eslint/no-explicit-any: warn` + - `@typescript-eslint/no-unused-vars` (underscore-prefix exempted) + - `simple-import-sort/imports`, `simple-import-sort/exports` — auto-fixable + - `complexity: warn` (15), `max-lines-per-function: warn` (120), `max-nested-callbacks: error` (3) + - Security rules from `eslint-plugin-security:recommended` +- **Special restriction.** `src/workflows/ship/scoped/triage.ts` carries a `no-restricted-syntax` rule forbidding GitHub mutations — ship-side triage is suggest-only. + +## Prettier + +`.prettierrc`: + +| Rule | Value | +| ---------------- | ---------- | +| `semi` | `true` | +| `singleQuote` | `false` | +| `trailingComma` | `"all"` | +| `printWidth` | `100` | +| `tabWidth` | `2` | +| `endOfLine` | `"lf"` | +| `arrowParens` | `"always"` | +| `bracketSpacing` | `true` | + +## Pre-commit hooks + +`.husky/pre-commit`: + +1. `gitleaks protect --staged` — secret scan. Hard exit 1 if `gitleaks` is missing. +2. `bunx lint-staged` — Prettier and ESLint on staged files. + +`.husky/commit-msg` runs `commitlint` against `@commitlint/config-conventional`. Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `build`, `ci`, `revert`, `localize`, `bump`. + +## Logging + +- Structured JSON via `pino` with child loggers per request. +- The ship workflow draws every `event` value from the typed `SHIP_LOG_EVENTS` constant in `src/workflows/ship/log-fields.ts` so a typo is a compile error. See [`../operate/observability.md`](../operate/observability.md). + +## Configuration + +- All process-level configuration is validated via `zod` at startup in `src/config.ts`. The process exits with a clear error if any required variable is missing or malformed. +- Environment variable group is the canonical doc surface — see [`../operate/configuration.md`](../operate/configuration.md). + +## Scripts + +The full list lives in [`../operate/setup.md`](../operate/setup.md#common-dev-commands). For PRs, what matters is `bun run check`: + +```bash +bun run check +# typecheck + lint + format + check:no-destructive + check:docs-sync + tests +``` + +`bun run audit:ci` (used by CI) wraps `bun audit --json` to gate on severity: + +- Blocks on `high` and `critical` advisories. +- Warns on `moderate` and `low`. +- Inline GHSA allowlist in `IGNORED` array; each entry has `ghsa`, `reason`, and `expires` (ISO date). Expired entries become warnings on next run. + +## CI pipeline + +Five workflow files form the pipeline; each owns one responsibility. + +| Workflow | Trigger | Owns | +| ------------------------------------ | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `.github/workflows/ci.yml` | `pull_request` + `push: main` + `workflow_call` | Quality gates only: typecheck, lint, format, audit:ci, test, build | +| `.github/workflows/secrets-scan.yml` | `push: branches-ignore: [gh-pages]` + `workflow_dispatch` | Standalone gitleaks scan, decoupled so every push (incl. chore/docs) is gated | +| `.github/workflows/dev-release.yml` | `push: branches-ignore: [main, v*]` + `workflow_dispatch` | Calls `ci.yml` → semantic-release dev (pre-release tag) → `docker-build.yml` | +| `.github/workflows/release.yml` | `workflow_dispatch` only (manual) | Calls `ci.yml` → semantic-release prod → `docker-build.yml` | +| `.github/workflows/docker-build.yml` | `workflow_call` + `workflow_dispatch` | Reusable image builder: matrix split-and-merge (amd64 on `ubuntu-24.04`, arm64 on `ubuntu-24.04-arm`), Trivy scan | + +Notes: + +- **Multi-arch images.** amd64 builds on `ubuntu-24.04`, arm64 builds natively on `ubuntu-24.04-arm` (free for public repos). Both runners are explicitly pinned (not `ubuntu-latest`) so the rolling alias cannot silently flip to a new major. Manifest assembled by `docker buildx imagetools create`. GHA cache scoped per arch. +- **Defense in depth.** Every dynamic input flowing into a `run:` block is passed via `env:` first. +- **Prod releases are manual.** Push to main triggers only `ci.yml`. Cut a release with `gh workflow run release.yml`. + +## Documentation discipline + +When a PR touches any of these surfaces, update the matching page under `docs/`: + +| Source | Doc | +| ----------------------------------------------- | ---------------------------------------------------------------------- | +| `src/config.ts` env schema | `docs/operate/configuration.md` | +| `src/shared/dispatch-types.ts` | `docs/operate/observability.md` + `docs/build/architecture.md` | +| `src/orchestrator/triage.ts` | `docs/operate/runbooks/triage.md` | +| `src/webhook/` routing or idempotency | `docs/build/architecture.md` | +| `src/k8s/ephemeral-daemon-spawner.ts` | `docs/operate/runbooks/daemon-fleet.md` + `docs/operate/deployment.md` | +| `src/daemon/` lifecycle | `docs/operate/runbooks/daemon-fleet.md` | +| `src/workflows/` registry, dispatcher, handlers | `docs/use/workflows/*.md` | +| New MCP server in `src/mcp/` | `docs/build/extending.md` | +| New Pino field or metric | `docs/operate/observability.md` | + +`bun run docs:build` (strict) runs in CI. `check:docs-sync` blocks PRs that touch `src/workflows/**` without an accompanying docs change. diff --git a/docs/build/extending.md b/docs/build/extending.md new file mode 100644 index 00000000..c1399891 --- /dev/null +++ b/docs/build/extending.md @@ -0,0 +1,181 @@ +# Extending + +Two extension points in this codebase: workflow handlers and MCP servers. Both follow a consistent registry pattern. + +## Adding a workflow + +A workflow is a verb the bot performs on a target (issue or PR). Six are registered today; adding a seventh is appending one entry to `src/workflows/registry.ts` plus a handler file. + +### Step 1 — write the handler + +`src/workflows/handlers/.ts` exports a `WorkflowHandler` (`src/workflows/registry.ts`): + +```typescript +export type WorkflowHandler = (ctx: WorkflowRunContext) => Promise; +``` + +`WorkflowRunContext` carries: + +| Field | Type | Notes | +| ------------------------------- | ---------------------------------------------- | --------------------------------------------- | +| `runId` | string | Unique run identifier. | +| `workflowName` | `WorkflowName` | Which workflow is executing. | +| `target` | `{type: "issue" \| "pr", owner, repo, number}` | GitHub entity. | +| `parent` | `{runId, stepIndex}` \| undefined | Set when this is a child step of a composite. | +| `logger` | `pino.Logger` | Structured logging. | +| `octokit` | `Octokit` | API client with installation token. | +| `deliveryId` | string \| null | Webhook delivery id for tracing. | +| `daemonId` | string | Daemon process id. | +| `setState(state, humanMessage)` | function | Persist partial state mid-execution. | + +`HandlerResult` is a discriminated union: + +```typescript +| { status: "succeeded"; state: unknown; humanMessage?: string } +| { status: "failed"; reason: string; state?: unknown; humanMessage?: string } +| { status: "handed-off"; state?: unknown; humanMessage?: string; childRunId: string } +``` + +Capture exactly one Markdown artifact (`.md`) so the tracking comment is self-documenting; the executor finalises the comment with `state.report` if present. + +### Step 2 — register + +Append one `RegistryEntry` to `rawRegistry` in `src/workflows/registry.ts`: + +```typescript +{ + name: "my-verb", + label: "bot:my-verb", + context: "pr", // "issue" | "pr" | "both" + requiresPrior: null, // or another WorkflowName + steps: [], // composite workflows fill this + handler: myVerbHandler, +} +``` + +The Zod schema validates at module load — a mistyped entry fails the process at boot. + +| Field | Type | Notes | +| --------------- | --------------------------- | ---------------------------------------- | +| `name` | `WorkflowName` (enum) | Add to `WorkflowNameSchema` first. | +| `label` | `^bot:[a-z]+$` | Hyphens allowed. | +| `context` | `"issue" \| "pr" \| "both"` | Where the workflow may run. | +| `requiresPrior` | `WorkflowName \| null` | Workflow that must have succeeded first. | +| `steps` | `WorkflowName[]` | Empty for leaf; populated for composite. | +| `handler` | `WorkflowHandler` | Function reference. | + +### Step 3 — make it discoverable from comments + +If the workflow should be reachable via mentions, extend the system prompt in `src/workflows/intent-classifier.ts` with at least three fixture comments and add it to `test/workflows/fixtures/intent-comments.json`. The enum the classifier returns is driven by the registry; the prompt narrative just needs to mention the new verb so the classifier picks it. + +### Step 4 — document and test + +- Add `docs/use/workflows/.md` matching the template used by the six built-ins. +- Add `test/workflows/handlers/.test.ts` covering the happy path and one failure mode. Integration via `test/workflows/dispatcher.test.ts` is automatic — if the registry entry is valid, dispatch works. +- The `check:docs-sync` script in CI fails any PR that touches `src/workflows/**` without updating the workflow docs tree. + +## Adding an MCP server + +The MCP registry lives at `src/mcp/registry.ts`. `resolveMcpServers()` returns a `Map` for the current request, conditionally activating servers based on context, options, and config. + +### Existing servers + +| Name | Transport | Purpose | +| ----------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------- | +| `comment_update` | stdio | Updates the tracking comment owned by the bot. Always on. | +| `inline_comments` | stdio | Posts inline review comments and replies on PR diffs. PR runs only. | +| `resolve_review_thread` | stdio | Resolves a single PR review thread — bound to one `(owner, repo, pullNumber)` per server instance. Wired by `resolve.ts`. | +| `daemon_capabilities` | stdio | Reports the executing daemon's local environment (CPU, memory, language toolchain) to the agent. | +| `repo_memory` | stdio | Persistent per-repo memory keyed by `(owner, repo, category)`. Backed by the `repo_memory` Postgres table. | +| `context7` | http | Library documentation snippets via Upstash Context7. Auto-skipped when `CONTEXT7_API_KEY` is unset. | + +### Transport types + +| Type | When to use | Example | +| ------- | ---------------------------------------------------------------- | ----------------- | +| `stdio` | Local process; needs per-request secrets injected via env vars. | `comment_update`. | +| `http` | Remote service with a stable URL; no per-request process needed. | `context7`. | + +### Option A — stdio server + +`src/mcp/servers/.ts`: + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +const MY_VAR = process.env["MY_VAR"]; +if (!MY_VAR) { + console.error("Error: MY_VAR is required"); + process.exit(1); +} + +const server = new McpServer({ name: "My Server", version: "1.0.0" }); + +server.tool( + "my_tool", + "Description Claude sees when deciding whether to use this tool", + { input: z.string().describe("Tool input") }, + async ({ input }) => ({ content: [{ type: "text" as const, text: "result" }] }), +); + +const transport = new StdioServerTransport(); +await server.connect(transport); +process.on("exit", () => void server.close()); +``` + +Add a helper in `src/mcp/registry.ts`: + +```typescript +function myServerDef(sharedEnv: Record): McpServerDef { + return { + type: "stdio", + command: "bun", + args: ["run", "src/mcp/servers/my-server.ts"], + env: { ...sharedEnv, MY_VAR: "value" }, + }; +} +``` + +Then add the conditional to `resolveMcpServers()`: + +```typescript +servers["my_server"] = myServerDef(sharedEnv); +``` + +`sharedEnv` already carries `GITHUB_TOKEN`, `REPO_OWNER`, `REPO_NAME`, and `GITHUB_EVENT_NAME`. + +The Dockerfile copies all of `src/mcp/` to the production image, so new server files are picked up automatically. No Dockerfile change is needed. + +### Option B — HTTP server + +```typescript +export function myRemoteServer(): McpServerDef { + return { + type: "http", + url: "https://my-service.example.com/mcp", + headers: { Authorization: `Bearer ${process.env["MY_API_KEY"]}` }, + }; +} +``` + +Register conditionally on credentials: + +```typescript +if (config.myApiKey) { + servers["my_remote"] = myRemoteServer(); +} +``` + +Add the env var to `src/config.ts` following the existing `context7ApiKey` pattern, document it in [`../operate/configuration.md`](../operate/configuration.md), and you're done. + +## The webhook → workflow boundary + +If your extension reacts to a GitHub event the bot does not yet handle (e.g. `push`, `pull_request_target`), the work splits in two: + +1. **Subscribe** to the event in the GitHub App settings (Permissions & events). +2. **Add a webhook handler** in `src/webhook/events/.ts` that parses the payload and dispatches via `dispatchByLabel` (label path) or `dispatchByIntent` (comment path). Webhook handlers must return within 10 s — fire `processRequest` with fire-and-forget semantics. +3. **Register the event handler** in `src/app.ts` alongside the existing `app.webhooks.on(...)` calls. + +Webhook handlers do **not** run business logic — they parse the event, build a `BotContext`, and dispatch. All bot work happens in workflow handlers, called from the daemon. diff --git a/docs/CHANGELOG.md b/docs/changelog.md similarity index 100% rename from docs/CHANGELOG.md rename to docs/changelog.md diff --git a/docs/index.md b/docs/index.md index 61159225..a900c58c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,20 +5,36 @@ hide: # GitHub App Playground -A GitHub App that responds to `@chrisleekr-bot` mentions on pull requests and issues, powered by the Claude Agent SDK. Every request is handed off to the daemon fleet over WebSocket; when triage flags the job as heavy or the queue backs up, the orchestrator spawns an ephemeral daemon Pod on Kubernetes so the same image scales on demand. +A GitHub App that responds to `@chrisleekr-bot` mentions on pull requests and issues, powered by the Claude Agent SDK. Every webhook is acknowledged in under ten seconds and handed to the daemon fleet over WebSocket; when triage flags the job as heavy or the queue backs up, the orchestrator spawns an ephemeral daemon Pod on Kubernetes so the same image scales on demand. -## Start here +## Three doors -- **[Setup](SETUP.md)** — GitHub App creation, local tunnel, environment variables. -- **[Architecture](ARCHITECTURE.md)** — end-to-end request flow, from webhook through the daemon fleet to the tracking comment. -- **[Deployment](DEPLOYMENT.md)** — Docker build, health probes, resource sizing. -- **[Extending](EXTENDING.md)** — add new webhook handlers and MCP servers. +
-## Operator guides +- :material-account-voice:{ .lg .middle } __Use the bot__ -- [Configuration](CONFIGURATION.md) — every environment variable the app reads. -- [Observability](OBSERVABILITY.md) — log fields, dispatch reasons, alerts. -- [Triage](TRIAGE.md) — binary heavy-job classifier behaviour and tuning. -- [Daemon mode](DAEMON.md) — persistent vs ephemeral daemons and the WebSocket protocol. + --- -This site tracks the `main` branch. See the repository `CHANGELOG.md` for release history. + Trigger workflows from comments, labels, or natural language. See what each `bot:*` command does and how to stop one mid-flight. + + [:octicons-arrow-right-24: Start with invocation](use/invoking.md) + +- :material-server:{ .lg .middle } __Run the service__ + + --- + + Get from `git clone` to a webhook receiving production traffic. Configuration, deployment, observability, and runbooks for the most common Day-2 issues. + + [:octicons-arrow-right-24: Start with setup](operate/setup.md) + +- :material-code-braces:{ .lg .middle } __Build on it__ + + --- + + Architecture, request flow, and how to add a new workflow or MCP server. Conventions and contribution rules. + + [:octicons-arrow-right-24: Start with architecture](build/architecture.md) + +
+ +This site tracks the `main` branch. Release history lives in the [changelog](changelog.md). diff --git a/docs/operate/configuration.md b/docs/operate/configuration.md new file mode 100644 index 00000000..f1824bdf --- /dev/null +++ b/docs/operate/configuration.md @@ -0,0 +1,134 @@ +# Configuration reference + +Every environment variable the app reads at startup, grouped by concern. The authoritative source is `src/config.ts` — values are validated via Zod at boot and the process exits if a required variable is missing or malformed. + +**Default** is the fallback when the variable is unset (blank means "no default — must be set when required"). **Required when** is the runtime condition under which the variable is mandatory. + +## GitHub App credentials + +Server mode only. If `ORCHESTRATOR_URL` is set, the process runs in daemon mode and these are not required. + +| Variable | Default | Required when | Notes | +| ------------------------ | ------- | ------------- | ----------------------------------------------------------------- | +| `GITHUB_APP_ID` | — | Server mode | Numeric App ID from the App settings page. | +| `GITHUB_APP_PRIVATE_KEY` | — | Server mode | Full PEM. Literal `\n` sequences are normalised to real newlines. | +| `GITHUB_WEBHOOK_SECRET` | — | Server mode | HMAC-SHA256 secret configured in the App settings. | + +## AI provider + +| Variable | Default | Required when | Notes | +| ---------------------------- | ------------------------------------------ | -------------------------------------------------- | ------------------------------------------------------------------------- | +| `CLAUDE_PROVIDER` | `anthropic` | — | `anthropic` or `bedrock`. | +| `CLAUDE_MODEL` | `claude-opus-4-7` (anthropic); — (bedrock) | Bedrock | Bedrock requires an explicit Bedrock model ID. | +| `ANTHROPIC_API_KEY` | — | Anthropic, unless `CLAUDE_CODE_OAUTH_TOKEN` is set | Console pay-as-you-go. Safe for multi-tenant deploys. | +| `CLAUDE_CODE_OAUTH_TOKEN` | — | Anthropic, unless `ANTHROPIC_API_KEY` is set | Max/Pro subscription token (`sk-ant-oat…`). Requires `ALLOWED_OWNERS`. | +| `AWS_REGION` | — | Bedrock | Resolved by the AWS SDK credential chain. | +| `AWS_PROFILE` | — | Optional (bedrock) | Local SSO profile for dev. | +| `AWS_ACCESS_KEY_ID` | — | Optional (bedrock) | Long-lived credential pair. Prefer profile or OIDC. | +| `AWS_SECRET_ACCESS_KEY` | — | Optional (bedrock) | Paired with `AWS_ACCESS_KEY_ID`. | +| `AWS_SESSION_TOKEN` | — | Optional (bedrock) | Temporary credentials. | +| `AWS_BEARER_TOKEN_BEDROCK` | — | Optional (bedrock, CI) | Set automatically by `aws-actions/configure-aws-credentials` OIDC. | +| `ANTHROPIC_BEDROCK_BASE_URL` | — | Optional (bedrock) | Override Bedrock runtime endpoint (VPC endpoint / proxy). | +| `ALLOWED_OWNERS` | — | OAuth token path | Comma-separated allowlist. Required when using `CLAUDE_CODE_OAUTH_TOKEN`. | + +## HTTP server + +| Variable | Default | Notes | +| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- | +| `PORT` | `3000` | HTTP webhook listener. | +| `LOG_LEVEL` | `info` | Pino level: `fatal`, `error`, `warn`, `info`, `debug`, `trace`. `debug` surfaces full webhook payloads. | +| `NODE_ENV` | `production` | `production`, `development`, `test`. | +| `TRIGGER_PHRASE` | `@chrisleekr-bot` | Mention text that triggers the bot. Local dev typically sets `@chrisleekr-bot-dev`. | +| `BOT_APP_LOGIN` | `chrisleekr-bot[bot]` | Bot's GitHub login. Used by the loop-prevention check. | +| `MAX_CONCURRENT_REQUESTS` | `3` | Ceiling on simultaneous Claude executions across the fleet. | +| `AGENT_TIMEOUT_MS` | `3600000` | Wall-clock budget for one agent execution (60 min). Lower only when the job is bounded. | +| `AGENT_MAX_TURNS` | unset | Optional Claude SDK turn cap. Unset = no cap. Overrides `DEFAULT_MAXTURNS`. | +| `DEFAULT_MAXTURNS` | unset | Process-wide turn cap. Set only if ops needs a hard ceiling. | +| `CLAUDE_CODE_PATH` | resolved from `node_modules` | Absolute path to the Claude Code CLI `cli.js`. | +| `CLONE_BASE_DIR` | `/tmp/bot-workspaces` | Parent directory for per-delivery clones. | +| `CLONE_DEPTH` | `50` | Shallow-clone depth. Increase for deeply-diverged PRs. | +| `CONTEXT7_API_KEY` | unset | Lifts Context7 MCP rate limiting. No other effect. | + +## Postgres + +Required whenever the orchestrator role is active. + +| Variable | Default | Notes | +| -------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | — | Postgres connection. Backs `executions`, `triage_results`, `workflow_runs`, `ship_intents`, `ship_iterations`, `ship_continuations`, `ship_fix_attempts`, `repo_memory`, `daemons`. | + +## Valkey + +Required whenever the orchestrator role is active. + +| Variable | Default | Notes | +| ------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `VALKEY_URL` | — | Backs the daemon job queue, in-flight set, the ephemeral-spawn cooldown, the `ship:tickle` sorted set, and ship cancel flags. | + +## Orchestrator and daemon + +| Variable | Default | Notes | +| ------------------------------ | --------------------- | ----------------------------------------------------------------------------------------------------- | +| `WS_PORT` | `3002` | Orchestrator WebSocket listener. Must differ from `PORT`. | +| `ORCHESTRATOR_URL` | — | Presence flips the process to daemon mode. Use `wss://` in production; `ws://` emits a warning. | +| `ORCHESTRATOR_PUBLIC_URL` | — | Public WebSocket URL the spawner injects into ephemeral Pods. | +| `DAEMON_AUTH_TOKEN` | — | Shared secret for the daemon ⇄ orchestrator handshake. Required on both sides. | +| `HEARTBEAT_INTERVAL_MS` | `30000` | Daemon → orchestrator ping cadence. | +| `HEARTBEAT_TIMEOUT_MS` | `90000` | Eviction threshold. Keep `≥ 2 × HEARTBEAT_INTERVAL_MS`. | +| `STALE_EXECUTION_THRESHOLD_MS` | `3600000` | How long a `running` execution may sit before the watcher fails it. Set `≥ AGENT_TIMEOUT_MS`. | +| `DAEMON_DRAIN_TIMEOUT_MS` | `300000` | Post-`SIGTERM` window to finish in-flight work. Raise to `≥ AGENT_TIMEOUT_MS` for zero mid-run kills. | +| `JOB_MAX_RETRIES` | `3` | Retries for transient daemon dispatch failures. | +| `OFFER_TIMEOUT_MS` | `5000` | How long the orchestrator waits for a daemon to claim an offer. | +| `QUEUE_WORKER_BACKOFF_MAX_MS` | `5000` | Upper bound on the queue-worker's sleep when no local daemon can take a job. | +| `LIVENESS_REAPER_INTERVAL_MS` | `30000` (min `20000`) | Cadence of the heartbeat-based reaper. | +| `DAEMON_UPDATE_STRATEGY` | `exit` | `exit`, `pull`, or `notify`. Advisory hint reported in the update response. | +| `DAEMON_UPDATE_DELAY_MS` | `0` | Delay before graceful shutdown after an update signal. | +| `DAEMON_MEMORY_FLOOR_MB` | `512` | Minimum free memory the orchestrator requires before dispatching. | +| `DAEMON_DISK_FLOOR_MB` | `1024` | Minimum free disk the orchestrator requires before dispatching. | + +## Ephemeral daemons + +Used when the orchestrator scales daemon capacity on demand. + +| Variable | Default | Notes | +| ---------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------ | +| `DAEMON_EPHEMERAL` | `false` | Set to `true` on ephemeral daemon Pods (injected by the spawner). Controls idle-exit. | +| `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` | `120000` | Ephemeral daemon exits after this idle window. | +| `EPHEMERAL_DAEMON_SPAWN_COOLDOWN_MS` | `30000` | Minimum time between ephemeral spawns (orchestrator side). | +| `EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` | `3` | Queue length that triggers an `ephemeral-daemon-overflow` spawn. | +| `EPHEMERAL_DAEMON_NAMESPACE` | `default` | Kubernetes namespace for spawned ephemeral Pods. | +| `DAEMON_IMAGE` | auto-detected | K8s image URI override. | +| `KUBECONFIG` | auto (in-cluster) | Kubernetes client config path. The client auto-detects in-cluster via `KUBERNETES_SERVICE_HOST`. | + +The orchestrator also expects a pre-existing `daemon-secrets` Kubernetes Secret in `EPHEMERAL_DAEMON_NAMESPACE`, mounted into the spawned Pod via `envFrom: secretRef: daemon-secrets`. See [`deployment.md`](deployment.md#ephemeral-daemon-kubernetes-requirements). + +## Triage + +| Variable | Default | Notes | +| ----------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ | +| `TRIAGE_ENABLED` | `true` | Kill-switch. When `false`, triage returns `heavy=false` and the job routes to `persistent-daemon`. | +| `TRIAGE_MODEL` | `haiku-3-5` | Alias resolved at runtime. | +| `TRIAGE_CONFIDENCE_THRESHOLD` | `1.0` | Below this, triage is treated as sub-threshold and the job routes to `persistent-daemon`. | +| `TRIAGE_MAX_TOKENS` | `256` | Cap on the JSON response. Above ~100 is wasted budget. | +| `TRIAGE_TIMEOUT_MS` | `5000` | Per-call wall clock. Beyond this, the circuit-breaker counter increments. | +| `INTENT_CONFIDENCE_THRESHOLD` | `0.75` | Range `[0, 1]`. Below this, a mention-driven comment gets a clarification reply instead of a dispatch. | + +## Ship + +| Variable | Default | Notes | +| --------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `MAX_WALL_CLOCK_PER_SHIP_RUN` | `4h` | Hard ceiling on a single intent's wall-clock budget. Accepts ms or `Nh` / `Nm` / `Ns`. Per-invocation `--deadline` is clamped to this. | +| `MAX_SHIP_ITERATIONS` | `50` | Iteration cap. Firing transitions the intent to terminal `human_took_over` with `terminal_blocker_category='iteration-cap'`. | +| `CRON_TICKLE_INTERVAL_MS` | `30000` | How often the cron tickle scans `ship:tickle` for due intents. | +| `MERGEABLE_NULL_BACKOFF_MS_LIST` | `500,1500,4500` | Comma-separated bounded backoff schedule used by the probe when `mergeable=null`. Exhaustion yields `mergeable_pending` and the session yields. | +| `REVIEW_BARRIER_SAFETY_MARGIN_MS` | `1200000` (20 min) | Minimum elapsed time since the last bot push before the bot may declare `ready` without a non-bot review on the current head SHA. | +| `FIX_ATTEMPTS_PER_SIGNATURE_CAP` | `3` | Max attempts per failure signature within a single intent. Cap firing terminates with `terminal_blocker_category='flake-cap'`. | +| `SHIP_FORBIDDEN_TARGET_BRANCHES` | empty | Comma-separated branches the bot refuses to shepherd PRs against. | + +## Mode matrix — what's required when + +| Role | Required | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Orchestrator (webhook server) | GitHub App credentials, one AI provider credential, `VALKEY_URL`, `DATABASE_URL`, `DAEMON_AUTH_TOKEN`. | +| Ephemeral-daemon scale-up | K8s API access + RBAC on `pods` in `EPHEMERAL_DAEMON_NAMESPACE`, `daemon-secrets` Secret. | +| Daemon process (`ORCHESTRATOR_URL` set) | `DAEMON_AUTH_TOKEN`, one AI provider credential. GitHub App credentials and data-layer URLs are NOT required. | diff --git a/docs/operate/deployment.md b/docs/operate/deployment.md new file mode 100644 index 00000000..52219891 --- /dev/null +++ b/docs/operate/deployment.md @@ -0,0 +1,252 @@ +# Deployment + +The repository ships **two container images** — an orchestrator and a daemon — built from separate Dockerfiles that share a byte-identical base. + +## Image topology + +| Image | Dockerfile | Role | Outbound network | +| -------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `orchestrator` | `Dockerfile.orchestrator` | Webhook server, WebSocket daemon registry, triage classifier, ephemeral-daemon spawner. | GitHub API, Anthropic / Bedrock, Postgres, Valkey, K8s API. | +| `daemon` | `Dockerfile.daemon` | Worker image with the toolchain Claude shells out to (`kubectl`, `helm`, `terraform`, `aws`, `gcloud`, `docker`, `go`, `rust`, …). | Orchestrator WebSocket (outbound), GitHub API, Anthropic. | + +The two images intentionally diverge after the shared base because their cost and attack surface differ. The shared prefix is enforced byte-identical by `scripts/check-dockerfile-base-sync.ts` (in CI) between the `# --- SHARED-BASE-BEGIN ---` and `# --- SHARED-BASE-END ---` markers. + +### Shared base stages + +| Stage | Base | Purpose | +| ------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `base` | `oven/bun:1.3.13` | Installs Node.js 20 (for the Claude Code CLI), npm 11, `curl`, `git`, `@anthropic-ai/claude-code` globally, plus targeted openssl CVE upgrades. | +| `development` | `base` | `bun install` (all deps) + `bun run build` → `dist/` (app, daemon main, MCP stdio servers). | +| `deps` | `base` | `bun install --production --ignore-scripts` (runtime deps only). | + +### Orchestrator-only stage + +| Stage | Base | Purpose | +| ------------ | ------ | ------------------------------------------------------------------------------------ | +| `production` | `base` | Copies `dist/`, production `node_modules/`, and `src/db/migrations/`. Runs as `bun`. | + +### Daemon-only stages + +| Stage | Base | Purpose | +| -------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `daemon-tools` | `base` | Installs the full toolchain — kubectl, helm, terraform, kustomize, k9s, stern, argocd, flux, tflint, yq, aws-cli, gcloud, docker CLI, go, rust, poetry, gh, azure-cli — and bakes `daemon-capabilities.static.json` for fast startup. | +| `production` | `daemon-tools` | Copies `dist/` and production `node_modules/`. Runs as `bun`. | + +Tool versions are parameterised by `ARG` (`KUBECTL_VERSION`, `HELM_VERSION`, etc.) and bumped together by Renovate/Dependabot. The Trivy scan in CI gates CVE regressions. + +## Build + +```bash +bun run docker:build:orchestrator # → chrisleekr/github-app-playground:local-orchestrator +bun run docker:build:daemon # → chrisleekr/github-app-playground:local-daemon +bun run docker:build # both +``` + +There is no default `Dockerfile` — always pass `-f`. + +### Build arguments + +| Argument | Default | Purpose | +| ----------------- | ------------- | ------------------------------------------------------------ | +| `PACKAGE_VERSION` | `untagged` | Stored as Docker label `com.chrisleekr.bot.package-version`. | +| `GIT_HASH` | `unspecified` | Stored as Docker label `com.chrisleekr.bot.git-hash`. | + +Daemon-only: + +| Argument | Default | Purpose | +| ---------------- | ----------- | ----------------------------------------------------- | +| `TARGETARCH` | from buildx | Selects amd64 / arm64 asset URLs. | +| `INSTALL_GCLOUD` | `true` | Skip the ~500 MB Google Cloud SDK install if `false`. | +| `INSTALL_LANGS` | `go rust` | Space-separated language toolchains. | + +```bash +docker build -f Dockerfile.orchestrator \ + --build-arg PACKAGE_VERSION=$(bun -e "console.log(require('./package.json').version)") \ + --build-arg GIT_HASH=$(git rev-parse --short HEAD) \ + -t chrisleekr/github-app-playground:$(git rev-parse --short HEAD)-orchestrator \ + . +``` + +## Run + +### Orchestrator + +```bash +docker run \ + --env-file .env \ + -p 3000:3000 \ + -p 3002:3002 \ + chrisleekr/github-app-playground:local-orchestrator +``` + +- `3000` — HTTP: webhook listener, `/healthz`, `/readyz`. +- `3002` — WebSocket: daemon registry (`WS_PORT`). Expose only on networks the daemons connect from. + +Shortcut: `bun run docker:run:orchestrator` (mounts `~/.aws` read-only for local Bedrock testing). + +### Daemon + +```bash +docker run \ + --env-file .env \ + -e ORCHESTRATOR_URL=ws://orchestrator-host:3002 \ + -e DAEMON_AUTH_TOKEN=... \ + -v $HOME/.aws:/home/bun/.aws:ro \ + chrisleekr/github-app-playground:local-daemon +``` + +The daemon does **not** expose any HTTP port and does **not** need GitHub App credentials — the orchestrator mints installation tokens and hands them off per job. + +Shortcut: `bun run docker:run:daemon` (connects back to `ws://host.docker.internal:3002`). + +## Health and readiness probes + +Endpoints exist on the **orchestrator image only**. Daemon liveness is tracked via the WebSocket heartbeat in the orchestrator's daemon registry. + +| Endpoint | Method | Success | Failure | Purpose | +| ---------- | ------ | ----------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `/healthz` | GET | `200 ok` | — | Liveness — process is alive (no external deps). | +| `/readyz` | GET | `200 ready` | `503 not ready` | Readiness — config validated and data layer reachable. Returns `503 not ready` during startup, when a dependency is down, or after `SIGTERM`. | + +`Dockerfile.orchestrator` ships with a Docker `HEALTHCHECK` invoking `curl -f http://localhost:3000/healthz`. Honoured by Docker Compose, ECS, Nomad, Swarm. Kubernetes ignores Docker `HEALTHCHECK` and uses the probe spec below. + +```yaml +livenessProbe: + httpGet: + path: /healthz + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + +readinessProbe: + httpGet: + path: /readyz + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 +``` + +For the daemon, replace HTTP probes with an `exec` probe that checks the WebSocket connection — see [`runbooks/daemon-fleet.md`](runbooks/daemon-fleet.md). + +## Graceful shutdown + +The orchestrator handles `SIGTERM` and `SIGINT`: + +1. Flips `/readyz` to `503` so the load balancer stops routing. +2. Calls `server.close()` — waits for in-flight HTTP requests. +3. MCP stdio child processes exit via their own `finally` blocks. +4. Force-exits after 290 seconds if shutdown hasn't completed (`src/app.ts`). + +Set `terminationGracePeriodSeconds: 300` on the Pod so SIGKILL lands 10 seconds after the force-exit. + +The daemon has its own drain contract driven by `DAEMON_DRAIN_TIMEOUT_MS`: it finishes the current job, refuses new offers, then disconnects. Match `terminationGracePeriodSeconds` to `DAEMON_DRAIN_TIMEOUT_MS` on the daemon Pod. + +## Resource recommendations + +### Orchestrator + +I/O-bound — never runs the pipeline itself. 1 GB is typically enough. + +| `MAX_CONCURRENT_REQUESTS` | Memory | CPU | +| ------------------------- | ------ | -------- | +| 1 | 1 GB | 1 vCPU | +| 3 (default) | 2 GB | 1–2 vCPU | +| 5 | 3 GB | 2 vCPU | + +### Daemon + +Dominated by what Claude runs inside it (`kubectl`, `terraform plan`, `docker build`). + +| Concurrent jobs | Memory | CPU | +| ------------------- | ------ | -------- | +| 1 | 2 GB | 1–2 vCPU | +| 3 (typical default) | 4 GB | 2–4 vCPU | + +The daemon image is ~2 GB unpacked. The same sizing applies to ephemeral daemon Pods spawned by the orchestrator (same image). + +### Disk + +Each job clones the target repo to `CLONE_BASE_DIR` (default `/tmp/bot-workspaces`) with `git clone --depth=${CLONE_DEPTH}` (default `50`). The directory is removed in the pipeline's `finally` block. + +Peak disk = `average_repo_size × concurrent_jobs`. For monorepos, mount a dedicated volume: + +```yaml +volumes: + - name: bot-workspaces + emptyDir: + sizeLimit: 5Gi +containers: + - name: github-app-playground + env: + - name: CLONE_BASE_DIR + value: /workspaces + volumeMounts: + - name: bot-workspaces + mountPath: /workspaces +``` + +## Ephemeral-daemon Kubernetes requirements + +If you want the orchestrator to spawn ephemeral daemon Pods on demand, two things must exist in `EPHEMERAL_DAEMON_NAMESPACE`. + +### Orchestrator RBAC + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: github-app-playground-ephemeral-spawner + namespace: ${EPHEMERAL_DAEMON_NAMESPACE} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["create", "get", "delete"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: github-app-playground-ephemeral-spawner + namespace: ${EPHEMERAL_DAEMON_NAMESPACE} +subjects: + - kind: ServiceAccount + name: github-app-playground + namespace: ${ORCHESTRATOR_NAMESPACE} +roleRef: + kind: Role + name: github-app-playground-ephemeral-spawner + apiGroup: rbac.authorization.k8s.io +``` + +Without these verbs every spawn yields `dispatch_reason=ephemeral-spawn-failed` and the job is rejected with a tracking-comment infra error. + +### `daemon-secrets` Secret + +Spawned ephemeral Pods get their config via `envFrom: secretRef: daemon-secrets`. Create this Secret once in `EPHEMERAL_DAEMON_NAMESPACE` with at minimum: + +- `DAEMON_AUTH_TOKEN` — daemon ⇄ orchestrator handshake. **Only source.** The spawner does not inline this into the Pod spec, so it cannot leak via `kubectl get pod -o yaml` or the Pod audit log. +- `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` (and `ALLOWED_OWNERS`) or Bedrock `AWS_*` vars. +- `VALKEY_URL`, `DATABASE_URL`. + +GitHub App private-key material is **not** placed in this Secret. The orchestrator mints installation tokens and hands them per-job, so blast radius does not need to expand to every ephemeral Pod. `ORCHESTRATOR_URL` is provided inline by the spawner from `ORCHESTRATOR_PUBLIC_URL`. + +### Ephemeral Pod security posture + +The spawner hardens every ephemeral Pod (see `src/k8s/ephemeral-daemon-spawner.ts`): + +- `automountServiceAccountToken: false` — the daemon never calls the K8s API itself. +- Pod `securityContext`: `runAsNonRoot: true`, `runAsUser: 1000`, `runAsGroup: 1000`, `seccompProfile: RuntimeDefault`. +- Container: `allowPrivilegeEscalation: false`, `capabilities.drop: [ALL]`. +- `restartPolicy: Never` and `activeDeadlineSeconds: 3600` cap the Pod hard. + +## Production tunables worth double-checking + +The full schema lives at [`configuration.md`](configuration.md). At minimum: + +| Variable | Production recommendation | +| ------------------------- | ------------------------------------------------------------ | +| `NODE_ENV` | `production` | +| `LOG_LEVEL` | `info` (`debug` exposes webhook payloads) | +| `MAX_CONCURRENT_REQUESTS` | Start at `3`, tune against memory and LLM budget | +| `AGENT_TIMEOUT_MS` | Stay below 3600 s — the GitHub installation-token TTL | +| `CLONE_BASE_DIR` | Override if `/tmp` is small or shared | +| `PORT`, `WS_PORT` | `3000`, `3002` (must match probes and the `WS_PORT` env var) | diff --git a/docs/operate/github-app.md b/docs/operate/github-app.md new file mode 100644 index 00000000..b93cadde --- /dev/null +++ b/docs/operate/github-app.md @@ -0,0 +1,131 @@ +# Creating the GitHub App + +Step-by-step guide for registering, configuring, and installing the `@chrisleekr-bot` GitHub App. For local development after the App exists, see [`setup.md`](setup.md). + +## 1. Register the App + +### 1.1 Open the registration form + +Personal account: **Settings → Developer settings → GitHub Apps → New GitHub App**. +Organization: **Org settings → Developer settings → GitHub Apps → New GitHub App**. + +Direct link: . + +> A user or organization can register up to 100 GitHub Apps. Source: [Registering a GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app). + +### 1.2 Basic information + +| Field | Value | Notes | +| --------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| GitHub App name | `chrisleekr-bot` | Globally unique, ≤ 34 chars, slugified to lowercase-with-dashes. | +| Homepage URL | `https://github.com/chrisleekr/github-app-playground` | Required; any valid HTTPS URL. | +| Description | `AI-powered code review bot — responds to @chrisleekr-bot mentions on PRs and issues.` | Optional. | + +### 1.3 Webhook configuration + +| Field | Value | +| ---------------- | ------------------------------------------------------------------ | +| Active | ✅ | +| Webhook URL | `https:///api/github/webhooks` | +| SSL verification | Enabled (default — keep it). | +| Webhook secret | Output of `openssl rand -hex 32`. Save as `GITHUB_WEBHOOK_SECRET`. | + +The path `/api/github` is set by `pathPrefix` in `createNodeMiddleware` (`src/app.ts`). Don't change the path unless you also change the source. + +For local dev, use a tunnel: + +```bash +bun run dev:ngrok +# or +smee --url https://smee.io/ --path /api/github/webhooks --port 3000 +``` + +### 1.4 OAuth, Setup, post-install + +Leave all OAuth, callback URL, Device Flow, and Setup URL fields empty/unchecked. This App uses **installation tokens only** (server-to-server) and never acts on behalf of an individual user. See [About authentication with a GitHub App](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/about-authentication-with-a-github-app). + +### 1.5 Permissions + +Repository permissions: + +| Permission | Setting | Why | +| ------------- | ------------ | --------------------------------------------------------------------- | +| Actions | Read-only | Read workflow run state for `bot:resolve` CI fixes. | +| Contents | Read & Write | Clone repos and push commits via the git CLI. | +| Issues | Read & Write | Read issue body / comments; post bot replies. | +| Pull requests | Read & Write | Read PR diff and context; post review comments and replies. | +| Metadata | Read-only | Auto-granted; required for all GitHub Apps. | +| Workflows | Read & Write | Modify `.github/workflows/*.yml` when an `implement` task touches CI. | + +Leave all organisation and account permissions at **No access**. Principle of least privilege — see [Choosing permissions for a GitHub App](https://docs.github.com/en/apps/creating-github-apps/setting-up-a-github-app/choosing-permissions-for-a-github-app). + +### 1.6 Subscribe to events + +| Checkbox | Actions handled | Handler | +| ---------------------------- | --------------------------------------------------------------- | -------------------------------------- | +| Issue comments | `issue_comment.created` | `src/webhook/events/issue-comment.ts` | +| Issues | `issues.labeled`, `issues.unlabeled` | `src/webhook/events/issues.ts` | +| Pull requests | `pull_request.opened` / `.labeled` / `.synchronize` / `.closed` | `src/webhook/events/pull-request.ts` | +| Pull request reviews | `pull_request_review.submitted` | `src/webhook/events/review.ts` | +| Pull request review comments | `pull_request_review_comment.created` / `.edited` / `.deleted` | `src/webhook/events/review-comment.ts` | +| Pull request review threads | `pull_request_review_thread.resolved` / `.unresolved` | `src/webhook/events/review-thread.ts` | +| Check runs | `check_run.completed` | `src/webhook/events/check-run.ts` | +| Check suites | `check_suite.completed` | `src/webhook/events/check-suite.ts` | + +The shepherding reactor uses `synchronize`, `closed`, `edited`, `deleted`, `check_run`, and `check_suite` to early-wake active sessions on `Valkey ZADD ship:tickle`. Every subscribed event you do not handle still hits your webhook URL — keep this list tight. + +> GitHub does not emit a `pull_request_review_thread.created` action. The only valid actions for that event are `resolved` and `unresolved`. + +### 1.7 Install scope + +| Option | Use when | +| -------------------- | -------------------------------------------------------- | +| Only on this account | Personal or single-org private deployment (recommended). | +| Any account | You plan to share the App publicly. | + +Click **Create GitHub App**. GitHub assigns the **App ID** and redirects to the App's General settings. + +## 2. Generate a private key + +On the App's General settings: + +1. Scroll to **Private keys**. +2. Click **Generate a private key** — GitHub immediately downloads `chrisleekr-bot.YYYY-MM-DD.private-key.pem`. +3. Move it to a password manager or secrets vault. **Never commit it.** + +The full PEM (including `-----BEGIN…` / `-----END…` lines) is the value of `GITHUB_APP_PRIVATE_KEY`. Single-line `.env` form: + +```bash +GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n\n-----END RSA PRIVATE KEY-----\n" +``` + +Or read from disk: + +```bash +export GITHUB_APP_PRIVATE_KEY="$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' chrisleekr-bot.private-key.pem)" +``` + +See [Managing private keys for GitHub Apps](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps) for rotation. + +## 3. Note the App ID + +On the General settings page, the **About** section shows the numeric **App ID** (e.g. `123456`). Save it as `GITHUB_APP_ID`. + +## 4. Install on repositories + +In the App settings sidebar, click **Install App**, then **Install** next to the target account. Choose: + +| Option | Effect | +| ------------------------ | ------------------------------------------------------------- | +| All repositories | Access to every current and future repository on the account. | +| Only select repositories | Limited to repositories you explicitly choose (recommended). | + +After installation, the bot only responds to mentions in repositories where the App is installed. + +## 5. Verify + +1. **Settings → Developer settings → GitHub Apps → your app → Advanced** — redeliver a recent webhook. +2. Open an issue in an installed repository and post `@chrisleekr-bot triage this` (or `@chrisleekr-bot-dev` locally). +3. The bot posts a tracking comment within seconds. + +If it doesn't, see the troubleshooting table in [`setup.md`](setup.md#testing-webhook-delivery). diff --git a/docs/operate/observability.md b/docs/operate/observability.md new file mode 100644 index 00000000..1db5ac9b --- /dev/null +++ b/docs/operate/observability.md @@ -0,0 +1,104 @@ +# Observability + +Structured JSON logs via [pino](https://getpino.io) are the primary signal. Every dispatch decision and every pipeline step carries a `deliveryId` so you can reconstruct a request end-to-end from a single log query. When `DATABASE_URL` is configured, the same information is persisted to `executions` and `triage_results` for aggregate reporting. + +## Common log fields + +| Field | Meaning | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| `deliveryId` | `X-GitHub-Delivery` header — stable across every log line for a single webhook. | +| `event` | GitHub event name (`pull_request`, `issue_comment`, …) or canonical event key for ship workflow logs. | +| `repo` | `owner/name` of the triggering repo. | +| `dispatch_target` | Always `daemon` (singleton — kept as a field for DB/log stability). | +| `dispatch_reason` | Why the job landed where it did. See [Dispatch reasons](#dispatch-reasons). | +| `isEphemeral` | Present on daemon-originating log lines. `true` if emitted by an ephemeral daemon. | +| `triage_fallback_reason` | Only present on triage fallbacks — see [`runbooks/triage.md`](runbooks/triage.md). | +| `confidence`, `heavy`, `rationale` | Triage outputs on success. | +| `cost_usd` | Agent-reported total cost from the SDK. | +| `workflowRunId`, `workflowName` | UUID of the `workflow_runs` row + workflow name. Stable per run. | +| `intentWorkflow`, `intentConfidence` | Intent-classifier verdict and confidence for comment triggers. | + +## Ship workflow log fields + +The shepherding lifecycle emits structured pino lines validated against the canonical Zod schema in `src/workflows/ship/log-fields.ts`. Field names and types are pinned so emitters cannot drift. + +| Field | Type | When present | +| --------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `event` | string (e.g. `ship.intent.transition`, `ship.probe.run`, `ship.reactor.fanout`) | Always. | +| `intent_id` | UUID | Always. | +| `pr` | `{owner, repo, number, installation_id}` | Always. | +| `iteration_n` | non-negative int | Always (0 on pre-iteration events). | +| `phase` | `probe` \| `fix` \| `reply` \| `wait` \| `terminal` | Iteration events. | +| `from_status` / `to_status` | session status | Transition events only. | +| `terminal_blocker_category` | blocker category | Terminal `human_took_over` transitions. | +| `non_readiness_reason` | enum | Probe events with non-ready verdict. | +| `trigger_surface` | `literal` \| `nl` \| `label` | Session-start events only. | +| `principal_login` | string | Session-start events only. | +| `spent_usd_cents` | non-negative integer | Always — cumulative session spend in cents (integer to avoid binary-fp drift in aggregations). | +| `wall_clock_ms` | non-negative integer | Always — cumulative session wall-clock. | +| `delta_usd_cents` | non-negative integer | Per-event spend (iteration events only). | +| `delta_ms` | non-negative integer | Per-event wall-clock duration. | + +The schema is the source of truth. Adding or renaming a field requires updating `src/workflows/ship/log-fields.ts`; the co-located test round-trips a sample through the schema and rejects unknown / mistyped fields. + +### Iteration / tickle / scoped event keys + +Every shepherding emitter draws its `event` value from the typed `SHIP_LOG_EVENTS` constant in `src/workflows/ship/log-fields.ts`. Operators can grep for these literals deterministically. + +| Event key | Where it fires | What it indicates | +| ------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `ship.iteration.enqueued` | `iteration.runIteration` after `enqueueJob` | A non-ready verdict bridged into the daemon `workflow_runs` pipeline. One row per iteration. | +| `ship.iteration.terminal_cap` | `iteration.runIteration` cap check | The intent hit `MAX_SHIP_ITERATIONS`. | +| `ship.iteration.terminal_deadline` | `iteration.runIteration` deadline check | The intent's `deadline_at` elapsed. | +| `ship.tickle.started` | `app.ts` boot, after `tickleScheduler.start()` | The cron tickle scheduler is scanning `ship:tickle`. | +| `ship.tickle.due` | `orchestrator.onStepComplete` early-wake **or** `session-runner.resumeShipIntent` | An intent is being re-entered. `source` discriminates `workflow_run_completion` vs scheduler. | +| `ship.tickle.skip_terminal` | `orchestrator.onStepComplete` early-wake | The hook found a `shipIntentId` but the intent is already terminal; the ZADD was skipped. | +| `ship.scoped..enqueued` | `dispatch-scoped.ts` after `enqueueJob` | A scoped command (`rebase`, `fix_thread`, `explain_thread`, `open_pr`) was enqueued. | +| `ship.scoped..daemon.completed` | `connection-handler.handleScopedJobCompletion` and the executor | Daemon reported `succeeded`. | +| `ship.scoped..daemon.failed` | Same | Daemon reported `halted` or `failed`. `reason` carries the structured halt reason. | + +### Querying example (Datadog / Loki) + +```text +event:"ship.intent.transition" to_status:"human_took_over" terminal_blocker_category:"flake-cap" +| count by pr.repo +``` + +## Dispatch reasons + +Canonical source: `src/shared/dispatch-types.ts`. Four values; all land on `dispatch_target=daemon`. + +| Reason | When the router sets it | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `persistent-daemon` | Routed to an existing persistent daemon. The default, hot path. Also used during cooldown when a scale-up was warranted but blocked by the cooldown window. | +| `ephemeral-daemon-triage` | Triage returned `heavy=true` and an ephemeral daemon Pod was spawned. | +| `ephemeral-daemon-overflow` | Queue length ≥ `EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` **and** the persistent pool has zero free slots; a spawn drains the overflow. | +| `ephemeral-spawn-failed` | A spawn was required but the K8s API call failed. The job is rejected with a tracking-comment infra error. | + +## Aggregate reporting + +When `DATABASE_URL` is set, helpers in `src/db/queries/dispatch-stats.ts` expose the most operator-relevant aggregates: + +| Helper | Returns | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eventsPerTarget(days)` | Count of executions grouped by `dispatch_target`. Post-collapse this is always a single `daemon` row — query `dispatch_reason` directly for the per-reason split. | +| `triageRate(days)` | Share of events whose `dispatch_reason` is `ephemeral-daemon-triage`. | +| `avgConfidenceAndFallback(days)` | Mean triage confidence plus fallback counts by reason. | +| `triageSpend(days)` | Cumulative `cost_usd` for triage-reached executions. | + +Call them from an internal admin endpoint, a scheduled job, or `bun repl`. + +## Alerts worth having + +- **Triage error rate.** `parse-error` + `llm-error` + `timeout` + `circuit-open` above a sustained threshold (e.g. 10 % over 15 minutes) signals provider trouble or a regression. +- **Ephemeral spawn failures.** Any `dispatch_reason=ephemeral-spawn-failed` points at RBAC, quota, or control-plane issues. +- **Heartbeat drift.** Daemons missing heartbeats past `HEARTBEAT_TIMEOUT_MS` get evicted; sustained eviction points at network or resource-floor issues. +- **OOM / crash loops.** Standard infra alerts. Durable idempotency means a restart will not replay a processed event. +- **Ship terminal-blocker rate.** A spike in `ship.intent.transition` events with `to_status:human_took_over` and `terminal_blocker_category:flake-cap` points at PR-flake regressions, not bot misbehaviour. + +## Health probes + +| Path | Purpose | +| ---------- | ---------------------------------------------------------------------------------------------------- | +| `/healthz` | Liveness — returns 200 once the HTTP server is bound. | +| `/readyz` | Readiness — 200 once config is validated and the data layer is reachable; flips to 503 on `SIGTERM`. | diff --git a/docs/operate/runbooks/daemon-fleet.md b/docs/operate/runbooks/daemon-fleet.md new file mode 100644 index 00000000..f32cc1e1 --- /dev/null +++ b/docs/operate/runbooks/daemon-fleet.md @@ -0,0 +1,136 @@ +# Runbook — daemon fleet + +A daemon is a standalone worker process that connects to the orchestrator over WebSocket, accepts job offers, and runs each job through `src/core/pipeline.ts`. The webhook server never runs the pipeline in-process — every execution happens on a daemon. + +## Persistent vs ephemeral + +Always qualify which kind you mean. The union of both at any given moment is the **daemon fleet**. + +| Type | How it starts | Lifetime | `DAEMON_EPHEMERAL` | +| -------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------- | ------------------ | +| **Persistent** | Deployed out-of-band (Helm, kubectl, `docker run`, systemd). | Long-lived; stays connected until `SIGTERM` or eviction. | unset / `false` | +| **Ephemeral** | Spawned on demand by the orchestrator as a bare Pod via the K8s API. | Exits after `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` (default 120 s) of no active job. | `true` | + +Only persistent daemons count toward the "persistent pool free slots" the orchestrator uses to decide whether an overflow spawn is warranted. Ephemeral daemons exist specifically to drain the current surge and disappear. + +## Daemon lifecycle + +```mermaid +flowchart LR + Boot["Process start"]:::start + Connect["WebSocket connect to ORCHESTRATOR_URL
Bearer DAEMON_AUTH_TOKEN"]:::work + Register["daemon register
capabilities + resources + isEphemeral"]:::work + Idle["Idle wait"]:::wait + Offer["job offer or scoped-job-offer"]:::work + Eval{{"Capacity check
memory floor + disk floor + slot free"}}:::fork + Accept["job accept"]:::work + Reject["job reject
with reason"]:::halt + Run["src/core/pipeline.ts
clone -> agent -> push -> cleanup"]:::work + Result["job result or scoped-job completion"]:::work + Drain["Drain on SIGTERM
refuse new offers"]:::wait + Exit["Exit"]:::done + IdleExit["Ephemeral idle exit
after EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS"]:::done + + Boot --> Connect --> Register --> Idle + Idle --> Offer --> Eval + Eval -->|fits| Accept --> Run --> Result --> Idle + Eval -->|no| Reject --> Idle + Idle -. SIGTERM .-> Drain --> Exit + Idle -. ephemeral idle .-> IdleExit + + classDef start fill:#0b5cad,stroke:#083e74,color:#ffffff + classDef work fill:#114a82,stroke:#0a2f56,color:#ffffff + classDef fork fill:#6a2080,stroke:#451454,color:#ffffff + classDef wait fill:#5c3d00,stroke:#3d2900,color:#ffffff + classDef halt fill:#852020,stroke:#5a1414,color:#ffffff + classDef done fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff +``` + +## Operational knobs + +The full list lives at [`../configuration.md`](../configuration.md#orchestrator-and-daemon). The handful you'll actually touch: + +| Variable | Default | Notes | +| ---------------------------------- | -------- | ---------------------------------------------------------------------------------- | +| `ORCHESTRATOR_URL` | — | Required. `wss://` in production; `ws://` emits a warning. | +| `DAEMON_AUTH_TOKEN` | — | Shared secret with the orchestrator. | +| `DAEMON_EPHEMERAL` | `false` | `true` on ephemeral daemon Pods (injected by the spawner). Enables idle-exit. | +| `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` | `120000` | Ephemeral daemons exit after this idle window. | +| `HEARTBEAT_INTERVAL_MS` | `30000` | Ping cadence. | +| `HEARTBEAT_TIMEOUT_MS` | `90000` | Orchestrator eviction threshold. Keep `≥ 2 × HEARTBEAT_INTERVAL_MS`. | +| `DAEMON_DRAIN_TIMEOUT_MS` | `300000` | Post-`SIGTERM` grace. Raise to `≥ AGENT_TIMEOUT_MS` to guarantee no mid-run kills. | +| `DAEMON_MEMORY_FLOOR_MB` | `512` | Below this, the orchestrator skips the daemon on dispatch. | +| `DAEMON_DISK_FLOOR_MB` | `1024` | Same, for free disk. | + +## Persistent daemon Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: github-app-playground-daemon + namespace: default +spec: + replicas: 2 + selector: + matchLabels: + app: github-app-playground-daemon + template: + metadata: + labels: + app: github-app-playground-daemon + spec: + terminationGracePeriodSeconds: 300 + containers: + - name: daemon + image: chrisleekr/github-app-playground:latest-daemon + envFrom: + - secretRef: + name: daemon-secrets + env: + - name: ORCHESTRATOR_URL + value: "wss://orchestrator.example.internal:3002" + - name: CLONE_BASE_DIR + value: "/workspaces" + volumeMounts: + - name: bot-workspaces + mountPath: /workspaces + volumes: + - name: bot-workspaces + emptyDir: + sizeLimit: 5Gi +``` + +Match `terminationGracePeriodSeconds` to `DAEMON_DRAIN_TIMEOUT_MS` so `SIGTERM` has time to drain in-flight work before `SIGKILL`. + +## Concurrency and scaling + +A daemon process handles up to its advertised `maxConcurrentJobs` at a time. Scale **horizontally** by running multiple persistent daemon pods. The orchestrator adds ephemeral daemons for bursts (triage `heavy=true` or queue overflow) — see [`../observability.md`](../observability.md#dispatch-reasons). + +### Scale-up rule + +On every event the orchestrator evaluates: + +1. **Triage.** A single-turn Haiku call returns `{heavy, confidence, rationale}`. `heavy=true` is one trigger. +2. **Overflow.** If `queue_length ≥ EPHEMERAL_DAEMON_SPAWN_QUEUE_THRESHOLD` **and** the persistent pool has zero free slots, that's the other trigger. +3. **Cooldown.** Spawns are rate-limited by `EPHEMERAL_DAEMON_SPAWN_COOLDOWN_MS`. During cooldown, heavy/overflow signals **don't** spawn — the job falls back to `persistent-daemon` and waits. +4. **Spawn.** When both a trigger fires and cooldown has elapsed, the orchestrator creates a bare Pod via the K8s API. A K8s API failure yields `dispatch_reason=ephemeral-spawn-failed` and the job is rejected with a tracking-comment infra error. + +## Hard constraints + +- `AGENT_TIMEOUT_MS` must stay below the GitHub installation-token TTL (3600 s) so the daemon cannot outlive its credentials. +- `EPHEMERAL_DAEMON_IDLE_TIMEOUT_MS` should be longer than typical heartbeat cadence so a short lull between back-to-back jobs does not cause a premature exit. +- `terminationGracePeriodSeconds` on the daemon Pod should match `DAEMON_DRAIN_TIMEOUT_MS`. + +## Common Day-2 issues + +| Symptom | Likely cause | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Sustained heartbeat eviction | Daemon CPU starvation, network partition, or `HEARTBEAT_TIMEOUT_MS` too low. | +| `dispatch_reason=ephemeral-spawn-failed` | Missing RBAC on `pods` in `EPHEMERAL_DAEMON_NAMESPACE`, missing `daemon-secrets`, or a control-plane issue. | +| Mid-run kills on rolling deploys | `terminationGracePeriodSeconds` < `DAEMON_DRAIN_TIMEOUT_MS`. | +| `executions.status='running'` rows piling up | A daemon died abruptly. The `LIVENESS_REAPER_INTERVAL_MS` reaper flips them to failed; check daemon pod logs. | + +## Implementation references + +`src/daemon/main.ts`, `src/orchestrator/ws-server.ts`, `src/orchestrator/ephemeral-daemon-scaler.ts`, `src/k8s/ephemeral-daemon-spawner.ts`, `src/core/pipeline.ts`, `src/shared/ws-messages.ts`. diff --git a/docs/operate/runbooks/stuck-ship-intent.md b/docs/operate/runbooks/stuck-ship-intent.md new file mode 100644 index 00000000..2b8fb28f --- /dev/null +++ b/docs/operate/runbooks/stuck-ship-intent.md @@ -0,0 +1,127 @@ +# Runbook — stuck `bot:ship` session + +A shepherding session that doesn't terminate cleanly leaves a row in `ship_intents` with status other than `ready_awaiting_human_merge` or `merged_externally`. This page is a guide to figuring out which class of stuck and what to do. + +## Database tables + +Two tables carry the bulk of operator-relevant state: + +| Table | Rows | +| -------------------- | ---------------------------------------------------------------------------------------------------- | +| `ship_intents` | One per session. Status, deadline, cumulative spend, terminal blocker category, tracking comment id. | +| `ship_iterations` | One per probe / fix / reply / review iteration. Verdict on probe rows, cost, wall-clock. | +| `ship_continuations` | One per active intent. `wake_at`, `state_blob`, `wait_for[]` array. | +| `ship_fix_attempts` | Retry ledger keyed by `(intent_id, signature)`. Drives `flake-cap` enforcement. | + +Migration files live under `src/db/migrations/`. The ship lifecycle was added in `008_ship_intents.sql`. + +## Status values + +| Status | Meaning | Recoverable? | +| ---------------------------- | ------------------------------------------------------ | ------------------------------------------- | +| `active` | Session is in flight (or about to be tickled). | — | +| `paused` | `bot:stop` issued. Deadline keeps counting. | Yes — `bot:resume`. | +| `ready_awaiting_human_merge` | Probe verdict was `ready`; tracking comment finalised. | Terminal — human merge expected. | +| `merged_externally` | PR was merged while session was active. | Terminal. | +| `pr_closed` | PR was closed (not merged) while session was active. | Terminal. | +| `human_took_over` | Foreign push detected, iteration cap, or flake cap. | Terminal — see `terminal_blocker_category`. | +| `deadline_exceeded` | `MAX_WALL_CLOCK_PER_SHIP_RUN` elapsed. | Terminal. | +| `aborted_by_user` | `bot:abort-ship` issued. | Terminal — no further mutations. | + +## Terminal blocker categories + +When `status='human_took_over'`: + +| Category | Meaning | Action | +| ---------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------- | +| `manual-push-detected` | A non-bot principal pushed to the PR. | If you want the bot to take over again, re-trigger `bot:ship`. | +| `iteration-cap` | The session ran `MAX_SHIP_ITERATIONS` rounds without resolving. | Re-scope the work or split the PR. | +| `flake-cap` | Same failure signature retried `FIX_ATTEMPTS_PER_SIGNATURE_CAP` times. | Investigate the flake; the bot will not retry indefinitely. | +| `merge-conflict-needs-human` | Rebase produced conflicts the bot would not resolve confidently. | Resolve manually, then re-trigger. | +| `permission-denied` | A required GitHub mutation returned 403. | Check App permissions / repo collaborator access. | +| `stopped-by-user` | Session was paused indefinitely. | `bot:resume` or `bot:abort-ship`. | +| `unrecoverable-error` | Catch-all for unexpected pipeline failures. | Read `ship_iterations.verdict_json` for the last iteration. | +| `design-discussion-needed` | Probe escalated when the agent flagged a non-mechanical decision. | Discuss in PR thread; re-trigger if direction is clear. | + +## Day-2 SQL queries + +All queries assume `psql` against `DATABASE_URL`. + +### Active sessions + +```sql +SELECT + id, + owner || '/' || repo AS repo, + pr_number, + status, + deadline_at, + spent_usd, + EXTRACT(EPOCH FROM (now() - created_at))::int AS age_seconds +FROM ship_intents +WHERE status IN ('active', 'paused') +ORDER BY created_at; +``` + +### Terminal-state distribution (last 7 days) + +```sql +SELECT + status, + terminal_blocker_category, + COUNT(*) AS n, + ROUND(AVG(spent_usd)::numeric, 2) AS avg_spend +FROM ship_intents +WHERE terminated_at IS NOT NULL + AND terminated_at > now() - interval '7 days' +GROUP BY status, terminal_blocker_category +ORDER BY n DESC; +``` + +### Top-spend sessions + +```sql +SELECT id, owner, repo, pr_number, status, spent_usd, created_at, terminated_at +FROM ship_intents +ORDER BY spent_usd DESC +LIMIT 20; +``` + +### Iterations for one intent + +```sql +SELECT iteration_n, kind, verdict_json->>'kind' AS verdict, cost_usd, started_at, finished_at +FROM ship_iterations +WHERE intent_id = '' +ORDER BY iteration_n; +``` + +### Fix-attempt heatmap (signatures retried near the cap) + +```sql +SELECT intent_id, signature, attempts, last_seen_at +FROM ship_fix_attempts +WHERE attempts >= 2 +ORDER BY attempts DESC, last_seen_at DESC; +``` + +## Triage decision tree + +```text +Is status terminal? +├── Yes — read terminal_blocker_category. Use the table above. +└── No — status is active or paused. + ├── status=paused — stopped by user, awaiting resume or abort. + └── status=active — read ship_continuations for this intent. + ├── wake_at in the past — tickle scheduler should pick it up next cycle. + │ If multiple cycles pass with no progress, check tickle-scheduler logs + │ (event:"ship.tickle.due") and ship.iteration.* events. + └── wake_at in the future — session is waiting on a check_run / review_comment / synchronize event + to fire the reactor. Verify the GitHub App is subscribed to those events. +``` + +## When to abort vs let it run + +- Wall-clock has not yet hit `deadline_at` and the iteration count is below `MAX_SHIP_ITERATIONS` → let the tickle scheduler run another cycle. +- Same failure signature has retried `FIX_ATTEMPTS_PER_SIGNATURE_CAP` times → the bot will terminate itself with `flake-cap` on the next check; no manual abort needed. +- Session is genuinely wrong direction → `bot:abort-ship` and re-scope. diff --git a/docs/operate/runbooks/triage.md b/docs/operate/runbooks/triage.md new file mode 100644 index 00000000..c513ff7e --- /dev/null +++ b/docs/operate/runbooks/triage.md @@ -0,0 +1,63 @@ +# Runbook — triage + +Triage is a binary `heavy` classifier. It runs on every event (subject to the kill-switch and circuit breaker) and answers one question: should this job prefer an ephemeral daemon? `heavy=true` is one of the two triggers that can spawn an ephemeral daemon Pod (the other is queue overflow). + +## What a call returns + +```json +{ "heavy": true, "confidence": 0.92, "rationale": "..." } +``` + +There is no `complexity` field and no `maxTurns` mapping — `maxTurns` always comes from `config.defaultMaxTurns` regardless of the triage outcome. + +## Confidence threshold + +At or above `TRIAGE_CONFIDENCE_THRESHOLD`, `heavy` is accepted as-is. Below it, the router treats the signal as `heavy=false` — the job routes to `persistent-daemon` and the log line carries `triage_fallback_reason=sub-threshold`. Day-one default is `1.0` so only perfectly confident results route an ephemeral spawn. + +## Circuit breaker + +Triage wraps the LLM call in a circuit breaker (`src/orchestrator/triage.ts`). Consecutive failures trip the breaker; while open, the function short-circuits to `heavy=false` and emits `triage_fallback_reason=circuit-open`. The breaker re-closes after a cooldown. + +## Six fallback reasons + +All emitted as `triage_fallback_reason` in pino logs. + +| Reason | Trigger | +| --------------- | ------------------------------------------------------------------- | +| `disabled` | `TRIAGE_ENABLED=false` — short-circuits without calling the LLM. | +| `circuit-open` | Breaker tripped after consecutive failures. | +| `timeout` | The call exceeded `TRIAGE_TIMEOUT_MS`. | +| `llm-error` | The provider returned an error. | +| `parse-error` | The JSON response failed schema validation. | +| `sub-threshold` | Parsed successfully but `confidence < TRIAGE_CONFIDENCE_THRESHOLD`. | + +## Cost implications + +Every event attempts triage. When `TRIAGE_ENABLED=false` or the breaker is open the call short-circuits **before** hitting Haiku, so those paths are free. When the call proceeds, one Haiku invocation is the dominant marginal cost on a busy install. + +Mitigations: + +- `TRIAGE_CONFIDENCE_THRESHOLD` defaults to `1.0` (strictest). Lower toward `0.8`–`0.9` to accept more heavy verdicts; raising above `1.0` is unsupported and gates out every response. The compute cost is unchanged either way — the knob only controls whether the result routes an ephemeral spawn. +- Flip `TRIAGE_ENABLED=false` during a provider incident to suppress spend without redeploying. +- Keep `TRIAGE_MAX_TOKENS` low (the response schema is ~40 tokens). + +## Tuning knobs + +| Variable | Default | When to change | +| ----------------------------- | ----------- | ------------------------------------------------------- | +| `TRIAGE_ENABLED` | `true` | Incident kill-switch. | +| `TRIAGE_MODEL` | `haiku-3-5` | Experiment with newer Haiku aliases for latency. | +| `TRIAGE_CONFIDENCE_THRESHOLD` | `1.0` | Relax to `0.8`–`0.9` once the classifier is calibrated. | +| `TRIAGE_MAX_TOKENS` | `256` | Only raise if rationale is being truncated. | +| `TRIAGE_TIMEOUT_MS` | `5000` | Raise if provider latency is consistently > 5 s. | + +Full schema: [`../configuration.md`](../configuration.md#triage). + +## Querying + +| Question | Approach | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Triage accuracy this week | Sample log lines with `heavy:true` or `heavy:false` and grade against the actual job duration / cost. | +| Spend on triage calls | `triageSpend(days)` helper in `src/db/queries/dispatch-stats.ts`. | +| Fallback rate | Log query: `triage_fallback_reason:*` grouped by reason. | +| Sub-threshold tail | Histogram of `confidence` for non-fallback events; if the 90th percentile is below `TRIAGE_CONFIDENCE_THRESHOLD`, lower the threshold. | diff --git a/docs/operate/setup.md b/docs/operate/setup.md new file mode 100644 index 00000000..e3451862 --- /dev/null +++ b/docs/operate/setup.md @@ -0,0 +1,117 @@ +# Local development setup + +This page covers running the bot on your laptop against a real GitHub App. For first-time GitHub App creation, see [`github-app.md`](github-app.md). For production deployment, see [`deployment.md`](deployment.md). + +## Prerequisites + +| Tool | Version | Purpose | +| ----------------------- | ------------------------------------------ | -------------------------------------------------------------------------------- | +| [Bun](https://bun.sh) | from `.tool-versions` (currently `1.3.13`) | Runtime and package manager. | +| Git | any | Repository checkout during agent execution. | +| Docker | any recent | Local Postgres + Valkey via `docker-compose.dev.yml`. | +| GitHub account | — | Admin access to the org or personal account where the App is registered. | +| Tunnelling tool | — | ngrok or smee.io to expose `localhost:3000` to GitHub. | +| AI provider credentials | — | One of: Anthropic API key, Claude Code OAuth token, AWS credentials for Bedrock. | + +## First run + +```bash +git clone https://github.com/chrisleekr/github-app-playground.git +cd github-app-playground +bun install + +# Start Postgres + Valkey in the background +bun run dev:deps + +# Copy and fill .env +cp .env.example .env +# Edit .env — see configuration.md for every variable. + +# Run database migrations +bun run db:migrate + +# Run in watch mode +bun run dev +``` + +The HTTP server binds to `PORT` (default `3000`). Hit `http://localhost:3000/healthz` to confirm it's up; `http://localhost:3000/readyz` confirms the data layer is reachable. + +## Expose the local server + +GitHub must reach your webhook URL over the internet. + +```bash +# Wrapped script: ngrok on port 3000 +bun run dev:ngrok +# Copy the https://....ngrok.io URL into the GitHub App's webhook URL field. +``` + +Alternative — smee.io: + +```bash +smee --url https://smee.io/ --path /api/github/webhooks --port 3000 +``` + +The local trigger phrase is conventionally `@chrisleekr-bot-dev` (set `TRIGGER_PHRASE` in `.env`) so the dev installation does not collide with the production bot's mention. + +## Common dev commands + +```bash +bun run dev # Watch mode +bun run start # Production binary (after bun run build) +bun run build # Compile to dist/ + +bun run check # Unified gate: typecheck + lint + format + tests + no-destructive +bun run typecheck # tsc --noEmit +bun run lint # ESLint +bun run lint:fix # ESLint auto-fix +bun run format # Prettier check +bun run format:fix # Prettier auto-fix + +bun test # Run tests via scripts/test-isolated.sh (Bun mock isolation) +bun run test:fast # Direct bun test (no isolation) +bun run test:watch # Watch mode +bun run test:coverage # With coverage report + +bun run audit:ci # Severity-gated bun audit (used by CI) +bun run db:migrate # Run migrations against DATABASE_URL +bun run dev:deps # Up Postgres + Valkey +bun run dev:deps:down # Tear down +bun run dev:daemon # Run a daemon locally against the running orchestrator + +bun run docs:install # Install MkDocs Python deps (one-time) +bun run docs:serve # Live-reload preview at http://localhost:8000 +bun run docs:build # Strict build (CI also runs this) +``` + +`bun run check` is the single command to run before opening a PR. + +## Running a daemon locally + +The webhook server embeds an orchestrator that talks to daemons over WebSocket. To exercise the full pipeline locally: + +```bash +# Terminal 1 — orchestrator (webhook server) +bun run dev + +# Terminal 2 — local daemon +bun run dev:daemon +``` + +The daemon connects back to `ws://localhost:3002` (`WS_PORT`) using `DAEMON_AUTH_TOKEN` from `.env`. From there, every `@chrisleekr-bot-dev` mention exercises the full webhook → orchestrator → daemon → pipeline path. + +## Testing webhook delivery + +1. Open an issue or PR in a repository where your dev App is installed. +2. Comment `@chrisleekr-bot-dev triage this`. +3. The bot creates a tracking comment within ~2 s. + +If nothing happens, check: + +| Symptom | Likely cause | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| 401 / 403 in tunnel logs | `GITHUB_WEBHOOK_SECRET` mismatch with the App settings (no trailing newline). | +| 200 OK but no comment | `ALLOWED_OWNERS` excludes the repo owner; or `MAX_CONCURRENT_REQUESTS` is saturated. | +| `GITHUB_APP_PRIVATE_KEY` parse error | Set the full PEM including `-----BEGIN…` / `-----END…` lines (literal `\n` is normalised). | +| `ANTHROPIC_API_KEY is required` | `CLAUDE_PROVIDER` defaults to `anthropic`; set the key or switch to `bedrock`. | +| Bot mention ignored locally | `TRIGGER_PHRASE` is unchanged from the default `@chrisleekr-bot`; the dev App expects `@chrisleekr-bot-dev`. | diff --git a/docs/use/invoking.md b/docs/use/invoking.md new file mode 100644 index 00000000..0214efa1 --- /dev/null +++ b/docs/use/invoking.md @@ -0,0 +1,90 @@ +# Invoking the bot + +The bot reacts to three kinds of input: mentions in a comment, labels applied to an issue or PR, and (for `bot:ship` only) natural-language asks that include the trigger phrase. All three converge on the same workflow registry — only the **surface** differs in logs. + +## The three surfaces + +| Surface | Where you put it | Example | +| ------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| **Mention + verb** | Issue or PR comment | `@chrisleekr-bot triage this` · `@chrisleekr-bot ship this please` | +| **Literal command** | PR comment | `bot:ship` · `bot:ship --deadline 2h` · `bot:abort-ship` | +| **Label** | Apply to issue or PR | `bot:triage`, `bot:plan`, `bot:implement`, `bot:review`, `bot:resolve`, `bot:ship`, `bot:stop`, `bot:resume`, `bot:abort-ship` | + +The trigger phrase that gates mentions is `@chrisleekr-bot` by default and can be overridden with `TRIGGER_PHRASE` (typically `@chrisleekr-bot-dev` for local development). + +## How a comment reaches a workflow + +```mermaid +flowchart TD + Cmt["Comment with @chrisleekr-bot or bot:verb"]:::input + Verify["Webhook verified
HMAC-SHA256"]:::guard + Idem["Idempotency check
delivery id + tracking comment"]:::guard + Allow["ALLOWED_OWNERS allowlist"]:::guard + Router{{"Trigger router"}}:::fork + Lit["Literal regex
bot:verb"]:::route + NL["Mention + NL classifier
Bedrock single-turn"]:::route + LabelEvt["issues.labeled or
pull_request.labeled"]:::input + LabelMatch["registry.getByLabel"]:::route + Enqueue["enqueueJob
Valkey queue:jobs"]:::store + Daemon["Daemon claims offer"]:::work + Pipe["src/core/pipeline.ts"]:::work + Track["Tracking comment finalised"]:::done + + Cmt --> Verify --> Idem --> Allow --> Router + Router --> Lit + Router --> NL + LabelEvt --> Allow + Allow -. label path .-> LabelMatch + Lit --> Enqueue + NL --> Enqueue + LabelMatch --> Enqueue + Enqueue --> Daemon --> Pipe --> Track + + classDef input fill:#0b5cad,stroke:#083e74,color:#ffffff + classDef guard fill:#164a3a,stroke:#0d2c24,color:#ffffff + classDef fork fill:#6a2080,stroke:#451454,color:#ffffff + classDef route fill:#8a5a00,stroke:#5c3d00,color:#ffffff + classDef store fill:#5c3d00,stroke:#3d2900,color:#ffffff + classDef work fill:#114a82,stroke:#0a2f56,color:#ffffff + classDef done fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff +``` + +## Idempotency + +A duplicate webhook delivery never spawns a duplicate job. The router checks two layers: + +1. **Fast in-memory** — a `Map` keyed by the `X-GitHub-Delivery` header, swept every 60 minutes (`src/webhook/router.ts`). Lost on restart. +2. **Durable** — `isAlreadyProcessed` looks for the hidden delivery marker that the bot embeds in its tracking comment. Survives pod restarts, OOM kills, and crash loops; works without `DATABASE_URL`. + +If both miss, the request proceeds to the allowlist + concurrency guard. + +## What you see while it runs + +Comment-driven runs stack four reactions on your trigger comment so the lifecycle is visible at a glance: + +| Stage | Reaction | +| -------------------------- | -------- | +| Trigger detected | 👀 | +| Job dispatched to a daemon | 🚀 | +| Workflow succeeded | 🎉 | +| Workflow failed | 😕 | + +Reactions are additive — the combined set is the audit trail. Label-driven runs skip reactions because there is no comment to react on. + +The bot also writes a single **tracking comment** per run. For workflows that take minutes (triage, plan, implement, review, resolve, ship), the comment opens with a "Working…" body and is rewritten in place at major checkpoints and at the terminal state. You only need to watch one comment. + +## What gets refused + +| Refusal | Cause | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| Silent skip | Repository owner is not in `ALLOWED_OWNERS`. No comment is posted. | +| "Capacity reached" reply | More than `MAX_CONCURRENT_REQUESTS` agent runs are already in flight. Re-invoke later. | +| Clarification reply | Mention-driven request whose intent classifier confidence fell below `INTENT_CONFIDENCE_THRESHOLD` (default `0.75`). | +| "Unsupported" reply | Mention-driven request whose intent does not map to any registered workflow. | +| `bot:ship` refusal | Target branch is in `SHIP_FORBIDDEN_TARGET_BRANCHES`, or the PR head is closed / on a fork without push access. | + +See [`use/safety.md`](safety.md) for what the bot will and will not do once a job is accepted. + +## Catalog + +A complete table of `bot:*` commands lives at [`use/workflows/`](workflows/index.md). The headline shipping workflow is documented at [`use/workflows/ship.md`](workflows/ship.md). diff --git a/docs/use/safety.md b/docs/use/safety.md new file mode 100644 index 00000000..0b8439a5 --- /dev/null +++ b/docs/use/safety.md @@ -0,0 +1,49 @@ +# What the bot will and won't do + +This page enumerates the boundary the bot enforces on itself. The boundary is defended in two places: handler code and a static guard at `scripts/check-no-destructive-actions.ts` that runs in `bun run check`. + +## Static guard — destructive actions + +`scripts/check-no-destructive-actions.ts` scans `src/workflows/ship/` (recursive) and the four scoped daemon executors for the following patterns. The CI gate fails the build on any match outside of comments. + +| Pattern | Why blocked | +| ---------------------------------------------------------------------- | --------------------------------------------------------------- | +| `git push --force` / `git push -f` | Always replaced with `--force-with-lease` after a clean rebase. | +| `git reset --hard` | The bot never discards local work without explicit intent. | +| `git branch -D` / `git push --delete` | Branch deletion is a human action. | +| `git filter-branch` / `git filter-repo` | History rewriting is out of scope. | +| `gh pr merge` / `mergePullRequest` (GraphQL) / `mergeBranch` (GraphQL) | The bot never merges. | + +`src/workflows/handlers/resolve.ts` documents a non-negotiable requirement that `octokit.rest.pulls.merge` must never be called (header comment + a step in the agent's instruction set). It is a documented constraint, not a runtime guard — the static guard above is what fails the build if anyone tries. + +## Pause / resume / abort (ship sessions) + +| Verb | Behaviour | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `bot:stop` | Sets `ship_intents.status = 'paused'`. The deadline keeps counting down. The session can be resumed. | +| `bot:resume` | Verifies no foreign push has landed since the pause, clears the Valkey cancel flag, re-enqueues the continuation. Refused if a manual push happened during the pause. | +| `bot:abort-ship` | Sets the Valkey cancel flag at `ship:cancel:{intent_id}`, waits ≤2 s for the next cooperative checkpoint, force-transitions to `aborted_by_user`. After abort, the bot performs zero further mutating actions on the PR. | + +## Foreign-push semantics + +The shepherding probe detects when a non-bot principal has pushed to the PR's head branch. The session terminates immediately with `human_took_over` + `terminal_blocker_category='manual-push-detected'`. The bot does not race the human or revert the push. + +## Forbidden target branches + +`SHIP_FORBIDDEN_TARGET_BRANCHES` (comma-separated) lists branches `bot:ship` will refuse to shepherd into. Typical values: `main`, `production`, `release`. The refusal is delivered as a maintainer-facing reply naming the offending branch; no `ship_intents` row is created. + +## Reviews + +`bot:review` posts findings as **inline comments**, one MCP call per finding. It never: + +- Submits a top-level `APPROVE` or `REQUEST_CHANGES` review (those are human prerogatives). +- Merges the PR. +- Bundles findings into a single wall-of-text review POST. + +## Idempotency + +A duplicate webhook delivery — same `X-GitHub-Delivery` header or same tracking-comment marker — is dropped before any work runs. The fast in-memory `Map` is lost on restart; the durable check (looking for the bot's hidden delivery marker in existing tracking comments) survives crash loops. + +## Fork PRs + +The bot's installation token cannot push to a fork branch. PR-side workflows (`review`, `resolve`, `ship`) detect this, post a top-level comment asking the contributor to rebase, and proceed against the stale head — flagging affected findings in the final report. diff --git a/docs/use/workflows/implement.md b/docs/use/workflows/implement.md new file mode 100644 index 00000000..cc11bd0c --- /dev/null +++ b/docs/use/workflows/implement.md @@ -0,0 +1,43 @@ +# `bot:implement` + +Opens a PR with code, tests, and a filled-out PR template based on the prior plan. + +| Field | Value | +| --------------- | ------------------------------------- | +| Label | `bot:implement` | +| Mention | `@chrisleekr-bot implement this` | +| Accepted target | Issue | +| Requires prior | A successful `plan` run | +| Artifact | `IMPLEMENT.md` | +| Side effects | New branch, new commits, new PR | +| Source | `src/workflows/handlers/implement.ts` | + +## Inputs + +- Issue body. +- The plan markdown from the prior `plan` run. +- A fresh shallow clone of the repository. + +## Outputs + +| Field | Type | Notes | +| ------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `state.pr_number`, `state.pr_url`, `state.branch` | strings | The PR the bot opened. | +| `state.report` | markdown | Full `IMPLEMENT.md` (Summary / Files changed / Commits / Tests run / Verification). Embedded in the tracking comment. | +| `state.costUsd`, `state.turns` | metrics | — | + +## PR detection + +`findRecentOpenedPr` filters on `pr.user?.type === 'Bot'` plus `created_at >= since - 5s`. It deliberately does not match on a hard-coded login slug — dev installs publish as `chrisleekr-bot-dev[bot]` and prod as `chrisleekr-bot[bot]`, so a slug check would produce false negatives. + +## PR body + +The agent reads `.github/PULL_REQUEST_TEMPLATE/bot-implement.md` and fills every section based on actual work, then passes it via `gh pr create --body-file …`. This keeps bot PRs structurally consistent and prevents `gh` from auto-falling back to the human PR template. + +## Stop conditions + +- Pipeline pushes a branch and opens a PR. +- Pipeline succeeds but `findRecentOpenedPr` returns null → handler fails with `"implement completed but no PR was found"`. +- Pipeline fails → handler reports the underlying error. + +The handler does **not** poll CI or reviewer state — that is `resolve`'s job, after `review` has run. diff --git a/docs/use/workflows/index.md b/docs/use/workflows/index.md new file mode 100644 index 00000000..fd7059be --- /dev/null +++ b/docs/use/workflows/index.md @@ -0,0 +1,35 @@ +# Workflows + +Six workflows are registered today (`src/workflows/registry.ts`). Each has a single label, a single comment-mention verb, and produces one Markdown artifact that becomes the body of the tracking comment. + +| Workflow | Label | Surfaces | What it does | Detail | +| --------------------------- | --------------- | ---------------------------------------------- | ---------------------------------------------------------------------------- | ---------------- | +| [`triage`](triage.md) | `bot:triage` | Issue label or comment | Decides whether an issue is actionable, with structural or runtime evidence | `TRIAGE.md` | +| [`plan`](plan.md) | `bot:plan` | Issue label or comment, after `triage` | Writes an implementation plan | `PLAN.md` | +| [`implement`](implement.md) | `bot:implement` | Issue label or comment, after `plan` | Opens a PR with code, tests, and a filled-out PR template | `IMPLEMENT.md` | +| [`review`](review.md) | `bot:review` | PR label or comment | Reads the diff in full, posts findings as inline comments | `REVIEW.md` | +| [`resolve`](resolve.md) | `bot:resolve` | PR label or comment | Fixes failing CI, replies to review threads, pushes new commits | `RESOLVE.md` | +| [`ship`](ship.md) | `bot:ship` | PR comment, label, or natural-language mention | Shepherds an open PR to merge-ready: probe → fix → reply → wait, until clean | tracking comment | + +## How they relate + +`triage`, `plan`, and `implement` are the issue-side cascade for new work. `review` and `resolve` are the PR-side pair: `review` proactively reads a diff and posts findings; `resolve` reactively answers existing feedback and fixes failing CI. The split is deliberate — conflating "look at this PR" with "fix this PR" was the design mistake the verb-rename corrected. + +`ship` is the PR shepherding lifecycle (its own state machine, its own database tables). It does not run the cascade above; it drives an open PR through the merge-readiness probe ladder until a human can hit merge. See [`ship.md`](ship.md). + +## Common rules across all workflows + +- **The bot never merges.** No workflow calls `pulls.merge` or posts an `APPROVE` / `REQUEST_CHANGES` review. Static guard at `scripts/check-no-destructive-actions.ts`. +- **Always-rebase semantics.** PR-side workflows (`review`, `resolve`, `ship`) rebase the branch onto base before reading the diff if it is behind, then `git push --force-with-lease`. Fork PRs cannot be force-pushed by the bot — it asks the contributor to rebase and proceeds against the stale head. +- **One Markdown artifact, one tracking comment.** Each run captures `.md` from the working tree before cleanup and embeds it verbatim in the tracking comment. +- **Cost is visible.** Every workflow records `cost_usd`, `turns`, and `wall_clock_ms` on the run row. The shepherding lifecycle exposes cumulative spend in the tracking comment header. + +## Trigger-comment intent classifier + +A comment that mentions the trigger phrase is routed through `src/workflows/intent-classifier.ts` — a single-turn Haiku call that returns `{ workflow, confidence, rationale }`. + +- `confidence < INTENT_CONFIDENCE_THRESHOLD` (default `0.75`) → the dispatcher posts a clarification reply and stops. +- `workflow` not in registry → refusal reply. +- `workflow` in registry → same dispatch as the label path. + +The classifier prompt distinguishes `review` (proactive — find bugs, post inline findings) from `resolve` (reactive — fix CI, answer feedback). Tune the threshold per environment with `INTENT_CONFIDENCE_THRESHOLD`. diff --git a/docs/use/workflows/plan.md b/docs/use/workflows/plan.md new file mode 100644 index 00000000..374a78f0 --- /dev/null +++ b/docs/use/workflows/plan.md @@ -0,0 +1,34 @@ +# `bot:plan` + +Writes an implementation plan for an issue that has already passed triage. + +| Field | Value | +| --------------- | --------------------------------------------------------------------- | +| Label | `bot:plan` | +| Mention | `@chrisleekr-bot plan this out` | +| Accepted target | Issue | +| Requires prior | A successful `triage` run on the same issue with `state.valid = true` | +| Artifact | `PLAN.md` | +| Side effects | None | +| Source | `src/workflows/handlers/plan.ts` | + +## Inputs + +- Issue body. +- The triage state from the prior run (verdict, evidence, recommended next). +- A fresh shallow clone of the repository. + +## Outputs + +| Field | Type | Notes | +| -------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------- | +| `state.plan` | markdown | Full `PLAN.md` body, captured before workspace cleanup. Embedded verbatim in the tracking comment. | +| `state.costUsd`, `state.turns`, `state.durationMs` | metrics | — | + +## Stop conditions + +The agent writes `PLAN.md`; the pipeline reports success or failure. No turn cap — the agent runs to completion. + +## Re-trigger semantics + +`plan` is **fresh** when a successful `plan` row exists for the issue created **after** the most recent successful `triage`. Re-applying the label when `plan` is stale enqueues a fresh run; an in-flight stale run is not interrupted, so wait for it to terminate before re-applying. diff --git a/docs/use/workflows/resolve.md b/docs/use/workflows/resolve.md new file mode 100644 index 00000000..da780b88 --- /dev/null +++ b/docs/use/workflows/resolve.md @@ -0,0 +1,41 @@ +# `bot:resolve` + +Fixes failing CI, replies to existing review threads, and pushes new commits. + +| Field | Value | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| Label | `bot:resolve` | +| Mention | `@chrisleekr-bot fix the CI failures` · `@chrisleekr-bot address the review comments` · `@chrisleekr-bot respond to the feedback` | +| Accepted target | Pull request | +| Requires prior | — | +| Artifact | `RESOLVE.md` | +| Side effects | New commits on the PR head branch; replies to review threads; force-push of a clean rebase if branch is behind base | +| Source | `src/workflows/handlers/resolve.ts` | + +## Method + +For each open reviewer comment the agent classifies as **Valid**, **Partially Valid**, **Invalid**, or **Needs Clarification**, then: + +- Fixes valid ones with new commits. +- Replies to all four classes appropriately. +- Fixes failing CI when there is a clear root cause. + +Branch refresh happens first if the head is stale (same logic as `review`). + +Reviewer-thread replies are posted via `gh api repos///pulls//comments//replies -X POST`. The bot's `gh` and `git` calls authenticate via `GH_TOKEN` and `GITHUB_TOKEN`, injected from the GitHub App installation token by `buildProviderEnv` in `src/core/executor.ts`. + +## Outputs + +| Field | Type | Notes | +| ------------------------------ | ---------- | ----------------------------------------------------------------------------------------- | +| `state.failing_checks` | `string[]` | Names of failing checks at start of run. | +| `state.top_level_comments` | number | Count of open top-level review comments. | +| `state.branch_state` | object | Pre-refresh snapshot. | +| `state.report` | markdown | Full `RESOLVE.md` (Summary / CI status / Review comments / Commits pushed / Outstanding). | +| `state.costUsd`, `state.turns` | metrics | — | + +## Stop conditions + +- `FIX_ATTEMPTS_CAP = 3` — maximum consecutive CI-fix attempts per run. +- `POLL_WAIT_SECS_CAP = 900` (15 min) — reviewer-patience window before the run terminates. +- The handler **never** calls `octokit.rest.pulls.merge` — merging is a human action. diff --git a/docs/use/workflows/review.md b/docs/use/workflows/review.md new file mode 100644 index 00000000..04a05643 --- /dev/null +++ b/docs/use/workflows/review.md @@ -0,0 +1,56 @@ +# `bot:review` + +Reads a PR diff in full, cross-references with the rest of the codebase, and posts findings as inline comments. + +| Field | Value | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Label | `bot:review` | +| Mention | `@chrisleekr-bot review this PR` · `@chrisleekr-bot do a code review` · `@chrisleekr-bot check for issues` | +| Accepted target | Pull request | +| Requires prior | — | +| Artifact | `REVIEW.md` | +| Side effects | Inline review comments via `mcp__github_inline_comment__create_inline_comment`; force-push of a clean rebase if branch is behind base | +| Source | `src/workflows/handlers/review.ts` | + +## Method + +The agent operates as a senior engineer: + +- Reads every changed file in full, not just the diff window. +- Cross-references callers, tests, and related code. +- Runs `bun test`, `bun run typecheck`, `bun run lint` when uncertain. +- Only posts findings it can defend with evidence. + +Each finding carries a severity prefix: + +| Severity | Meaning | +| ----------- | --------------------------------------------------- | +| `[blocker]` | Must fix before merge — correctness or security. | +| `[major]` | Should fix before merge — likely bug, missing test. | +| `[minor]` | Nice to fix — readability. | +| `[nit]` | Taste, optional. Not counted in `findings.total`. | + +Findings are posted **one MCP call per finding**, never as a single bundled review. This guarantees each finding lands on the right line with its own resolvable thread. + +## No-findings case + +The agent must still post a top-level review body listing exactly what was checked (files read, classes of issue scanned, tests run) and why no issues were flagged. Silence is indistinguishable from "didn't actually look". + +## Branch refresh + +If the PR head is behind base **and** the branch is not on a fork, the agent rebases onto base, resolves conflicts honestly (reads the surrounding code, runs typecheck and tests, never blindly takes ours/theirs), and force-pushes with `--force-with-lease`. Fork PRs get a comment asking the contributor to rebase, then the review proceeds against the stale head with affected findings flagged. + +## Outputs + +| Field | Type | Notes | +| ----------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------- | +| `state.head_sha` | string | The SHA the review ran against (post-rebase if applicable). | +| `state.changed_files`, `state.additions`, `state.deletions` | numbers | Diff stats. | +| `state.branch_state` | `{commits_behind_base, commits_ahead_of_base, is_fork}` | Pre-refresh snapshot. | +| `state.findings` | `{blocker, major, minor, nit, total}` | Counted from the severity tags; `total` excludes `nit`. | +| `state.report` | markdown | Full `REVIEW.md`. | +| `state.costUsd`, `state.turns` | metrics | — | + +## Push policy + +The only push acceptable from `review` is `git push --force-with-lease` after a clean rebase onto base (same diff, fresh head SHA). The handler never creates code commits, never calls `pulls.merge`, never posts an `APPROVE` or `REQUEST_CHANGES` review. diff --git a/docs/use/workflows/ship.md b/docs/use/workflows/ship.md new file mode 100644 index 00000000..94bff3b3 --- /dev/null +++ b/docs/use/workflows/ship.md @@ -0,0 +1,125 @@ +# `bot:ship` — PR shepherding to merge-ready + +The shepherding lifecycle takes an open pull request from "needs work" to "ready for human merge". The bot drives the probe → fix → reply → wait loop until the merge-readiness probe says the PR is clean. **The bot never merges**; the final action is always a human's. + +The lifecycle lives in `src/workflows/ship/` (entry point `runShipFromCommand` in `session-runner.ts`). Each session is a row in `ship_intents`, with iteration history in `ship_iterations` and wake state in `ship_continuations`. + +## How to invoke + +| Surface | Example | Notes | +| ----------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| **Literal** | `bot:ship` · `bot:ship --deadline 2h` | PR comment. Deterministic regex; never costs an LLM call. | +| **Natural** | `@chrisleekr-bot ship this please` | Requires the trigger-phrase mention. Without the mention the comment is skipped at zero cost. | +| **Label** | Apply `bot:ship` (or `bot:ship/deadline=2h`) to a PR | The bot self-removes the label after acting. Re-applying re-triggers. | + +The four lifecycle verbs are `ship`, `stop`, `resume`, `abort-ship`. All four are available on all three surfaces. + +`--deadline` accepts `Nh` / `Nm` / `Ns`. The session deadline is clamped to `MAX_WALL_CLOCK_PER_SHIP_RUN` (default 4h). + +## How to monitor + +Each session writes a single canonical tracking comment marked with ``. The body shows current phase, last action, next queued action, iteration count, USD spent, deadline, and (on terminal) the blocker category. One comment is enough to know exactly where the bot is. + +## How to pause, resume, abort + +| Verb | Effect | Recoverable? | +| ---------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | +| `bot:stop` | Sets `ship_intents.status = 'paused'`. Deadline keeps counting down. | Yes — `bot:resume`. | +| `bot:resume` | Verifies no foreign push since the pause, clears the cancel flag, re-enqueues the continuation. | — | +| `bot:abort-ship` | Sets the Valkey cancel flag, waits ≤2 s for a cooperative checkpoint, then force-transitions to `aborted_by_user`. | No. After abort, the bot performs zero further mutating actions on the PR. | + +## What runs each iteration + +```mermaid +flowchart LR + Iter["Iteration N starts"]:::start + Probe["Probe
GraphQL PR snapshot"]:::work + Verdict{{"Verdict"}}:::fork + Behind["Refresh branch
git rebase --force-with-lease"]:::fix + Failing["Resolve failing checks"]:::fix + Pending["Wait for pending checks
tickle on check_run.completed"]:::wait + Threads["Reply to open review threads
resolve thread on success"]:::fix + ChangesReq["Wait for human action
changes_requested"]:::wait + Ready["Terminal:ready
tracking comment + status flip"]:::done + Took["Terminal:human_took_over
foreign push detected"]:::halt + + Iter --> Probe --> Verdict + Verdict -->|behind base| Behind --> Iter + Verdict -->|failing checks| Failing --> Iter + Verdict -->|pending checks| Pending --> Iter + Verdict -->|open threads| Threads --> Iter + Verdict -->|changes requested| ChangesReq --> Iter + Verdict -->|ready| Ready + Probe -. detects manual push .-> Took + + classDef start fill:#0b5cad,stroke:#083e74,color:#ffffff + classDef work fill:#164a3a,stroke:#0d2c24,color:#ffffff + classDef fork fill:#6a2080,stroke:#451454,color:#ffffff + classDef fix fill:#8a5a00,stroke:#5c3d00,color:#ffffff + classDef wait fill:#5c3d00,stroke:#3d2900,color:#ffffff + classDef done fill:#2a6f2a,stroke:#1a4d1a,color:#ffffff + classDef halt fill:#852020,stroke:#5a1414,color:#ffffff +``` + +The verdict ladder is ordered: `human_took_over` > `behind_base` > `failing_checks` > `pending_checks` > `mergeable_pending` > `changes_requested` > `open_threads` > `ready`. The first matching rung wins — fixing failing checks always precedes replying to threads, and a manual push always wins outright. + +`mergeable=null` is treated specially: the probe backs off through `MERGEABLE_NULL_BACKOFF_MS_LIST` (default `500,1500,4500`); exhausting the list yields a `mergeable_pending` verdict and the session yields rather than spinning. + +## Status values + +```mermaid +stateDiagram-v2 + [*] --> active : runShipFromCommand + active --> paused : bot:stop + paused --> active : bot:resume + active --> ready_awaiting_human_merge : verdict=ready + active --> human_took_over : foreign push, iteration cap, or flake cap + active --> deadline_exceeded : MAX_WALL_CLOCK_PER_SHIP_RUN + active --> merged_externally : pull_request.closed merged + active --> pr_closed : pull_request.closed not merged + active --> aborted_by_user : bot:abort-ship + paused --> aborted_by_user : bot:abort-ship + paused --> deadline_exceeded : deadline elapsed while paused + ready_awaiting_human_merge --> [*] + human_took_over --> [*] + deadline_exceeded --> [*] + merged_externally --> [*] + pr_closed --> [*] + aborted_by_user --> [*] +``` + +## What the bot will and won't do + +| Will | Won't | +| ------------------------------------------------------------------- | ---------------------------------------------------------------- | +| Force-push with `--force-with-lease` after a clean rebase onto base | Force-push without rebasing | +| Push fix commits in response to failing CI | Merge the PR (`gh pr merge` is statically guarded) | +| Reply to review threads with the `resolve-review-thread` MCP | Post `APPROVE` or `REQUEST_CHANGES` reviews | +| Mark a draft PR ready-for-review on terminal `ready` | Cancel a foreign push — manual push wins; the session terminates | +| Self-remove the `bot:ship` label after acting | Take any mutating action after `bot:abort-ship` | + +If the target branch matches `SHIP_FORBIDDEN_TARGET_BRANCHES` (e.g. `main,production`), the trigger is refused before any session is created. + +## Re-triggering + +Re-applying the `bot:ship` label or re-commenting `bot:ship` on the same PR while a session is **active** is a no-op. Re-applying after the session is **terminal** starts a fresh session — the prior `ship_intents` row is preserved for audit. + +## Tuning knobs + +Configured at the process level via [`operate/configuration.md`](../../operate/configuration.md#ship). The two you most often touch: + +| Variable | Default | Effect | +| ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------- | +| `MAX_WALL_CLOCK_PER_SHIP_RUN` | `4h` | Hard ceiling on a session's wall-clock budget. Per-invocation `--deadline` is clamped to this value. | +| `MAX_SHIP_ITERATIONS` | `50` | Iteration cap. Firing transitions to `human_took_over` with `terminal_blocker_category='iteration-cap'`. | + +## When a human should step in + +The tracking comment puts the answer at the top: any terminal status other than `ready_awaiting_human_merge` and `merged_externally` means human attention is needed. `terminal_blocker_category` names which class: + +- `flake-cap` — the same failure signature was retried `FIX_ATTEMPTS_PER_SIGNATURE_CAP` times (default 3); investigate the flake. +- `iteration-cap` — the session ran `MAX_SHIP_ITERATIONS` rounds without resolving; re-scope the work. +- `manual-push-detected` — someone pushed to the PR; the bot stepped back. Re-trigger `bot:ship` if you want the bot to take it from here. +- `merge-conflict-needs-human` — the rebase produced conflicts the bot would not resolve confidently. + +For Day-2 SQL and the other terminal categories, see [`operate/runbooks/stuck-ship-intent.md`](../../operate/runbooks/stuck-ship-intent.md). diff --git a/docs/use/workflows/triage.md b/docs/use/workflows/triage.md new file mode 100644 index 00000000..27c9291e --- /dev/null +++ b/docs/use/workflows/triage.md @@ -0,0 +1,48 @@ +# `bot:triage` + +Decides whether an issue is actionable. For bug-class issues, the agent must establish either a reproduction, a structural defect (with `file:line` citations), or an invariant test before declaring the bug valid. + +| Field | Value | +| --------------- | ----------------------------------- | +| Label | `bot:triage` | +| Mention | `@chrisleekr-bot triage this` | +| Accepted target | Issue | +| Requires prior | — | +| Artifact | `TRIAGE.md` + `TRIAGE_VERDICT.json` | +| Side effects | None | +| Source | `src/workflows/handlers/triage.ts` | + +## Inputs + +- Issue title and body. +- A fresh shallow clone of the repository (`Read`, `Grep`, `Glob`, `Bash`, `Write` available to the agent). + +## Method + +The agent classifies the issue (bug, feature, refactor, docs, unclear). For bugs it walks the harness ladder — unit → mocked unit → integration with `bun run dev:deps` Postgres+Valkey → multi-process docker-compose — and names the highest rung tried. Three evidence paths are accepted: + +1. **Code inspection** — `file:line` citations for a structural defect (module-scoped state, missing constraint, race window across an `await`, unguarded shared resource). +2. **Runtime test** — a command that exercises the claim (`bun test`, `bun run typecheck`, a CLI invocation, a `/tmp` scratch script). +3. **Invariant test** — pins down the property the fix will rely on (e.g. "N concurrent callers → exactly 1 succeeds"). Preferred over synthetic race repros because it survives the fix as a regression guard. + +"Race condition we can't trigger" alone is not a valid escape hatch. + +## Outputs + +| Field | Type | Notes | +| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `state.valid` | boolean | Verdict. | +| `state.confidence` | float `[0, 1]` | Agent's self-assessment. | +| `state.summary` | string | Verdict rationale; uncapped. Embedded into the failed-cascade reason when `valid = false`. | +| `state.recommendedNext` | `'plan'` \| `'stop'` | — | +| `state.evidence` | `Array<{file, line?, note?}>` | — | +| `state.reproduction` | `{attempted, reproduced, details}` | `attempted=false` for non-bug class. `reproduced=null` is allowed only after the harness ladder is walked AND an invariant test is ruled out. | +| `state.report` | markdown | The full `TRIAGE.md`. Embedded verbatim in the tracking comment. | + +## Stop conditions + +- Agent writes both `TRIAGE.md` and `TRIAGE_VERDICT.json`; the JSON validates against the Zod schema. +- `valid = false` → handler returns `failed` and any composite cascade halts here. +- Missing markdown, malformed JSON, or an SDK error → `failed` with a specific reason. + +There is no turn cap on triage — the agent runs until the verdict is honestly defensible. diff --git a/mkdocs.yml b/mkdocs.yml index cf5ad0d3..3077ddd1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -92,16 +92,30 @@ markdown_extensions: nav: - Home: index.md - - Setup: SETUP.md - - Architecture: ARCHITECTURE.md - - Configuration: CONFIGURATION.md - - Bot workflows: BOT-WORKFLOWS.md - - PR shepherding (`bot:ship`): SHIP.md - - Operator guides: - - Observability: OBSERVABILITY.md - - Triage: TRIAGE.md - - Daemon mode: DAEMON.md - - Deployment: DEPLOYMENT.md - - Extending: EXTENDING.md - - Contributing: CONTRIBUTING.md - - Changelog: CHANGELOG.md + - Use the bot: + - Invoking: use/invoking.md + - Workflows: + - Catalog: use/workflows/index.md + - bot:ship: use/workflows/ship.md + - bot:triage: use/workflows/triage.md + - bot:plan: use/workflows/plan.md + - bot:implement: use/workflows/implement.md + - bot:review: use/workflows/review.md + - bot:resolve: use/workflows/resolve.md + - Safety: use/safety.md + - Run the service: + - Local development: operate/setup.md + - GitHub App creation: operate/github-app.md + - Deployment: operate/deployment.md + - Configuration: operate/configuration.md + - Observability: operate/observability.md + - Runbooks: + - Daemon fleet: operate/runbooks/daemon-fleet.md + - Triage: operate/runbooks/triage.md + - Stuck bot:ship session: operate/runbooks/stuck-ship-intent.md + - Build on it: + - Architecture: build/architecture.md + - Extending: build/extending.md + - Conventions: build/conventions.md + - Contributing: build/contributing.md + - Changelog: changelog.md diff --git a/scripts/check-docs-sync.ts b/scripts/check-docs-sync.ts index 79d9c5ed..45aceab9 100644 --- a/scripts/check-docs-sync.ts +++ b/scripts/check-docs-sync.ts @@ -1,18 +1,19 @@ #!/usr/bin/env bun /** * Fails CI when a PR touches `src/workflows/**` without also updating - * `docs/BOT-WORKFLOWS.md` (FR-019 / SC-007 doc-sync guard). + * any page under `docs/use/workflows/`. * - * Reads the diff between `BASE_SHA..HEAD_SHA` (env vars set by CI) and - * compares the two path sets. Tests and markdown files under - * `src/workflows/` are exempt. + * Reads the diff between `BASE_SHA...HEAD_SHA` (env vars set by CI; + * three-dot range, so commits unique to the PR head relative to the + * merge base) and compares the two path sets. Tests and markdown + * files under `src/workflows/` are exempt. */ import { spawnSync } from "node:child_process"; import { exit } from "node:process"; const WORKFLOW_PATH = /^src\/workflows\//; const WORKFLOW_EXEMPT = /^src\/workflows\/.*\.(test\.ts|md)$/; -const DOC_PATH = /^docs\/BOT-WORKFLOWS\.md$/; +const DOC_PATH = /^docs\/use\/workflows\/.*\.md$/; function diffFiles(base: string, head: string): string[] { const res = spawnSync("git", ["diff", "--name-only", `${base}...${head}`], { @@ -36,15 +37,15 @@ const touchedDoc = files.some((f) => DOC_PATH.test(f)); if (touchedWorkflows.length > 0 && !touchedDoc) { console.error( [ - "❌ Doc-sync check failed (FR-019).", + "❌ Doc-sync check failed.", "", "The following src/workflows/ files changed without a matching", - "docs/BOT-WORKFLOWS.md update:", + "update under docs/use/workflows/:", ...touchedWorkflows.map((f) => ` - ${f}`), "", - "Update docs/BOT-WORKFLOWS.md in this PR, or mark the change as", - "test/docs-only by moving it under src/workflows/**/*.test.ts or", - "src/workflows/**/*.md.", + "Update the relevant docs/use/workflows/*.md page in this PR, or", + "mark the change as test/docs-only by moving it under", + "src/workflows/**/*.test.ts or src/workflows/**/*.md.", ].join("\n"), ); exit(1); diff --git a/src/config.ts b/src/config.ts index 82815a6f..64b8189a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -492,21 +492,6 @@ const configSchema = z // those target branches; the maintainer-facing rejection message // surfaces the offending branch name. shipForbiddenTargetBranches: shipForbiddenTargetBranchesField, - - // Feature flag — when true, the shepherding handler uses the structural - // probe verdict (`src/workflows/ship/verdict.ts` + `probe.ts`) as the - // terminal-readiness signal. When false, the legacy in-process - // review/resolve loop runs unchanged. Defaults off for safe rollout - // (research.md R8 cutover plan); flipped on after Phase 1+2 soak. - shipUseProbeVerdict: z.boolean().default(false), - - // Feature flag — when true, the shepherding handler releases the - // daemon slot between iterations and re-enters via continuation - // (Valkey `ship:tickle` + Postgres `ship_continuations`). When false, - // the legacy in-process loop holds the slot for the full session. - // Defaults off for safe rollout; flipped on after the probe verdict - // path is validated. - shipUseContinuationLoop: z.boolean().default(false), }) .superRefine((data, ctx) => { validateServerModeCredentials(data, ctx); @@ -800,14 +785,6 @@ function loadConfig(): Config { reviewBarrierSafetyMarginMs: process.env["REVIEW_BARRIER_SAFETY_MARGIN_MS"], fixAttemptsPerSignatureCap: process.env["FIX_ATTEMPTS_PER_SIGNATURE_CAP"], shipForbiddenTargetBranches: process.env["SHIP_FORBIDDEN_TARGET_BRANCHES"], - shipUseProbeVerdict: parseBooleanEnv( - "SHIP_USE_PROBE_VERDICT", - process.env["SHIP_USE_PROBE_VERDICT"], - ), - shipUseContinuationLoop: parseBooleanEnv( - "SHIP_USE_CONTINUATION_LOOP", - process.env["SHIP_USE_CONTINUATION_LOOP"], - ), }); assertOauthRequiresAllowlist(cfg); diff --git a/test/config.test.ts b/test/config.test.ts index 6f6a1341..8f167e41 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -283,8 +283,6 @@ describe("configSchema — ship workflow defaults", () => { expect(result.data.reviewBarrierSafetyMarginMs).toBe(1_200_000); expect(result.data.fixAttemptsPerSignatureCap).toBe(3); expect(result.data.shipForbiddenTargetBranches).toEqual([]); - expect(result.data.shipUseProbeVerdict).toBe(false); - expect(result.data.shipUseContinuationLoop).toBe(false); } }); @@ -435,27 +433,3 @@ describe("configSchema — SHIP_FORBIDDEN_TARGET_BRANCHES parsing", () => { if (result.success) expect(result.data.shipForbiddenTargetBranches).toEqual([]); }); }); - -describe("configSchema — SHIP_USE_* feature flags", () => { - it("defaults all three rollout flags to false", () => { - const result = configSchema.safeParse({ ...ANTHROPIC_BASE }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.shipUseProbeVerdict).toBe(false); - expect(result.data.shipUseContinuationLoop).toBe(false); - } - }); - - it("accepts boolean true overrides", () => { - const result = configSchema.safeParse({ - ...ANTHROPIC_BASE, - shipUseProbeVerdict: true, - shipUseContinuationLoop: true, - }); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.shipUseProbeVerdict).toBe(true); - expect(result.data.shipUseContinuationLoop).toBe(true); - } - }); -});