Skip to content

feat(gate): gate.ignoredCheckRuns — exclude a third-party check from CI resolution entirely - #9813

Merged
JSONbored merged 2 commits into
mainfrom
feat/ignored-check-runs
Jul 29, 2026
Merged

feat(gate): gate.ignoredCheckRuns — exclude a third-party check from CI resolution entirely#9813
JSONbored merged 2 commits into
mainfrom
feat/ignored-check-runs

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Problem

gate.advisoryCheckRuns stops a non-passing third-party check from gating — but still routes it to a manual-review hold. For a check whose verdict carries no signal for your repo, that trades a stall for a queue of human decisions.

Live on the ORB: a vendor app publishes both a real security scan and a heuristic "Contributor trust" score. The trust score fails for known-good contributors, so every one of their otherwise-flawless PRs becomes a manual review. This previously auto-closed those PRs (#4414); the fix made it hold instead — better, but it still means the maintainer touches every PR, and contributors are left wondering whether the bar is being applied fairly.

Manual-review volume is the actual complaint: 277 hold decision-records across 75 contributor PRs in 14 days, and this class is a large share of it.

The feature

gate:
  advisoryCheckRuns:                 # unchanged: excluded from CI, but a failure HOLDS
    - { name: Security scan, appSlug: example-security-app }
  ignoredCheckRuns:                  # new: excluded from CI, and never holds
    - { name: Contributor trust, appSlug: example-security-app }

A matched run is treated as if it did not exist — never gates, never counts as "still running", never holds. Its conclusion is surfaced as ignoredCheckDetails (informational only) so the exclusion is visible rather than the check silently vanishing.

Design points worth review:

  • Same anti-spoof matching as advisory: { name, appSlug } both required; a name-only match is untrusted, so a contributor-controlled same-name run from another app can't get itself ignored.
  • Sibling checks are unaffected — the vendor's security scan keeps gating while its trust score is ignored. That's the whole point, and it has its own test.
  • Ignore wins over advisory when a check is in both lists: it is the stronger, more explicit intent.
  • Cache correctness: both live-fact memo keys and the durable CI-state cache key now include the ignore list, exactly as they already do for advisoryCheckRuns — otherwise a stale entry keeps gating on a check you just ignored.
  • Trailing-optional throughout: unset ⇒ byte-identical behavior for every existing repo and self-hoster.

Tests

6 behavioral cases in backfill-2.test.ts: the motivating regression (failing ignored check ⇒ passed, no hold — the decisive difference from advisory), sibling-still-gates, pending-doesn't-stall, ignore-beats-advisory, spoof resistance, and unset-is-unchanged. Plus manifest parse/serialize/resolve coverage (810 pass) and the executor's call-shape assertions updated to pin the new argument rather than loosened.

tsc, docs:drift-check, manifest:drift-check, engine-parity:drift-check, ui:openapi:check, contract:api-schemas:check, ui-derived-types:check, dead-source-files:check all green. .loopover.yml.example documents the field and when to reach for it over advisoryCheckRuns.

After this merges

Server-side config change on all three repos: move Contributor trust from advisoryCheckRuns to ignoredCheckRuns, keeping the security scan gating. Tracked alongside the broader manual-review reduction work in #9808.

…CI entirely

advisoryCheckRuns stops a non-passing third-party check from GATING, but still
routes it to a manual-review hold. For a check whose verdict carries no signal
for a repo, that trades a stall for a queue of human decisions: on the ORB, a
vendor's heuristic contributor-trust score fails for known-good contributors, so
every one of their otherwise-flawless PRs became a manual review -- automation
replaced by maintainer time, and contributors left wondering whether the bar is
applied fairly.

Add gate.ignoredCheckRuns: same spoof-resistant { name, appSlug } matching, but a
matched run is treated as if it did not exist -- never gates, never counts as
pending, never holds. Its conclusion is surfaced in the aggregate as
ignoredCheckDetails (informational only) so the exclusion stays visible instead
of the check silently vanishing.

Sibling checks from the SAME app are unaffected: a vendor's real security scan
keeps gating while its trust score is ignored. If a check is named in both lists
ignore wins, being the stronger and more explicit intent.

Both live-fact memo keys and the durable CI-state cache key now include the
ignore list, so changing it invalidates cached aggregates the way changing
advisoryCheckRuns already does. Trailing-optional throughout: unset means
byte-identical behavior for every existing repo and self-hoster.
@loopover-orb

loopover-orb Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Important

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏳ LoopOver is waiting…

LoopOver has seen this pull request and is waiting on CI checks to finish before reviewing it. This comment will update once the review runs.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed · 🟨 Waiting

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
loopover-ui cfe35e5 Commit Preview URL

Branch Preview URL
Jul 29 2026, 11:01 AM

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

❌ 6 Tests Failed:

Tests completed Failed Passed Skipped
25103 6 25097 21
View the top 3 failed test(s) by shortest run time
test/unit/config-templates.test.ts > config/examples review templates (#1682) > loopover.full.yml body matches .loopover.yml.example from WHERE IT LIVES onward
Stack Traces | 0.0352s run time
AssertionError: expected '# WHERE IT LIVES (first match wins):\…' to be '# WHERE IT LIVES (first match wins):\…' // Object.is equality

- Expected
+ Received

@@ -260,29 +260,10 @@
    # column or dashboard toggle.
    advisoryCheckRuns:
      - name: Contributor trust
        appSlug: example-security-app

-   # Check-runs to IGNORE ENTIRELY (#9810) — the stronger sibling of advisoryCheckRuns above. Same
-   # spoof-resistant { name, appSlug } matching, but a matched run is treated as if it did not exist: it never
-   # gates CI, never counts as "still running", and — unlike advisory — never routes the PR to a manual-review
-   # hold either. Its conclusion is surfaced informationally only.
-   #
-   # Use this when a check's verdict carries no signal for YOUR repo while OTHER checks from the same app stay
-   # meaningful. The motivating case: a vendor app publishes both a real security scan AND a heuristic
-   # contributor-trust score. The scan is worth gating on; the trust score fails for perfectly good
-   # contributors, and listing it under advisoryCheckRuns still converts every one of their otherwise-clean PRs
-   # into a manual review — automation replaced by a queue of human decisions, and contributors left wondering
-   # whether they are being judged fairly. Ignoring the trust check keeps the scan's protection and drops the
-   # noise. If BOTH lists name the same check, ignore wins (it is the stronger, more explicit intent).
-   #
-   # List of { name, appSlug }, or omit. Default: not configured (byte-identical behavior for every repo that
-   # doesn't opt in). Config-as-code only — no DB column or dashboard toggle.
-   ignoredCheckRuns:
-     - name: Contributor trust
-       appSlug: example-security-app
-
    # Promote a confident AI-judgment-only finding (one the reviewer itself placed under "Blockers", never
    # a "Nit") into a real, deterministic gate blocker instead of leaving it advisory (#3907). Only matters
    # for repos already running the registry content lane (see contentLane below) — content/registry repos
    # have no schema/lint/codecov net to catch a semantically-wrong-but-structurally-valid defect, so their
    # own AI reviewer's judgment is the only thing that ever catches it.

 ❯ test/unit/config-templates.test.ts:39:57
test/unit/agent-approval-queue.test.ts > agent approval queue (#779) > falls back to expectedCiContexts when the accept-time branch-protection read fails
Stack Traces | 0.0626s run time
AssertionError: expected "vi.fn()" to be called with arguments: [ { …(29) }, 'owner/repo', 'h7', …(4) ]

Received:

  1st vi.fn() call:

@@ -46,12 +46,13 @@
        "send": [Function send],
      },
    },
    "owner/repo",
    "h7",
-   Any<String>,
+   "test-installation-token",
    Set {
      "build",
    },
-   Any<String>,
+   "installation:5",
+   null,
    null,
  ]


Number of calls: 1

 ❯ test/unit/agent-approval-queue.test.ts:617:34
test/unit/agent-approval-queue.test.ts > agent approval queue (#779) > unions branch-protection contexts into the accept-time live CI re-check
Stack Traces | 0.0727s run time
AssertionError: expected "vi.fn()" to be called with arguments: [ { …(29) }, 'owner/repo', 'h7', …(4) ]

Received:

  1st vi.fn() call:

@@ -46,13 +46,14 @@
        "send": [Function send],
      },
    },
    "owner/repo",
    "h7",
-   Any<String>,
+   "test-installation-token",
    Set {
      "branch-required",
      "build",
    },
-   Any<String>,
+   "installation:5",
+   undefined,
    undefined,
  ]

  2nd vi.fn() call:

@@ -46,13 +46,14 @@
        "send": [Function send],
      },
    },
    "owner/repo",
    "h7",
-   Any<String>,
+   "test-installation-token",
    Set {
      "branch-required",
      "build",
    },
-   Any<String>,
-   undefined,
+   "installation:5",
+   null,
+   null,
  ]


Number of calls: 2

 ❯ test/unit/agent-approval-queue.test.ts:602:34
test/unit/agent-approval-queue.test.ts > agent approval queue (#779) > threads the repo's expectedCiContexts into the accept-time live CI re-check
Stack Traces | 0.0989s run time
AssertionError: expected "vi.fn()" to be called with arguments: [ { …(29) }, 'owner/repo', 'h7', …(4) ]

Received:

  1st vi.fn() call:

@@ -46,13 +46,14 @@
        "send": [Function send],
      },
    },
    "owner/repo",
    "h7",
-   Any<String>,
+   "test-installation-token",
    Set {
      "build",
      "test",
    },
-   Any<String>,
+   "installation:5",
+   undefined,
    undefined,
  ]

  2nd vi.fn() call:

@@ -46,13 +46,14 @@
        "send": [Function send],
      },
    },
    "owner/repo",
    "h7",
-   Any<String>,
+   "test-installation-token",
    Set {
      "build",
      "test",
    },
-   Any<String>,
-   undefined,
+   "installation:5",
+   null,
+   null,
  ]


Number of calls: 2

 ❯ test/unit/agent-approval-queue.test.ts:586:34
test/unit/queue.test.ts > queue processors > surfaces stale optional CI after the stale-CI cap
Stack Traces | 0.314s run time
AssertionError: expected "fetchLiveCiAggregatePreferGraphQl" to be called with arguments: [ Anything, 'owner/agent-repo', …(5) ]

Received:

  1st fetchLiveCiAggregatePreferGraphQl call:

  [
-   Anything,
+   {
+     "ADMIN_GITHUB_LOGINS": "jsonbored",
+     "AI_REVIEW_CLOSE_CONFIDENCE_AUTOTUNE_ENABLED": "false",
+     "AI_REVIEW_CLOSE_CONFIDENCE_TIGHTEN_ENABLED": "false",
+     "CONFIG_DRIFT_SENTINEL_ENABLED": "false",
+     "DB": TestD1Database {
+       "db": DatabaseSync {
+         "isOpen": true,
+         "isTransaction": false,
+         Symbol(sqlite-type): "node:sqlite",
+       },
+     },
+     "GITHUB_APP_ID": "3824093",
+     "GITHUB_APP_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDkJS/tbJPwgJDe
+ pWBaCnLmSw3LY4lWzLd/IjXtqtxVmh5MSMc2zpuD76r1nCh8whzlTSSlVQU/BSNX
+ .../MFo/wVkYg7uMBgHhvuxv/VTPWe9Jfsq6f79K6OsPSXUfY0z
+ XyMzZIUqTvkjITn53z5TqmBkU4NgbIi6xVpQQlrK5OU/foCsKUuL/Yg89v01ZuOa
+ ik9lBQboH952pv/7Oiy+4KxBPM+ptER/JzVxQ9kcCxBaxdRN44UbcR4uLVq7dfPQ
+ WD52nLA12xkqEYiIqqU1MO3xbr5AKB3Oduj1cOLTQXiSW7BC3moC7GyAOUHCrl2+
+ WiMsn4TfAgMBAAECggEARuKhBhFu09J2cJ8MlV1avuAB/LhEA1KfDuLHw70LMZOt
+ p0ETmJ1QJ4mvYhmvYLgFN1+lK7ODakXD6fcKaNQ1P0L5Ne7GNhGH3IIkIEDxZ6NM
+ zAXuLRe59y+EjHoBFJ/YqLvG4myrVN7foN8F9mFcmL0p3nbj5xWsbV1qDrH+B1aL
+ SlDcQ5gJIoJdiQ7ID52kYwzANznxyW1JvUvpt+CUre6/uVMdKgE2Drf9G2r6wgql
+ FdRBPobeNx8x5xgJhjlb1ddQS4cK3jRGxGJPKf2SK8pgn+dWZR9ngpITJz8RUMw6
+ OQFPYHhsMzRLVfBa89WxVQLeeE/Nm9BBsePtCm5GcQKBgQD67FhUJvfCAd69LrDr
+ XwTNtnnJ/sSlJ9jZH3vZLRtxVTupP0w9qMV2zYyjcl8byrQnW6Vc/jgv8iTVGUDl
+ HWJDt4OgRoxMrPcyeMnf/eecZBU6VSt502nzODCITsiAQA2w5Uy4PU4LbyEP190K
+ Pkfu0OpHRRhIi7XZjAs5Y5Nu0QKBgQDowt0pAWpE+Pr97ME5KvM4qOaTWnnEHK2d
+ Hva2fWHccvJRcJKEQILAxKcRuQ7+6Fe/Cvy+UisDPKBspfCekt1LlXXEBYV6sHXH
+ cza/OQ07LZ/Sa15hraSPcL1QywJp+rpkmQLCYftYCKoXn2ytkkYeOmkK0ikgpU/P
+ lVzQBUOErwKBgQCuP8iLD9CH6pKKX80+dmq4jFqA+qoGYuiExwdTZq6jAmR/ANTq
+ ELSn7tAWhQslGpwGpO7t6CCOsyH+1h2pvcEKc3s1pygw6wBzDUxrf0AC68GOE60w
+ Wa8+1qtlTQiRf2nCiUnAu9nhuNeF0ArQ3TVI082Nlp7pahPrsgPtY4fzsQKBgDmr
+ vnxge2InhpjPxnhRqf3J64drDWyH5oRdnadNnaevW1bOW58lFMCKeRKtFYgXLVaK
+ dGD7nuioZt9Epj/EB8yodYtiCtaBTk1iwJfUnPCit0y9iZ8OFQ3tUxrnkeuMUg6G
+ 0uYyvMHtM6eyuoyvB2yJ8Gjb9ZC/JO08BskL5NTFAoGAeWOqYd2BKFJPrNyw2qIA
+ .../Ev3/Aygy8srsI9CTeXEub0imNP/uPu0qz6riffHVgksf4LoDL
+ Ze6UiPiZea2mJFcsAMm/QcoxxKYw9yJvTWfSCmXRXzo9YeDxxvL+Lp9k+CgYEJ9L
+ um550x1qhJ2o/0s6Q4rTE9I=
+ -----END PRIVATE KEY-----",
+     "GITHUB_APP_SLUG": "loopover-orb",
+     "GITHUB_WEBHOOK_SECRET": "test-webhook-secret",
+     "GITTENSOR_REGISTRY_URL": "https://raw.githubusercontent..../validator/weights/master_repositories.json",
+     "GITTENSOR_UPSTREAM_REF": "test",
+     "GITTENSOR_UPSTREAM_REPO": "entrius/gittensor",
+     "INTERNAL_JOB_TOKEN": "dev-internal-token",
+     "JOBS": {
+       "send": [Function send],
+     },
+     "LOOPOVER_API_TOKEN": "test-api-token",
+     "LOOPOVER_AUTO_FILE_DRIFT_ISSUES": "false",
+     "LOOPOVER_DRIFT_ISSUE_REPO": "test-harness/no-self-repo-match",
+     "LOOPOVER_EXPERIMENTAL_GITTENSOR": "false",
+     "LOOPOVER_FAIRNESS_ANALYTICS": "false",
+     "LOOPOVER_MCP_TOKEN": "test-mcp-token",
+     "LOOPOVER_REVIEW_REPOS": "JSONbored/gittensory,acme/widgets",
+     "LOOPOVER_SKIP_AUTOMATION_BOT_PRS": "true",
+     "MCP_ACTUATION_REPO_ALLOWLIST": "*",
+     "MCP_READ_REPO_ALLOWLIST": "*",
+     "PUBLIC_API_ORIGIN": "https://api.loopover.ai",
+     "PUBLIC_SITE_ORIGIN": "https://loopover.ai",
+     "SATISFACTION_FLOOR_AUTOTUNE_ENABLED": "false",
+     "SELFHOST_TRANSIENT_CACHE": {
+       "claim": [Function claim],
+       "del": [Function del],
+       "get": [Function get],
+       "releaseIfValue": [Function releaseIfValue],
+       "set": [Function set],
+     },
+     "WEBHOOKS": {
+       "send": [Function send],
+     },
+   },
    "owner/agent-repo",
    "a7",
-   Any<String>,
+   "installation-token",
    Set {
      "trusted-required-ci",
    },
    "installation:9001",
+   undefined,
    undefined,
  ]

  2nd fetchLiveCiAggregatePreferGraphQl call:

  [
-   Anything,
+   {
+     "ADMIN_GITHUB_LOGINS": "jsonbored",
+     "AI_REVIEW_CLOSE_CONFIDENCE_AUTOTUNE_ENABLED": "false",
+     "AI_REVIEW_CLOSE_CONFIDENCE_TIGHTEN_ENABLED": "false",
+     "CONFIG_DRIFT_SENTINEL_ENABLED": "false",
+     "DB": TestD1Database {
+       "db": DatabaseSync {
+         "isOpen": true,
+         "isTransaction": false,
+         Symbol(sqlite-type): "node:sqlite",
+       },
+     },
+     "GITHUB_APP_ID": "3824093",
+     "GITHUB_APP_PRIVATE_KEY": "-----BEGIN PRIVATE KEY-----
+ MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDkJS/tbJPwgJDe
+ pWBaCnLmSw3LY4lWzLd/IjXtqtxVmh5MSMc2zpuD76r1nCh8whzlTSSlVQU/BSNX
+ .../MFo/wVkYg7uMBgHhvuxv/VTPWe9Jfsq6f79K6OsPSXUfY0z
+ XyMzZIUqTvkjITn53z5TqmBkU4NgbIi6xVpQQlrK5OU/foCsKUuL/Yg89v01ZuOa
+ ik9lBQboH952pv/7Oiy+4KxBPM+ptER/JzVxQ9kcCxBaxdRN44UbcR4uLVq7dfPQ
+ WD52nLA12xkqEYiIqqU1MO3xbr5AKB3Oduj1cOLTQXiSW7BC3moC7GyAOUHCrl2+
+ WiMsn4TfAgMBAAECggEARuKhBhFu09J2cJ8MlV1avuAB/LhEA1KfDuLHw70LMZOt
+ p0ETmJ1QJ4mvYhmvYLgFN1+lK7ODakXD6fcKaNQ1P0L5Ne7GNhGH3IIkIEDxZ6NM
+ zAXuLRe59y+EjHoBFJ/YqLvG4myrVN7foN8F9mFcmL0p3nbj5xWsbV1qDrH+B1aL
+ SlDcQ5gJIoJdiQ7ID52kYwzANznxyW1JvUvpt+CUre6/uVMdKgE2Drf9G2r6wgql
+ FdRBPobeNx8x5xgJhjlb1ddQS4cK3jRGxGJPKf2SK8pgn+dWZR9ngpITJz8RUMw6
+ OQFPYHhsMzRLVfBa89WxVQLeeE/Nm9BBsePtCm5GcQKBgQD67FhUJvfCAd69LrDr
+ XwTNtnnJ/sSlJ9jZH3vZLRtxVTupP0w9qMV2zYyjcl8byrQnW6Vc/jgv8iTVGUDl
+ HWJDt4OgRoxMrPcyeMnf/eecZBU6VSt502nzODCITsiAQA2w5Uy4PU4LbyEP190K
+ Pkfu0OpHRRhIi7XZjAs5Y5Nu0QKBgQDowt0pAWpE+Pr97ME5KvM4qOaTWnnEHK2d
+ Hva2fWHccvJRcJKEQILAxKcRuQ7+6Fe/Cvy+UisDPKBspfCekt1LlXXEBYV6sHXH
+ cza/OQ07LZ/Sa15hraSPcL1QywJp+rpkmQLCYftYCKoXn2ytkkYeOmkK0ikgpU/P
+ lVzQBUOErwKBgQCuP8iLD9CH6pKKX80+dmq4jFqA+qoGYuiExwdTZq6jAmR/ANTq
+ ELSn7tAWhQslGpwGpO7t6CCOsyH+1h2pvcEKc3s1pygw6wBzDUxrf0AC68GOE60w
+ Wa8+1qtlTQiRf2nCiUnAu9nhuNeF0ArQ3TVI082Nlp7pahPrsgPtY4fzsQKBgDmr
+ vnxge2InhpjPxnhRqf3J64drDWyH5oRdnadNnaevW1bOW58lFMCKeRKtFYgXLVaK
+ dGD7nuioZt9Epj/EB8yodYtiCtaBTk1iwJfUnPCit0y9iZ8OFQ3tUxrnkeuMUg6G
+ 0uYyvMHtM6eyuoyvB2yJ8Gjb9ZC/JO08BskL5NTFAoGAeWOqYd2BKFJPrNyw2qIA
+ .../Ev3/Aygy8srsI9CTeXEub0imNP/uPu0qz6riffHVgksf4LoDL
+ Ze6UiPiZea2mJFcsAMm/QcoxxKYw9yJvTWfSCmXRXzo9YeDxxvL+Lp9k+CgYEJ9L
+ um550x1qhJ2o/0s6Q4rTE9I=
+ -----END PRIVATE KEY-----",
+     "GITHUB_APP_SLUG": "loopover-orb",
+     "GITHUB_WEBHOOK_SECRET": "test-webhook-secret",
+     "GITTENSOR_REGISTRY_URL": "https://raw.githubusercontent..../validator/weights/master_repositories.json",
+     "GITTENSOR_UPSTREAM_REF": "test",
+     "GITTENSOR_UPSTREAM_REPO": "entrius/gittensor",
+     "INTERNAL_JOB_TOKEN": "dev-internal-token",
+     "JOBS": {
+       "send": [Function send],
+     },
+     "LOOPOVER_API_TOKEN": "test-api-token",
+     "LOOPOVER_AUTO_FILE_DRIFT_ISSUES": "false",
+     "LOOPOVER_DRIFT_ISSUE_REPO": "test-harness/no-self-repo-match",
+     "LOOPOVER_EXPERIMENTAL_GITTENSOR": "false",
+     "LOOPOVER_FAIRNESS_ANALYTICS": "false",
+     "LOOPOVER_MCP_TOKEN": "test-mcp-token",
+     "LOOPOVER_REVIEW_REPOS": "JSONbored/gittensory,acme/widgets",
+     "LOOPOVER_SKIP_AUTOMATION_BOT_PRS": "true",
+     "MCP_ACTUATION_REPO_ALLOWLIST": "*",
+     "MCP_READ_REPO_ALLOWLIST": "*",
+     "PUBLIC_API_ORIGIN": "https://api.loopover.ai",
+     "PUBLIC_SITE_ORIGIN": "https://loopover.ai",
+     "SATISFACTION_FLOOR_AUTOTUNE_ENABLED": "false",
+     "SELFHOST_TRANSIENT_CACHE": {
+       "claim": [Function claim],
+       "del": [Function del],
+       "get": [Function get],
+       "releaseIfValue": [Function releaseIfValue],
+       "set": [Function set],
+     },
+     "WEBHOOKS": {
+       "send": [Function send],
+     },
+   },
    "owner/agent-repo",
    "a7",
-   Any<String>,
+   "installation-token",
    Set {
      "trusted-required-ci",
    },
    "installation:9001",
+   undefined,
    undefined,
  ]


Number of calls: 2

 ❯ test/unit/queue.test.ts:1872:25
test/unit/queue.test.ts > queue processors > durable CI-state snapshot cache (#selfhost-ci-verification, cross-job) > REGRESSION (#selfhost-ci-verification gate review): a swallowed branch-protection read failure never writes the fail-open aggregate through to the durable cache
Stack Traces | 0.347s run time
AssertionError: expected { …(24) } to match object { ciState: 'passed', …(1) }
(22 matching properties omitted from actual)

- Expected
+ Received

  {
-   "ciRequiredContextsKey": "[\"trusted-required-ci\"]|adv:",
+   "ciRequiredContextsKey": "[\"trusted-required-ci\"]|adv:|ign:",
    "ciState": "passed",
  }

 ❯ test/unit/queue.test.ts:3172:81

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@JSONbored JSONbored self-assigned this Jul 29, 2026
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 674 bytes (0.01%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
loopover-ui 7.89MB 674 bytes (0.01%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: loopover-ui

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/add-scalar-classes-B4rd6hz9.js (New) 2.16MB 2.16MB 100.0% 🚀
assets/tanstack-vendor-CmnX2f_3.js (New) 928.53kB 928.53kB 100.0% 🚀
openapi.json 476 bytes 746.61kB 0.06%
assets/docs.fumadocs-spike-api-reference-CDdDZ54G.js (New) 443.45kB 443.45kB 100.0% 🚀
assets/AgentScalarChatInterface.vue-DuE6TdWz.js (New) 201.7kB 201.7kB 100.0% 🚀
assets/modal-CtrG4gnE.js (New) 184.5kB 184.5kB 100.0% 🚀
assets/client-BJz6DoqU.js (New) 151.47kB 151.47kB 100.0% 🚀
assets/maintainer-panel-A_r8zCQw.js (New) 78.99kB 78.99kB 100.0% 🚀
assets/routes-BhCl1uhu.js (New) 35.96kB 35.96kB 100.0% 🚀
assets/owner-panel-Bfuiql37.js (New) 27.99kB 27.99kB 100.0% 🚀
assets/app-BaeFnw5o.js (New) 25.78kB 25.78kB 100.0% 🚀
assets/ui-vendor-CuxhGzNB.js (New) 24.57kB 24.57kB 100.0% 🚀
assets/miner-panel-6RkMM1VF.js (New) 20.24kB 20.24kB 100.0% 🚀
assets/app.runs-xIi0YQrS.js (New) 20.22kB 20.22kB 100.0% 🚀
assets/api._op-CjsB1oJL.js (New) 17.57kB 17.57kB 100.0% 🚀
assets/self-hosting-docs-audit-B85Qu5El.js (New) 16.6kB 16.6kB 100.0% 🚀
assets/docs._slug-IGnQhdh3.js (New) 15.52kB 15.52kB 100.0% 🚀
assets/playground-panel-CGPeJ4zZ.js (New) 14.42kB 14.42kB 100.0% 🚀
assets/fairness-C4h2okn6.js (New) 13.98kB 13.98kB 100.0% 🚀
assets/app.audit-BbFHKBG2.js (New) 10.08kB 10.08kB 100.0% 🚀
assets/app.config-generator-Cv3AXRpp.js (New) 10.06kB 10.06kB 100.0% 🚀
assets/maintainers-Bp9DKFWB.js (New) 8.06kB 8.06kB 100.0% 🚀
assets/miners-BIvKLkPq.js (New) 7.91kB 7.91kB 100.0% 🚀
assets/agents-BYc7WsIf.js (New) 7.74kB 7.74kB 100.0% 🚀
assets/commands-panel-CFJPLmp2.js (New) 6.65kB 6.65kB 100.0% 🚀
assets/maintainer-workflow-DgRMDOcs.js (New) 6.52kB 6.52kB 100.0% 🚀
assets/digest-panel-q9Iv6zEK.js (New) 6.15kB 6.15kB 100.0% 🚀
assets/repos._owner._repo.quality-CE2zw-OG.js (New) 6.14kB 6.14kB 100.0% 🚀
assets/docs-nav-BvfvnFWG.js (New) 6.01kB 6.01kB 100.0% 🚀
assets/docs.index-ZYgHBhzT.js (New) 5.95kB 5.95kB 100.0% 🚀
assets/api.index-O2sPqAdA.js (New) 4.7kB 4.7kB 100.0% 🚀
assets/docs-CvloreDo.js (New) 2.7kB 2.7kB 100.0% 🚀
assets/api-TtmX05Js.js (New) 2.69kB 2.69kB 100.0% 🚀
assets/docs-page-ChCkllRb.js (New) 2.1kB 2.1kB 100.0% 🚀
assets/table-BXJdvJUF.js (New) 1.75kB 1.75kB 100.0% 🚀
assets/app.workbench-DVb4Va0z.js (New) 1.58kB 1.58kB 100.0% 🚀
assets/tabs-J4v6bFSt.js (New) 1.39kB 1.39kB 100.0% 🚀
assets/app.repos-DQFYBizj.js (New) 1.07kB 1.07kB 100.0% 🚀
assets/input-Cr6thSRQ.js (New) 796 bytes 796 bytes 100.0% 🚀
assets/file-cog-Besz4T-Y.js (New) 758 bytes 758 bytes 100.0% 🚀
assets/app.maintainer-BuZ-XjHL.js (New) 502 bytes 502 bytes 100.0% 🚀
assets/app.owner-BPNxlG0g.js (New) 474 bytes 474 bytes 100.0% 🚀
assets/app.commands-NZWPvfSt.js (New) 455 bytes 455 bytes 100.0% 🚀
assets/app.playground-BCo9bDYn.js (New) 442 bytes 442 bytes 100.0% 🚀
assets/index-BTsaZ_We.js (New) 438 bytes 438 bytes 100.0% 🚀
assets/app.digest-BcJlmOuR.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/eye-off-y6QFA6Az.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/app.miner-Cwf0mUsB.js (New) 422 bytes 422 bytes 100.0% 🚀
assets/key-round-BnPZcyIn.js (New) 355 bytes 355 bytes 100.0% 🚀
assets/bot-B0VVR3Za.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/trash-2-BsQtwpzW.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/save-B7ygFrc_.js (New) 327 bytes 327 bytes 100.0% 🚀
assets/git-pull-request-arrow-C8vKcZu1.js (New) 321 bytes 321 bytes 100.0% 🚀
assets/list-checks-QNEgChHS.js (New) 279 bytes 279 bytes 100.0% 🚀
assets/compass-tPn7gKVc.js (New) 251 bytes 251 bytes 100.0% 🚀
assets/history-CpP6pdqn.js (New) 237 bytes 237 bytes 100.0% 🚀
assets/message-square-bQdRmT4b.js (New) 233 bytes 233 bytes 100.0% 🚀
assets/lock-Dbdo2QL_.js (New) 206 bytes 206 bytes 100.0% 🚀
assets/rotate-cw-DXJaC74f.js (New) 201 bytes 201 bytes 100.0% 🚀
assets/play-Dukb0p8d.js (New) 190 bytes 190 bytes 100.0% 🚀
assets/circle-check-iESuMrLt.js (New) 178 bytes 178 bytes 100.0% 🚀
assets/search-CAJ1bajm.js (New) 174 bytes 174 bytes 100.0% 🚀
assets/add-scalar-classes-Co4bxscz.js (Deleted) -2.16MB 0 bytes -100.0% 🗑️
assets/tanstack-vendor-BrLkyrp7.js (Deleted) -928.35kB 0 bytes -100.0% 🗑️
assets/docs.fumadocs-spike-api-reference-DfTn6R6t.js (Deleted) -443.45kB 0 bytes -100.0% 🗑️
assets/AgentScalarChatInterface.vue-NOan_aMG.js (Deleted) -201.7kB 0 bytes -100.0% 🗑️
assets/modal-WOXR3gUX.js (Deleted) -184.5kB 0 bytes -100.0% 🗑️
assets/client-DuA7DV3N.js (Deleted) -151.47kB 0 bytes -100.0% 🗑️
assets/maintainer-panel-D2txyoLr.js (Deleted) -78.99kB 0 bytes -100.0% 🗑️
assets/routes-BUD43Duw.js (Deleted) -35.96kB 0 bytes -100.0% 🗑️
assets/owner-panel-D1grqkVY.js (Deleted) -27.97kB 0 bytes -100.0% 🗑️
assets/app-BUEzAAxm.js (Deleted) -25.78kB 0 bytes -100.0% 🗑️
assets/ui-vendor-C5Zk7cdo.js (Deleted) -24.57kB 0 bytes -100.0% 🗑️
assets/miner-panel-B9ZOgJfW.js (Deleted) -20.24kB 0 bytes -100.0% 🗑️
assets/app.runs-Ekyd81-a.js (Deleted) -20.22kB 0 bytes -100.0% 🗑️
assets/api._op-B9v07T4F.js (Deleted) -17.57kB 0 bytes -100.0% 🗑️
assets/self-hosting-docs-audit-Dl24X7Sh.js (Deleted) -16.6kB 0 bytes -100.0% 🗑️
assets/docs._slug-Laux4Rro.js (Deleted) -15.52kB 0 bytes -100.0% 🗑️
assets/playground-panel-DclhOVAD.js (Deleted) -14.42kB 0 bytes -100.0% 🗑️
assets/fairness-Biy4-HRq.js (Deleted) -13.98kB 0 bytes -100.0% 🗑️
assets/app.audit-_RqbGU90.js (Deleted) -10.08kB 0 bytes -100.0% 🗑️
assets/app.config-generator-iDSNNebO.js (Deleted) -10.06kB 0 bytes -100.0% 🗑️
assets/maintainers-C3GJwuNu.js (Deleted) -8.06kB 0 bytes -100.0% 🗑️
assets/miners-D1zyYrMj.js (Deleted) -7.91kB 0 bytes -100.0% 🗑️
assets/agents-DmTYsj0e.js (Deleted) -7.74kB 0 bytes -100.0% 🗑️
assets/commands-panel-YM6uCe0t.js (Deleted) -6.65kB 0 bytes -100.0% 🗑️
assets/maintainer-workflow-HKB2HzUv.js (Deleted) -6.52kB 0 bytes -100.0% 🗑️
assets/digest-panel-IfWFF3ds.js (Deleted) -6.15kB 0 bytes -100.0% 🗑️
assets/repos._owner._repo.quality-C2P3mODQ.js (Deleted) -6.14kB 0 bytes -100.0% 🗑️
assets/docs-nav-BZW4wpMx.js (Deleted) -6.01kB 0 bytes -100.0% 🗑️
assets/docs.index-Rcc5TwxR.js (Deleted) -5.95kB 0 bytes -100.0% 🗑️
assets/api.index-DJCuwmYz.js (Deleted) -4.7kB 0 bytes -100.0% 🗑️
assets/docs-D3CkX6Y3.js (Deleted) -2.7kB 0 bytes -100.0% 🗑️
assets/api-DGUNEAEd.js (Deleted) -2.69kB 0 bytes -100.0% 🗑️
assets/docs-page-jawKODwl.js (Deleted) -2.1kB 0 bytes -100.0% 🗑️
assets/table-BOvRRjKc.js (Deleted) -1.75kB 0 bytes -100.0% 🗑️
assets/app.workbench-BQ61iCDh.js (Deleted) -1.58kB 0 bytes -100.0% 🗑️
assets/tabs-Cr7FkTc-.js (Deleted) -1.39kB 0 bytes -100.0% 🗑️
assets/app.repos-Bb4hCDDr.js (Deleted) -1.07kB 0 bytes -100.0% 🗑️
assets/input-BeAeWi4a.js (Deleted) -796 bytes 0 bytes -100.0% 🗑️
assets/file-cog-CxWOBttH.js (Deleted) -758 bytes 0 bytes -100.0% 🗑️
assets/app.maintainer-SV2QZgE-.js (Deleted) -502 bytes 0 bytes -100.0% 🗑️
assets/app.owner-BZcFkBha.js (Deleted) -474 bytes 0 bytes -100.0% 🗑️
assets/app.commands-Cf7Jgc82.js (Deleted) -455 bytes 0 bytes -100.0% 🗑️
assets/app.playground-BtOEQUHd.js (Deleted) -442 bytes 0 bytes -100.0% 🗑️
assets/index-BzAArDB0.js (Deleted) -438 bytes 0 bytes -100.0% 🗑️
assets/app.digest-DYxtqglb.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/eye-off-BFKMYgZr.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/app.miner-OJ6QJjbG.js (Deleted) -422 bytes 0 bytes -100.0% 🗑️
assets/key-round-hI5MoUun.js (Deleted) -355 bytes 0 bytes -100.0% 🗑️
assets/bot-DbcCDqdg.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/trash-2-Cfk4PTpD.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/save-COPHD__w.js (Deleted) -327 bytes 0 bytes -100.0% 🗑️
assets/git-pull-request-arrow-BIq6V7ox.js (Deleted) -321 bytes 0 bytes -100.0% 🗑️
assets/list-checks-DKhy3zDq.js (Deleted) -279 bytes 0 bytes -100.0% 🗑️
assets/compass-D1D2dVCC.js (Deleted) -251 bytes 0 bytes -100.0% 🗑️
assets/history-C7_c8eS9.js (Deleted) -237 bytes 0 bytes -100.0% 🗑️
assets/message-square-pqmOfA5s.js (Deleted) -233 bytes 0 bytes -100.0% 🗑️
assets/lock-DYpBx3vw.js (Deleted) -206 bytes 0 bytes -100.0% 🗑️
assets/rotate-cw-BNWX5pjV.js (Deleted) -201 bytes 0 bytes -100.0% 🗑️
assets/play-UKVcci2r.js (Deleted) -190 bytes 0 bytes -100.0% 🗑️
assets/circle-check-D8Bv-iR2.js (Deleted) -178 bytes 0 bytes -100.0% 🗑️
assets/search-C71-2cOv.js (Deleted) -174 bytes 0 bytes -100.0% 🗑️

…only files failing codecov/patch

Two things Codecov's patch report surfaced.

REAL BUG: gate.present did not consider ignoredCheckRuns, so a manifest whose
gate block set ONLY that field parsed as present:false -- the block would be
treated as absent and the ignore silently dropped. Found by adding the
parse/round-trip/resolve test the field was missing (the serialize branch was
the 3 uncovered engine lines); the test failed on the presence assertion before
it ever reached serialization. Exactly the class 99% patch coverage exists for.

ARTIFACT: the other 4 flagged lines are in three engine `types/*.ts` modules
that contain ZERO runtime declarations. v8 instruments nothing in a pure type
file, so it reports 0% and any added line counts as uncovered -- the same
artifact src/env.d.ts is already ignored for in codecov.yml. Ignore the three,
and guard the claim: a new test fails if any ignored path ever gains a runtime
declaration, and fails if a listed path stops being listed, so the ignore can
never quietly start hiding real code. Mutation-tested in both directions.
@JSONbored
JSONbored merged commit 84c2462 into main Jul 29, 2026
4 of 5 checks passed
@JSONbored
JSONbored deleted the feat/ignored-check-runs branch July 29, 2026 11:00
JSONbored added a commit that referenced this pull request Jul 29, 2026
…ty scan (#9818)

* chore(superagent): disable contributor-trust scoring, keep the security scan

The contributor-trust check has been posting `action_required` on established
contributors' PRs — shin-core scored 10/100 "dangerous" on PR #9816 — and was a
major source of manual-review volume.

Two independent reasons to turn it off rather than tune it further:

1. The trustedAuthors exemption does not work. shin-core is listed in it and was
   still flagged. An exemption that does not exempt is worse than none: it looks
   handled while every affected PR still stalls.
2. Its verdict has no consumer. LoopOver now lists this check under
   gate.ignoredCheckRuns (#9813) on all three gate repos, so a non-passing result
   no longer gates, pends, or holds anything. Leaving it on produces a permanently
   red check on contributor PRs that nothing acts on — noise that makes good
   contributors think they have failed something.

prScan stays enabled and still gates CI normally: that is the protection worth
having, and it is a different check from the same app. trustedAuthors is kept
(inert) so the history survives if this is ever revisited.

* chore(superagent): drop the trustedAuthors allowlist too

It provably does not work (a listed author was still flagged action_required) and
is inert with contributorTrust disabled. Keeping it would only suggest the repo
has a working exemption mechanism that it does not.
JSONbored added a commit that referenced this pull request Jul 29, 2026
…s parity test requires (#9822)

The second way #9813 left main red. `.loopover.yml.example` documents the new `gate.ignoredCheckRuns`
field; `config/examples/loopover.full.yml` does not -- and
`test/unit/config-templates.test.ts` asserts the two are byte-identical from the WHERE IT LIVES
marker onward, precisely so a field cannot be documented in one place and missing from the other.

The full example now carries the same block. Nothing else in the two bodies differed.
JSONbored added a commit that referenced this pull request Jul 29, 2026
…n change (#9829)

main's CI has been red since #9813 (gate.ignoredCheckRuns) merged -- the last
four ci.yml runs on main all failed, and every branch cut from or merged with
main inherits it. #9804 went red on these tests with no changes of its own
anywhere near them.

#9813's implementation is correct; only the expectations were left behind:

  * fetchLiveCiAggregate / fetchLiveCiAggregatePreferGraphQl gained an eighth
    parameter (ignoredCheckRuns) while four toHaveBeenCalledWith assertions
    still passed seven;
  * the durable cache key gained an `|ign:` segment, while queue.test.ts's
    durable-cache regression still expected `|adv:` alone;
  * the whole ignoredCheckRuns block (19 lines) was added to
    .loopover.yml.example but not to config/examples/loopover.full.yml, which
    config-templates.test.ts requires byte-identical from `# WHERE IT LIVES`
    onward.

No production behaviour changes. This matters beyond a red build: the gate
auto-CLOSES a contributor PR on red CI, so a contributor who opened a PR after
#9813 landed would be closed for a defect that is not theirs and that they
cannot fix -- the same harm #9798 and #9801 describe, except this one does not
self-correct.

Closes #9828
JSONbored added a commit that referenced this pull request Jul 29, 2026
…either

gate.ignoredCheckRuns (#9813) excluded the check from LoopOver's own CI
aggregate, but mergeable_state is GITHUB's computation and stays "unstable"
while the check exists at all -- and derivePrDisposition holds unconditionally
on unstable. So the ignore was half-effective: the check no longer failed the
gate, and the PR was held anyway. Observed live on #9816
immediately after the config flip, reason "mergeable_state is unstable --
non-required check(s) not passing: Contributor trust".

Dismiss an unstable state ONLY when the ignore list fully explains it: at least
one ignored run concluded non-passing, our aggregate found no non-required
failure, and ciState is not failed. Any other unstable cause still holds, and
the flag never rescues a PR held for a different reason.

Also make the un-itemized unstable message actionable. It used to say only "a
non-required check or status is not passing" -- no check name, no next step.
GitHub never says why, and the aggregate can legitimately fail to itemize it (a
commit status rather than a check-run, an unreadable app page, a run that
appeared after CI was read). Name that ambiguity and point at the Checks tab.
JSONbored added a commit that referenced this pull request Jul 29, 2026
…either

gate.ignoredCheckRuns (#9813) excluded the check from LoopOver's own CI
aggregate, but mergeable_state is GITHUB's computation and stays "unstable"
while the check exists at all -- and derivePrDisposition holds unconditionally
on unstable. So the ignore was half-effective: the check no longer failed the
gate, and the PR was held anyway. Observed live on #9816
immediately after the config flip, reason "mergeable_state is unstable --
non-required check(s) not passing: Contributor trust".

Dismiss an unstable state ONLY when the ignore list fully explains it: at least
one ignored run concluded non-passing, our aggregate found no non-required
failure, and ciState is not failed. Any other unstable cause still holds, and
the flag never rescues a PR held for a different reason.

Also make the un-itemized unstable message actionable. It used to say only "a
non-required check or status is not passing" -- no check name, no next step.
GitHub never says why, and the aggregate can legitimately fail to itemize it (a
commit status rather than a check-run, an unreadable app page, a run that
appeared after CI was read). Name that ambiguity and point at the Checks tab.
JSONbored added a commit that referenced this pull request Jul 29, 2026
…nvocation

Review blocker on #9821, and correct: resolveReviewKnobs was computed, logged,
and then DROPPED. Only selfConsistencyRuns was consumed, so "choose provider,
model, effort" was unimplemented at the one place it takes effect -- and the doc
comment claiming the fields "ride through to the provider invocation" was false.

Apply them where review.ai_model's overrides already land:

  claudeModel/codexModel/ollamaModel/openaiModel/openaiCompatibleModel/
  anthropicModel  <- reviewKnobs.model ?? review.ai_model ?? env
  claudeEffort/codexEffort                <- reviewKnobs.effort ?? ... ?? env

reviewKnobs wins over review.ai_model because it is strictly more specific: it is
the only layer that can differ PER PR, and an escalation existing at all means it
should win for the PR that triggered it. Unset ⇒ `??` falls straight through, so
a repo using only review.ai_model is untouched.

Provider also now governs the BYOK key. The providerKey gate runs before the
knobs are resolved and only knew settings.aiReviewProvider, so an ESCALATED
provider would have kept using a stored key belonging to a different one. Same
rule, re-applied with the resolved value: a mismatch drops the key.

Also re-syncs config/examples/loopover.full.yml with .loopover.yml.example --
caught by running the FULL suite this time, which is the same break class that
took main down after #9813.
JSONbored added a commit that referenced this pull request Jul 29, 2026
…either (#9820)

* fix(gate): an ignored check must not hold the PR via mergeable_state either

gate.ignoredCheckRuns (#9813) excluded the check from LoopOver's own CI
aggregate, but mergeable_state is GITHUB's computation and stays "unstable"
while the check exists at all -- and derivePrDisposition holds unconditionally
on unstable. So the ignore was half-effective: the check no longer failed the
gate, and the PR was held anyway. Observed live on #9816
immediately after the config flip, reason "mergeable_state is unstable --
non-required check(s) not passing: Contributor trust".

Dismiss an unstable state ONLY when the ignore list fully explains it: at least
one ignored run concluded non-passing, our aggregate found no non-required
failure, and ciState is not failed. Any other unstable cause still holds, and
the flag never rescues a PR held for a different reason.

Also make the un-itemized unstable message actionable. It used to say only "a
non-required check or status is not passing" -- no check name, no next step.
GitHub never says why, and the aggregate can legitimately fail to itemize it (a
commit status rather than a check-run, an unreadable app page, a run that
appeared after CI was read). Name that ambiguity and point at the Checks tab.

* fix(gate): an ignored check must not hold the PR via mergeable_state either

gate.ignoredCheckRuns (#9813) excluded the check from LoopOver's own CI
aggregate, but mergeable_state is GITHUB's computation and stays "unstable"
while the check exists at all -- and derivePrDisposition holds unconditionally
on unstable. So the ignore was half-effective: the check no longer failed the
gate, and the PR was held anyway. Observed live on #9816
immediately after the config flip, reason "mergeable_state is unstable --
non-required check(s) not passing: Contributor trust".

Dismiss an unstable state ONLY when the ignore list fully explains it: at least
one ignored run concluded non-passing, our aggregate found no non-required
failure, and ciState is not failed. Any other unstable cause still holds, and
the flag never rescues a PR held for a different reason.

Also make the un-itemized unstable message actionable. It used to say only "a
non-required check or status is not passing" -- no check name, no next step.
GitHub never says why, and the aggregate can legitimately fail to itemize it (a
commit status rather than a check-run, an unreadable app page, a run that
appeared after CI was read). Name that ambiguity and point at the Checks tab.
loopover-orb Bot pushed a commit that referenced this pull request Jul 29, 2026
…9837)

contract:api-schemas:check fails on main: #9813 added ignoredCheckRuns to
RepositorySettingsSchema in src/ without committing the regenerated contract
copy. Fourth piece of that PR's fallout, after the three #9829 fixed.

@loopover/contract is what MCP and miner consume, and api-schemas.ts is what an
external client validates a settings write against -- so a client built on the
published contract silently strips ignoredCheckRuns: accepted by the server,
absent from the schema, configuration that appears to save and then does nothing.

Generated output, not hand-edited: `npm run contract:api-schemas`.

Closes #9836
JSONbored added a commit that referenced this pull request Jul 29, 2026
…ard the coverage exposed

Codecov flagged 39 uncovered lines on #9821, and closing them found a REAL bug:
gateConfigToJson's outer aiReview guard was never taught the two new fields, so a
manifest whose aiReview block set ONLY effort/selfConsistencyRuns serialized to
NOTHING -- the setting silently vanished on the next snapshot reload. The new
round-trip assertion failed on exactly that before touching coverage at all.
(An earlier edit had aimed at this guard and hit the presence expression instead;
the round-trip test is what caught the difference.)

Coverage added:
 - engine parse: full/partial/invalid/non-mapping guardrailEscalation, invalid
   effort + runs values warn and stay null, presence flips on EACH of the six
   fields alone (the #9813 presence-gap class, per field), serialize round-trips
   full AND partial blocks
 - resolution: every field lands on effective settings; absent leaves the stored
   value untouched
 - END-TO-END escalation: env self-consistency OFF, manifest hardGuardrailGlobs
   + guardrailEscalation.selfConsistencyRuns=3, a guarded changed file -- the
   pipeline delivers 1 primary + 2 rotated-exemplar extra calls, proving the
   resolved knobs reach the invocation (the #9821 review blocker, now pinned by
   an integration test, not just the precedence mirror)
 - INERT invariant: same escalation block, no guarded file -- zero
   selfConsistency usage rows (counted via the usage marker, not raw AI.run
   calls, matching the flag-off baseline's discipline)
JSONbored added a commit that referenced this pull request Jul 29, 2026
…— per repo, and escalated on guarded paths (#9821)

* feat(gate): choose provider, model, effort and self-consistency runs — per repo, and escalated on guarded paths

Effort DID already exist (CLAUDE_AI_EFFORT -> `claude --effort`, CODEX_AI_EFFORT ->
model_reasoning_effort) but only as a GLOBAL env var, while provider and model were
already per-repo manifest fields. Self-consistency runs had the same asymmetry.
So the two knobs that most directly control review depth were the two a repo could
not set.

Close that, and use it where it matters most. Adds:

  gate.aiReview.effort / .selfConsistencyRuns   — per-repo parity with the env vars
  gate.guardrailEscalation.{provider,model,effort,selfConsistencyRuns}
                                                — used INSTEAD when a PR touches a
                                                  hardGuardrailGlobs path

Precedence resolves per field independently: escalation > per-repo > global. An
escalation block that sets only `effort` still inherits the repo's model and the
global provider -- the common ask is "same model, think harder", not "swap the
model out".

WHY: a guardrail hit used to change exactly one thing -- it suppressed auto-merge
and queued a human. It bought no extra analysis at all: a PR touching
.github/workflows/** got the same single-pass, same-model, same-effort review as a
README typo. Measured on the production ORB that was 74 distinct PRs held in 14
days, on repos whose guardrail lists had already been narrowed twice. A guarded
path can now buy MORE SCRUTINY instead of maintainer toil; manual review stays the
fallback when the escalated review is not clean.

Every layer unset ⇒ null throughout ⇒ byte-identical to today for every existing
repo and self-hoster. An escalation is logged (ai_review_guardrail_escalated) and
describeReviewEscalation renders the one-line "why" for the panel/decision record.

* fix(gate): actually apply the resolved effort/model/provider at the invocation

Review blocker on #9821, and correct: resolveReviewKnobs was computed, logged,
and then DROPPED. Only selfConsistencyRuns was consumed, so "choose provider,
model, effort" was unimplemented at the one place it takes effect -- and the doc
comment claiming the fields "ride through to the provider invocation" was false.

Apply them where review.ai_model's overrides already land:

  claudeModel/codexModel/ollamaModel/openaiModel/openaiCompatibleModel/
  anthropicModel  <- reviewKnobs.model ?? review.ai_model ?? env
  claudeEffort/codexEffort                <- reviewKnobs.effort ?? ... ?? env

reviewKnobs wins over review.ai_model because it is strictly more specific: it is
the only layer that can differ PER PR, and an escalation existing at all means it
should win for the PR that triggered it. Unset ⇒ `??` falls straight through, so
a repo using only review.ai_model is untouched.

Provider also now governs the BYOK key. The providerKey gate runs before the
knobs are resolved and only knew settings.aiReviewProvider, so an ESCALATED
provider would have kept using a stored key belonging to a different one. Same
rule, re-applied with the resolved value: a mismatch drops the key.

Also re-syncs config/examples/loopover.full.yml with .loopover.yml.example --
caught by running the FULL suite this time, which is the same break class that
took main down after #9813.

* test(gate): cover every new manifest branch, and fix the serialize guard the coverage exposed

Codecov flagged 39 uncovered lines on #9821, and closing them found a REAL bug:
gateConfigToJson's outer aiReview guard was never taught the two new fields, so a
manifest whose aiReview block set ONLY effort/selfConsistencyRuns serialized to
NOTHING -- the setting silently vanished on the next snapshot reload. The new
round-trip assertion failed on exactly that before touching coverage at all.
(An earlier edit had aimed at this guard and hit the presence expression instead;
the round-trip test is what caught the difference.)

Coverage added:
 - engine parse: full/partial/invalid/non-mapping guardrailEscalation, invalid
   effort + runs values warn and stay null, presence flips on EACH of the six
   fields alone (the #9813 presence-gap class, per field), serialize round-trips
   full AND partial blocks
 - resolution: every field lands on effective settings; absent leaves the stored
   value untouched
 - END-TO-END escalation: env self-consistency OFF, manifest hardGuardrailGlobs
   + guardrailEscalation.selfConsistencyRuns=3, a guarded changed file -- the
   pipeline delivers 1 primary + 2 rotated-exemplar extra calls, proving the
   resolved knobs reach the invocation (the #9821 review blocker, now pinned by
   an integration test, not just the precedence mirror)
 - INERT invariant: same escalation block, no guarded file -- zero
   selfConsistency usage rows (counted via the usage marker, not raw AI.run
   calls, matching the flag-off baseline's discipline)

* fix(gate): consume the resolved model/effort in ai-review.ts itself, where the reviewer looked

Re-review blocker: the diff still showed only selfConsistencyRuns consumed in
src/services/ai-review.ts. The orchestration DID pre-apply model/effort to the
claude*/codex*/HTTP-model fields, so behavior was correct -- but this module,
which owns the doc comment claiming the fields "ride through to the provider
invocation", never read them, and any OTHER caller passing reviewKnobs would
have been silently ignored.

Fold reviewKnobs.model/effort into the AiRunCorrelation, which IS what becomes
the provider's per-call options at this module's dispatch. Same ?? precedence as
the orchestration, so applying it in both places is idempotent, and this module
is now self-sufficient.

Correct the doc comment to state exactly what is consumed WHERE, including that
`provider` is deliberately NOT consumed here -- it governs BYOK key selection at
the orchestration, before this runs, and rides along for logging/replay only.

Test captures the options the provider actually receives (claudeEffort/
claudeModel/codexEffort/anthropicModel) for a guarded-path PR with an escalation
block, rather than asserting on the precedence mirror.

* test(engine): cover the new manifest branches in the ENGINE's own suite

Codecov kept reporting focus-manifest.ts at ~65% despite the root vitest suite
covering every added line. The reports disagree because they measure differently:
the root v8 run instruments only the 11 lines it actually executes from that
source, while the engine's own c8 run uses --all over dist/ and instruments all
66 -- and the engine's node:test suite never exercised the new branches. Codecov
merges both, so 21 lines read as uncovered no matter how thorough the vitest
tests were.

Add the engine-side tests: parse, per-field presence, serialize round-trip (full
AND partial -- the case whose guard was genuinely broken), invalid-value
rejection, non-mapping escalation, and absent-means-null including that the
serializer emits no escalation block at all.

Engine c8 now reports 0 of 66 added lines uncovered, down from 21.
JSONbored added a commit that referenced this pull request Jul 30, 2026
…stry, write down the coverage rule (#9949)

* chore(guards): pin the focus-manifest field registry, document the coverage asymmetry

Refs #9860 (items 4 and 5).

ITEM 4. FOCUS_MANIFEST_TOP_LEVEL_FIELDS is a hand-kept list of 28 keys whose doc
comment claims it is every top-level key parseFocusManifest actually reads.
Nothing checked that claim, and it is load-bearing both ways: a key the parser
reads but the list omits is reported to operators as an unknown field, while a
key in the list the parser never reads is worse -- the validator blesses it, the
runtime ignores it, and the operator's setting silently does nothing while every
surface says it is fine. #9813 and #9821 each shipped a bug from missing one of
this registry's touchpoints.

The relation is now computed from the parser's own source rather than trusted.
Currently in sync at 28/28, so this pins today's state rather than fixing a live
bug. Source-scanned deliberately: the failure is a key the parser reads and the
list forgot, which produces no behavioural difference to assert on. Includes an
anti-vacuity check, because a refactor renaming the local would otherwise empty
both sets and make the assertions pass while checking nothing.

Not guarded: the parse/serialize pairing. Four parsers (AutoReview, MaxFindings,
SelfHostAiModel, Visual) have no *ConfigToJson of their own because they are
nested under the review config and serialized through it, so a naive 1:1 check would fire
four false positives -- and a checker that cries wolf gets muted.

ITEM 5. The engine coverage asymmetry is now a contributor-facing rule rather
than folklore. packages/loopover-engine/src/** is credited by two uploads whose
hits Codecov unions, and the two runs disagree about which lines exist (c8 --all
instruments files no test imports; v8 does not), so a genuinely 100%-tested
engine change can land at ~65% on codecov/patch. The rule: change engine source,
add the test to packages/loopover-engine/test/** as well.

Also corrects AGENTS.md, which stated that only src/** is measured -- untrue
since the engine flag, and misleading in exactly the direction that costs a PR.

And records the issue's own closing note: ui:typecheck is in test:ci but not in
the root typecheck script, so a locally-clean type change can still break
apps/** (#9815).

* chore(mcp): derive the help banner's --login command list instead of typing it

Refs #9860 (item 1).

The LOOPOVER_LOGIN line in `loopover-mcp --help` named its commands as hand-typed
prose, and it had already drifted: it omitted contributor-profile,
explain-review-risk and watch, all three of which accept --login and resolve it
through the same resolveLogin fallback. A user reading --help was told those
commands would not pick up their configured login. They do.

Derived from CLI_COMMAND_SPEC now -- every command declaring --login in its usage
resolves it that way, and resolveLogin is the single helper that does it, so the
usage table is the fact to read rather than a second list to keep in step.

Also drops a no-op .replace() on the usage line beside it.

Items 1 and 2 of #9860 are otherwise already closed by the MCP contract epic:
STDIO_TOOL_DESCRIPTORS derives from STDIO_TOOL_NAMES, printHelp's command list
derives from CLI_COMMAND_SPEC, the README block is generated by
mcp:tool-reference, and the stdio bin now declares no zod shapes of its own --
all 119 z. uses are z.infer over contract-imported schemas. This line was the
one hand-maintained list left in that surface.
JSONbored added a commit that referenced this pull request Jul 31, 2026
FOCUS_MANIFEST_TOP_LEVEL_FIELDS' own header says "Every top-level key
parseFocusManifest below actually reads." That is a statement about code twenty
lines further down, and nothing enforced it -- the same shape as the turbo.json
inputs comment #9860 calls out as the tell: a snapshot written as a guarantee.

The list is the single source of truth for unknown-field detection (#9065), so
drift is silent in both directions and asymmetrically bad:

  DECLARED BUT NEVER READ -- the parser accepts the field, warns about nothing,
  and ignores it. An operator sets it in .loopover.yml, sees no error, and the
  setting does nothing. This is the worse direction: it looks configured.

  READ BUT NOT DECLARED -- the parser consumes the field while warning "unknown
  top-level field" about it, and config-lint's recognizedFieldsFor leaves it out
  of the recognized count. A working field reported as a mistake.

Adding a field means editing two places twenty lines apart, and #9813 and #9821
each shipped a bug from missing one touchpoint of a multi-touchpoint field.

Computed, not restated: the declared names come out of the array literal and the
used names out of the function body, so this file holds no second list and
cannot itself go stale.

It is a regex over source rather than the TS AST, which is a real tradeoff. The
access pattern is uniform and mechanical, and every extractor THROWS when the
shape it depends on is missing -- a renamed constant, a moved signature, or a
refactor to destructuring all fail loudly instead of matching nothing and
reporting "no drift". A guard that passes vacuously is worse than none, because
its presence reads as coverage.

Holds today at 28 declared and 28 read, empty both ways, so this lands green and
guards forward. Mutation-tested: declaring an unread field, reading an
undeclared one, and removing a still-read declaration each fail with the field
named.

Closes #9979
loopover-orb Bot pushed a commit that referenced this pull request Jul 31, 2026
…ds (#9980)

FOCUS_MANIFEST_TOP_LEVEL_FIELDS' own header says "Every top-level key
parseFocusManifest below actually reads." That is a statement about code twenty
lines further down, and nothing enforced it -- the same shape as the turbo.json
inputs comment #9860 calls out as the tell: a snapshot written as a guarantee.

The list is the single source of truth for unknown-field detection (#9065), so
drift is silent in both directions and asymmetrically bad:

  DECLARED BUT NEVER READ -- the parser accepts the field, warns about nothing,
  and ignores it. An operator sets it in .loopover.yml, sees no error, and the
  setting does nothing. This is the worse direction: it looks configured.

  READ BUT NOT DECLARED -- the parser consumes the field while warning "unknown
  top-level field" about it, and config-lint's recognizedFieldsFor leaves it out
  of the recognized count. A working field reported as a mistake.

Adding a field means editing two places twenty lines apart, and #9813 and #9821
each shipped a bug from missing one touchpoint of a multi-touchpoint field.

Computed, not restated: the declared names come out of the array literal and the
used names out of the function body, so this file holds no second list and
cannot itself go stale.

It is a regex over source rather than the TS AST, which is a real tradeoff. The
access pattern is uniform and mechanical, and every extractor THROWS when the
shape it depends on is missing -- a renamed constant, a moved signature, or a
refactor to destructuring all fail loudly instead of matching nothing and
reporting "no drift". A guard that passes vacuously is worse than none, because
its presence reads as coverage.

Holds today at 28 declared and 28 read, empty both ways, so this lands green and
guards forward. Mutation-tested: declaring an unread field, reading an
undeclared one, and removing a still-read declaration each fail with the field
named.

Closes #9979
loopover-orb Bot pushed a commit that referenced this pull request Jul 31, 2026
…ust its cache key (#10163)

gate.ignoredCheckRuns (#9813) is supposed to exclude a maintainer-declared check-run
from CI resolution entirely, and the reducer + fetchLiveCiAggregatePreferGraphQl honour
it. But ci-resolution.ts folded it into the durable cache key at all three hand-offs
while dropping it from the arg object passed downstream, so cachedFetchLiveCiAggregate's
ignoredCheckRuns was always undefined -- TypeScript can't catch it because the property
is optional at every hop. The consequence on the live maintenance path: ciAggregate's
ignoredCheckDetails is always [], ignoredCheckNonPassing is always empty, and
unstableExplainedByIgnoredChecks is always false, so the #9810 follow-up that stops an
ignored check from holding a PR at mergeable_state 'unstable' can never fire in
production, and the planner and executor can disagree about the same PR's CI state.

Forward args.ignoredCheckRuns at all three call sites (fetchLiveCiAggregateWithRequiredContexts,
cachedLiveCiAggregate, refreshLiveCiAggregate), mirroring how advisoryCheckRuns is already
threaded through the same hops. Signatures stay optional so positional callers are
byte-identical; the requiredContextsKey composition and advisoryCheckRuns threading are
unchanged, and nothing outside ci-resolution.ts is touched.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant