Repository navigation
feat(pr-pipeline): stop lanes at their start gate on a low GITHUB_TOKEN budget - #15385
Conversation
…EN budget check-kill-switch now gates on the GITHUB_TOKEN budget as well as the two manual switches. With both switches off it makes one GET /rate_limit with the job's token, reads the core pool from the x-ratelimit-* headers (else the body's resources.core), and stops the lane while remaining is under limits.api github-token start-gate (default 0.1) of the limit and the reset is ahead. A failed read proceeds with a warning. Each switch also accepts true@<UTC time>, which stops until that time. Every stop keeps reason=kill-switch; a new cause output names switch, expiry, budget or ok. The read runner marks a budget stop with a top-level step, so work-unblock's sweep tells it apart from the job listing alone: it re-fires such a run on the next sweep, and its record never counts toward limits.limit-stops-per-issue or the flat kill-switch wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQb9Q1HHi6JACp77x2ZbE7
The gate reads its start-gate share through rate-guard.mjs; the one sparse checkout that carried check-kill-switch without log-api-budget fell back to the built-in share with a warning on every run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQb9Q1HHi6JACp77x2ZbE7
The gate reads limits.api github-token start-gate from a RESOLVED_CONFIG its job's env sets, and no workflow sets one for the gate yet, so every gate uses the rate-limits.json value today. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQb9Q1HHi6JACp77x2ZbE7
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 07d327a262
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
check-kill-switch takes a budget input (default true). budget: "false" skips the GET /rate_limit read, so the gate checks the switches only. It is set on the 13 gates in jobs that run after a model turn and consume its output (the write runner, intake-triage apply jobs, the audit, tidy, skill edit and changelog push and publish jobs, and pr-unblock's adjudicate-feedback), so a low budget never throws finished model work away; the rate guard's stop-floor stays their backstop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQb9Q1HHi6JACp77x2ZbE7
check-kill-switch's budget input now defaults to "false" (switches only). budget: "true" is set on the 17 gates that start new work: the read runner's broker-check, intake triage and refine select, intake re-entry, the work-unblock sweep, the pr-unblock dispatcher and the schedule ticks that start work. Reviews, merges, verdicts, verify notes, writes after a model turn, handed-off model legs and evals check the switches only. check-kill-switch.test.mjs classifies every gate in .github/workflows and fails on one it does not know. A budget stop writes the window's reset as an output. broker-check's budget step carries it in its name, and work-unblock's sweep re-fires a budget-stopped issue run only once that reset has passed (an hour after the run ended when the name carries none), so each stop gets one record. The true@<time> switch form is dropped: the other switch readers do not honor it. Each kill-switch report step prints the cause beside the reason. The start-gate share is read from rate-limits.json alone, with no copy in the gate; the docs say a pr-pipeline.yaml value has no effect on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQb9Q1HHi6JACp77x2ZbE7
On a start-gate budget stop, broker-check sets skip-reason budget, so the read runner's run job skips instead of failing at "Assert the broker allowed this attempt". The report job posts the same neutral check and skips the lane-run comment, as it does for the broker's kill-switch refusal. broker-check reads the budget only for a caller that passes the new start-gate input (the issue lane and pr-refine), and only on the first call in the slot. Handed-off and fallback legs, and pr-unblock, which fixes existing pull requests, check the switches only. work-unblock's sweep keeps one budget record per issue and edits it in place for each window. Report steps name a budget stop as the low GITHUB_TOKEN budget and its reset. The pr-pipeline.yaml schema rejects start-gate, which stays valid in rate-limits.json. The gate test matches any uses: path to the action and checks which read-runner calls pass start-gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQb9Q1HHi6JACp77x2ZbE7
…ff it A refused GET /rate_limit (403, 429, retry-after, or x-ratelimit-remaining 0) now stops a start gate with cause budget and a future reset (the header's, else now + retry-after, else now + 60), so a spent token never leads to more calls. Transport errors, timeouts, 5xx, 401 and unreadable bodies still proceed. A share of 0 makes no read. The skill-edit gate ends a budget stop neutral with a notice, and its later steps wait on the gate. The pr-unblock dispatcher, pr-refine and the issue lane's fallback leg check the switches only. Gates that never read the budget keep main's stop wording. The gate test now checks that no step a budget stop runs fails the job, and that every budget job checks out log-api-budget beside the gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d7fc23abf9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
claude-security-review reviewed Security review: PR #15385 at
|
|
claude-review reviewed Code review (full diff at 8ba6f5f, blocking findings only)This lane already reviewed an earlier head of this PR, so this pass reports blocking findings only. Scope. I read every hunk of all 30 changed files in What I checked and found sound:
Findings
Spend (no hold). One GET /rate_limit per start gate a job reaches. GitHub counts it against the secondary limit, so it spends GITHUB_TOKEN quota only and creates no billed resource. |
…cord Editing the single budget record moved its date to the latest edit, so the backoff charged earlier budget re-adds as infrastructure re-fires. Each edit now carries the dates of the edits before it, and the record reads as one re-fire marker per date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The pr-unblock lane stopped
When a retry on this head can change the outcome, the lane runs one on its own, up to its per-head cap, and edits this comment if it stops again; a run that does not stop clears the status. When no retry can (a review thread the lane does not take or settle, or a failing check after the retries are spent), a person resolves the threads or gets the checks green, then dispatches the workflow below: that run finds the cause gone and clears the status. On a pull request the lanes App opened, the dispatchers start that run once per stop; on any other, a person does. A new commit to the branch starts clean. To run it again on this head now, dispatch the gh workflow run pr-unblock.yml -R melodic-software/claude-code-plugins -f pr-number=15385 -f activity=resolve-conflicts |
…t-gate-budget # Conflicts: # .github/scripts/work-unblock/label-unblocked.mjs
…start-gate calls once Main added check-kill-switch gates to work-resolve-issue.yml's admit and pick-slot jobs. They pass no budget input, so they are switch gates; the issue lane's budget is read at broker-check's first call, the one place label-unblocked reads a budget stop from. The read-runner start-gate test asserted only inside an if, which the cant-fail scan rejects; it now collects each workflow's start-gate calls and asserts the whole map once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQb9Q1HHi6JACp77x2ZbE7
|
The pr-verify verifier failed head What it wrote (33 lines)This pull request leaves the merge queue, and ci-status holds it out while it carries |
No related issue: owner decision Q1-Q5 (2026-11-11), hub research .work/research/start-gate-breaker
Summary
Lanes that start new work now check the live GITHUB_TOKEN budget at their own start gate, so no desktop script has to flip a kill switch.
CLAUDE_LANES_DISABLEDandCLAUDE_LANES_REPO_DISABLEDbecome manual brakes: nothing in this repository writes them, and they still proceed only on exactlyfalse. Gates that finish or judge existing work keep checking the switches only, so a low budget never throws that work away or stalls a head..github/actions/log-api-budget/rate-limits.jsonis on every merge hold list's floor, so this PR waits for a person.Fix
.github/actions/check-kill-switch:budgetinput, default"false"(switches only).budget: "true"and both switches off, the gate makes one GET /rate_limit with thetokeninput (default${{ github.token }}, the same defaultcheck-trusted-trigger/action.ymluses). It reads the core pool fromx-ratelimit-limit,-remainingand-reset, else from the body'sresources.core, and stops while remaining <start-gatex limit and the reset is still ahead.start-gatein rate-limits.json (0.1), read from that file alone. If it cannot be read, the gate warns and proceeds without a budget read, so there is no copy of it in the gate. A budget read that fails (network error, timeout, 401, 500, an unreadable body) proceeds with a warning; the rate guard'sstop-floor(fix(pr-pipeline): stop a GitHub API pool at its stop floor, not only at zero #15305) is the backstop. A read GitHub refuses as limited (403 or 429, anyretry-after, orx-ratelimit-remaining: 0, a bare 403 included) stops the lane with causebudgetuntil the header's reset, else now +retry-after, else now + 60 s (refusal()in check-kill-switch.mjs). That keeps a limited token from making more calls (owner Q3: no ban risk). It is fail-closed on purpose: a persistent bare 403 on GET /rate_limit stops every start gate, one window at a time, and switch-only gates keep finishing existing work. The hub kept this over a reviewer's suggestion to proceed on a bare 403 (hub decision D129).start-gatenotice with the body's core block and the response headers, the production probe for research gap B1.proceedandreason=kill-switchare unchanged. Newcause(switch,budgetorok) andreset(the window's reset in epoch seconds, on a budget stop).budget: "true", plus broker-check's first call):broker-check(pr-run-activity-read.yml) reads the budget only on a starting caller's first call:budget: ${{ inputs.start-gate && (inputs.fallback == '' || inputs.fallback == 'credit-stop') && 'true' || 'false' }}. A newstart-gateinput (defaultfalse) is set by the issue lane's two first-call legs inwork-resolve-issue.yml:resolve-issue, andresolve-issue-subscription, which is the slot's first call when the key leg was skipped. The fallback leg and handed-off legs check the switches only. pr-refine and pr-unblock fix existing PRs and pass none;intake-triage.ymlkill-switchandrefine-select, andintake-reenter.ymlreenter;work-unblock.ymllabel-unblocked(the sweep);maintenance-reconcile-lost-runsreconcile,maintenance-audit-lane-runscollectandtriage-sweep,maintenance-delete-stale-branchesplan,maintenance-file-recurring-issues,maintenance-monitor-signalsmonitor, thegatejob ofmaintenance-measure-skill-edit,maintenance-tidy-code-upkeepandupstream-apply-changelog, andpost-merge-test-mutationprep.pr-review-hosted,pr-review-security-hosted,pr-review-main),pr-rate-merge-risk,pr-verifyandpr-verify-capture;pr-merge(merge,enable-auto-merge,post-merge-dispatch) andpr-feedback-dispatch(post-verify-notes,dispatch,rerun-ci-status);broker-checkalready read the budget seconds earlier);pr-unblock-dispatch(dispatch,refine), pr-unblock's select jobs and pr-refine, which fix existing PRs;work-resolve-issueadmitandpick-slot(added on main): the issue lane reads its budget at broker-check's first call, the one place label-unblocked reads a budget stop from;check-kill-switch.test.mjsenumerates everyuses:of a path ending inactions/check-kill-switchin.github/workflows, and checks which read-runner calls passstart-gate. It fails on any gate it does not classify, and on a start gate withoutbudget: "true"or a switch-only gate with anybudgetline.causebesidereason. That is 33 report steps in 21 workflows, plus measure-skill-edit's gate check. In the 28 of them that say "the lanes kill switch stops", plus measure-skill-edit's check, a budget stop reads "the GITHUB_TOKEN budget is low until (epoch seconds), which stops". The review steps (pr-review-hosted,pr-review-security-hosted,pr-review-main) printcausetoo, for the same log shape, but keep their switch wording: their gates are switch-only, socausethere is alwaysswitchorok.skip-reason: budget. Therunjob skips on any non-empty skip-reason, so it never reaches "Assert the broker allowed this attempt". The report job treats the stop like the broker's kill-switch refusal: on a PR it posts a neutral check under the skip-reason enum'skill-switch, which the dispatchers and limit-stop already handle, and no lane-run comment. In issue mode no lease was taken, so it skips. In work-resolve-issue,apply-issue-patchandreport-outcomeskip (there is no scan output) and the run ends green. The sweep then takes the budget branch inlabel-unblocked.mjsdidNoWork():budgetfrom the budget step,killed, a check-stage breaker trip carryingbudget, and inrefireRefusal()due= the step's reset. Main's pre-model rate-limit stop (limited,reset) is kept beside it: a budget stop never reaches that branch, and across runs amint-failedstall outranks a breaker trip.REVIEW_STOP_NOTEin reconcile.mjs stays as it is: review gates are switch-only, so that note can only mean a switch.pr-run-activity-read.yml: broker-check gets a top-levelStop on a low GITHUB_TOKEN budget until ${{ steps.kill.outputs.reset }}step, which runs only oncause == 'budget'. The model job no longer reads the budget and carries no such step..github/scripts/work-unblock/label-unblocked.mjs: the sweep reads that step from the job listing it already fetches, with no new API call. A budget stop is a check-stage breaker trip whose record carries<!-- work-unblock cause=budget -->on its second line. It never counts towardlimits.limit-stops-per-issueor the flat kill-switch wait. It re-fires only once the reset named in the step has passed (an hour after the run ended when the name carries none), so each stop gets one re-fire, not one per sweep. An issue keeps one budget record: a later budget stop edits it in place (PATCH) rather than posting a new comment, and its marker time is the last edit.true@<time>is not in this PR. It returns later as one shared parser that every switch reader uses.start-gateis valid in rate-limits.json (rate-guard.mjsFIELDS,BUILT_IN_LIMITSand a newFILE_ONLY_FIELDS). The pr-pipeline.yamllimits.apischema rejects it. rate-guard.test checks both. No other share changed. intake-triage's one sparse checkout that lacked log-api-budget now has it..changes/pr-pipeline/feat-kill-switch-start-gate-budget-16d00f6e.md(minor).Call cost: one GET /rate_limit per start gate a job reaches (15 of 84 gates), none on the other 69, and none when a switch already stops. label-unblocked adds no call; the tests assert no log read. GitHub notes this call can count against the secondary limit (not the primary one), so it is made once, with no retry and a 10 s timeout.
Verification
resetoutput, the sparse-checkout case, and the classification of every gate.label-unblocked.mjsresolved to keep both stops; independent review of the merge commit: no bug, MERGE): label-unblocked 395 of 395, resolve-config callers 284 of 284, check-kill-switch 53 of 53.node --test --test-concurrency=4 .github/actions/*/*.test.mjs .github/scripts/*/*.test.mjs: 9190 of 9191 pass. The one failure wascredential-legs.test.mjs's pin of broker-check's outputs. That pin is updated for the newskip-reasonsource, and its file then passes 3 of 3.scripts/check-changelog-fragments.sh --checkand--check-required origin/main: exit 0.zizmor --persona=regular --no-online-audits .github/workflows .github/actions/check-kill-switch/action.yml: no findings.Related
true@<time>: a follow-up PR adds one shared parser for every switch reader (pr-require-checks, merge-holdsswitchHold,babysit_merge_policy.py, the broker).switchedflag indidNoWork).name:with its expression evaluated. If it does not, the sweep still classifies the stop as a budget stop and waits an hour after the run ended; a test covers the unevaluated name.session: GitHub Support
🤖 Generated with Claude Code
https://claude.ai/code/session_01VQb9Q1HHi6JACp77x2ZbE7