Skip to content

feat: triage-dispatch-modes Slice C — US1 MVP label/keyword routing (T014-T026) - #19

Merged
chrisleekr merged 20 commits into
mainfrom
20260415-000159-triage-dispatch-modes
Apr 15, 2026
Merged

chrisleekr merged 20 commits into
mainfrom
20260415-000159-triage-dispatch-modes

Conversation

@chrisleekr

@chrisleekr chrisleekr commented Apr 15, 2026

Copy link
Copy Markdown
Owner

Summary

Slice C of the triage-dispatch-modes feature — the US1 MVP. Label + keyword routing now works end-to-end against shared-runner and isolated-job targets, with auto-mode triage still deferred to US2 (Slice D).

Follows PR #18 (Slice B foundational). Upcoming: D (US2 auto-triage), E (US3 capacity), F (US4 telemetry), G (polish).

What lands

Task Artefact
T015 + T017 Pure classifyStatic(ctx) in src/k8s/classifier.ts. Label precedence (bot:job → isolated-job, bot:shared → shared-runner, bot:job wins when both present), then word-bounded keyword match (docker / compose / dind), else ambiguous. 16 tests, 100% line + function coverage.
T021 + T022 resolveAllowedTools extended with the in-pod branch — when AGENT_JOB_MODE=isolated-job OR AGENT_CONTEXT_B64 is non-empty, grants Bash(docker:* / docker-compose:* / npm:* / npx:* / bun:* / bunx:* / make:* / sh:* / bash:* / cp:* / mv:*). 5 new test cases including a regression guard for the inline / daemon / shared-runner negative paths.
T023 decideDispatch now invokes classifyStatic for non-inline modes; clear classifications win over the static-default echo. Inline mode short-circuits the classifier (operator's explicit AGENT_JOB_MODE=inline is itself the strong signal).
T018 + T014 dispatchToSharedRunner(ctx, decision) posts to ${INTERNAL_RUNNER_URL}/internal/run with X-Internal-Token shared-secret auth, maps every documented status code to a typed SharedRunnerError (validation / unauthorized / duplicate / at-capacity / internal / timeout / network / unconfigured), retries once on 429 per contract. Contract test pins the typed-error surface + the unconfigured guard.
T019 spawnIsolatedJob(ctx, decision) in src/k8s/job-spawner.ts. Lazy K8s client init (loadFromCluster() in-cluster vs loadFromDefault() out-of-cluster); infra-absent thrown before any network call when neither env is set. Builds the V1Job per research.md R8: backoffLimit:0, ttlSecondsAfterFinished, activeDeadlineSeconds:1800, init container waits for Docker daemon, claude-agent + docker:27-dind sidecar with shared emptyDir, resource requests 500m/1Gi → limits 2000m/4Gi.
T020 src/k8s/job-entrypoint.ts — runs INSIDE the spawned pod via bun run src/k8s/job-entrypoint.ts. Decodes BotContext from AGENT_CONTEXT_B64, re-mints Octokit from GitHub App credentials, invokes runInlinePipeline, exits 0/1. Defensive asString / asNumber decoding so a malformed envelope fails cleanly instead of crashing.
T024 dispatch() switch lights up the three target branches: daemon → existing dispatchNonInline (Phase 2 path, unchanged), shared-runner → dispatchToSharedRunner, isolated-job → spawnIsolatedJob. NotImplementedError class kept as a typed surface for future targets.
T025 FR-018 graceful rejection: isolated-job + JobSpawnerError(infra-absent)recordInfraAbsentRejection writes a rejection execution row with dispatch_target="isolated-job" and posts a tracking comment explaining the platform won't silently downgrade. Other JobSpawnerError kinds bubble to the runtime-failure path.

Deferred to in-PR follow-ups

  • T016 — integration test for US1 dispatch cascade: needs a richer mock harness (GitHub API + Claude Agent SDK + K8s client). The unit tests + the contract test + the updated router tests give reasonable coverage; the integration test is the next thing I'll add if reviewers want it before merge.
  • T026 — JSDoc audit pass: the new exports in classifier.ts, shared-runner-dispatcher.ts, job-spawner.ts, job-entrypoint.ts already carry JSDoc with @param / @returns / @throws per constitution §VIII. A formal one-pass review of every touched file is the polish step.

Checks

  • bun run check → typecheck ✓, lint 0 errors, format ✓, 24/24 test files pass (was 22 in Slice B; added classifier + shared-runner contract).
  • git diff --stat main...HEAD (Slice C only) → 7 files changed, ~1100 LOC including tests.

Known-safe behavioural changes

  • The old auto-mode behaviour where dispatchNonInline mapped autoshared-runner is gone — decideDispatch now resolves auto into a concrete target via defaultDispatchTarget, and the dispatch_mode execution-row column reflects that target. Existing tests that asserted dispatchMode === "shared-runner" for auto mode were re-pointed to test daemon mode (the path they were actually exercising via dispatchNonInline).
  • The "isolated-job throws NotImplementedError" Slice B test was removed (no longer accurate — we now actually call spawnIsolatedJob). Replaced with a positive test that asserts the FR-018 graceful-rejection comment is posted when K8s auth is absent.

Test plan

  • bun run check green
  • Existing CI checks expected to pass
  • CodeRabbit + Copilot review — will address blocking comments before merge
  • Manual smoke (shared-runner): set AGENT_JOB_MODE=shared-runner, INTERNAL_RUNNER_URL=…, INTERNAL_RUNNER_TOKEN=…; trigger a webhook with bot:shared label; verify the dispatch-decision log shows dispatchTarget=shared-runner, dispatchReason=label, triageInvoked=false and the dispatcher hits /internal/run.
  • Manual smoke (isolated-job, infra-absent): set AGENT_JOB_MODE=isolated-job without KUBERNETES_SERVICE_HOST / KUBECONFIG; trigger a bot:job-labelled webhook; verify the rejection comment posts and no Job is created.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added support for isolated Kubernetes job execution as a new dispatch target alongside existing options
    • Implemented automatic routing detection using labels and keywords (e.g., Docker, compose, dind) to determine optimal execution environment
    • Enhanced dispatch decision logic with static classification for deterministic, consistent routing
  • Tests

    • Added comprehensive test coverage for job isolation, dispatch routing, and execution flow validation

chrisleekr and others added 18 commits April 15, 2026 08:08
Add a PATCH-level carve-out under Technology Constraints > AI
orchestration permitting non-agent single-turn inference
(classification, embedding, summarisation) via `src/ai/llm-client.ts`
using the raw Anthropic or Bedrock SDKs. Multi-turn tool-using flows
remain on `@anthropic-ai/claude-agent-sdk`.

Unblocks the triage-dispatch-modes feature
(specs/20260415-000159-triage-dispatch-modes). The triage call is a
single-turn no-tool classification; the prior blanket ban targeted
agent-loop bypasses, not pure inference. Carve-out is guarded by the
circuit-breaker, latency, and cost requirements in FR-020 / SC-003 /
SC-005 of that spec.

Per §Amendment Procedure this ships as a standalone PR ahead of any
feature code that depends on the carve-out.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Drop specific file reference (`src/ai/llm-client.ts`) from the
  policy bullet — the constitution states policy, not paths. Adaptor
  module location is implementation detail, still constrained to the
  `src/ai/` namespace.
- Drop forward reference to an unmerged spec from the policy bullet
  (Sync Impact Report still carries it as historical rationale).
- Tighten the carve-out's enforcement contract to require all three
  layers: spec documentation, runtime guards in the adaptor, and
  fail-fast Zod config validation at startup. Addresses CodeRabbit's
  concern that "spec enforces" is too weak for a runtime invariant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bundle the spec artefacts for the triage-and-dispatch-modes feature
branch so subsequent implementation commits have the design documents
they reference.

- spec.md: 21 functional requirements, 4 user stories, 5 dispatch
  targets, edge cases with label-vs-keyword precedence
- plan.md: 7 phases mapped to 63 tasks
- research.md: R1–R10 decisions (Haiku triage, dual provider, K8s
  config, pending queue mechanics, circuit breaker, maxTurns mapping)
- data-model.md: §3 triage schema, §4 schema extension, §6 pending
  queue entry, §7 config surface (16 env vars)
- quickstart.md: Mermaid dispatch cascade, 4 smoke test scenarios
- contracts/: triage JSON Schema, shared-runner /internal/run, dispatch
  telemetry log + 4 FR-014 aggregate SQL queries
- checklists/requirements.md: 16/16 passing
- tasks.md: T000–T060 plus T031a/b across 7 phases
- CLAUDE.md: Active Technologies updated with the two new runtime deps
  (automated output of update-agent-context.sh during speckit.plan)

No source code changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements T001 (add runtime deps), T002 (verify @types/node dev dep —
already present at ^20.19.39, no-op), and T003 (baseline bun run check
green) for the triage-dispatch-modes feature.

- `@anthropic-ai/bedrock-sdk@^0.28.1` — Bedrock path for the forthcoming
  `src/ai/` adaptor (data-model §7 AWS env vars).
- `@kubernetes/client-node@^1.4.0` — `BatchV1Api` for in-cluster Job
  spawning (research.md R3 + R8).

Also fix the `check` script: it invoked `bun test` (Bun's single-process
runner) instead of `bun run test`, which triggers the required
`scripts/test-isolated.sh` wrapper that runs each test file in its own
Bun process to avoid mock.module() bleed. CI already uses `bun run test`
so CI was green; the bug only surfaced when developers ran
`bun run check` locally. The fix brings local quality gate into
alignment with CI and constitution §Quality Gate.

Baseline verified: `bun run check` → typecheck ✓, lint (0 errors,
45 pre-existing warnings), format ✓, 21/21 test files pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extend the Zod config schema with the dispatch-target vocabulary and the
seven new env vars the feature needs. Aligns config with the canonical
DispatchTarget enum from data-model.md so downstream code can stop
guessing at legacy names.

Enum / rename changes:
- AGENT_JOB_MODE gains "daemon", renames "ephemeral-job" to "isolated-job"
- DEFAULT_DISPATCH_MODE -> DEFAULT_DISPATCH_TARGET (widened; auto mode
  may not fall back to inline)
- SHARED_RUNNER_URL -> INTERNAL_RUNNER_URL (pairs with existing
  INTERNAL_RUNNER_TOKEN; shared-runner / auto require both)

New env vars:
- TRIAGE_TIMEOUT_MS=5000
- TRIAGE_MAXTURNS_TRIVIAL=10 / _MODERATE=30 / _COMPLEX=50 (FR-008a)
- DEFAULT_MAXTURNS=30
- MAX_CONCURRENT_ISOLATED_JOBS=3
- PENDING_ISOLATED_JOB_QUEUE_MAX=20

Cross-field validation via Zod superRefine; isolated-job without K8s
auth logs a startup warning instead of failing parse (so other targets
remain usable). Caller update: connection-handler now reads
config.triageMaxTurnsComplex. Removed parseMaxTurnsEnv and its tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Cover the seven new env vars, the two cross-field validators, and the
K8s-auth startup-warning path introduced in T004. Brings src/config.ts
line coverage to 92.77% — meets the ≥90% bar for security-critical
modules per constitution §V.

- New describe: defaults for triageTimeoutMs / maxConcurrentIsolatedJobs
  / pendingIsolatedJobQueueMax, string-coercion for all seven numeric
  env vars, and a zero-value rejection table driving each of them.
- New describe: auto-mode-requires-non-inline-default — positive and
  negative cases for FR-003.
- New describe: shared-runner auth gate — URL missing, token missing,
  auto-mode inheritance (auto can route to shared-runner), and the
  negative "daemon / isolated-job don't require these creds" cases.
- New describe: warnIfIsolatedJobWithoutKubernetesAuth — uses a
  scoped-env helper that captures console.warn, asserts warn fires
  iff mode ∈ {isolated-job, auto} AND neither KUBERNETES_SERVICE_HOST
  nor KUBECONFIG is set, and no-ops otherwise. Restores original env +
  console.warn in the finally block so the in-process test runner
  can't leak state across tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extends `executions` with the five dispatch-decision columns from
data-model.md §4 (dispatch_target, dispatch_reason, triage_confidence,
triage_cost_usd, triage_complexity) and introduces the new triage_results
table per §3. Both sets of columns use CHECK constraints that mirror
the DispatchTarget / DispatchReason / complexity / provider enums — kept
in sync with src/shared/dispatch-types.ts (T007) by convention.

Defaults on the two new NOT NULL columns ('inline' / 'static-default')
backfill pre-existing rows on ALTER TABLE ADD COLUMN, which correctly
describe historical behaviour: rows written before this migration used
the inline pipeline with no triage cascade.

Filename is 003_ rather than 002_ because migration 002_repo_knowledge
from PR #14 already claimed the 002 slot; dispatch_decisions sorts
after repo_knowledge on the migration runner's alphabetical ordering.

Note: dispatch_mode (from migration 001) is kept for backward compat;
new code writes dispatch_target. A future migration may consolidate.

Tests: update the migrate test suite to (a) drop triage_results in
the before/afterAll cleanup so reruns don't leak state, (b) expect
three applied migrations instead of two, (c) verify the five new
executions columns are present with their canonical defaults, and
(d) verify the triage_results schema has every required column with
correct nullability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single source of truth for the dispatch vocabulary used by the router,
the DB migration, the tracking-comment "why here?" line, and the
dispatch-telemetry log contract. Canonical lists live in
data-model.md §1 and §2.

- DispatchTarget: string-literal union over the four concrete targets
  ("inline" | "daemon" | "shared-runner" | "isolated-job"). "auto" is
  deliberately excluded — it's a platform-wide config mode, never a
  target. Mirrors the executions.dispatch_target CHECK constraint from
  migration 003.
- DispatchReason: eight-value union per FR-010 (label, keyword, triage,
  default-fallback, triage-error-fallback, static-default,
  capacity-rejected, infra-absent). Mirrors the
  executions.dispatch_reason CHECK constraint.
- Both exported with a *_SCHEMA Zod enum for runtime validation and a
  hot-path type guard (isDispatchTarget / isDispatchReason) that
  short-circuits on typeof===string before the array lookup.
- Named exports only per constitution §VIII and spec T007.

Tests (test/shared/dispatch-types.test.ts): 11 cases covering
canonical-order assertions, positive/negative Zod acceptance (legacy
"ephemeral-job" explicitly rejected to prevent drift), comprehensive
near-miss typo rejection on DispatchReason, and a type-level narrowing
assertion on isDispatchTarget. 100% line + function coverage.

Note: spec T007/T008 nominally co-located the test under src/. Moved
to test/shared/ because the repo's test-isolated.sh runner only globs
`test/**/*.test.ts` — co-located tests would silently miss CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduce the one-line "why here?" renderer required by SC-007. Takes
a DispatchReason + DispatchTarget and emits a human-readable sentence
suitable for the tracking comment header. Exhaustively switched on
the eight canonical DispatchReason values from src/shared/dispatch-types.

- Happy-path reasons ("label", "keyword", "triage", "default-fallback",
  "triage-error-fallback", "static-default") open with "Routed to
  `<target>` …" so operators can see both the landing and the reason
  at a glance.
- Rejection reasons ("capacity-rejected", "infra-absent") deliberately
  do NOT use "Routed", since nothing was routed. Regression-guarded by
  a dedicated assertion in the test.
- Triage detail (confidence, complexity, rationale) is NOT rendered
  here — that belongs to `renderTriageSection` in US2 (T037), which
  can be composed into the comment body when a TriageResult exists.
  This keeps the helper usable in rejection paths with no triage row.

Tests (4 new cases, appended to the existing suite):
- Exhaustive non-empty / deduplication / single-line sanity check over
  every DispatchReason value.
- Every (reason × target) combination includes the target name
  verbatim in the output.
- Spot-check regression guard ensuring each reason's output contains
  the operator-facing keyword that distinguishes it from its
  neighbours ("below threshold", "unavailable", "platform default",
  "capacity", "infrastructure").
- Rejection-reasons-don't-say-Routed guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduce the routing layer that Slices C/D/E light up. Slice B
delivers only the scaffolding + telemetry log + the inline / daemon /
shared-runner branches that already exist on main — isolated-job
surfaces as NotImplementedError (wired in US1 T019) and auto mode
resolves into a concrete target via the configured default
(triage layered on in US2 T035).

Public surface added to src/webhook/router.ts:
- `class NotImplementedError extends Error` — named subclass carrying
  the attempted `target: DispatchTarget` so log lines and catch blocks
  can distinguish "feature not shipped" from an unexpected failure.
- `interface DispatchDecision { target, reason, maxTurns }` — in-memory
  record matching data-model.md §5. `triage` / `complexity` come later.
- `async decideDispatch(ctx)` — pure config-echo for now. Keeping it
  `async` from day 1 avoids a call-site signature thrash when US2
  wires in the triage LLM await.
- `async dispatch(ctx, decision)` — switch on target. Inline branch
  owns the original inline-pipeline code verbatim (DB create-execution
  + runInlinePipeline). daemon / shared-runner delegate to the
  pre-existing `dispatchNonInline` helper (no behaviour change).
  isolated-job throws NotImplementedError("isolated-job").

processRequest changes:
- Calls `decideDispatch` after concurrency check, before dispatching.
- Emits the canonical "dispatch decision" pino.info log per
  contracts/dispatch-telemetry.md §1. Slice B omits triage fields
  (triageInvoked:false); US2 T036 extends.
- Concurrency-slot release moved to respect the decision target:
  inline decrements on success/failure; daemon/shared-runner keep the
  slot until the daemon's job:result message (unchanged semantics).
- NotImplementedError is caught at the processRequest boundary so
  operators see a single structured error log instead of an uncaught
  throw poisoning the webhook listener.

Tests (appended to the existing router.test.ts):
- decideDispatch returns {inline, static-default, maxTurns>0} against
  the test-env default AGENT_JOB_MODE=inline.
- dispatch(isolated-job) throws NotImplementedError; assertion checks
  both the instanceof chain (NotImplementedError + Error) and the
  typed target / name / message fields.
- processRequest emits the dispatch-decision log with the exact fields
  from §1 of the telemetry contract (deliveryId, owner, repo,
  dispatchTarget, dispatchReason, triageInvoked:false).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implementation fixes:
- src/db/migrations/003_dispatch_decisions.sql: tighten rationale
  CHECK to `LENGTH >= 1 AND <= 500` so empty strings cannot slip in.
  Aligns with the Zod TriageResponseSchema (min 1). Also fix stale
  header comment "002_…" → "003_…".
- src/orchestrator/connection-handler.ts:501: fall back to
  `config.defaultMaxTurns` (30) instead of `config.triageMaxTurnsComplex`
  (50) when agentMaxTurns is unset and triage didn't run. FR-008a says
  DEFAULT_MAXTURNS applies to unknown / triage-skipped cases — the old
  mapping over-allocated turns.
- src/webhook/router.ts: `dispatch()` now passes the resolved
  DispatchDecision into `dispatchNonInline()`, and the execution row's
  dispatch_mode column reflects `decision.target` rather than the raw
  `config.agentJobMode`. Behaviour identical in Slice B (decideDispatch
  echoes the config); pre-empts a latent bug for Slice C where
  label/keyword rules can flip the target away from the global mode.
- src/webhook/router.ts: `decideDispatch` docstring updated to match
  the actual Slice B behaviour (auto mode resolves to
  defaultDispatchTarget, not NotImplementedError).

Spec + docs sync:
- Bulk-rename 002_dispatch_decisions → 003_dispatch_decisions across
  research.md, plan.md, quickstart.md, tasks.md (the actual file lives
  at 003 because 002 was already taken by repo_knowledge).
- Bulk-rename ephemeral-job → isolated-job in research.md (R5, R8 had
  pre-rename wording).
- contracts/dispatch-telemetry.md §5.2: SQL FILTER clause now includes
  `triage-error-fallback` so the triage-invocation metric doesn't
  under-count attempts that failed over to the default.
- data-model.md §1: source-of-truth for DispatchTarget corrected to
  src/shared/dispatch-types.ts (was incorrectly pointing at src/config.ts).
- spec.md FR-010: canonical enum now uses `label` / `keyword`
  (matching impl + data-model §2) instead of the earlier
  `label-override` / `keyword-match`.
- tasks.md: absolute /Users/chrislee/... paths replaced with
  repo-relative; task-count line corrected to 63.
- .env.example: "JSON" heading replaced with "scalar env vars (FR-008a)".
- CLAUDE.md: Bun version floor bumped from ≥1.3.8 to ≥1.3.12 to match
  the `packageManager` pin.

`bun run check` → typecheck ✓, lint 0 errors, format ✓, 22/22 files pass.
`bun test test/db/migrate.test.ts` → 6/6 pass against a fresh DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…017)

Pure, deterministic, free-of-cost function that implements steps 1 and 2
of the FR-003 dispatch cascade: label override → keyword match → defer.
Returns a discriminated union so callers can TypeScript-narrow on
outcome without runtime casts.

Precedence (FR-016, spec edge case):
- `bot:job` label → isolated-job (reason: label)
- `bot:shared` label → shared-runner (reason: label)
- `bot:job` wins when both labels are present — container-capable is
  the stricter environment the user asked for at least part of
- Keyword rules apply only when no label matched (labels always win)
- `docker` / `compose` / `dind` as whole words (case-insensitive) →
  isolated-job (reason: keyword). Whole-word bound prevents false
  positives on identifiers like `composer.json` or substrings like
  `dindee`.
- Event-type heuristic left as an explicit fall-through placeholder
  (research.md R1 left this open); pins the current behaviour so
  adding a rule later is a deliberate tested change.
- Otherwise: `{outcome: "ambiguous"}` — caller consults
  defaultDispatchTarget or triage (auto mode only)

Tests: 16 cases, 100% line + function coverage. Covers every label
path, every keyword rule, label+keyword conflict, empty trigger body,
substring-within-identifier negatives, URL edge case (hyphens/slashes
are word boundaries → `/docker-tutorial` DOES match), purity /
idempotence, no input mutation, TypeScript narrowing assertions, and
a regression guard on the event-type fall-through.

Next (T023): wire classifyStatic into decideDispatch so the router
honours labels + keywords before the config echo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extend resolveAllowedTools with the in-pod branch: when the runtime
detects it's executing inside an isolated-job pod (AGENT_JOB_MODE=
isolated-job OR AGENT_CONTEXT_B64 non-empty), grant the container
tooling that's deliberately denied to inline / daemon / shared-runner.

Tools added inside the pod:
- Bash(docker:*) / Bash(docker-compose:*) — DinD sidecar invocations
- Bash(npm:*) / Bash(npx:*) / Bash(bun:*) / Bash(bunx:*) — package
  managers Claude can use during a build / publish flow
- Bash(make:*) — common build orchestrator
- Bash(sh:*) / Bash(bash:*) — shell escape hatches Claude needs when
  the explicit allow-list doesn't cover a tool
- Bash(cp:*) / Bash(mv:*) — file ops outside the workspace, e.g. when
  staging build artefacts for a release

Detection via process.env (not config) because this matters only at
the pod's runtime — the orchestrator never calls resolveAllowedTools
with isolated-job intent. Either signal alone suffices, making the
function robust to a partially populated env.

Tests (5 cases, withEnv helper restores process.env on each exit):
- No container tools when both env vars are unset (regression guard)
- Full set when AGENT_JOB_MODE='isolated-job'
- Full set when AGENT_CONTEXT_B64 is non-empty (orthogonal trigger)
- No container tools for AGENT_JOB_MODE in {inline, daemon,
  shared-runner} — verifies the branch fires only for isolated-job
- isolated-job tools come from env alone, independent of the
  daemonCapabilities branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Steps 1+2 of the dispatch cascade are now live: a webhook with a
recognised label or keyword routes to the matching target with reason
"label" or "keyword", bypassing the static-default echo. Inline mode
skips classification entirely — inline never benefits from container
or shared targets, and the operator's explicit AGENT_JOB_MODE=inline
is itself the strong signal.

Behaviour:
- AGENT_JOB_MODE != "inline" AND classifier returns clear → use the
  classified target + reason ("label" or "keyword")
- Otherwise: same Slice B static-default path (echo agentJobMode, or
  defaultDispatchTarget for "auto")

Triage (step 3) remains short-circuited until US2 T035 lands. Auto
mode still falls back to defaultDispatchTarget, gated by the config
invariant `auto ⇒ default ≠ inline` so it can never silently downgrade.

Test changes: none required — existing router tests assert behaviour
on inline mode (test env default) where the classifier is skipped.
US1's classifyStatic-driven paths are exercised end-to-end by T016
integration test (next).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements `dispatchToSharedRunner(ctx, decision): Promise<ExecutionResult>`
matching contracts/shared-runner-internal.md:

- POSTs to ${INTERNAL_RUNNER_URL}/internal/run with X-Internal-Token
  shared-secret auth, X-Request-Id = deliveryId for correlation, and a
  request body assembled from BotContext + DispatchDecision (deliveryId,
  serialised botContext, maxTurns, traceFields).
- Maps every documented status code to a typed `SharedRunnerError`:
    400 → validation, 401 → unauthorized, 409 → duplicate,
    429 → at-capacity (one retry with 250ms back-off per contract),
    500 → internal, 504 → timeout, fetch-throw → network,
    config missing → unconfigured (defensive — Zod superRefine should
    have caught this at startup).
- Runtime type guards (no per-call Zod parse) over the success / error
  envelopes, with synthetic fallback envelopes for non-JSON or malformed
  responses. Trusted infra inside the cluster, so this is the right
  speed/safety trade-off — Zod would burn ~0.5ms per call for no
  meaningful safety on a known consumer.

The dispatcher does NOT own tracking-comment updates or the executions
DB row — those stay with the router/inline-pipeline layer so the
DispatchDecision is in scope when they're written.

Contract test (T014): pins the typed-error surface (every `kind` value
maps 1:1 to a documented status) and the unconfigured guard. Full
fetch-stub coverage is deferred to T016 integration tests, which will
exercise the dispatcher inside the router with controlled env that can
supply INTERNAL_RUNNER_URL/TOKEN before the config singleton parses.

24 test files pass; new file at 100% line coverage where exercised.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…20,T024,T025)

Lights up the isolated-job target end-to-end: the router now resolves
label `bot:job` (or docker / compose / dind keywords) into a real
Kubernetes Job submission instead of NotImplementedError.

src/k8s/job-spawner.ts (T019, research.md R3 + R8):
- Lazy K8s client init: KubeConfig.loadFromCluster() when
  KUBERNETES_SERVICE_HOST is set, KubeConfig.loadFromDefault() when
  KUBECONFIG is set, else throws JobSpawnerError("infra-absent")
  before any network call. Cached on first success.
- buildJobSpec produces the V1Job per R8: backoffLimit:0,
  ttlSecondsAfterFinished, activeDeadlineSeconds:1800, init container
  waits for Docker daemon, claude-agent container runs the entrypoint
  with AGENT_JOB_MODE=isolated-job + AGENT_CONTEXT_B64, docker:27-dind
  privileged sidecar with shared emptyDir for /var/lib/docker, agent
  reaches it via DOCKER_HOST=tcp://localhost:2375. Resource requests
  500m/1Gi → limits 2000m/4Gi.
- Provider env forwarding mirrors the inline executor's
  buildProviderEnv: anthropicApiKey OR claudeCodeOauthToken OR Bedrock
  triple, exactly one path active per Zod-validated config.
- Typed JobSpawnerError surface with four kinds: infra-absent,
  auth-load-failed, api-rejected (4xx), api-unavailable (5xx /
  network). Status code is duck-typed off the K8s client's thrown
  response object.
- Returns synthetic ExecutionResult marker on submit; real cost /
  duration / turns are written to the executions row by the
  entrypoint when execution settles. Watch loop with timeout is US3
  (T046).

src/k8s/job-entrypoint.ts (T020):
- New executable invoked from inside the Job pod via
  `bun run src/k8s/job-entrypoint.ts`.
- Decodes BotContext from AGENT_CONTEXT_B64 (base64+JSON), with
  defensive `asString` / `asNumber` helpers so a malformed envelope
  exits 1 cleanly rather than crashing.
- Re-mints an Octokit instance from the GitHub App credentials
  (forwarded via env, not the original installation token — too
  short-lived to survive pod schedule latency).
- Invokes runInlinePipeline; exits 0 / 1 so K8s marks the Job
  succeeded / failed appropriately.

src/webhook/router.ts (T024, T025):
- dispatch() switch lights up the three target branches:
    case "daemon"        → existing dispatchNonInline (Phase 2 path)
    case "shared-runner" → new dispatchToSharedRunner (T018, HTTP)
    case "isolated-job"  → new spawnIsolatedJob (T019, K8s Job)
- FR-018 graceful rejection (T025): isolated-job + JobSpawnerError of
  kind "infra-absent" → recordInfraAbsentRejection writes an
  execution row with dispatch_target="isolated-job" and posts a
  tracking comment explaining the platform won't silently downgrade
  to a different target. Other JobSpawnerError kinds (auth-load,
  api-rejected, api-unavailable) bubble to processRequest's catch
  for the standard runtime-failure path.
- NotImplementedError class kept as a typed surface for any future
  target that ships with the same scaffolding pattern (no current
  callers, but cheap to keep).

Tests:
- Existing non-inline router tests (8 cases) re-pointed from
  agentJobMode="shared-runner"/"auto" to "daemon" — they were
  exercising the dispatchNonInline orchestrator path, which is now
  daemon-only. Test descriptions and assertions updated to match.
- Replaced the "throws NotImplementedError for isolated-job" test
  with two new cases:
    * dispatch(isolated-job) in a test env without K8s auth resolves
      (no throw) and posts the FR-018 rejection comment containing
      "isolated-job" + "not currently configured"
    * NotImplementedError remains a typed export with name + target
      fields for future targets

24 test files pass; 0 errors, 0 format issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 15, 2026 08:57
@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@chrisleekr has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 42 minutes and 58 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 42 minutes and 58 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2eaa2e78-a29e-45ac-aa9e-30cf65c3d0da

📥 Commits

Reviewing files that changed from the base of the PR and between 4ec9b09 and 251ecdb.

📒 Files selected for processing (7)
  • Dockerfile
  • src/k8s/job-entrypoint.ts
  • src/k8s/job-spawner.ts
  • src/k8s/shared-runner-dispatcher.ts
  • src/orchestrator/history.ts
  • src/webhook/router.ts
  • test/k8s/classifier.test.ts
📝 Walkthrough

Walkthrough

This PR introduces an isolated Kubernetes Job execution mode for webhook event processing. It adds a static event classifier to determine dispatch targets via labels or keywords, a Kubernetes job spawner to submit jobs, an in-pod entrypoint to orchestrate execution, a shared-runner HTTP dispatcher, and integrates these components into the webhook router. Tool resolution is extended to include container-related Bash tools in isolated job mode, detected via environment variables.

Changes

Cohort / File(s) Summary
Kubernetes Job Execution Infrastructure
src/k8s/job-spawner.ts, src/k8s/job-entrypoint.ts, src/k8s/classifier.ts
Implements isolated Kubernetes Job submission and execution. Job spawner constructs Job manifests with init container, main agent container, and docker-dind sidecar; handles auth via kubeconfig/in-cluster detection; exports typed error kinds (infra-absent, auth-load-failed, api-rejected, api-unavailable). Entrypoint decodes base64-serialized BotContext, reconstructs Octokit credentials, normalizes context fields, invokes inline pipeline, and exits with appropriate status. Classifier provides deterministic label/keyword-based dispatch routing (isolated-job vs shared-runner) with precedence rules and regex-based whole-word keyword matching.
Shared-Runner Dispatch
src/k8s/shared-runner-dispatcher.ts
Dispatches events to internal runner via POST to /internal/run. Implements response parsing with runtime type guards, HTTP status-to-error-kind mapping (400→validation, 401→unauthorized, 409→duplicate, 500→internal, 504→timeout), single-retry logic for 429 at-capacity responses, and exports typed SharedRunnerError with optional status and executionId fields.
Integration & Tool Resolution
src/webhook/router.ts, src/core/prompt-builder.ts
Router integrates static classification via classifyStatic() when non-inline, routes shared-runner dispatch to new dispatcher, replaces isolated-job NotImplementedError with spawnIsolatedJob() wrapped in infra-absence error handling that posts rejection comments. Prompt builder conditionally appends container-related Bash tools (docker, docker-compose, npm, npx, bun, bunx, make, sh, bash, cp, mv) when AGENT_JOB_MODE="isolated-job" or AGENT_CONTEXT_B64 is non-empty.
Test Suite
test/k8s/classifier.test.ts, test/k8s/job-entrypoint.ts, test/k8s/job-spawner.ts, test/contract/shared-runner-internal.test.ts, test/core/prompt-builder.test.ts, test/webhook/router.test.ts
Comprehensive tests validating classifier label/keyword precedence and purity, job spawner spec construction and error handling, job entrypoint context deserialization, shared-runner error contract (8 error kinds), isolated-job tool inclusion via environment variables, and router dispatch routing with infra-absence comment posting.

Sequence Diagram(s)

sequenceDiagram
    participant Webhook as Webhook Event
    participant Router as Router
    participant Classifier as Static Classifier
    participant Spawner as Job Spawner
    participant K8sAPI as Kubernetes API
    participant Entrypoint as Job Entrypoint
    participant Pipeline as Inline Pipeline

    Webhook->>Router: POST /webhook
    Router->>Classifier: classifyStatic(ctx)
    Classifier-->>Router: { outcome: "clear", mode: "isolated-job"|"shared-runner", reason: "label"|"keyword" }
    
    alt isolated-job target
        Router->>Spawner: spawnIsolatedJob(ctx, decision)
        Spawner->>K8sAPI: createNamespacedJob(jobSpec)
        K8sAPI-->>Spawner: Job created
        Spawner-->>Router: ExecutionResult { success: true, durationMs: 0 }
    else shared-runner target
        Router->>Router: dispatchToSharedRunner(ctx, decision)
        Router->>Router: POST /internal/run
    end
    
    note over K8sAPI,Entrypoint: Pod Execution (Async)
    K8sAPI->>Entrypoint: Container starts
    Entrypoint->>Entrypoint: Decode AGENT_CONTEXT_B64
    Entrypoint->>Entrypoint: Reconstruct Octokit
    Entrypoint->>Pipeline: runInlinePipeline(ctx)
    Pipeline-->>Entrypoint: Result
    Entrypoint->>Entrypoint: Exit 0|1
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PR #18: Scaffolds decideDispatch, dispatch routing, and NotImplementedError for isolated-job; this PR implements the isolated-job execution path that was previously stubbed.
  • PR #14: Extends resolveAllowedTools with daemon-capabilities-driven MCP injection; this PR further extends tool resolution to include container Bash tools for isolated job pods.
  • PR #13: Establishes dual-mode dispatch foundation (inline vs non-inline) and introduces agentJobMode config; this PR builds on that foundation by implementing concrete isolated-job and shared-runner dispatch paths.

Poem

🐇 A job spawns in the K8s clouds so high,
With labels and keywords to classify,
Docker and compose tools bundled tight,
The rabbit's pipeline runs through the night!
hop hop — isolated jobs take flight! 🚀

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing US1 MVP label/keyword routing for triage-dispatch-modes, with specific task references (T014-T026) that align with the substantial feature work across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 20260415-000159-triage-dispatch-modes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements US1 MVP routing for the triage-dispatch-modes feature by adding deterministic label/keyword classification and wiring dispatch for shared-runner and isolated-job targets, with supporting K8s/shared-runner integrations and expanded tool allow-listing for in-pod execution.

Changes:

  • Added classifyStatic(ctx) for label/keyword-based dispatch target classification and integrated it into decideDispatch.
  • Implemented shared-runner HTTP dispatch with typed error mapping and a contract test for the error surface/unconfigured guard.
  • Added isolated-job K8s Job spawning + in-pod entrypoint, and extended resolveAllowedTools for isolated-job pods, with new unit tests.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/webhook/router.ts Routes via classifyStatic, dispatches to shared-runner/isolated-job, adds infra-absent rejection handling.
src/k8s/classifier.ts New deterministic label/keyword classifier producing clear vs ambiguous outcomes.
src/k8s/shared-runner-dispatcher.ts New dispatcher to shared-runner /internal/run with typed errors and one 429 retry.
src/k8s/job-spawner.ts New K8s Job spec builder + submitter for isolated-job execution.
src/k8s/job-entrypoint.ts New in-pod entrypoint to reconstruct context and run the inline pipeline.
src/core/prompt-builder.ts Expands tool allow-list when running inside an isolated-job pod.
test/webhook/router.test.ts Updates router tests for daemon-mode expectations and adds isolated-job infra-absent rejection test.
test/k8s/classifier.test.ts Adds unit tests for classifier label precedence, keyword matching, and purity guarantees.
test/core/prompt-builder.test.ts Adds unit tests for isolated-job tool allow-listing behavior.
test/contract/shared-runner-internal.test.ts Adds contract test for shared-runner dispatcher typed-error surface and unconfigured guard.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/webhook/router.ts Outdated
Comment on lines +318 to +321
* FR-018 rejection write-path. Persists an execution row with
* dispatch_reason="infra-absent" and updates the tracking comment so the
* maintainer sees a clear refusal. Never silently downgrades to a different
* target — that defeats the purpose of asking for isolated execution.

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc for recordInfraAbsentRejection claims it persists an execution row with dispatch_reason="infra-absent", but the createExecution call here only sets dispatchMode and the reason parameter is only used for logging. Either update the comment to match what’s actually persisted, or (if the schema now supports a dispatch-reason column) extend the execution write to store the rejection reason so analytics/debugging can rely on it.

Suggested change
* FR-018 rejection write-path. Persists an execution row with
* dispatch_reason="infra-absent" and updates the tracking comment so the
* maintainer sees a clear refusal. Never silently downgrades to a different
* target that defeats the purpose of asking for isolated execution.
* FR-018 rejection write-path. Records/logs an infra-absent isolated-job
* rejection and updates the tracking comment so the maintainer sees a clear
* refusal. Never silently downgrades to a different target that defeats
* the purpose of asking for isolated execution.

Copilot uses AI. Check for mistakes.
Comment thread test/k8s/classifier.test.ts Outdated
Comment on lines +102 to +110
it("does NOT match on keyword-within-URL (whole-word semantics)", () => {
// Word-boundary treats hyphens as word chars in some locales — we pass a
// URL-like string to make sure a random path containing "docker" still
// matches (because hyphens/slashes ARE word boundaries).
const result = classifyStatic(
makeCtx({ triggerBody: "see https://example.com/docker-tutorial" }),
);
// `docker` IS a whole word here (bounded by `/` and `-`), so this DOES match.
// The test pins the behaviour so the regex doesn't silently change under us.

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test case’s description/comment is internally contradictory: it says it “does NOT match on keyword-within-URL”, but the assertions below explicitly pin that it does match when bounded by / and -. Please rewrite the test name/comment to reflect the actual expected behavior so future readers don’t misinterpret what’s being pinned.

Suggested change
it("does NOT match on keyword-within-URL (whole-word semantics)", () => {
// Word-boundary treats hyphens as word chars in some locales — we pass a
// URL-like string to make sure a random path containing "docker" still
// matches (because hyphens/slashes ARE word boundaries).
const result = classifyStatic(
makeCtx({ triggerBody: "see https://example.com/docker-tutorial" }),
);
// `docker` IS a whole word here (bounded by `/` and `-`), so this DOES match.
// The test pins the behaviour so the regex doesn't silently change under us.
it("matches on keyword-within-URL when bounded by `/` and `-` (whole-word semantics)", () => {
// Pass a URL-like string to verify that `docker` still matches here,
// because it is a whole word bounded by `/` and `-`.
const result = classifyStatic(
makeCtx({ triggerBody: "see https://example.com/docker-tutorial" }),
);
// This test pins that behavior so the regex doesn't silently change under us.

Copilot uses AI. Check for mistakes.
Comment thread src/k8s/job-entrypoint.ts
Comment on lines +64 to +69
const owner = asString(decoded["owner"], "");
const repo = asString(decoded["repo"], "");
const entityNumber = asNumber(decoded["entityNumber"], 0);
const installationId = asNumber(decoded["installationId"], 0);
if (owner === "" || repo === "" || entityNumber === 0 || installationId === 0) {
log.error(

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

job-entrypoint requires installationId to be present in the decoded AGENT_CONTEXT_B64 payload, but the server-side context serialization (serializeBotContext) does not include an installationId field (it only strips octokit/log). As written, isolated-job pods will always fail the required-fields check and exit 1. Fix by either (a) looking up the installation ID inside the pod via app.octokit.rest.apps.getRepoInstallation({ owner, repo }) (similar to src/orchestrator/connection-handler.ts), or (b) extending the serialized context type to include installationId and ensuring it’s populated before encoding.

Copilot uses AI. Check for mistakes.
Comment thread src/k8s/job-spawner.ts
Comment on lines +161 to +170
{
name: "wait-for-docker",
image,
command: [
"sh",
"-c",
"until DOCKER_HOST=tcp://localhost:2375 docker info >/dev/null 2>&1; do sleep 1; done",
],
env: [{ name: "DOCKER_HOST", value: "tcp://localhost:2375" }],
},

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initContainer runs docker info but uses image (the bot’s Bun/Node image), which the repo’s Dockerfile does not install the Docker CLI into. This will make the initContainer fail immediately (docker: not found) and prevent the Job from starting. Use an image that contains the Docker client (e.g., a docker:* CLI image) or ensure the configured JOB_IMAGE includes a Docker CLI.

Copilot uses AI. Check for mistakes.
Comment thread src/k8s/job-spawner.ts
Comment on lines +173 to +178
{
name: "claude-agent",
image,
command: ["bun", "run", "src/k8s/job-entrypoint.ts"],
env: providerEnv,
volumeMounts: [{ name: "workspace", mountPath: "/workspace" }],

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The claude-agent container image is the same image used by the webhook server, but that image currently does not include the Docker CLI. Since isolated-job mode explicitly allows Bash(docker:*) / Bash(docker-compose:*), the agent will fail at runtime even though the sidecar provides the daemon. Ensure the job image bundles a Docker client (or otherwise make docker available in PATH) so allowed tools are actually usable.

Copilot uses AI. Check for mistakes.
Comment on lines +203 to +212
function isSuccessResponse(value: unknown): value is RunnerSuccessResponse {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
v["ok"] === true &&
typeof v["executionId"] === "string" &&
typeof v["costUsd"] === "number" &&
typeof v["durationMs"] === "number" &&
typeof v["turns"] === "number"
);

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSuccessResponse() doesn’t validate the status field even though RunnerSuccessResponse requires it and the caller uses parsed.body.status to decide success. A malformed 200 response missing status would currently be treated as "success" and reported as success: false instead of surfacing a protocol error. Tighten the type guard to require status to be either "success" or "failure" (and consider validating the other expected fields you rely on).

Copilot uses AI. Check for mistakes.
Comment thread src/webhook/router.ts Outdated
Comment on lines +295 to +297
case "shared-runner":
await dispatchToSharedRunner(ctx, decision);
return;

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dispatch() now routes shared-runner directly to dispatchToSharedRunner(), but unlike the inline/daemon paths it does not write an execution record first. This contradicts the dispatcher’s own contract comment (it says the caller writes the executions DB row) and means shared-runner runs won’t have the same DB/audit trail or context persistence. Consider creating the execution row (like dispatchNonInline does) before calling dispatchToSharedRunner (and handle DB-unconfigured similarly to other paths).

Suggested change
case "shared-runner":
await dispatchToSharedRunner(ctx, decision);
return;
case "shared-runner": {
const db = getDb();
if (db !== null) {
try {
const serializedCtx = serializeBotContext(ctx);
await createExecution({
deliveryId: ctx.deliveryId,
repoOwner: ctx.owner,
repoName: ctx.repo,
entityNumber: ctx.entityNumber,
entityType: ctx.isPR ? "pull_request" : "issue",
eventName: ctx.eventName,
triggerUsername: ctx.triggerUsername,
dispatchMode: "shared-runner",
contextJson: serializedCtx,
});
} catch (recordErr) {
ctx.log.error({ err: recordErr }, "Failed to create shared-runner execution record (non-fatal)");
}
}
await dispatchToSharedRunner(ctx, decision);
return;
}

Copilot uses AI. Check for mistakes.
Comment thread src/webhook/router.ts Outdated
Comment on lines +295 to +313
case "shared-runner":
await dispatchToSharedRunner(ctx, decision);
return;
case "isolated-job":
throw new NotImplementedError("isolated-job");
try {
await spawnIsolatedJob(ctx, decision);
} catch (err) {
// FR-018 graceful rejection (T025): infra-absent → write a rejection
// execution row + post a tracking-comment update, do NOT downgrade
// to a different target. Other JobSpawnerError kinds (auth-load,
// api-rejected, api-unavailable) bubble to the processRequest
// catch where they're logged as runtime failures.
if (err instanceof JobSpawnerError && err.kind === "infra-absent") {
await recordInfraAbsentRejection(ctx, decision, err.message);
return;
}
throw err;
}
return;

Copilot AI Apr 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active-concurrency accounting will leak for both shared-runner and isolated-job targets. processRequest() increments activeCount for every request, but for these targets there’s no decrement on success, and the infra-absent rejection path returns without decrementing (unlike dispatchNonInline, which manages the counter). This will eventually force the server into permanent “at capacity” rejections. Add an explicit decrementActiveCount() when the shared-runner call completes, and after isolated-job submission/rejection (or implement an equivalent completion signal for those targets).

Copilot uses AI. Check for mistakes.
@chrisleekr

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Seven issues surfaced by Copilot on the Slice C PR:

- router: shared-runner + isolated-job leaked the concurrency slot — only
  inline was decremented on return. Extend decrement to all targets whose
  ownership is handed off at this level; daemon still self-manages.
- router: shared-runner path now writes an executions row (matching inline)
  so the DB/audit trail is populated before the HTTP dispatch.
- history.createExecution: accept optional dispatchReason so rejection /
  classifier paths can persist the real reason (infra-absent, label, keyword)
  instead of falling through to the DB DEFAULT 'static-default'. Wired into
  all three router write-sites.
- router.recordInfraAbsentRejection: JSDoc now matches the persisted row
  (mode=isolated-job, reason=infra-absent).
- shared-runner-dispatcher.isSuccessResponse: tighten guard to require
  status ∈ {"success","failure"}; a malformed 200 missing status was being
  reported as success=false instead of surfacing a protocol error.
- job-entrypoint: BotContext carries no installationId, so the previous
  required-field check aborted every pod. Switch to resolving the
  installation via apps.getRepoInstallation (same pattern as
  orchestrator/connection-handler).
- job-spawner: initContainer ran `docker info` from the bot image (no
  docker CLI). Switch the wait-for-docker initContainer to docker:27-cli
  (same major as the dind sidecar).
- Dockerfile: copy the static `docker` binary from docker:27-cli into the
  production image so the claude-agent container in isolated-job pods can
  exercise the Bash(docker:*) allow-list against the dind sidecar.
- classifier.test: the URL-path test name said "does NOT match" while
  assertions pinned that it DOES match. Rewrote the name + body comment
  so the intent and assertions agree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@chrisleekr

Copy link
Copy Markdown
Owner Author

Addressed all 7 substantive findings from Copilot's review in 85e93e5:

# File Finding Fix
1 router.ts Concurrency leak: shared-runner & isolated-job never decrement activeCount Extended decrement logic in processRequest to all targets whose ownership is handed off at this level; daemon still self-manages via dispatchNonInline
2 router.ts shared-runner path didn't write an executions row Mirror the inline path — createExecution before HTTP dispatch
3 router.ts recordInfraAbsentRejection JSDoc claimed it persists dispatch_reason="infra-absent" but didn't Extended createExecution to accept optional dispatchReason; wired into all 3 router write-sites so the JSDoc is now literally true
4 shared-runner-dispatcher.ts isSuccessResponse didn't validate status field Tightened guard to require status ∈ {"success","failure"}
5 job-entrypoint.ts installationId never populated by serializeBotContext — pod always aborted Resolve installation via apps.getRepoInstallation inside the pod (same pattern as orchestrator/connection-handler.ts)
6 job-spawner.ts Init container ran docker info from the bot image (no docker CLI) Switch to docker:27-cli (same major as the dind sidecar)
7 Dockerfile Bot image has no docker CLI — isolated-job claude-agent container couldn't invoke Bash(docker:*) Copy static docker binary from docker:27-cli into production stage

Also rewrote the contradictory test name in classifier.test.ts:110 (nitpick).

Full check passes: tsc 0 errors, ESLint 0 errors, all 24 test files green. Waiting for CodeRabbit's fresh review (rate-limit window elapsed; re-triggered above).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
src/k8s/job-entrypoint.ts (1)

81-94: Consider if webhookSecret is strictly required for the App constructor.

The webhookSecret is used for webhook signature verification, not for obtaining installation tokens. The App constructor works without it when only calling getInstallationOctokit(). However, requiring it here maintains consistency with the server-side config validation and prevents misconfiguration.

This is not a bug — just noting the dependency is stricter than the minimum required by the Octokit SDK.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/k8s/job-entrypoint.ts` around lines 81 - 94, The current validation
enforces webhookSecret even though App can be constructed without it for
getInstallationOctokit(); update the check to only require config.appId and
config.privateKey, and when building the App instance pass webhookSecret
conditionally (include webhookSecret: config.webhookSecret only if defined) so
getInstallationOctokit still works without a webhook secret; reference
config.appId, config.privateKey, config.webhookSecret, the App constructor, and
getInstallationOctokit when making this change and add a brief inline comment
noting webhookSecret is only needed for webhook verification.
src/k8s/job-spawner.ts (2)

160-170: Init container uses application image for shell commands.

The init container uses the full application image (image variable) just to run a shell polling loop. This works but loads a larger image than necessary. Consider using a minimal image like busybox or docker:27-cli for the wait-for-docker check.

♻️ Suggested optimization
           initContainers: [
             {
               name: "wait-for-docker",
-              image,
+              image: "docker:27-cli",
               command: [
                 "sh",
                 "-c",
                 "until DOCKER_HOST=tcp://localhost:2375 docker info >/dev/null 2>&1; do sleep 1; done",
               ],
               env: [{ name: "DOCKER_HOST", value: "tcp://localhost:2375" }],
             },
           ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/k8s/job-spawner.ts` around lines 160 - 170, The init container
"wait-for-docker" is using the application image (image variable) just to run a
shell loop; change it to a minimal utility image (e.g., "busybox" or
"docker:27-cli") instead of using image to avoid pulling the large app image.
Update the initContainers entry (the one with name "wait-for-docker" in
job-spawner.ts) to set image to the chosen minimal image, keep the command and
env (DOCKER_HOST) intact, and adjust imagePullPolicy if needed for your
registry.

112-136: API credentials passed as plain environment variables.

Sensitive credentials (ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN) are passed directly as environment variables in the Job spec. While functional and common for short-lived Jobs, this means credentials are visible in kubectl describe job output and stored in etcd.

For improved security posture, consider referencing Kubernetes Secrets instead:

🔐 Example using secretKeyRef
// Instead of:
providerEnv.push({ name: "ANTHROPIC_API_KEY", value: config.anthropicApiKey ?? "" });

// Consider:
providerEnv.push({
  name: "ANTHROPIC_API_KEY",
  valueFrom: { secretKeyRef: { name: "claude-credentials", key: "anthropic-api-key" } }
});

This requires pre-creating the Secret in the namespace but prevents credential exposure in Job specs and kubectl output.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/k8s/job-spawner.ts` around lines 112 - 136, Replace the plain-text
environment variable entries for API credentials with secret references: instead
of pushing objects with value set from config.anthropicApiKey and
config.claudeCodeOauthToken into providerEnv via providerEnv.push(...), add
entries using valueFrom.secretKeyRef that reference a Kubernetes Secret (e.g.,
name "claude-credentials" with keys like "anthropic-api-key" and
"claude-code-oauth-token"); ensure the code paths that currently check
config.anthropicApiKey and config.claudeCodeOauthToken switch to creating
secret-based env entries and document/create the required Secret in the
namespace before Job creation so credentials are not embedded in the Job spec or
etcd.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/k8s/job-spawner.ts`:
- Around line 227-242: The createNamespacedJob call uses an object parameter but
the BatchV1Api.createNamespacedJob signature expects positional args; update the
call in job-spawner.ts to pass config.jobNamespace and jobSpec as positional
parameters to client.batch.createNamespacedJob (i.e.,
client.batch.createNamespacedJob(config.jobNamespace, jobSpec)) while leaving
the surrounding try/catch and JobSpawnerError handling (including status
extraction and message) intact.

In `@src/k8s/shared-runner-dispatcher.ts`:
- Around line 203-213: The type guard isSuccessResponse currently omits
validating the status field; update it to also check that v["status"] ===
"success" (matching RunnerSuccessResponse's allowed values) so the guard only
returns true for genuine success responses and prevents unsafe access to
parsed.body.status elsewhere (e.g., where parsed.body.status is read). Locate
and modify the isSuccessResponse function to include typeof v["status"] ===
"string" && v["status"] === "success" (or equivalent strict check) in the
returned predicate.

In `@src/webhook/router.ts`:
- Around line 298-314: Both dispatchToSharedRunner and spawnIsolatedJob paths
never release the concurrency slot that processRequest increments, causing slot
leaks on both success and error; update the isolated-job and shared-runner
dispatch code to call the same slot-release function used by the inline path
(e.g., releaseConcurrencySlot or decrementConcurrencySlot) immediately after
successful await dispatch and also in all error branches (including the
infra-absent branch that calls recordInfraAbsentRejection and before rethrowing
other JobSpawnerError kinds), so the slot is always released regardless of
outcome; modify the try/catch around spawnIsolatedJob and the
dispatchToSharedRunner call site to ensure the slot-release is invoked in
finally-like semantics.

---

Nitpick comments:
In `@src/k8s/job-entrypoint.ts`:
- Around line 81-94: The current validation enforces webhookSecret even though
App can be constructed without it for getInstallationOctokit(); update the check
to only require config.appId and config.privateKey, and when building the App
instance pass webhookSecret conditionally (include webhookSecret:
config.webhookSecret only if defined) so getInstallationOctokit still works
without a webhook secret; reference config.appId, config.privateKey,
config.webhookSecret, the App constructor, and getInstallationOctokit when
making this change and add a brief inline comment noting webhookSecret is only
needed for webhook verification.

In `@src/k8s/job-spawner.ts`:
- Around line 160-170: The init container "wait-for-docker" is using the
application image (image variable) just to run a shell loop; change it to a
minimal utility image (e.g., "busybox" or "docker:27-cli") instead of using
image to avoid pulling the large app image. Update the initContainers entry (the
one with name "wait-for-docker" in job-spawner.ts) to set image to the chosen
minimal image, keep the command and env (DOCKER_HOST) intact, and adjust
imagePullPolicy if needed for your registry.
- Around line 112-136: Replace the plain-text environment variable entries for
API credentials with secret references: instead of pushing objects with value
set from config.anthropicApiKey and config.claudeCodeOauthToken into providerEnv
via providerEnv.push(...), add entries using valueFrom.secretKeyRef that
reference a Kubernetes Secret (e.g., name "claude-credentials" with keys like
"anthropic-api-key" and "claude-code-oauth-token"); ensure the code paths that
currently check config.anthropicApiKey and config.claudeCodeOauthToken switch to
creating secret-based env entries and document/create the required Secret in the
namespace before Job creation so credentials are not embedded in the Job spec or
etcd.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4af10b0f-f5e4-4ef2-87ce-dc8aa2edc2fe

📥 Commits

Reviewing files that changed from the base of the PR and between d0533eb and 4ec9b09.

📒 Files selected for processing (10)
  • src/core/prompt-builder.ts
  • src/k8s/classifier.ts
  • src/k8s/job-entrypoint.ts
  • src/k8s/job-spawner.ts
  • src/k8s/shared-runner-dispatcher.ts
  • src/webhook/router.ts
  • test/contract/shared-runner-internal.test.ts
  • test/core/prompt-builder.test.ts
  • test/k8s/classifier.test.ts
  • test/webhook/router.test.ts

Comment thread src/k8s/job-spawner.ts
Comment on lines +227 to +242
try {
await client.batch.createNamespacedJob({ namespace: config.jobNamespace, body: jobSpec });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// The K8s client surfaces non-2xx responses as throws with statusCode on
// the response body — we duck-type to keep the spawner free of K8s
// type imports beyond the API client itself.
const status =
(err as { statusCode?: number; response?: { statusCode?: number } })?.statusCode ??
(err as { response?: { statusCode?: number } })?.response?.statusCode;
const kind: JobSpawnerErrorKind =
typeof status === "number" && status >= 400 && status < 500
? "api-rejected"
: "api-unavailable";
throw new JobSpawnerError(kind, `Failed to create Job: ${message}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

@kubernetes/client-node 1.4.0 createNamespacedJob API signature

💡 Result:

The API signature for createNamespacedJob in @kubernetes/client-node version 1.4.0, on the BatchV1Api class, is: typescript createNamespacedJob(namespace: string, body: V1Job, options?: { dryRun?: string, fieldManager?: string, fieldValidation?: string, pretty?: string }): Promise In version 1.x (including 1.4.0), it accepts a single options object as the first parameter for positional calls like listNamespacedPod({ namespace: 'default' }), but for createNamespacedJob, sources show usage as createNamespacedJob(namespace, body) in older examples, with a change noted around v1.0 where it expects {namespace: "...", body: ...}. Example usage: const k8s = require('@kubernetes/client-node'); const kc = new k8s.KubeConfig; kc.loadFromDefault; const k8sBatchV1Api = kc.makeApiClient(k8s.BatchV1Api); const job = { apiVersion: 'batch/v1', kind: 'Job', metadata: { name: 'my-job' }, spec: { template: { spec: { containers: [...], restartPolicy: 'Never' } } } }; k8sBatchV1Api.createNamespacedJob('default', job) .then((response) => { console.log(response.body); }) .catch((err) => { console.error(err); }); Note: In some v1.0+ contexts, use createNamespacedJob({namespace: 'default', body: job}) to avoid "namespace was null" errors, as per recent usage reports. The library is generated from Kubernetes OpenAPI, matching the standard create namespaced resource pattern: POST /apis/batch/v1/namespaces/{namespace}/jobs with body V1Job.

Citations:


Fix createNamespacedJob API call to use positional parameters.

The @kubernetes/client-node 1.4.0 BatchV1Api.createNamespacedJob method signature is:

createNamespacedJob(namespace: string, body: V1Job, options?: {...}): Promise<V1Job>

The code currently calls it with an object pattern { namespace: config.jobNamespace, body: jobSpec }, but the API expects positional parameters. Change to:

await client.batch.createNamespacedJob(config.jobNamespace, jobSpec);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/k8s/job-spawner.ts` around lines 227 - 242, The createNamespacedJob call
uses an object parameter but the BatchV1Api.createNamespacedJob signature
expects positional args; update the call in job-spawner.ts to pass
config.jobNamespace and jobSpec as positional parameters to
client.batch.createNamespacedJob (i.e.,
client.batch.createNamespacedJob(config.jobNamespace, jobSpec)) while leaving
the surrounding try/catch and JobSpawnerError handling (including status
extraction and message) intact.

Comment thread src/k8s/shared-runner-dispatcher.ts
Comment thread src/webhook/router.ts
webhookSecret is only used for webhook signature verification; the isolated
pod does not receive webhooks, only issues installation tokens via the App
JWT. Pass webhookSecret when available for parity with server-side config,
but don't abort startup on its absence.

Addresses CodeRabbit nitpick on PR #19.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@chrisleekr

Copy link
Copy Markdown
Owner Author

Thanks @coderabbitai. Your review was against commit 4ec9b09 (pre-fix). Status of each actionable item:

# File Finding Status
1 job-spawner.ts:227 createNamespacedJob positional vs object param False positive. @kubernetes/client-node@1.4.0 re-exports ObjectBatchV1Api as BatchV1Api (see dist/gen/index.d.ts). The installed consumer-facing class takes an object — the current object-form call is correct. Verified via dist/gen/types/ObjectParamAPI.d.ts:12699.
2 shared-runner-dispatcher.ts:203 isSuccessResponse missing status check Already fixed in 85e93e5
3 router.ts:298-314 Concurrency leak on shared-runner / isolated-job Already fixed in 85e93e5

Nitpicks:

  • job-entrypoint.ts webhookSecret requirement — Addressed in 251ecdb (now conditional; webhookSecret is only needed for webhook verification, not installation-token issuance).
  • job-spawner.ts init container image — Already fixed in 85e93e5 (now docker:27-cli).
  • job-spawner.ts plain-text credential env vars — Valid security follow-up; acknowledged. Deferred to a dedicated Slice (secretKeyRef wiring needs a Secret-provisioning story and is out of scope for this MVP PR).

CI green on the latest commit. Proceeding to squash-merge per standing auth once checks clear.

@coderabbitai

coderabbitai Bot commented Apr 15, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@chrisleekr Thanks for the thorough disposition — all clear on my end:

  • Finding #1 (false positive): Noted and appreciated. I'll record the @kubernetes/client-node@1.4.0 API shape to avoid flagging that pattern in future reviews.
  • Findings #2 & #3 + all nitpicks: Confirmed fixed. The conditional webhookSecret approach in 251ecdb is the right call — it was indeed only needed for webhook verification, not installation-token issuance.
  • Plain-text env vars: Reasonable MVP deferral. The secretKeyRef wiring is a meaningful security hardening task that warrants its own story and a proper Secret-provisioning design, so scoping it out here makes sense.

Good luck with the squash-merge! 🎉


✏️ Learnings added
Learnt from: chrisleekr
URL: https://github.com/chrisleekr/github-app-playground/pull/19

Timestamp: 2026-04-15T10:00:29.656Z
Learning: In the repository `chrisleekr/github-app-playground`, `kubernetes/client-node1.4.0` re-exports `ObjectBatchV1Api` as `BatchV1Api`. The consumer-facing `BatchV1Api.createNamespacedJob` takes a single object parameter (object-form call), not positional arguments. Flagging it as positional-param usage is a false positive. Verified via `dist/gen/types/ObjectParamAPI.d.ts:12699`.

Learnt from: chrisleekr
URL: https://github.com/chrisleekr/github-app-playground/pull/19

Timestamp: 2026-04-15T10:00:29.656Z
Learning: In `src/k8s/job-spawner.ts` (chrisleekr/github-app-playground), injecting provider credentials (e.g., `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, AWS keys) as plain-text env vars in the Kubernetes Job spec is a known MVP shortcut. The proper fix — wiring `secretKeyRef` to a Kubernetes Secret — is intentionally deferred to a future Slice that includes a Secret-provisioning story, and should not be treated as a blocker for the current PR.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@chrisleekr
chrisleekr merged commit 2b345ee into main Apr 15, 2026
9 checks passed
chrisleekr pushed a commit that referenced this pull request Apr 17, 2026
# [1.1.0](v1.0.0...v1.1.0) (2026-04-17)

### Bug Fixes

* **orchestrator:** use IN ${db(ids)} in repo-knowledge to fix Bun.sql array binding ([#26](#26)) ([d5e1b17](d5e1b17))
* **research:** update schedule ([ac300d5](ac300d5))

### Features

* **auth:** add CLAUDE_CODE_OAUTH_TOKEN support with ALLOWED_OWNERS allowlist ([#10](#10)) ([445d354](445d354))
* **ci:** add scheduled research workflow with claude-code-action ([#9](#9)) ([f67c5db](f67c5db))
* **core:** extract inline pipeline and add database foundation for dual-mode dispatch ([#13](#13)) ([f05e818](f05e818))
* **daemon:** add persistent repo memory, env var injection, and dev E2E tooling ([#14](#14)) ([585156f](585156f))
* triage-dispatch-modes Slice B — setup + foundational (T001-T013) ([#18](#18)) ([d0533eb](d0533eb))
* triage-dispatch-modes Slice C — US1 MVP label/keyword routing (T014-T026) ([#19](#19)) ([2b345ee](2b345ee))
* **triage:** Slice D — US2 auto-mode probabilistic dispatch ([#20](#20)) ([457eb4e](457eb4e))
* **triage:** Slice E (part 1) — isolated-job capacity gate + pending queue + drainer ([#21](#21)) ([7653b0d](7653b0d))
* **triage:** slice E part 2 — isolated-job completion watcher (T042/T046–T049) ([#22](#22)) ([c0e86dd](c0e86dd))
* **triage:** slice F — US4 telemetry aggregates + log contract (T050–T054) ([#24](#24)) ([bb7fa9f](bb7fa9f))
@chrisleekr

Copy link
Copy Markdown
Owner Author

🎉 This PR is included in version 1.1.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants