Skip to content

feat(github-checks): rewire the worker onto the backend-owned plan loop (A3b-2) - #3667

Merged
chelojimenez merged 4 commits into
mainfrom
feat/github-checks-plan-client
Aug 3, 2026
Merged

feat(github-checks): rewire the worker onto the backend-owned plan loop (A3b-2)#3667
chelojimenez merged 4 commits into
mainfrom
feat/github-checks-plan-client

Conversation

@chelojimenez

@chelojimenez chelojimenez commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What this does

Rewires the GitHub-checks worker onto the backend-owned plan/verdict loop that landed in backend #843 + #847. Receiver-first: the backend already speaks this contract; this is the caller.

The new sequence

1.  claim                                   (unchanged)
2.  POST /plan/begin {triggerId, repoFullName, headSha} → {planId}
      ← BEFORE any sandbox work, so provision/clone/detect failures are attributable
3.  provision → clone → bounded reads       (reported as LADDER attempts, no candidateId)
4.  the deterministic ladder                → localCandidates[]
5.  compute evidenceDigest                  (this repo is now the SOLE encoder)
6.  POST /plan/candidates {planId, evidenceDigest, resolverVersion, localCandidates}
                                            → {candidates[], stopPolicy}
7.  execute the candidates IN THE PLAN'S ORDER, fresh sandbox per candidate,
    previous box killed BEFORE the next is provisioned; POST /attempt at each
    phase boundary with an explicit idempotencyKey
8.  obey the typed action: continue_phase | try_next_candidate | run_eval | complete
9.  on run_eval: launch the suite, then IMMEDIATELY
      POST /attempt {phase:'eval', ok:true, runId}   ← this is what BINDS the run
    then await the run
10. POST /complete {triggerId, planId, runId}        ← NO `outcome` field
11. cleanup unchanged (ephemeral server row, sandboxes, heartbeat, lease)

The moat boundary — what the inspector stopped owning

stopped why
ordering candidates the backend orders them (cache hit first, then ours, capped at 3)
deciding continue/stop the returned action is authoritative
computing the outcome derived backend-side from the attempt log + the BOUND run
resolveAndStart's attribution mapping it reports a failureKind; FAILURE_KIND_POLICY decides what it means

Kept, deliberately and in the open: deterministic mcpjam.yaml parsing and detection, clone/build/start/probe, E2B lifecycle, egress lockdown, cleanup, /proc listener identity, structured telemetry.

Accepted cost, named: a check is now backend-dependent mid-flight, so a self-hosted inspector cannot run checks standalone. That is the moat working as intended, stated rather than emergent.

The encoder move

server/services/github-checks/evidence-digest.ts is new and is the single encoder — the backend retired its own and now shape-checks the digest only (opaque hex sha-256). Ported verbatim in behaviour: fixed ordered path list, present/absent markers, per-path byte budgets, length-prefixed framing, sha-256, covering resolverVersion. Two improvements over the mirrored version: the budgets are now imported from the detector's real constants instead of hand-copied (assertEvidenceBudgetsCoverDetector() still runs at module load), and computeResolverEvidenceDigest() takes the reader's own output, so what is hashed is exactly what detection saw.

The golden vector came across as an inspector regression test and reproduces the pinned digest 139b76e3…cb06, so cache entries written under the old backend encoder stay addressable.

Typed action + fail closed

continue_phase / try_next_candidate / run_eval / complete, obeyed verbatim. An action this build does not recognize — or a 200 with no action at all — is normalized to complete with unknownAction, never "carry on". run_eval is the only action allowed to follow an accepted candidate; anything else stops.

Degraded mode and 409s

  • 409 (and 400) = HARD STOP. Never retried, never fallen back from. The reason is surfaced in the check output.
  • Unreachable / 5xx / 404 = degraded: stop, and record backend_unreachable on the first call that gets through (a terminalAttempt on /complete). Never a green, never a PR-blamed outcome, and never a silent local fallback — localCandidates are never executed in our own order.
  • /plan/begin failing means no plan exists, so nothing is provisioned at all and the check is completed neutral through the legacy planless shape (the only place the worker still names an outcome; it retires with that shape).
  • Transport replays happen exactly once, only where the idempotency key makes them safe. Every /attempt carries an explicit caller-generated key (plan:candidate:phase:seq), fresh per distinct attempt and identical on a replay, so a retry replays rather than duplicating a corpus row.
  • evals_failed is never sent: it is not in the worker-facing union. A failed eval launch is {phase:'eval', ok:false, failureKind:'sandbox_error'}; once the run is bound, no further attempt is posted at all.

The in-box eval-failure diagnostic survives as display text only — it no longer reclassifies an outcome, because after the eval attempt the verdict comes from the bound run.

#3644 hardening — verified intact after the refactor

  • launch identity from the spawn handle (never a writable /tmp PID file);
  • bounded in-box reads (head -c, never cat);
  • setsid wrapping, process group trusted only when pgrp === pid;
  • listener identity checked for every listener on the port, with listener_mismatch (evidence about the candidate) and listener_unknown (evidence about us) reported as different kinds;
  • an oversized mcpjam.yaml still attributed recipe_invalid rather than collapsing to "absent" — pinned by a test that runs the REAL ladder.

Re-run against a live E2B sandbox after the refactor (scripts/verify-listener-identity.ts, four scenarios, four fresh boxes, all killed):

SUMMARY
  ✓ happy        our spawned server binds the port          → ok
  ✓ squatter     detached hook process binds first          → mismatch
  ✓ doublefork   reparented, ancestry gone, pgrp identifies → ok
  ✓ ipv6         binds `::` only, walk still attributes it  → ok
exit 0

Tests

New/rewritten, all over the existing injected-fake dep seam:

  • check-plan.test.ts (15) — idempotency keys, the same key on a replay, 409 = one call and a hard stop, 404/5xx = unreachable carrying the phase it was at, unknown action fails closed, /complete sends no outcome, and the degraded marker being dropped (not the completion) when the backend refuses it.
  • resolve-and-start.test.ts (31) — the plan's order beats the detector's, each typed action drives the right next step, unknown action stops, 409 = no retry and no local fallback, /plan/candidates unreachable executes nothing, pre-candidate provision/clone failures reported against the begun plan, recipe_invalid (incl. over-cap) and no_candidates as ladder-scoped resolve failures, fresh box per candidate with kill-before-provision, listener mismatch/unknown, plus the unchanged judgeListeners matrix.
  • evidence-digest.test.ts (12) — the golden vector, budgets covering the detector, absent ≠ empty, unforgeable framing, byte- not code-unit truncation.
  • github-checks-worker.test.ts (56) — plan begun before any sandbox work, eval attempt posted AT LAUNCH carrying the runId, no second attempt once bound, no evals_failed anywhere, degraded marker on the completion, planless infra_error with nothing executed.
npx vitest run server/services/__tests__/github-checks-worker.test.ts \
  server/services/__tests__/github-checks-worker-source.test.ts \
  server/services/github-checks/__tests__
→ 10 files, 451 tests passed, EXIT CODE 0
npm run check:mcp-v1-runtime-imports  → EXIT CODE 0

(github-checks-worker.test.ts needs mcpjam-inspector/node_modules and the generated harness bundle present; in a bare worktree it fails to load on @ai-sdk/harness/agent at unmodified HEAD too. Both were supplied before the runs above, and the suite loads and passes.)

Not done here

e2e awaits the backend deploy. The routes are merged on backend main but plan/begin still 404s in prod, so no live end-to-end was attempted. Once deployed: fixture with mcpjam.yaml → declared; yaml deleted → detected; broken yaml → recipe_invalid; nothing resolvable → recipe_unresolvable.

The follow-up that retires the legacy explicit-outcome /complete shape and the worker-facing recipe PUT stays tracked; the planless report path in this PR is the last consumer of the former.

🤖 Generated with Claude Code


Note

High Risk
Large refactor of GitHub check execution and verdict attribution; incorrect binding, completion, or degraded-mode handling could mis-report PR checks or strand runs until backend deploy aligns with the new routes.

Overview
Moves GitHub PR checks onto a backend-owned plan loop: begin → ladder attempts → candidates → per-phase /attempt with idempotency keys → /complete without an outcome. The control plane derives pass/fail from the attempt log and the bound eval run; the worker no longer sends evals_failed or other PR verdicts.

Worker flow: /plan/begin runs before any sandbox work. resolveAndStart executes candidates in the plan’s order and obeys typed actions (continue_phase, try_next_candidate, run_eval, complete). On eval launch, onRunStarted posts { phase: 'eval', ok: true, runId } immediately so the run binds to the check. Failures use describeCheckFailure for display only; planless infra_error remains only when begin never returns a plan.

New modules: check-plan.ts (HTTP session, 409 hard-stop, degraded backend_unreachable, unknown action → fail closed), evidence-digest.ts (sole recipe evidence encoder + golden vector), service-route.ts (shared Convex transport). classifyCheckFailure / outcomeForRunResult and worker-side outcome reporting are removed.

Reliability: abandonPreparedRun finalizes runs when eval binding (onRunStarted) throws so rows are not left running with pending iterations.

Reviewed by Cursor Bugbot for commit ada5921. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Rewired the GitHub-checks worker onto the backend-owned plan/verdict loop so the backend orders candidates, returns typed actions, and derives the conclusion. The worker reports attempts with idempotency keys, binds the eval run at launch, completes with no outcome, and now uses a single evidence-digest encoder and a shared service-route transport with pinned behavior.

  • New Features

    • Plan loop: begin → candidates → per-phase attempts with idempotency keys → complete (no outcome).
    • Typed actions enforced (continue_phase, try_next_candidate, run_eval, complete); unknown actions fail closed.
    • Eval run bound at launch; no local outcome classification.
    • Fresh sandbox per candidate with kill-before-provision; strict degraded policy (409/400 = hard stop; 5xx/404/unreachable = degraded, no local fallback).
    • Evidence-digest encoder moved here; golden vector preserved; budgets imported; covers RESOLVER_VERSION; shared service-route transport.
  • Bug Fixes

    • Prevent stranded runs when binding throws by abandoning prepared runs to reach a terminal state.
    • Enforce the plan wall-clock deadline at candidate boundaries and again after provisioning; 0 means no deadline.
    • Unified plan-route retry/classify into one helper; added tests for binding abort and deadline checks.
    • Pinned service-route transport behavior with tests: correct token/URL, tolerate malformed bodies, propagate timeouts/aborts, and ensure the deadline covers the body read.
    • Clarified repoFiles note in the evidence digest: no cross-repo replay (cache keyed by repo+evidence), but within-repo listing changes may require a future hash-version bump.

Written for commit ada5921. Summary will update on new commits.

Review in cubic

…op (A3b-2)

The worker stops ordering candidates, deciding continue/stop and computing the
final outcome. It now begins a plan before any sandbox work, reports every phase
as an attempt, obeys the backend's typed action, binds the eval run at launch,
and completes with no `outcome` field.

It keeps — deliberately, in the open — deterministic mcpjam.yaml parsing and
detection, clone/build/start/probe, E2B lifecycle, egress lockdown, cleanup, and
the /proc listener-identity check.

The evidence-digest encoder moves here: the backend retired its own and treats
the digest as opaque, so this repo is the single encoder. The golden vector
comes across as a regression test and still reproduces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@dosubot dosubot Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Aug 3, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@dosubot dosubot Bot added the enhancement New feature or request label Aug 3, 2026
@dosubot

dosubot Bot commented Aug 3, 2026

Copy link
Copy Markdown

📄 Knowledge review

Dosu skipped reviewing this PR because your organization has used its 200 included credits for the month. Your usage will reset on 2026-09-01. To have Dosu review this PR before then, ask your organization admin to upgrade to a pro account.


Leave Feedback Ask Dosu about inspector Add Dosu to your team

@chelojimenez

chelojimenez commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@cursor

cursor Bot commented Aug 3, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_690b513c-d9d5-4b8e-b346-cddc6c763a40)

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Internal preview

Preview URL: https://mcp-inspector-pr-3667.up.railway.app
Deployed commit: ad000f4
PR head commit: ada5921
Backend target: staging fallback.
Health: ✅ Convex reachable
Access is employee-only in non-production environments.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: afe34fe5-57d8-42e8-bcb0-6354deb1dc87

📥 Commits

Reviewing files that changed from the base of the PR and between 4b77df7 and ada5921.

📒 Files selected for processing (2)
  • mcpjam-inspector/server/services/github-checks/__tests__/service-route.test.ts
  • mcpjam-inspector/server/services/github-checks/evidence-digest.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • mcpjam-inspector/server/services/github-checks/evidence-digest.ts

Walkthrough

The change adds backend-owned GitHub check plans, typed attempt actions, retry handling, and completion requests without client-derived outcomes. Resolver execution follows planned candidates, submits versioned evidence digests, records phase-specific attempts, and returns candidate identifiers. The worker opens plans before sandbox work, binds evaluation runs at launch, reports planless infrastructure failures separately, and delegates verdict derivation to completion. Tests cover transport behavior, digest encoding, candidate execution, lifecycle handling, cleanup, lease loss, heartbeat behavior, and degraded modes.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

mcpjam-inspector/server/services/github-checks/__tests__/service-route.test.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: ESLint configuration in --config is invalid:

  • Unexpected top-level property "__esModule".

    at ConfigValidator.validateConfigSchema (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2177:19)
    at ConfigArrayFactory._normalizeConfigData (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3019:19)
    at ConfigArrayFactory._loadConfigData (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:21)
    at ConfigArrayFactory.loadFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
    at createCLIConfigArray (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)
    at new CascadingConfigArrayFactory (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3735:29)
    at new CLIEngine (/soundcheck/node_modules/eslint/lib/cli-engine/cli-engine.js:617:36)
    at new ESLint (/soundcheck/node_modules/eslint/lib/eslint/eslint.js:430:27)
    at Object.execute (/soundcheck/node_modules/eslint/lib/cli.js:410:24)
    at async main (/soundcheck/node_modules/eslint/bin/eslint.js:152:22)

mcpjam-inspector/server/services/github-checks/evidence-digest.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

Error: ESLint configuration in --config is invalid:

  • Unexpected top-level property "__esModule".

    at ConfigValidator.validateConfigSchema (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2177:19)
    at ConfigArrayFactory._normalizeConfigData (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3019:19)
    at ConfigArrayFactory._loadConfigData (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:21)
    at ConfigArrayFactory.loadFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
    at createCLIConfigArray (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)
    at new CascadingConfigArrayFactory (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3735:29)
    at new CLIEngine (/soundcheck/node_modules/eslint/lib/cli-engine/cli-engine.js:617:36)
    at new ESLint (/soundcheck/node_modules/eslint/lib/eslint/eslint.js:430:27)
    at Object.execute (/soundcheck/node_modules/eslint/lib/cli.js:410:24)
    at async main (/soundcheck/node_modules/eslint/bin/eslint.js:152:22)


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
mcpjam-inspector/server/services/github-checks/resolve-and-start.ts (1)

394-425: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff

stopPolicy.wallClockMs is received and never enforced.

issued.stopPolicy reaches Line 405 as log context only. maxCandidates needs no local enforcement — the backend bounds the search by issuing fewer candidates and by returning complete once the cap is spent, as Line 521 records. wallClockMs has no such second enforcement point.

The consequence is narrow but real: while the backend keeps answering try_next_candidate, each candidate spends a full build-and-probe cycle, and nothing in this loop consults the deadline the backend just sent. The lease heartbeat keeps succeeding throughout, so it is not a backstop.

Either check the elapsed time against wallClockMs at the top of each iteration and stop with a named reason, or record in the docblock that the field is display-only and the backend owns the deadline.

♻️ Sketch of a per-iteration deadline check
+    const deadlineAt =
+      issued.stopPolicy.wallClockMs > 0
+        ? deps.now() + issued.stopPolicy.wallClockMs
+        : null;
+
     for (let index = 0; index < issued.candidates.length; index += 1) {
       const planned = issued.candidates[index];
       const recipe = recipeOf(planned);
+      if (deadlineAt !== null && deps.now() >= deadlineAt) {
+        throw new CheckStoppedByPlan("plan_wall_clock_exhausted");
+      }
       if (index > 0) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpjam-inspector/server/services/github-checks/resolve-and-start.ts` around
lines 394 - 425, Enforce issued.stopPolicy.wallClockMs in the candidate
execution loop around recipeOf and freshSandbox by tracking the plan start time
and checking elapsed time at the top of each iteration. When the deadline is
reached, stop with a named CheckStoppedByPlan reason, while preserving the
existing maxCandidates handling and normal candidate execution behavior.
mcpjam-inspector/server/services/github-checks/check-plan.ts (1)

530-584: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the shared retry-and-classify loop.

beginCheckPlan repeats the exact control flow of HttpCheckPlanSession.call at Lines 474-523: one transport replay, 200 && ok success, 404 as unreachable, other 4xx as protocol, anything else as unreachable. The two copies agree today. They are also two places a future timeout or status-policy edit must land — the same hazard service-route.ts was extracted to close, reappearing one layer up.

A free function taking post, step, path, body and an optional at would serve both: beginCheckPlan calls it before a session exists, and call delegates to it afterwards.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpjam-inspector/server/services/github-checks/check-plan.ts` around lines
530 - 584, Extract the shared retry-and-response-classification logic from
beginCheckPlan and HttpCheckPlanSession.call into a reusable free function
accepting post, step, path, body, and optional at parameters. Preserve one
transport retry sequence, 200/ok success handling, 404 PlanUnreachableError,
other 4xx PlanProtocolError, and all other responses as PlanUnreachableError,
then have beginCheckPlan and call delegate to this helper.
mcpjam-inspector/server/services/github-checks/evidence-digest.ts (1)

324-343: 🗄️ Data Integrity & Integration | 🔵 Trivial

Clarify repoFiles’ place in the cache-key rules.

computeResolverEvidenceDigest hashes the fixed file bytes plus the resolver version, and the detector explicitly documents repoFiles.kind as not joining the R3 cache key because it comes from the same commit. Add one short exception in the digest rules that DetectionInputs.repoFiles is intentionally out of scope, so THE DIGEST MUST COVER EVERY BYTE DETECTION IS CAPABLE OF READING does not invite future inclusion of repoFiles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpjam-inspector/server/services/github-checks/evidence-digest.ts` around
lines 324 - 343, The digest rules documentation should explicitly state that
DetectionInputs.repoFiles is intentionally excluded from the R3 cache key
because it comes from the same commit, while the digest continues hashing the
fixed file contents and resolverVersion. Update the documentation nearest
computeResolverEvidenceDigest or the digest rules, without changing the hashing
implementation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcpjam-inspector/server/services/github-checks-worker.ts`:
- Around line 849-858: Handle failures from onRunStarted in the run-start flow
by invoking the same best-effort cleanup used by the unowned-run branch: mark
setup-pending iterations failed and finalize the recorder before propagating the
original error. Ensure this cleanup covers errors such as conflicts or
PlanUnreachableError and prevents the prepared run from remaining running with
pending iterations.

---

Nitpick comments:
In `@mcpjam-inspector/server/services/github-checks/check-plan.ts`:
- Around line 530-584: Extract the shared retry-and-response-classification
logic from beginCheckPlan and HttpCheckPlanSession.call into a reusable free
function accepting post, step, path, body, and optional at parameters. Preserve
one transport retry sequence, 200/ok success handling, 404 PlanUnreachableError,
other 4xx PlanProtocolError, and all other responses as PlanUnreachableError,
then have beginCheckPlan and call delegate to this helper.

In `@mcpjam-inspector/server/services/github-checks/evidence-digest.ts`:
- Around line 324-343: The digest rules documentation should explicitly state
that DetectionInputs.repoFiles is intentionally excluded from the R3 cache key
because it comes from the same commit, while the digest continues hashing the
fixed file contents and resolverVersion. Update the documentation nearest
computeResolverEvidenceDigest or the digest rules, without changing the hashing
implementation.

In `@mcpjam-inspector/server/services/github-checks/resolve-and-start.ts`:
- Around line 394-425: Enforce issued.stopPolicy.wallClockMs in the candidate
execution loop around recipeOf and freshSandbox by tracking the plan start time
and checking elapsed time at the top of each iteration. When the deadline is
reached, stop with a named CheckStoppedByPlan reason, while preserving the
existing maxCandidates handling and normal candidate execution behavior.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ca66e4c8-2152-4abf-8e77-7b3b6c0565df

📥 Commits

Reviewing files that changed from the base of the PR and between 19360ec and fe23200.

📒 Files selected for processing (10)
  • mcpjam-inspector/server/services/__tests__/github-checks-worker.test.ts
  • mcpjam-inspector/server/services/github-checks-worker.ts
  • mcpjam-inspector/server/services/github-checks/__tests__/check-plan.test.ts
  • mcpjam-inspector/server/services/github-checks/__tests__/evidence-digest.test.ts
  • mcpjam-inspector/server/services/github-checks/__tests__/resolve-and-start.test.ts
  • mcpjam-inspector/server/services/github-checks/check-plan.ts
  • mcpjam-inspector/server/services/github-checks/evidence-digest.ts
  • mcpjam-inspector/server/services/github-checks/resolve-and-start.ts
  • mcpjam-inspector/server/services/github-checks/resolver/index.ts
  • mcpjam-inspector/server/services/github-checks/service-route.ts

Comment thread mcpjam-inspector/server/services/github-checks-worker.ts

@cubic-dev-ai cubic-dev-ai 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.

7 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="mcpjam-inspector/server/services/github-checks/check-plan.ts">

<violation number="1" location="mcpjam-inspector/server/services/github-checks/check-plan.ts:436">
P2: Degraded completions send a differently shaped idempotency key (`plan:candidate:phase:degraded:seq`) than the `/attempt` contract. If the backend validates the documented four-part key, it rejects the inline marker and this method intentionally completes without `backend_unreachable`, losing the neutral degraded attribution; retain the normal sequence-only shape.</violation>

<violation number="2" location="mcpjam-inspector/server/services/github-checks/check-plan.ts:491">
P2: A 200 response with an unparseable or empty body is classified as `backend_unreachable` instead of failing closed. `service-route.ts` intentionally tolerates a malformed body by returning `{ status: 200, body: null }`, but `call()` only accepts a 200 when `body?.ok` is truthy, so that tolerated 200 falls through to `PlanUnreachableError`. That makes the degraded marker say "backend unreachable" (attributed `infra_error`) for a backend that actually responded 200, which is a different branch than the `unknown_action` fail-closed path the module's Rule 3 describes. Surface 200 responses (even with a missing/`ok:false` body) so `attempt()` can fail closed to `complete` + `unknownAction` here, and reserve `PlanUnreachableError` for genuinely unreachable/5xx/404 cases.</violation>
</file>

<file name="mcpjam-inspector/server/services/github-checks/service-route.ts">

<violation number="1" location="mcpjam-inspector/server/services/github-checks/service-route.ts:35">
P3: Timeout and malformed-response handling has no direct coverage despite being the new transport boundary. Add fetch-mocked tests for a normal response, malformed JSON, fetch abort, and abort while `response.json()` is pending.</violation>

<violation number="2" location="mcpjam-inspector/server/services/github-checks/service-route.ts:35">
P3: Service-route behavior can drift between workers because this repeats Scheduled Evals' backend transport. Extract a configurable shared helper so authentication, timeout, and body-read semantics have one implementation.</violation>
</file>

<file name="mcpjam-inspector/server/services/github-checks-worker.ts">

<violation number="1" location="mcpjam-inspector/server/services/github-checks-worker.ts:1260">
P2: Failures between candidate acceptance and `runEvalSuite` completion are completed without an eval-phase failure attempt. Record `eval: ok:false, failureKind:'sandbox_error'` for unbound setup failures such as bearer minting or ephemeral-server creation before completing.</violation>

<violation number="2" location="mcpjam-inspector/server/services/github-checks-worker.ts:1289">
P1: An unknown or missing backend action completes without the required degraded marker. Preserve the unknown-action signal through `CheckStoppedByPlan` and include a `backend_unreachable` terminal attempt so a malformed response cannot be attributed from the preceding candidate attempt.</violation>
</file>

<file name="mcpjam-inspector/server/services/github-checks/evidence-digest.ts">

<violation number="1" location="mcpjam-inspector/server/services/github-checks/evidence-digest.ts:330">
P1: Candidate selection can be replayed for a checkout whose ownership listing changed, because `repoFiles` affects resolver output but is absent from `evidenceDigest`. Include a deterministic, bounded encoding of the listing (path and kind), and bump the evidence-hash version so a new tracked file or symlink cannot reuse a plan issued for another checkout.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

): string {
return computeRecipeEvidenceHash({
resolverVersion,
files: {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: Candidate selection can be replayed for a checkout whose ownership listing changed, because repoFiles affects resolver output but is absent from evidenceDigest. Include a deterministic, bounded encoding of the listing (path and kind), and bump the evidence-hash version so a new tracked file or symlink cannot reuse a plan issued for another checkout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcpjam-inspector/server/services/github-checks/evidence-digest.ts, line 330:

<comment>Candidate selection can be replayed for a checkout whose ownership listing changed, because `repoFiles` affects resolver output but is absent from `evidenceDigest`. Include a deterministic, bounded encoding of the listing (path and kind), and bump the evidence-hash version so a new tracked file or symlink cannot reuse a plan issued for another checkout.</comment>

<file context>
@@ -0,0 +1,343 @@
+): string {
+  return computeRecipeEvidenceHash({
+    resolverVersion,
+    files: {
+      "mcpjam.yaml":
+        inputs.mcpjamYaml.kind === "present" ? inputs.mcpjamYaml.text : null,
</file context>

// things travelling here are display text and — when the backend went away
// mid-flight — the degraded marker.
const details = described.detailsMarkdown ?? evalFailureDetails;
const marker = degradedMarker(error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: An unknown or missing backend action completes without the required degraded marker. Preserve the unknown-action signal through CheckStoppedByPlan and include a backend_unreachable terminal attempt so a malformed response cannot be attributed from the preceding candidate attempt.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcpjam-inspector/server/services/github-checks-worker.ts, line 1289:

<comment>An unknown or missing backend action completes without the required degraded marker. Preserve the unknown-action signal through `CheckStoppedByPlan` and include a `backend_unreachable` terminal attempt so a malformed response cannot be attributed from the preceding candidate attempt.</comment>

<file context>
@@ -1179,57 +1221,81 @@ export async function executeClaimedCheck(
+    // things travelling here are display text and — when the backend went away
+    // mid-flight — the degraded marker.
+    const details = described.detailsMarkdown ?? evalFailureDetails;
+    const marker = degradedMarker(error);
+    await safeComplete({
+      ...(details
</file context>

: {}),
idempotencyKey: `${this.planId}:${attempt.candidateId ?? "ladder"}:${
attempt.phase
}:degraded:${this.attemptSeq}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Degraded completions send a differently shaped idempotency key (plan:candidate:phase:degraded:seq) than the /attempt contract. If the backend validates the documented four-part key, it rejects the inline marker and this method intentionally completes without backend_unreachable, losing the neutral degraded attribution; retain the normal sequence-only shape.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcpjam-inspector/server/services/github-checks/check-plan.ts, line 436:

<comment>Degraded completions send a differently shaped idempotency key (`plan:candidate:phase:degraded:seq`) than the `/attempt` contract. If the backend validates the documented four-part key, it rejects the inline marker and this method intentionally completes without `backend_unreachable`, losing the neutral degraded attribution; retain the normal sequence-only shape.</comment>

<file context>
@@ -0,0 +1,584 @@
+          : {}),
+        idempotencyKey: `${this.planId}:${attempt.candidateId ?? "ladder"}:${
+          attempt.phase
+        }:degraded:${this.attemptSeq}`,
+      },
+    };
</file context>

await safeReport({
triggerId: claimed.triggerId,
outcome,
await safeComplete({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Failures between candidate acceptance and runEvalSuite completion are completed without an eval-phase failure attempt. Record eval: ok:false, failureKind:'sandbox_error' for unbound setup failures such as bearer minting or ephemeral-server creation before completing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcpjam-inspector/server/services/github-checks-worker.ts, line 1260:

<comment>Failures between candidate acceptance and `runEvalSuite` completion are completed without an eval-phase failure attempt. Record `eval: ok:false, failureKind:'sandbox_error'` for unbound setup failures such as bearer minting or ephemeral-server creation before completing.</comment>

<file context>
@@ -1179,57 +1221,81 @@ export async function executeClaimedCheck(
-    await safeReport({
-      triggerId: claimed.triggerId,
-      outcome,
+    await safeComplete({
+      // VERIFIED, never adopted: this must equal the run the `eval` attempt
+      // bound, and the backend 409s if it does not.
</file context>

Comment thread mcpjam-inspector/server/services/github-checks-worker.ts Outdated
lastTransportError = error;
continue;
}
if (response.status === 200 && response.body?.ok) return response.body;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A 200 response with an unparseable or empty body is classified as backend_unreachable instead of failing closed. service-route.ts intentionally tolerates a malformed body by returning { status: 200, body: null }, but call() only accepts a 200 when body?.ok is truthy, so that tolerated 200 falls through to PlanUnreachableError. That makes the degraded marker say "backend unreachable" (attributed infra_error) for a backend that actually responded 200, which is a different branch than the unknown_action fail-closed path the module's Rule 3 describes. Surface 200 responses (even with a missing/ok:false body) so attempt() can fail closed to complete + unknownAction here, and reserve PlanUnreachableError for genuinely unreachable/5xx/404 cases.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcpjam-inspector/server/services/github-checks/check-plan.ts, line 491:

<comment>A 200 response with an unparseable or empty body is classified as `backend_unreachable` instead of failing closed. `service-route.ts` intentionally tolerates a malformed body by returning `{ status: 200, body: null }`, but `call()` only accepts a 200 when `body?.ok` is truthy, so that tolerated 200 falls through to `PlanUnreachableError`. That makes the degraded marker say "backend unreachable" (attributed `infra_error`) for a backend that actually responded 200, which is a different branch than the `unknown_action` fail-closed path the module's Rule 3 describes. Surface 200 responses (even with a missing/`ok:false` body) so `attempt()` can fail closed to `complete` + `unknownAction` here, and reserve `PlanUnreachableError` for genuinely unreachable/5xx/404 cases.</comment>

<file context>
@@ -0,0 +1,584 @@
+        lastTransportError = error;
+        continue;
+      }
+      if (response.status === 200 && response.body?.ok) return response.body;
+      if (response.status >= 400 && response.status < 500) {
+        if (response.status === 404) {
</file context>

return { convexUrl, serviceToken };
}

export async function postServiceRoute(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Timeout and malformed-response handling has no direct coverage despite being the new transport boundary. Add fetch-mocked tests for a normal response, malformed JSON, fetch abort, and abort while response.json() is pending.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcpjam-inspector/server/services/github-checks/service-route.ts, line 35:

<comment>Timeout and malformed-response handling has no direct coverage despite being the new transport boundary. Add fetch-mocked tests for a normal response, malformed JSON, fetch abort, and abort while `response.json()` is pending.</comment>

<file context>
@@ -0,0 +1,84 @@
+  return { convexUrl, serviceToken };
+}
+
+export async function postServiceRoute(
+  path: string,
+  body: Record<string, unknown>
</file context>

return { convexUrl, serviceToken };
}

export async function postServiceRoute(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: Service-route behavior can drift between workers because this repeats Scheduled Evals' backend transport. Extract a configurable shared helper so authentication, timeout, and body-read semantics have one implementation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcpjam-inspector/server/services/github-checks/service-route.ts, line 35:

<comment>Service-route behavior can drift between workers because this repeats Scheduled Evals' backend transport. Extract a configurable shared helper so authentication, timeout, and body-read semantics have one implementation.</comment>

<file context>
@@ -0,0 +1,84 @@
+  return { convexUrl, serviceToken };
+}
+
+export async function postServiceRoute(
+  path: string,
+  body: Record<string, unknown>
</file context>

Review triage on #3667.

`prepareEvalRun` creates the run row AND one pending iteration row per
attempt before the `eval` attempt is posted, so a throw from the binding
call (a 409 from the state machine, an unreachable backend) bypassed the
catch around `execute()` where the cleanup lives — leaving the run
`running` with every iteration `pending`, permanently. Aborting is still
right: a run nothing may read is a run not worth paying for. It just has
to reach a terminal state on the way out.

The cleanup the unowned-run branch already performed is now one helper,
`abandonPreparedRun`, used by both aborts so they cannot drift.

Also from the same review:

- `stopPolicy.wallClockMs` was received and never enforced. `maxCandidates`
  needs no local enforcement (the backend issues fewer candidates and
  returns `complete` once the cap is spent), but nothing stopped a full
  build-and-probe cycle per candidate against the deadline the backend
  itself sent — the lease heartbeat is not a backstop. Checked at each
  candidate boundary now; `0` still means "no deadline issued".
- `beginCheckPlan` and `HttpCheckPlanSession.call` had two copies of the
  same retry-and-classify loop, which a future timeout or status-policy
  edit could half-apply. One `callPlanRoute`, both callers delegate.
- Documented `DetectionInputs.repoFiles` as the deliberate exception to
  "the digest must cover every byte detection is capable of reading", so
  the rule does not invite churning the digest with a listing that cannot
  vary independently of the commit.

Tests: the binding abort is pinned in `github-checks-worker-source.test.ts`
(verified failing without the fix), the deadline and the `0` case in
`resolve-and-start.test.ts`. 454 tests pass, exit 0.
@cursor

cursor Bot commented Aug 3, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_f4585b33-6296-4a6b-a81d-98c9ef5f6140)

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 6 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread mcpjam-inspector/server/services/github-checks/resolve-and-start.ts Outdated
Follow-up to the wall-clock enforcement in the previous commit, from
cubic's review of it (P2, valid).

The deadline was checked once per candidate, at the top of the iteration
— before that candidate's own sandbox is provisioned and the repo cloned.
Those are minutes of budget themselves, so a candidate that was inside
the deadline when its iteration began could be outside it by the time
there was a box to build in, and start the expensive half anyway.

Checked again after `freshSandbox`, immediately before `buildAndStart`.
Both checks go through one `assertWithinDeadline()` so they cannot drift.

Pinned by a test that advances the clock during the SECOND provision:
the box is created, and then nothing is built in it. Verified failing
without the post-provision check. 455 tests pass, exit 0.
@cursor

cursor Bot commented Aug 3, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_c974d901-b1d1-4d25-9e9c-ede10f4d5ce9)

…oFiles note

Second cubic review run (it posted while its check still read
`in_progress`, so the first triage pass missed it). Two changes; the
rest of that run is answered in the PR thread rather than in code,
because the backend source settles them.

`service-route.ts` is the new transport boundary and had no direct
coverage, which matters more than usual here: `check-plan.ts` classifies
on `{status, body}` alone, so anything this function gets wrong arrives
one layer up wearing the shape of a legitimate answer. Six tests pin the
parts that carry that weight — the token header and URL composition, a
malformed body being TOLERATED, a body read that hit the deadline
surfacing as a TIMEOUT rather than as `body: null` (the two are
indistinguishable to the caller otherwise), an aborted fetch propagating
instead of becoming a synthetic status, and the deadline being disarmed
on the way out. Each was checked against a mutated source first.

The `repoFiles` note added in the previous commit asserted the exclusion
was safe. That was too strong, and it came from taking a review comment
at face value. Corrected to state what is actually true: cross-repository
replay cannot happen because the backend's cache is keyed by
`by_repo_evidence` — `(repoFullName, evidenceHash)` — but WITHIN one
repository a commit can leave every hashed byte identical and still
change the listing, most sharply by replacing a tracked entry point with
a symlink into `node_modules/`. Fresh detection suppresses that; a cache
hit issued when the path was a regular file does not. Closing it needs a
`RECIPE_EVIDENCE_HASH_VERSION` bump that orphans every existing entry, so
it is documented as tracked-not-settled rather than decided here.

461 tests pass, exit 0.
@cursor

cursor Bot commented Aug 3, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_eab35827-3148-47ac-b91e-8fcc363747d8)

@chelojimenez

Copy link
Copy Markdown
Contributor Author

Review triage

Fixed in e34e36e, 4b77df7, ada5921:

  • github-checks-worker.ts:857 (cubic P2 / CodeRabbit major) — real, fixed. A throw from the binding call left the run running with every iteration pending. The cleanup the unowned-run branch already did is now one abandonPreparedRun helper used by both aborts. Pinned by a test verified failing without it.
  • stopPolicy.wallClockMs never enforced (CodeRabbit) — fixed, then resolve-and-start.ts:424 (cubic P2) — also fixed: the deadline is re-read after freshSandbox, since provision and clone are themselves minutes of budget.
  • beginCheckPlan / call duplicate retry loop (CodeRabbit) — fixed, one callPlanRoute.
  • service-route.ts:35 P3 (no coverage) — fixed. Six tests, each checked against a mutated source.

Not changing, with reasons:

  • evidence-digest.ts:337 P1 (repoFiles absent from the digest) — real but narrower than stated, and not a drive-by fix. Cross-repository replay cannot happen: the cache is keyed by by_repo_evidence(repoFullName, evidenceHash). Within one repository it is genuinely not tight (identical hashed bytes + a tracked entry point becoming a symlink into node_modules/ replays a plan that ownership-proof suppression would refuse today). Closing it needs a RECIPE_EVIDENCE_HASH_VERSION bump that orphans every existing entry — a deliberate cost this PR explicitly designed against, so I documented the gap precisely instead of deciding it. @chelojimenez this one is yours to call.
  • github-checks-worker.ts:1321 P1 (unknown action completes without a degraded marker) — not a correctness bug. deriveCheckOutcome case 4 already handles it: a verify: ok followed by silence returns infra_error / no_eval_attempt_reported (checkPlans.ts:586). Never green, never PR-blamed. The marker would only change the reason string, not the outcome.
  • github-checks-worker.ts:1292 P2 (no eval-phase failure attempt for unbound setup failures) — same reason. Both routes end at infra_error; FAILURE_KIND_POLICY['sandbox_error'].terminalOutcome is infra_error too.
  • check-plan.ts:436 P2 (degraded key shape) — invalid. attemptIdempotencyKey takes the caller-supplied key verbatim (trim().slice(0, 128)) and validates no shape. The extra :degraded: segment is deliberate — it must not collide with an already-committed row at the same phase — and a refusal is already handled by retrying without the marker.
  • check-plan.ts:491 P2 (200 with unparseable body → unreachable rather than fail-closed) — cosmetic. Both paths land on infra_error; only the reason string differs. Worth revisiting if the reason strings start driving anything.
  • service-route.ts:35 P3 (share the transport with Scheduled Evals) — out of scope, a cross-subsystem refactor that belongs in its own PR.

@chelojimenez
chelojimenez merged commit 0fdab98 into main Aug 3, 2026
13 checks passed
@chelojimenez
chelojimenez deleted the feat/github-checks-plan-client branch August 3, 2026 16:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant