Skip to content

fix(ci): re-dispatch a pr-unblock activity the broker refused as workflow-modified - #13123

Merged
kyle-sexton merged 3 commits into
mainfrom
fix/pr-unblock-refire-workflow-modified
Nov 4, 2026
Merged

kyle-sexton merged 3 commits into
mainfrom
fix/pr-unblock-refire-workflow-modified

Conversation

@kyle-sexton

Copy link
Copy Markdown
Member

No related issue: hub ledger L250; an address-feedback round the broker refused as workflow-modified, because pr-unblock.yml changed on main mid-run, waited for a hand re-dispatch (#13048 after #13074).

Summary

A pr-unblock run in flight when a change to .github/workflows/pr-unblock.yml merges is refused by the token broker with lane-token-denied: ... workflow-modified: .github/workflows/pr-unblock.yml differs from refs/heads/main. For resolve-conflicts, refire-refused.mjs already recovers it: on #13027 it posted its marker at 04:54Z and pushed an empty commit, and the next dispatcher tick sent the new head. For address-feedback (and its apply-feedback-patch and publish-feedback jobs) nothing recovered it. On #13048, refused run 41772051393 (04:56Z) sat until someone re-dispatched round 41776369074 by hand. An empty commit is the wrong cure there, since it restarts both review lanes before any new round.

Fix

  • feedback-dispatch.mjs: a new sweepRefused pass, first in the tick sweep's decide-then-send loop. It re-dispatches an address-feedback round only when that round's newest run on the PR meets all of these:

    • it failed within FAILED_RUN_WINDOW_HOURS (2 h);
    • it is linked by postingRunId to a check on the PR's current head;
    • it was refused for a reason refire-refused's now-exported tokenRefusal classifies as workflow-modified on a lane file.

    The re-dispatch, or any later run, ends it, so each refused run is sent exactly once. A head that already has SWEEP_HEAD_CAP (3) rounds is skipped.

  • refire-refused.mjs: dispatcherRefires: ["address-feedback"] leaves those runs to the sweep, so each activity has one retry path. resolve-conflicts keeps the empty-commit path.

  • Trust: the re-dispatch is the tick's own attempt-1 dispatcher run, with its pr-feedback-dispatched-<upstream>-<run> record, the route every sweep send takes (check-trusted-trigger.mjs:805-881). No broker, trust-list or permission change.

  • Telemetry: each dispatched-run record gains refire_of_run_id, fed by new refire-of[-N] step outputs in all five record steps (pr-feedback-dispatch.yml). The log gets a refire: #N: run X was refused as workflow-modified line.

  • signals.mjs and pr-merge.yml: comments updated to the split.

Risk

  • The window is 2 h, against the empty-commit path's 72 h, and a head at the cap now gets only a log line.
  • UNVERIFIED: that the broker reads the dispatched-run record by name only, never its body. This rests on the workflow comment, not on azure-iac source.
  • No test covers a refire that is the second send in a tick (refire-of-2).

Verification

  • feedback-dispatch.test.mjs 226/226. refire-refused, the pr-merge workflow tests and workflow-gates together pass 201/201.

  • The tests cover:

    • a refused run is re-dispatched once, on main, with the refire field;
    • there is no second send when a later run exists;
    • there is no send for a kill-switch 403, a 503, another workflow file, a refusal outside the window, a moved head, or a head at the cap;
    • pr-merge pushes no empty commit for a refused address-feedback run.

    The main tests fail on origin/main's source.

  • The record step's bash, run by hand with REFIRE_OF both empty and set, emits valid JSON.

  • scripts/check-affected.sh --base origin/main --no-fetch: exit 0.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 3 commits November 4, 2026 01:34
…flow-modified

An address-feedback round in flight when a lane workflow file changes on
main gets its push or reply token refused as workflow-modified, a re-run
keeps the old file, and the head's check runs then hold it. pr-merge's
refire-refused answered with an empty commit on its schedule, which
restarts both review lanes before any new round, and #13048 was dispatched
by hand before that pass came.

The half-hourly tick's feedback sweep now sends such a round again first:
a fresh attempt-1 dispatch on main's files at the same head, with the
head's newest review run as the upstream, as every sweep dispatch. It acts
once per refused run (only the newest run of the activity for the PR
counts), only when the refused run posted a check on the current head
within FAILED_RUN_WINDOW_HOURS, and under SWEEP_HEAD_CAP rounds at that
head. Check runs started before the refused run ended stop counting. The
refusal is read by refire-refused's tokenRefusal, now exported, and
refire-refused leaves address-feedback's workflow-modified runs to the
sweep. The dispatched-run record names the refused run in
refire_of_run_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-workflow-modified

# Conflicts:
#	.github/scripts/pr-unblock/feedback-dispatch.mjs
#	.github/workflows/pr-feedback-dispatch.yml
@kyle-sexton
kyle-sexton marked this pull request as ready for review November 4, 2026 07:05
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-04T07:08:36.598497Z d6915f5 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

claude-security-review reviewed d6915f58c85425937a8261ed3898a124cb398437: read 7 of 7 changed file(s); 0 finding(s), 0 as inline comments.

Security review: PR #13123

I found no security issues. I read all 7 changed files in full in the diff. These trusted-path files got the extra scrutiny: refire-refused.mjs, feedback-dispatch.mjs, pr-feedback-dispatch.yml and pr-merge.yml.

What I checked

  • Who the new sweepRefused pass can reach. It walks listed, not pulls, so it also looks at owner PRs. openPulls (feedback-dispatch.mjs#L957-L967) still admits only PRs that meet all of these:

    • opened by the lanes App, or by an adopted owner;
    • not a draft;
    • with a head branch in this repository.

    A fork PR or an outside author never reaches the pass. The send then goes through dispatchAtHead → dispatchFeedback, which still applies every per-PR gate (#L550-L681):

    • the owner adoption check and ownerSkip;
    • hold labels and the human-gated label gate (prHold / gateSource);
    • both review-lane markers on the head;
    • a thread the lane can work;
    • the slot being enabled and applying.

    So the refire skips none of the gates that decided the original round.

  • Round bounds. requestSince makes check runs that started before the refused run stop counting, much as a fix request does. Three things still bound it:

    • sweepRefused checks rounds.length >= SWEEP_HEAD_CAP before it sends anything (#L1223-L1226);
    • a limit stop keeps its reset hold (#L686);
    • only the newest run counts, so each refused run is sent once.
  • Trust route. The re-dispatch is the tick's own attempt-1 dispatch on refs/heads/main. It goes out through the same deferred send() and dispatched-run record as every other sweep send. Nothing changes in check-trusted-trigger, request-lane-token, the broker config or the trusted-actor list. The dispatch job's permissions: block is unchanged (checks: read was already granted; only its comment changed).

  • Workflow record steps. REFIRE_OF reaches run: through env:, not through ${{ }} inside the script. All five steps check it against ^([1-9][0-9]{0,14})?$ before using it in printf, so no JSON or shell injection can get into the uploaded record. The value itself is String(last.id), which has already passed RUN_ID.

  • Import side effects. feedback-dispatch.mjs now imports tokenRefusal from refire-refused.mjs. That module runs main() only under the import.meta.url === pathToFileURL(process.argv[1]) guard (refire-refused.mjs#L584). Importing it cannot start the empty-commit push from the dispatcher's context.

  • Narrowed recovery in refire-refused.mjs. dispatcherRefires now stops the empty-commit recovery for address-feedback refusals. That path is a recovery mechanism, not a merge hold or trust control. Leaving it out admits nothing new and releases no hold.

  • Embedded text. The PR context contains no embedded directives.

Spend: a refused address-feedback round is now re-dispatched, which spends model usage on extra lane runs. It is capped at SWEEP_HEAD_CAP (3) rounds per head and SWEEP_TICK_CAP (5) per tick. This draws on subscription or prepaid usage only; it is not a hold.

critical-findings: 0

@github-actions

github-actions Bot commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

claude-review reviewed d6915f58c85425937a8261ed3898a124cb398437: read 7 of 7 changed file(s); 0 finding(s), 0 as inline comments.

Code review: PR #13123, re-dispatch an address-feedback round the broker refused as workflow-modified

Result: no blocking or advisory findings. I read every hunk of all 7 changed files, and I read the surrounding code in feedback-dispatch.mjs and refire-refused.mjs.

What I checked

  • sweepRefused and dispatchFeedback work together correctly.
    • requestSince = Date.parse(last.updated_at) drops the refused run's own address-feedback and reply check runs from existing/replies, so the re-dispatch is not skipped as "already has a check run". A limit stop keeps its reset hold, because counts keeps limitResetsAt runs.
    • holds ignores the refused run because it is completed.
    • The newest-run rule ends the chain after one send, and a re-run attempt also counts as the newest run.
    • SWEEP_HEAD_CAP bounds a chain of repeated refusals on one head.
  • Owner PRs: sweepRefused runs over listed, which includes adopted owner PRs. dispatchFeedback still applies ownerSkip, the hold labels, the human gate, the marker checks and the slot checks, so that wider scope bypasses no gate.
  • tokenRefusal extraction: this is a straight refactor. refusedFile keeps its behaviour for every activity except address-feedback refused as workflow-modified, which now returns null (covered by the new refire-refused test). refire-refused.mjs guards main behind an import.meta.url check, so importing it from feedback-dispatch.mjs has no side effects.
  • Plumbing: refireOf passes from the decision through the send to onSent and into the refire-of[-N] output, which is written only when set. The record step's regex accepts an empty or valid id, and the ${REFIRE_OF:+...} expansion only adds the field when it is set. The new listAnnotations call goes through the budgeted request, and checks: read already covers annotations.
  • Docs: no convention doc says address-feedback refusals get an empty commit, so the split leaves no doc contradicting the code.

Not verified by me: the author's test counts (226/226 and 201/201), the hand run of the record-step bash, and the claim that the broker reads the dispatched-run record by name only. I had no shell, so these are the author's claims. The PR's own Risk section names these remaining gaps:

  • The recovery window is now 2 h, down from the empty-commit path's 72 h. A dispatcher outage longer than that now leaves a refused address-feedback round to a person.
  • No test covers a refire that is the second send in a tick (refire-of-2).

Spend (prepaid, not a hold):

  • The new pass adds a jobs read and annotation reads on GITHUB_TOKEN for each recently failed address-feedback run per tick.
  • Each refire starts one more address-feedback lane run, which uses model credits.

Other checks

  • Closing issue: none. The PR names a hub ledger entry, so there are no acceptance criteria to check.
  • Path-scoped rules: no .claude/rules/ glob matches the changed files.
  • Plugin manifests: none changed.
  • Prior findings and round cap: no prior findings; 0 rounds against a cap of 3, so the cap did not apply.
  • Embedded instructions: none found in the PR context.

@kyle-sexton
kyle-sexton added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit f0a18c3 Nov 4, 2026
73 checks passed
@kyle-sexton
kyle-sexton deleted the fix/pr-unblock-refire-workflow-modified branch November 4, 2026 07:21
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