feat: triage-dispatch-modes Slice C — US1 MVP label/keyword routing (T014-T026) - #19
Conversation
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>
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (7)
📝 WalkthroughWalkthroughThis 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 intodecideDispatch. - 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
resolveAllowedToolsfor 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.
| * 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. |
There was a problem hiding this comment.
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.
| * 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. |
| 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. |
There was a problem hiding this comment.
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.
| 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. |
| 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( |
There was a problem hiding this comment.
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.
| { | ||
| 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" }], | ||
| }, |
There was a problem hiding this comment.
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.
| { | ||
| name: "claude-agent", | ||
| image, | ||
| command: ["bun", "run", "src/k8s/job-entrypoint.ts"], | ||
| env: providerEnv, | ||
| volumeMounts: [{ name: "workspace", mountPath: "/workspace" }], |
There was a problem hiding this comment.
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.
| 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" | ||
| ); |
There was a problem hiding this comment.
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).
| case "shared-runner": | ||
| await dispatchToSharedRunner(ctx, decision); | ||
| return; |
There was a problem hiding this comment.
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).
| 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; | |
| } |
| 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; |
There was a problem hiding this comment.
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).
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
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>
|
Addressed all 7 substantive findings from Copilot's review in
Also rewrote the contradictory test name in Full check passes: |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
src/k8s/job-entrypoint.ts (1)
81-94: Consider ifwebhookSecretis strictly required for the App constructor.The
webhookSecretis used for webhook signature verification, not for obtaining installation tokens. TheAppconstructor works without it when only callinggetInstallationOctokit(). 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 (
imagevariable) just to run a shell polling loop. This works but loads a larger image than necessary. Consider using a minimal image likebusyboxordocker:27-clifor 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 joboutput 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
kubectloutput.🤖 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
📒 Files selected for processing (10)
src/core/prompt-builder.tssrc/k8s/classifier.tssrc/k8s/job-entrypoint.tssrc/k8s/job-spawner.tssrc/k8s/shared-runner-dispatcher.tssrc/webhook/router.tstest/contract/shared-runner-internal.test.tstest/core/prompt-builder.test.tstest/k8s/classifier.test.tstest/webhook/router.test.ts
| 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}`); | ||
| } |
There was a problem hiding this comment.
🧩 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:
- 1: https://www.npmjs.com/package/@kubernetes/client-node
- 2: https://stackoverflow.com/questions/72659209/kubernetes-client-job-create
- 3: https://github.com/kubernetes-client/javascript
- 4: https://github.com/kubernetes-client/javascript/releases
- 5: https://stackoverflow.com/questions/79436997/createnamespacedjob-fails-with-namespace-was-null-or-undefined
- 6: https://stackoverflow.com/questions/56990265/how-can-i-generate-a-v1job-object-for-the-kubernetes-nodejs-api-client-from-a
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.
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>
|
Thanks @coderabbitai. Your review was against commit
Nitpicks:
CI green on the latest commit. Proceeding to squash-merge per standing auth once checks clear. |
|
Tip For best results, initiate chat on the files or code changes.
Good luck with the squash-merge! 🎉 ✏️ Learnings added
If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
# [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))
|
🎉 This PR is included in version 1.1.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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
classifyStatic(ctx)insrc/k8s/classifier.ts. Label precedence (bot:job→ isolated-job,bot:shared→ shared-runner,bot:jobwins when both present), then word-bounded keyword match (docker/compose/dind), elseambiguous. 16 tests, 100% line + function coverage.resolveAllowedToolsextended with the in-pod branch — whenAGENT_JOB_MODE=isolated-jobORAGENT_CONTEXT_B64is non-empty, grantsBash(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.decideDispatchnow invokesclassifyStaticfor non-inline modes; clear classifications win over the static-default echo. Inline mode short-circuits the classifier (operator's explicitAGENT_JOB_MODE=inlineis itself the strong signal).dispatchToSharedRunner(ctx, decision)posts to${INTERNAL_RUNNER_URL}/internal/runwithX-Internal-Tokenshared-secret auth, maps every documented status code to a typedSharedRunnerError(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.spawnIsolatedJob(ctx, decision)insrc/k8s/job-spawner.ts. Lazy K8s client init (loadFromCluster()in-cluster vsloadFromDefault()out-of-cluster);infra-absentthrown 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.src/k8s/job-entrypoint.ts— runs INSIDE the spawned pod viabun run src/k8s/job-entrypoint.ts. Decodes BotContext fromAGENT_CONTEXT_B64, re-mints Octokit from GitHub App credentials, invokesrunInlinePipeline, exits 0/1. DefensiveasString/asNumberdecoding so a malformed envelope fails cleanly instead of crashing.dispatch()switch lights up the three target branches: daemon → existingdispatchNonInline(Phase 2 path, unchanged), shared-runner →dispatchToSharedRunner, isolated-job →spawnIsolatedJob. NotImplementedError class kept as a typed surface for future targets.JobSpawnerError(infra-absent)→recordInfraAbsentRejectionwrites a rejection execution row withdispatch_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
classifier.ts,shared-runner-dispatcher.ts,job-spawner.ts,job-entrypoint.tsalready carry JSDoc with@param/@returns/@throwsper 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
dispatchNonInlinemappedauto→shared-runneris gone —decideDispatchnow resolves auto into a concrete target viadefaultDispatchTarget, and thedispatch_modeexecution-row column reflects that target. Existing tests that asserteddispatchMode === "shared-runner"for auto mode were re-pointed to test daemon mode (the path they were actually exercising via dispatchNonInline).Test plan
bun run checkgreenAGENT_JOB_MODE=shared-runner,INTERNAL_RUNNER_URL=…,INTERNAL_RUNNER_TOKEN=…; trigger a webhook withbot:sharedlabel; verify the dispatch-decision log showsdispatchTarget=shared-runner,dispatchReason=label,triageInvoked=falseand the dispatcher hits/internal/run.AGENT_JOB_MODE=isolated-jobwithoutKUBERNETES_SERVICE_HOST/KUBECONFIG; trigger abot:job-labelled webhook; verify the rejection comment posts and no Job is created.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests