Repository navigation
fix(pr-pipeline): route pr-unblock stops to a failing status, not needs-human, unless a person must decide - #10252
kyle-sexton wants to merge 40 commits into
Conversation
…ds-human, unless a person must decide One table, .github/scripts/pr-unblock/escalation-routes.json, gives each pr-unblock stop reason its activity and route. report-escalation.sh, gate-source.mjs and clear-escalations.mjs read it. `hold` keeps the needs-human label and resume-key comment for SAFETY reasons and for questions only a person settles. `fail` adds no label: report-stop-status posts a failing pr-unblock/<activity> (or pr-unblock/conflict) status on the gated head, which a new head does not carry, and one lane-stopped comment per activity is edited in place. UNCERTAIN and VALID-defer replies now go through the existing adjudicator. A pr-verify note left open ends feedback-verifier-open, and a stood finding left open again ends feedback-stood-open. Both route to fail. clear-escalations lifts labels that older runs set for reasons now routed to fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…existing caps, and report the ones left red A stop routed to a failing status now skips lane-stopped, which is a new skip reason in the schema. On a select job's own check it ends the head, so a deterministic stop is no longer re-dispatched blindly. On the push, reply or verdict job's check, the activity's dispatcher releases the head for a same-head retry, through limit-stop.mjs stopRelease. Each activity keeps the per-head cap it already has: - resolve-conflicts and review-dependency: limit-stops-per-head - fix-ci: fix-ci-restarts and maxRunsPerHead - address-feedback: SWEEP_HEAD_CAP The fix-ci and address-feedback retries run one model rung up. The rung is read in the run from the head's own stop record by the GitHub Actions app, never from a dispatch input. A retry that does not stop posts success over the failing status, through report-escalation.sh clear. Each stop comment now names its run, so every stop edits it. fix-ci model escalations of protected-path or weaken-required stay with a person, as ci-fix-protected-path and the new ci-fix-weaken-required. fix-ci's round-cap stop now posts its own check instead of leaving the head undispatched-but-unrecorded. The monitoring lane gains signal 10, stalled lane stop. It lists open pull requests, of any author, whose stop comment names the current head and has had no attempt of that activity on the head for over 2 hours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and cover labels clear-escalations lifted The stalled lane stop signal treated any later activity check as an attempt. That hid two kinds of PR: one with a select job's own lane-stopped skip, and one with a no-op feedback retry that skipped not-applicable. A skip is no longer an attempt. The signal also reads clear-escalations' needs-human-cleared comment. A cleared fail-routed reason, such as #10136's ci-fix-escalated, is a stop on that head, so a lifted label is still reported once its retries are spent. The reads now stay within the call budget: at most 20 pull requests and 20 check-run reads, and a full comment page with no marker is recorded as a gap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not-park # Conflicts: # .github/scripts/pr-unblock/dependency-dispatch.mjs # .github/scripts/pr-unblock/dispatch.mjs # .github/scripts/pr-unblock/fix-ci-dispatch.mjs
Codex Review SummaryThis 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. |
|
claude-security-review reviewed Security review: PR #10252 (pr-unblock stops route to a failing status)I read the whole diff and the PR context. Most of the review went on the trust-control files: critical-findings: 0 I found no CRITICAL issue. None of these widened:
Held reasons. Safety, security, intent and design stop reasons stay New ci-status hold step.
Label clearing.
Adjudicator now handles UNCERTAIN and VALID-defer replies (not counted as a finding).
Retries (not counted as a finding). Same-head retries and rung-ups run the model more often, up to the per-head caps. That spends only prepaid API credit or subscription usage. manifest: no Finding
|
|
claude-security-review holds this pull request for a person (critical-security) at CRITICAL trust-control finding: .github/scripts/pr-merge/clear-escalations.mjs now removes the needs-human merge-hold label for every fail-routed reason, unconditionally for ci-fix-* reasons. This narrows a merge hold, and the replacement hold is not shown by the code. A person should confirm the replacement holds (failing pr-unblock/* status, the thread-resolution ruleset, the gate's failing-check blocker) cover every cleared reason. The IMPORTANT companion finding is that escalation-routes.mjs and escalation-routes.json are outside HOLD_PATH_FLOOR. claude-lane: claude-security-review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5488e10cf2
ℹ️ 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-review reviewed I reviewed the diff in Finding (advisory): the lane-stopped comment text tells readers "This needs no person" for every fail-routed reason. The PR body says four of them ( Checked and found consistent:
Closing issues: none (the PR states "No related issue"), so there are no acceptance criteria to check. Cost: this change spends only prepaid or subscription usage (same-head retries, rung-up model runs, adjudicator turns, and one more monitor GraphQL query run). It starts no real charge, so there is no hold. |
clear-escalations.mjs and gate-source.mjs now import escalation-routes.mjs, so the floor's import closure loads it and test_merge_holds_sync requires it on the floor. List it beside its sibling pr-unblock modules in babysit_risk.HIGH_FLOOR, HOLD_PATH_FLOOR, the review lanes' hold-paths and ci-status's hold_patterns, and pin the floor at 38 entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix-ci and the in-run fix-ci-rung-up both post a `pr-unblock / fix-ci` check. When the rung-up's apply-patch stopped, withStops reclassified only the second check as failed; the first stayed "Passed: no changes", so skipReason held the head at `!runs.every(retryable)` and the same-head restart never ran. withStops now groups the checks by check suite (one workflow run), reads each group from its last check, and marks the group failed when a stop in the same suite (or, with no suite id, by timing) followed any of its checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uite report-check-run creates the fix-ci and apply-patch checks over the API, and GitHub attaches such a check to a check suite of its choosing on the head (ci-status lands in pr-require-checks' suite), so two runs' checks can share a suite id and collapse into one attempt, which would keep the per-head cap from ever tripping. Read the posting run from the check's details_url, which report-check-run sets to that run's page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stalled-stop signal listed open pull requests oldest update first and read only the first 20, so a pull request with a fresh lane-stopped comment sorted behind idle ones and was never read once more than 20 were older; with 37 open today, the permanent gap also left signal 10 unread on every run. It now pages the open list most recently updated first (at most 3 pages), reads comments for the first 40, and names the true open count in the gap instead of a single page's 100. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not-park # Conflicts: # .github/scripts/pr-unblock/fix-ci-dispatch.test.mjs
|
claude-security-review holds this pull request for a person (critical-security) at Trust-control change: the PR removes the claude-lane: claude-security-review |
…s read clear-escalations took needs-human off any ci-fix-* reason routed to a failing status without reading a CI result. It now reads the current head's ci-lanes commit status (one GraphQL read per PR) and clears only when the GitHub Actions bot recorded it failed. Green, pending, missing, another creator, or an unreadable read keeps the label. ci-status itself is not read: it fails on the label, so it proves nothing. The ci-lanes reader moves from fix-ci-dispatch.mjs into gate-source.mjs, already on the merge path floor, so clear-escalations imports no new file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
report-stop-status posted report-escalation.sh clear after any write job that succeeded with no stop, so a same-head address-feedback retry whose reply script skipped no-model-turn posted success over the earlier feedback-not-landed or feedback-invalid failure. The write runner now hands up its skip reason (the gate's reason or the check run's skip), and clear runs only when the push, reply or verdict job acted. The sweep already counts a no-model-turn skip toward SWEEP_HEAD_CAP, so a stuck head stays red and the stalled-stop signal lists it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The ci-lanes commit status needs statuses: read, which neither the lane App token nor the merge job's token holds, so that read always failed closed and a fix-ci label could never clear. clear-escalations now reads the head's check runs with the job's own token (checks: read, passed as CHECKS_TOKEN) and clears only when a GitHub Actions check other than ci-status failed, through fix-ci-context's failingChecks, which moves to gate-source.mjs on the merge path floor. Green, skipped, another app's, a listing past three pages, or a failed read keeps the label. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The stalled-stop source listed open pull requests over REST and read one comment page per pull request, up to 3 + 40 + 20 of the run's 250-call budget. One GraphQL query now lists the 40 most recently updated open pull requests with each one's newest 100 comments and the open count; at most two more queries read older comments, batched one alias per pull request (batchQuery, the stacked branch's pattern). Worst case: 1 + 2 + 20 calls. A lane-stopped comment for an older head also set `marked`, so it hid the current head's stop in an older comment and recorded no gap. Only a marker naming the current head now ends the read; a pull request whose older comments stay unread past the rounds is a gap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a stop #10092 stalled with no stop on record. Three gaps: - The address-feedback select skipped not-applicable when it kept no thread, even with an unresolved one it does not take (a Codex-root thread, a review of an older head). It now escalates feedback-untaken-threads, routed to a failing status, and skips only when no thread is unresolved (or awaiting-human when a person holds the PR). threadAuthors' reviewer set is unchanged. - The dispatcher's hasWorkableThread skipped keepThreads' review-head check, so it dispatched for a stale-head thread the select job then skipped, and that neutral skip marked the head handled for good. Both now judge it with onReviewedHead, and THREADS_QUERY reads each first comment's pullRequestReview author and commit. - A reply job that failed before its script decided (the 20:57 run failed at its lane token) left an empty escalation reason, so nothing was posted. The write runner now names feedback-publish-failed, routed to a failing status, when a publish-feedback run job fails before its script succeeded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fix-ci clear counted a failure from any GitHub Actions check, so a red advisory pr-test-windows run kept clearing the label while ci-status, which does not aggregate it, went green with the label gone. The read now lists the head's pr-require-checks.yml runs (actions: read on the same job token) and counts only check runs in their check suites. It reads every check run on the head (filter=all), so a contract-only run's later skip of a lane does not hide that lane's failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ailed reply's stop The previous commit let select escalate feedback-untaken-threads, but the dispatcher still refused a PR whose only unresolved threads select will not keep, so the escalation could not fire for #10092's shape. On an ungated head the dispatcher now fires for any unresolved thread. Select escalates, and its not-run job's lane-stopped check ends the head: a reply check's lane-stopped skip no longer releases a head whose address-feedback check is a select stop, so the next tick does not fire again. A gated head still dispatches only for a workable thread, and onReviewedHead still decides what select keeps. feedback-publish-failed is now derived in pr-unblock.yml from needs.publish-feedback.result == 'failure' with no escalation reason, instead of a failure() step's output in the write runner, which relied on a failed job's outputs reaching the caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not-park # Conflicts: # .github/scripts/pr-merge/clear-escalations.mjs # .github/scripts/pr-merge/clear-escalations.test.mjs # .github/scripts/pr-unblock/fix-ci-context.mjs # .github/scripts/pr-unblock/fix-ci-dispatch.test.mjs # .github/workflows/pr-merge.yml
A fail-routed pr-unblock stop ends in a failing pr-unblock/<activity> commit status, but only ci-status is required, so the merge queue and a hand merge ignored it. ci-status now fails while the newest pr-unblock/* status on the head by github-actions[bot] is failure or error, naming each context; a later success on the same head supersedes it, as report-escalation.sh clear reads it. In the merge queue it reads the queued pull request's head. Unreadable reads fail closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's failingChecks now counts a cancelled check as failing (#10269); merged into gate-source.mjs it also reaches clear-escalations, where a cancelled lane keeps ci-status red once the label is off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…finds gone - escalation-routes.json joins escalation-routes.mjs on every hold list: HOLD_PATH_FLOOR, HIGH_FLOOR, ci-status's hold_patterns and both review lanes' hold-paths, with the tests that pin them. test_merge_holds_sync.py now follows a module-relative file a floored module reads, so the next such data file fails there. - address-feedback select and fix-ci-context output stop-resolved when the stop is gone on the head (no hold and no unresolved thread; a whole, settled check listing with none failing). report-stop-status then runs report-escalation.sh clear for that activity on that head. fix-ci reads the head's checks before its round cap. A select that fails to read writes no outputs and clears nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix-ci-context reads the head's checks before the round cap, so the resolver test's fake lists one failing check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| } | ||
| checks += 1; | ||
| const runs = (await get(`/repos/${repo}/commits/${head}/check-runs?check_name=${encodeURIComponent(`pr-unblock / ${stop.activity}`)}&filter=all&per_page=100`)).check_runs ?? []; | ||
| if (runs.some((run) => attempted(run) && Date.parse(run.started_at) > Date.parse(stop.stoppedAt))) continue; |
There was a problem hiding this comment.
Advisory: a stop the lane already cleared stays listed as a stalled lane stop.
A stop counts as stalled when no non-skip pr-unblock / <activity> check started after the lane-stopped comment's last edit. The stop-resolved path (a person resolves every thread, or CI is re-run green) ends the select job with Skipped: not-applicable, then posts success over the failing status. attempted() ignores that skip. The comment is not edited or removed, and the monitor cannot read statuses.
So after 2 hours, any head whose stop was cleared this way and is still open is listed in the rolling issue as stalled. The PR body says only that a no-op feedback retry skipping not-applicable is not an attempt. It does not address this case, and the added tests do not appear to cover it.
Options: have clear edit the lane-stopped comment (for example, mark it cleared), or have the signal treat a not-applicable skip that started after the stop as a resolution for address-feedback and fix-ci.
claude-lane: claude-review
| // A reason escalation-routes.json now routes to a failing status rather | ||
| // than a person, on a label an older lane run set: fix-ci's once the head's | ||
| // checks read failed, so it stays red without the label (green, missing or | ||
| // unreadable keeps it); any other once the head is not one the lane stopped | ||
| // on, since the lane checks a new head itself. | ||
| ...Object.fromEntries( | ||
| Object.keys(ROUTES) | ||
| .filter((reason) => escalationRoute(reason) === "fail" && reason !== "unresolved-conflict") | ||
| .map((reason) => [ | ||
| reason, | ||
| async ({ pull, keys, failing }) => | ||
| escalationActivity(reason) === "fix-ci" | ||
| ? (await failing()) | ||
| ? "the lane now ends it in a failing status, not a person, and the head's own checks read failed" | ||
| : null | ||
| : keys.length > 0 && !keys.includes(pull.head.sha) | ||
| ? "the lane now ends it in a failing status, not a person, and checks the new head itself" | ||
| : null, | ||
| ]), | ||
| ), |
There was a problem hiding this comment.
severity: CRITICAL
Trust control: merge hold. clear-escalations now lifts needs-human on a head move alone.
For every reason escalation-routes.json routes to fail, this check returns "cleared" once keys.length > 0 && !keys.includes(pull.head.sha). The only condition is that the head differs from the resume key. Nothing is read about the new head. A fix-ci reason does at least read the head's checks. Every other fail reason has no such read.
The replacement hold is the failing pr-unblock/* status. It is posted on the stopped head only, and "a new head carries none" (report-escalation.sh). So after the label comes off, nothing holds the new head.
Exploit path: a label set by an older run for feedback-not-landed, feedback-invalid, feedback-partial, feedback-unadjudicated, dependency-round-cap, dependency-control-uncited, unexpected-path-staged or repeat-cap was a hold a person had to lift. Any push to the branch now makes the next sweep remove it. No lane has to run on the new head first, so no check proves the stop cause is gone. For unexpected-path-staged, the conflict resolution touched paths outside the conflicted ones. For the dependency reasons, the lane refused to mark a Dependabot update for merge. A one-line push drops that hold and leaves no status on the new head.
This is the same weakening the PR describes for older labels as an intended change. The code gives no condition that the lane has re-run and stopped being stopped on the new head. Reading the new head's own pr-unblock/<activity> status, or a later successful lane check run, before clearing would keep a lane that cannot reach the head from being treated as cleared.
The file is on the merge floor, so a person has to approve this change.
claude-lane: claude-security-review
| // The dispositions that leave a thread open, each one an adjudicator settles | ||
| // on an adjudicable thread instead of a person. | ||
| export const DISPUTED = Object.freeze(["INCORRECT", "UNCERTAIN", "VALID-defer"]); |
There was a problem hiding this comment.
severity: IMPORTANT
Narrowed hold: UNCERTAIN and VALID-defer replies now go to a model adjudicator, not a person.
DISPUTED adds UNCERTAIN and VALID-defer to the dispositions that disputes stages and settleDisputes settles. publish-feedback.mjs also drops the old line that set feedback-uncertain for any such reply. Before, a finding the fixing lane called uncertain, or called valid but deferred, always ended in a held feedback-uncertain for a person. Now an adjudicator model that returns verdict lane produces the fixed reply and resolves the thread (settleDisputes). The repository ruleset's required_review_thread_resolution then no longer blocks the merge.
Exploit path: a non-CRITICAL, non-security-review code-review finding (for example an IMPORTANT correctness or access-control finding that the lane marked VALID-defer). One model verdict on text the PR author can influence closes it, and the thread no longer blocks merge. CRITICAL and security-review threads stay held through the adjudicable check, and that part is correct.
VALID-defer is the lane agreeing the finding is valid. Sending that to an adjudicator, whose lane verdict means "the finding is incorrect", is a different case from the existing INCORRECT dispute. Consider keeping VALID-defer held, or accepting only a reviewer or human verdict for it.
claude-lane: claude-security-review
|
claude-security-review holds this pull request for a person (critical-security) at Trust-control change that weakens a merge hold. In .github/scripts/pr-merge/clear-escalations.mjs (CHECKS), the sweep now removes the needs-human label from a PR when its head moves, for every reason escalation-routes.json routes to fail. The failing pr-unblock/* status that replaces the label is on the old head only, so the new head carries no hold. Related: adjudicate-feedback.mjs sends UNCERTAIN and VALID-defer replies to a model adjudicator instead of a person. claude-lane: claude-security-review |
…ALID-defer on a lane verdict clear-escalations lifted needs-human for a non-fix-ci fail-routed reason once the head moved, though that activity's own escalation keeps its lane off the PR (gate-source laneEscalationAllows) and the new head carries no pr-unblock status in the label's place. Only fix-ci reasons, whose head's own checks read failed, now clear; every other fail-routed reason stays for a person. settleDisputes resolved a VALID-defer thread on a `lane` verdict, which says the finding does not hold where the fixing lane had agreed it does. That thread now stays open with no reply and the round ends feedback-unadjudicated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not-park # Conflicts: # .github/workflows/pr-run-activity-write.yml
… open publish-feedback's dispute test still expected a lane verdict to resolve a VALID-defer thread; it now expects the thread left open with only the lane's reply and feedback-unadjudicated, and a stands verdict to hand it to the next round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…abel The clear-escalations comments, README and changelog fragment said an activity's own escalation keeps its lane off the pull request, which does not hold for address-feedback (it still works a lane-escalated PR). State the reason that holds for every activity: a lane posts a pr-unblock/* status on a head only when it stops there or clears its own failure, so a moved head carries nothing in the label's place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e cp lines The origin/main merge brought write-runner-never-rules' check that the apply-patch base copy names each sibling module in its own `cp .base/... "$RUNNER_TEMP/<dir>/"` line; the combined cp of escalation-routes.mjs and escalation-routes.json failed it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… failure fix-ci-context and the fix-ci dispatcher read a head's check runs with filter=latest, which returns only a contract-only run's later skip of a lane; failingChecks then saw no failure, and stop-resolved could clear a failing pr-unblock/fix-ci status. Both listCommitCheckRuns adapters now read filter=all, changed together so the dispatcher and the select agree; failingChecks already prefers a real result over a skip. The read stays at most three pages per head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g it report-escalation.sh clear posts success over the lane's failing status, which the monitoring lane cannot read, so a stop cleared by a same-head retry or stop-resolved stayed a stalled lane stop. clear now adds a `- Cleared:` line naming the head to the activity's lane-stopped comment, through the same lookup and edit the stop uses (stopped_comment, edit_comment); a failed edit only warns. collect.mjs ends that activity's stop on a marked head, a cleared needs-human of it included, and spends no check-run read on it. report-stop-status gains issues: write for the edit. The changelog fragment also records the filter=all listing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ps listing it" This reverts commit f8a1c3e: its comment edit needed issues: write on report-stop-status, a scope not added for a monitor false alarm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| fi | ||
| cat >"$body" <<EOF | ||
| $marker | ||
| The pr-unblock lane stopped \`$activity\` on this pull request: $why. This needs no person: the run ends in a failing \`$context\` status on the head the lane gated. |
There was a problem hiding this comment.
The comment says "This needs no person" for every fail-routed reason, but the PR says four of them end with nothing left for the lane to do. feedback-partial, feedback-unadjudicated, feedback-stood-open and feedback-verifier-open cannot change on a same-head retry. A person has to resolve the threads, or the stalled-stop signal reports it after two hours. feedback-untaken-threads has the same problem when the open thread is a human reviewer's. A reader who trusts this line will wait on a lane that will not act again, while ci-status stays red.
To reproduce, run ESCALATION_REASON=feedback-unadjudicated bash report-escalation.sh. The comment still opens with "This needs no person", though the retry text further down is the same for every reason.
Suggestion: make the opening claim depend on the route entry, for example a per-reason retry flag in escalation-routes.json. Reasons that retry would keep this sentence. The others would say that a person resolves the threads or pushes a new head.
claude-lane: claude-review
| ".github/scripts/pr-unblock/escalation-routes.mjs", | ||
| ".github/scripts/pr-unblock/escalation-routes.json", |
There was a problem hiding this comment.
severity: IMPORTANT
The floor covers the routing table but not the code that acts on it. The PR says that putting escalation-routes.json on the floor means moving a reason from hold to fail needs a person. That holds only for edits to the table. The code that turns a route into a hold or a thread resolution is not on the floor (HOLD_PATH_FLOOR, babysit_risk.HIGH_FLOOR, ci-status hold_patterns and the review lanes' hold-paths):
.github/scripts/pr-unblock/report-escalation.shbranches onroute == holdto add theneeds-humanlabel, and onstatus/failto skip it..github/scripts/pr-unblock/adjudicate-feedback.mjssettleDisputes,ORDERandadjudicabledecide which threads an adjudicator may resolve instead of a person..github/scripts/apply-patch/apply-patch.mjsheldEscalationandHELD_ESCALATIONSdecide which fix-ci escalations hold..github/scripts/pr-unblock/publish-feedback.mjsandlimit-stop.mjsdecide the stop skip and retry release.pr-unblock.ymlandpr-run-activity-write.ymlwire the reason to those scripts.
Exploit path: a lanes-App-authored pull request, which takes the exempt-author branch in ci-status, edits report-escalation.sh to ignore hold and post a status instead. It could also edit adjudicate-feedback.mjs so a security-review thread counts as adjudicable. None of those paths matches a floor glob, so no person-hold applies. Every held reason, including the safety ones, would then stop labelling, and a thread no person should resolve could be resolved by a model verdict. test_merge_holds_sync.py now follows a module-relative file a floored module reads, but these scripts are not floored modules, so nothing flags them.
Suggested fix: floor the hold/fail decision points (at least report-escalation.sh, adjudicate-feedback.mjs and apply-patch.mjs) in the same four places, or state that the review lanes alone cover them.
claude-lane: claude-security-review
No related issue: owner rule 2026-10-26 (a person only for intent, design or HIGH/CRITICAL security)
Refs: #10136
Refs: #10125
Refs: #10118
Summary
The pr-unblock lane put
needs-humanon a pull request for every stop, defects included (a CI fix the rung-up retry could not land, an uncertain review reply, a round cap). This routes each stop through one table,.github/scripts/pr-unblock/escalation-routes.json:hold: the label and the resume-key comment stay as they are. These are SAFETY reasons (paths the lane token may not push, supply-chain refusals), intent, design and security questions, a person's own thread or hold, and findings no adjudicator may judge (security-review or CRITICAL).fail: no label. The run ends in a failing commit status on the head the activity gated,pr-unblock/conflictfor resolve-conflicts andpr-unblock/<activity>otherwise. It also posts one lanes App comment per activity, which the next stop edits in place. A new head carries no status.A stop that came after a model turn is retried on the same head, under the per-head cap each dispatcher already keeps. fix-ci and address-feedback retries run one model rung up. A retry that acts and does not stop clears the status; one that ends on a skip leaves it. Once the retries are spent, the monitoring lane's new
stalled lane stopsignal names the PR in its rolling issue, so a red PR always reaches someone withoutneeds-human.Fix
escalation-routes.jsonis the one routing table: each reason's activity, route and text.report-escalation.sh,gate-source.mjs(escalationActivity,escalationRoute) andclear-escalations.mjsall read it.report-escalation.sh:<!-- pr-unblock:lane-stopped activity=<a> -->comment and writes nothing when the text is unchanged.statusmode posts the failing status for any fail-routed reason and nothing for a held one.pr-unblock.yml:report-conflict-statusbecomesreport-stop-status. It runs whenreport-escalationdoes, on the same reason, never on a cancelled run, and posts on the select job's head. It keeps its ownGITHUB_TOKENwithstatuses: write; no token scope changes.Bounded step for
feedback-uncertain:UNCERTAINandVALID-deferreplies now go through the existing adjudicator:adjudicate-feedback.mjsDISPUTED, and the scan job'sreplies-disputedgate inpr-run-activity-read.yml.laneverdict (the finding does not hold) never resolves aVALID-deferthread, where the fixing lane agreed the finding holds (settleDisputes): the thread stays open with no adjudication reply, so the ruleset's thread resolution still holds the merge, and the round endsfeedback-unadjudicated(fail). Areviewerverdict hands it to the next round, andhumanasks the question, as for any other dispute.feedback-verifier-open(fail). A stood finding left open again endsfeedback-stood-open(fail).feedback-disputedforINCORRECT,feedback-uncertainotherwise.ci-fix-*: every reason routes to fail exceptci-fix-protected-pathand the newci-fix-weaken-required. Both are held, and both are now also emitted when the model itself escalatesprotected-pathorweaken-required(apply-patch.mjsheldEscalation).Caps (
repeat-cap,feedback-not-converging,dependency-round-cap,ci-fix-round-cap) route to fail, per D3.gate-source.mjsandclear-escalations.mjstreat the lane-stopped marker as the lane's own.clear-escalations.mjsremoves a label an older run set for a reason now routed to fail. Aci-fix-*reason clears only after it reads the head'spr-require-checks.ymlruns and their check runs, and a lane check other thanci-statusreads failed (failingChecks, now ingate-source.mjson the merge path floor).ci-statusitself fails on the label, so it proves nothing. An advisory lane (pr-test-windows) does not count, becauseci-statuswould go green once the label is off. A cancelled lane check counts as failed too, as main'sfailingChecksnow reads it (fix(pr-pipeline): let fix-ci take a job cancelled past its time limit #10269), and ci-status reads a cancelled lane as red. Green, skipped, missing or unreadable keeps the label. The read uses the merge job's own token (actions: read,checks: read, passed asCHECKS_TOKEN), because the lane token holds neither. Every other fail-routed reason keeps the label for a person. A lane posts apr-unblock/*status on a head only when it stops there (failure) or clears its own failure (report-escalation.sh clear), so a moved head carries nothing that shows the cause gone or fails that head in the label's place. The pr-merge job's token has nostatuses: readeither, so reading the new head's status there would need a scope change.The merge path floor holds
escalation-routes.jsonbesideescalation-routes.mjs, so moving a reason fromholdtofailneeds a person:merge-holds.mjsHOLD_PATH_FLOOR,babysit_risk.HIGH_FLOOR, ci-status'shold_patterns, both review lanes'hold-paths, and the tests that pin them.test_merge_holds_sync.pynow also follows a module-relative file a floored module reads (new URL("./…", import.meta.url)), so the next such data file fails there. It names one file it reaches that is not on the floor:docs/conventions/pr-pipeline/pr-pipeline.schema.json, whose API-limit defaultsrate-guard.mjsreads.The
pr-run-activity-write.ymlenum gainsfeedback-stood-open,feedback-verifier-openandci-fix-weaken-required.New skip reason
lane-stopped(pr-pipeline.schema.json), emitted for every fail-routed stop throughescalation-routes.mjsstopSkip, in place ofawaiting-human:On a select job's own check, it ends the head, so deterministic stops are not re-run. These are the caps and a conflict no model can resolve.
fix-ci-not-runnow runs on the round-cap stop as well.On the push, reply or verdict job's check, the dispatcher releases the head for a same-head retry (
limit-stop.mjsstopRelease/releasedBy), under its existing cap:apply-conflict-patchlimit-stops-per-head= 3apply-dependency-verdictlimit-stops-per-head= 3apply-patchfix-ci-restarts= 1 retry, withinmaxRunsPerHead= 3publish-feedbackSWEEP_HEAD_CAP= 3, with the other releasing skipsRung-up on retry:
fix-ci-contextand the address-feedback select read the head's own lane-stop record and outputmodel-rung1 (escalation-routes.mjsretryRung). The record must come from the GitHub Actions app.workflow_dispatchinput sets the rung.reviewprofile names noescalate-to. resolve-conflicts retries as a fresh session, because its select job holds nochecks: read; adding that scope was out of bounds for this change.report-stop-statusalso runs after an attempt whose push, reply or verdict job acted and ended without a stop.report-escalation.sh clearthen posts success over a failure the lane left on that head. A job that succeeded on a skip (the reply script'sno-model-turn, a gate or head-recheck skip) landed nothing, so the failure stays. The write runner hands up a newskip-reasonoutput for this.address-feedback select: with no kept thread and an unresolved one it does not take (a Codex-root thread, a review of an older head, a thread it already answered), it escalates the new
feedback-untaken-threads(fail) instead of skippingnot-applicable. It skipsnot-applicableonly with no unresolved thread, andawaiting-humanon a held PR.threadAuthors' reviewer set is unchanged.The feedback dispatcher now fires once per ungated head that has any unresolved thread, workable or not, so select reaches
feedback-untaken-threads. A select job's own lane-stopped check now ends the head: the reply job's matching lane-stopped skip no longer releases it, so the next tick does not fire again. What select keeps is still decided byonReviewedHead, shared bykeepThreadsandhasWorkableThread;THREADS_QUERYreads each first comment'spullRequestReview { author commit }. A gated head still dispatches only for a workable thread.A reply job that fails with no escalation reason (fix(app-control): find plugin control skills at manifest-declared paths #10092's run failed at its lane token) now stops as the new
feedback-publish-failed(fail).pr-unblock.ymlderives it fromneeds.publish-feedback.result == 'failure', so it does not depend on a failed job's outputs.ci-status (
pr-require-checks.yml, new stepFail a head a pr-unblock lane stopped) fails while the newestpr-unblock/*commit status on the head bygithub-actions[bot]isfailureorerror, and names each such context. Onlyci-statusis required, so without this the merge queue and a hand merge ignored a failingpr-unblockstatus. It reads newest per context asreport-escalation.sh cleardoes, so the lane's latersuccesson the same head supersedes the failure, and another account's status neither holds nor clears. It runs on pull request runs (the event's head) and in the merge queue (the queued pull request's head, read live; the group ref's SHA is the base). An unparsable ref, a bad head SHA or an unreadable pull request or status list fails closed. A status post re-runs nothing, so a stop posted after ci-status passed on a head holds at the merge queue's run, not on the pull request's check.Each lane-stopped comment names its run, so every stop edits it and its last edit is the newest stop's time.
Known gap: a stop cleared on its head (a same-head retry, or
stop-resolved) can still be listed by the monitor's stalled-stop signal after two hours. The signal reads comments and check runs, and the monitor job has nostatuses: read, which GitHub lists the commit-status endpoints under (https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps). It is a false alarm, not a weakened hold, so no scope is added for it.fix-ci-contextand the fix-ci dispatcher readcheck-runs?filter=all(bothlistCommitCheckRunsadapters), so a contract-only run's later skip of a lane no longer hides that lane's failure fromfailingChecksor letsstop-resolvedclear a red head. Each still reads at most three pages.Monitoring lane signal 10,
stalled lane stop(.github/scripts/maintenance-monitor-signals/):needs-human-clearedcomment for a reason now routed to fail, that is older than 2 hours, and nopr-unblock / <activity>attempt has started on that head since.Skipped:check does not count as an attempt. That covers a select job's own stop, and a no-op feedback retry that skippednot-applicable.statuses: read.CALL_BUDGET250. The whole run's reads come to at most 166 fromLIMITS, assuming one page of filed issues: 1 rate-limit, 25 pr-merge budget, 31 jobs in use, 49 failed runs, 7 main run, 11 merge-queue, 7 sweep, 1 lane merges, 5 lane stops, 23 stalled stops, 4 audits, 1 filed issues, 1 label. That leaves 84 for writes.pr-unblock-dispatch.ymlruns on cron9,39, every 30 minutes, and the feedback sweep runs off each tick. A retry the lane will make starts within one tick, so four ticks with none mean the retries are spent.What still holds the merge once the label is gone: unresolved review threads for every address-feedback stop (the
mainruleset setsrequired_review_thread_resolution: true), a conflict for resolve-conflicts, red ci-lanes, and soci-status, for fix-ci,ci-statusitself on any failingpr-unblock/*status, and the merge gate's failing-check blocker (babysit_checks.classify_checks, which counts a commit status) for all of them.Bounded step before each
failends for good:feedback-uncertainandfeedback-disputed: the adjudicator, which never resolves aVALID-deferthread on alaneverdict.feedback-not-converging. These are final for the head.fix-ci-restarts= 1).feedback-partial,feedback-unadjudicated,feedback-stood-openandfeedback-verifier-open. The lane's reply is already the thread's last comment, so the retry's select escalatesfeedback-untaken-threads. A person resolving the threads frees the head: the nextaddress-feedbackdispatch finds no unresolved thread and clears the status (below). Otherwise these end at the stalled-stop signal.stop-resolved, thenreport-escalation.sh clearinreport-stop-status, the existing writer):address-feedback: no human-gated hold and no unresolved review thread.fix-ci: a whole listing of the head's check runs (total_countread, past three pages it is not whole), every one completed, none failing. fix-ci now reads the checks before the round cap, so a head re-run green after the cap clears too.inputs.activity's own context, on the head the select read, and only when the select job succeeded; a failed read writes no outputs.resolve-conflictsandreview-dependencyare unchanged.ci-statusthat already failed stays failed until something re-runs it (the next review lane'srerun-ci-status, or a person).clearcount apr-unblock/*status by creatorgithub-actions[bot], the identity of every workflow'sGITHUB_TOKEN. A status can be forged only by a same-repository pull request whose own workflow posts one, which needs an account that can push workflow files: not the lanes App (noworkflowspermission) and not a fork (read-only token). That account can already remove aneeds-humanlabel, so the status hold is exactly as strong as the label hold it replaces. Posting with the App token would need astatusesgrant the broker does not give, and checkingtarget_urlproves only that a run exists, so neither is added. Every merge to main goes through the merge queue (ruleset "merge-queue", no bypass actors), where ci-status runs again.Workflow files changed:
pr-unblock.yml,pr-run-activity-read.yml,pr-run-activity-write.yml,pr-merge.yml(the clear step gets the job's own token asCHECKS_TOKEN) andpr-require-checks.yml(the ci-status step above). The lane token cannot push these.pr-run-activity-write.ymlalso copiesescalation-routes.mjsand its table besideapply-patch.mjs. No token scope changed.Verification
node --testin.github/scripts/pr-unblock: 812 pass, 0 fail. New tests cover:lane-stoppedis final.fix-ci-restarts.SWEEP_HEAD_CAP.limit-stops-per-head."1"only after a GitHub Actions lane stop.bash .github/scripts/pr-unblock/report-escalation.test.sh: 803 ok, 0 not ok. It covers:clear: success over the lane's own failure onlynode --testin.github/scripts/apply-patch: 135 pass, including the held-escalation split..github/scripts/pr-merge: 476 pass, including a fix-ci clear on a red pr-require-checks lane only (green, ci-status-only red, a redpr-test-windows, skipped, another app's, or a failed read keeps it; a newer skipped duplicate does not hide a failure)..github/scripts/maintenance-monitor-signals: 78 pass, including the 23-call worst case and an older head's marker that no longer hides the current head's stop. The detector:lane-stoppedornot-applicableskipci-fix-escalatedlabel, the shape feat(planning): give acceptance criteria ids and trace them to plan phases and decompose slices #10136 has.github/actions/resolve-config: 698 pass, including a skip that does not clear the stop status andfeedback-publish-failedfrom a failed reply job..github/actions/report-check-run: 101 pass.address-feedback.test.mjs(every thread resolved by hand clears; an untaken, a taken or a held thread does not; a failed thread read throws and writes no outputs; a check listing past three pages is not whole),fix-ci-context.test.mjs(no failing check clears, at the round cap too; a failing, cancelled or in-progress check, a cut-short listing, a hold or a closed PR does not; a failed listing read throws and writes no outputs),callers.test.mjs(report-stop-statusrunsclearon a resolved select that succeeded, not on a failed one). Each failed before the change.test_merge_holds_sync.pyfailed before the list change (the table unheld, 38 != 39). Babysit Python suite: 3073 pass.scripts/ci-lanes-pr-hold.test.sh: 156 pass.scripts/ci-review-lanes.test.sh: 98 pass.node --test .github/scripts/pr-merge: 476 pass.scripts/write-runner-never-rules.test.sh: 76 pass.scripts/plan-test-lanes.test.sh: 45 pass.adjudicate-feedback.test.mjs).feedback-protected-pathand the other held reasons still skipawaiting-human.scripts/ci-hold-pr-unblock-status.test.sh: 27 pass, 0 fail; it fails on the base, which has no such step. It covers nopr-unblock/*status, a success and a pending one passing; a failure and an error each failing and named; a newer success over a failure passing and a newer failure over a success failing; another account's status neither holding nor clearing; an unreadable status list, a bad head, an unreadable queued pull request and an unparsable merge-group ref failing closed; and the merge queue reading the queued pull request's head, not the group's base.scripts/check-lane-coverage.sh --checkis green (the step is notcontinue-on-error, so its exit code is the hold).b34abd39, with origin/main merged:clear-escalations.test.mjs("a label for a reason now routed to a failing status: fix-ci comes off on a red head, others stay after the head moves", over every non-fix-ci fail-routed reason in the table) andadjudicate-feedback.test.mjs("settleDisputes never resolves a VALID-defer finding on a lane verdict") each failed before the fix and pass after;publish-feedback.test.mjs's dispute test now expects the same.node --testover.github/scripts/pr-unblock,.github/scripts/pr-merge,.github/actions/resolve-config,.github/scripts/apply-patchand.github/scripts/maintenance-monitor-signals: 2211 pass, 0 fail. Babysit Python suite: 3073 pass.scripts/ci-hold-pr-unblock-status.test.sh27 pass,report-escalation.test.sh0 not ok,scripts/ci-lanes-pr-hold.test.sh156 pass,scripts/check-lane-coverage.test.sh41 pass,scripts/check-lane-coverage.sh --checkgreen, actionlint 1.7.12 clean. The origin/main merge composedpr-run-activity-write.yml's apply-patch base copy (main'smerge-holds.mjsbeside this PR'sescalation-routes.mjsand its table, onecpline each);scripts/write-runner-never-rules.test.sh76 pass, and the other write-runner tests scoped to that workflow pass. Thefilter=alladvisory is fixed: the new tests inaddress-feedback.test.mjsandfix-ci-dispatch.test.mjseach failed before the change. The stalled-stop false alarm is accepted, not fixed (above). On the final head, the node suites pass 2213,report-escalation.test.sh803 ok and 0 not ok, the babysit suite 3073, and the shell suites, lane coverage, actionlint and shellcheck are clean.check-changelog-fragments.sh --checkare clean.Related
ci-fix-escalated):clear-escalationstakes the label off on its next sweep after this merges, if a lane check on that head still reads failed. ci-lanes on that head stays red. Two hours later the stalled-stop signal lists it, unless a new head arrives first.feedback-uncertain): the open thread is a pr-verify note. New runs route that case tofeedback-verifier-open, but the existing label's recorded reason is still a held one, so it is not cleared automatically.feedback-uncertain): the open thread is a security-reviewseverity: IMPORTANTfinding. It stays held until the owner decides whether IMPORTANT counts as HIGH under rule R3.🤖 Generated with Claude Code