fix(cursor-review): own a per-PR concurrency group so skip-cursor-review cancels an in-flight panel - #303
Conversation
…iew cancels an in-flight panel
The documented caller group keys on `github.event.label.name`, which puts
`labeled: skip-cursor-review` in a different group from `labeled: cursor-review`.
A veto applied mid-panel therefore cancelled nothing: it started a run that
no-opped in the Gate while the panel it was meant to stop ran on and posted its
review. The Gate's own comment claimed the opposite.
Give the reusable its own workflow-level `concurrency:` group, the shape
pr-size.yml and pr-area-label.yml already use here, so it reaches every caller
through the normal pin bump with no caller edit and no permission change.
Slot rule: the trigger label and the `skip-cursor-review` veto label share one
`trigger` slot, so applying the veto mid-flight cancels the running panel.
Every other label gets its own `format('label-{0}', …)` slot, so an unrelated
label add never kills a running review, and `pull_request_review_thread` events
stay out of `trigger` so resolving a finding thread cannot cancel a panel.
Under `run_without_label: true` the four plain PR actions the Gate accepts join
`trigger` as well — otherwise a synchronize-triggered panel would sit in a slot
the veto cannot reach, which is the case this block exists for.
Gate comments corrected, the three doc sites re-stated (caller groups are now
optional belt-and-braces, and a caller must never name its group
`cursor-review-reusable-*` or it deadlocks its own run), and a new
test_workflow_concurrency.py pins the slot rule, cancel-in-progress, the
namespace prefix and the no-other-workflow-shares-the-group deadlock guard.
|
Warning Review paused — included plan limit reachedKeep your review moving with free on-demand reviews.
On-demand reviews are free for the next 22 days.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Promotion and pricing detailsOn-demand reviews are free for the next 22 days. After that, they cost $0.25 per reviewed file. Review limit detailsOr wait 16 minutes for your next included review. Limit details: You’ve used the included review currently available. Your 131 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Team Run ID: 📒 Files selected for processing (4)
Comment |
There was a problem hiding this comment.
🔍 Cursor Review — Consolidated panel
Triggered by @mattmillerai.
Found 10 finding(s).
| Severity | Count |
|---|---|
| 🟠 High | 2 |
| 🟡 Medium | 4 |
| 🟢 Low | 4 |
Panel: 6/6 reviewers contributed findings.
…bel, stop paying for a cancelled run, and correct the caller-group docs Review follow-ups on the reusable's own concurrency group. Workflow: - Guard the trigger-label disjunct with `inputs.review_label != ''`. The input is `required: false`, so a caller can pass ''. GitHub coerces a missing `github.event.label` and '' alike in a mixed comparison, so without the guard every non-label event — `pull_request_review_thread` included — compared EQUAL and joined `trigger`, making a thread resolve cancel a running panel: the case the block's own comment called impossible. - `consolidate` and `notify-complete` move from `always()` to `!cancelled()`. Cancelling is now an advertised escape hatch, and on a cancelled run `always()` spent up to 30 minutes on a judge whose payload nothing could post, then DMed the triggerer "Cursor review failed to post" for what was a deliberate veto. Cell-level failures are unaffected: `cancelled()` asks whether the RUN was cancelled, so a red or cancelled matrix cell still reaches the judge and still notifies. `blocking-gate` keeps `always()` — a cancelled run that skipped it would mint a green required check, the fail-open BE-4691 added it to close. - Comments: the gate no longer claims an in-flight run "has already been cancelled" by the time it decides. Cancellation is requested, asynchronous and slot-scoped; a `label-<name>` occupant is not cancelled at all. Records the case gap too — expression `==` is case-insensitive while the gate's own test is a case-sensitive shell `=`, so a case-mismatched `review_label` cancels a panel and then no-ops. Test: - `test_trigger_label_and_veto_label_share_one_slot` asserted substring containment over the whole disjunction, which stayed green when the veto comparison was demoted into the nested `run_without_label` arm or its `||` swapped for `&&` — the regressions it exists to catch. It now splits the disjunction on its TOP-LEVEL `||` and requires the veto to be its own unconditional operand. All four mutations fail it; the empty-label guard gets its own test. - The deadlock guard globs `*.yaml` as well as `*.yml`. Docs (three sites): - "your caller group is optional / redundant but harmless" was wrong for the caller shapes this guide recommends. Under the default `run_without_label: false` the reusable's group does not supersede on push, so a widened or blocking caller that drops its PR-number-only group posts reviews against superseded head SHAs — and a PR-number-only caller group is coarser than the reusable's, so it cancels first and leaves the per-slot isolation nothing to refine. Both sides are now stated; every example says KEEP THIS. - Ships the second hard rule `pr-size` carries and this had dropped: call it from a dedicated workflow file, because cancellation is run-scoped — and unlike the deadlock rule, this one arrives silently at a pin bump. - New blocking-gate gotcha for the mid-panel veto: the cancelled run and the veto run both publish `Blocking gate` on the same head SHA, red and green respectively, and nothing orders them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 The reviews loop filed Linear follow-up ticket(s) for review thread(s) deferred as out of scope for this PR:
The following carry
|
ELI-5
Applying
skip-cursor-reviewto a PR while a review panel was already running did nothing to that panel. It kept going and posted its review anyway. This gives the reusable workflow its own "one run per PR" group, and deliberately puts the trigger label and the veto label in the same slot — so applying the veto now cancels the panel that is in flight. Every other label gets its own slot, so adding an unrelated label still never kills a running review.What was wrong
The documented caller concurrency group is
cursor-review-pr-<n>-${{ github.event.label.name }}.labeled: skip-cursor-reviewandlabeled: cursor-reviewtherefore resolve to different groups and cannot cancel each other. The veto started a run that no-opped in the Gate while the panel it was meant to stop ran to completion and posted. The Gate's own comment claimed the opposite: "Removing it cancels any in-progress run via the concurrency group and then no-ops here."What changed
.github/workflows/cursor-review.yml— a workflow-levelconcurrency:block, above the existing workflow-levelenv:. Same shapepr-size.ymlandpr-area-label.ymlalready use here, so it reaches every caller at its next pin bump with no caller edit and no permission change.Slot rule, as rendered by the group expression:
labeled/unlabeledof the trigger labeltriggerlabeled/unlabeledofskip-cursor-reviewtriggeropened/reopened/ready_for_review/synchronize, only underrun_without_label: truetriggerlabel-<name>pull_request_review_threadlabel-Gate comments corrected — the stale "removing it cancels any in-progress run" claim now states which events share the
triggerslot, and that a caller pinned below this commit still relies on its own label-scoped group, which covers the trigger label alone. The unblock comment notes the slot has already cancelled anything in flight and that the dup-check still covers an unblock after a completed review at this SHA. The veto and unblock logic is unchanged.Docs, four sites —
docs/callers/cursor-review.md(a new "The reusable owns a group of its own" section, a new "Veto mid-flight" gotcha for older pins, a new blocking-gate gotcha, and the blocking-gate line amended to "stops new panels from running and cancels a running one"),.github/cursor-review/README.md(the caller example's group line and the "Escape hatches" skip bullet), and the caller pattern in thecursor-review.ymlheader. Every caller example says KEEP THIS: the reusable's group refines a caller group, it does not replace one (see "Review round 1" below). All of them carry both hard rulespr-sizecarries — never name a caller groupcursor-review-reusable-*(that deadlocks the caller's own run), and call the reusable from a dedicated workflow file, because cancellation is run-scoped..github/cursor-review/tests/test_workflow_concurrency.py— pins the workflow-levelconcurrencykey,cancel-in-progress: true, thecursor-review-reusable-prefix, the per-PR key, both labels inside the same&& 'trigger'disjunction, theformat('label-{0}'namespacing, and a deadlock guard asserting no other file under.github/workflows/mentions the prefix.Review round 1 (commit 2)
The panel raised 10 findings; 8 are addressed in code, 2 in docs + a follow-up.
review_labeldragged every non-label event intotrigger. The input isrequired: false, so a caller can pass'', and GitHub coerces a missinggithub.event.labeland''alike in a mixed comparison — sopull_request_review_thread: resolvedcompared EQUAL and joinedtrigger, cancelling a running panel. Exactly the case the block's own comment called impossible. Guarded withinputs.review_label != '', with its own test.run_without_label: falsethe reusable's group does not supersede on push, so a widened or blocking caller that drops its PR-number-only group posts reviews against superseded head SHAs — and underblocking: truegates red on threads for code that no longer exists. In the other direction, a PR-number-only caller group is coarser than the reusable's: it cancels the whole caller run on any label or thread event, before the per-slot isolation can refine anything. Both sides are now stated, and every example says KEEP THIS.pr-sizehard rule was dropped. Cancellation is run-scoped, so the reusable's group takes down the whole caller run — and unlike the deadlock rule, this one arrives silently at a pin bump with no caller edit to warn anyone. Now shipped in all three doc sites.always()onconsolidateandnotify-complete→!cancelled(). Cancelling is now an advertised escape hatch, and on a cancelled runalways()spent up to 30 minutes on a judge whose payload nothing could post, then DMed the triggerer "Cursor review failed to post" for a deliberate veto. Cell-level failures are unaffected —cancelled()asks whether the RUN was cancelled, so a red or cancelled matrix cell still reaches the judge and still notifies.blocking-gatedeliberately keepsalways(). Three reviewers proposed the opposite. Its comment argues the case at length: a cancelled run that skipped the gate would mint a green required check, which is the fail-open BE-4691 added the job to close. Not changed.run_without_labelarm — or swapping its||for&&— kept it green. It now splits on the TOP-LEVEL||and requires the veto to be its own unconditional operand. Mutation-checked: all four mutations fail it (see Verification).label-<name>occupant is not cancelled at all.*.yamltoo.Two findings are documented rather than fixed, both stated plainly in the docs:
==is case-insensitive while the gate's own test is a case-sensitive shell=, so areview_labelthat differs from the real label only in case reachestrigger, cancels the panel and then no-ops. GitHub expressions have no case-sensitive string compare and nolower(), so this is not fixable in the expression — it is a caller-config requirement, and now a documented one. Reachable only via that misconfiguration.Blocking gateverdict racy. The cancelled run trips the "review did not land" guard and reports red; the veto run hasshould_run=false, falls through to the live thread query and reports green — same head SHA, nothing orders them. Which verdict a vetoed PR should get is a policy question about a deliberately fail-closed job, not a concurrency bug, so it is written up in the blocking-gate gotchas and proposed as a follow-up rather than decided here.Judgment calls
run_without_labeldisjunct is an explicit action allowlist, not an empty-label.nametest. The plan sketchedinputs.run_without_label && github.event.label.name == '' && github.event_name == 'pull_request'. That also admitsedited,closed,assigned,converted_to_draftand any otherpull_requestaction a caller widens itstypes:to — each of which would then cancel a running panel and immediately no-op in the Gate. The allowlist mirrors the Gate's owncaselist exactly (opened|reopened|ready_for_review|synchronize), so the concurrency slot and the "should this run" decision cannot drift apart. It also sidesteps GitHub's loosenull == ''coercion on a missinggithub.event.label.test_workflow_job_isolation.pydeliberately parses with regex — this repo is stdlib-only andtest-cursor-review-scripts.ymlinstalls no requirements, so ayamlimport simply would not run in CI. It reuses that suite'sWORKFLOWpath and comment-stripping discipline instead.triggerdisjunction is located with a balanced-paren walk, not a regex. A fixed-depth regex stops matching the moment the condition nests one level deeper and reports "the slot rule is gone" when it is merely nested.ci-cursor-review.ymlcaller is left alone. It pins by SHA, and moving that pin isbump-cursor-review-callers.yml's job, not this PR's. Its group (cursor-review-pr-…) does not collide with the new one.Verification
python3 -m unittest discover -s .github/cursor-review/tests -p 'test_*.py'— 599 passed, 0 failed (6 new).python3 -m unittest discover -s .github/workflow-pins/tests -p 'test_*.py'— 341 passed, 0 failed.python3 .github/workflow-pins/check_workflow_pins.py— OK, 11 workflows declareworkflows_ref, none with a default, every ref checkout guarded.python3 .github/agents-md-integrity/check_agents_md.py --root .— passed (2 pre-existing warnings, unchanged by this PR).actionlint .github/workflows/cursor-review.yml(v1.7.7) — the same two findings as onmain, byte-identical, none in the new block: the pre-existingSC2153 HEAD_REFfalse positive and the knownjob.workflow_shaproperty (the BE-4169/BE-8077 carve-out AGENTS.md documents). Baseline captured by stashing this diff and re-running.inputs-in-concurrencyquestion the plan flagged is answered empirically: injecting${{ secrets.NOPE }}into the group made actionlint print "contextsecretsis not allowed here. available contexts aregithub,inputs,vars" against this exact key — so the context list is validated,inputsis on it, and the check is non-vacuous. Reverted immediately.cancel-in-progress: false→ fails; drop theformat('label-{0}'namespacing → fails; rename the group tocursor-review-pr-→ fails; giveci-cursor-review.ymlthecursor-review-reusable-prefix → the deadlock guard fails; delete the wholeconcurrency:block → all six fail.Residual
The live end-to-end verification named in the plan was not run, and cannot be run from this PR. The plan asks for: apply
cursor-review, wait for panel cells to start, applyskip-cursor-review, confirm the run showscancelledand no review is posted; removeskip-cursor-reviewand confirm exactly one fresh panel; and apply an unrelated label mid-panel and confirm nothing is cancelled. This repo self-enrols viaci-cursor-review.yml, which pinscursor-review.ymlby merged-main SHA precisely so a PR cannot change the reviewer judging it — so the panel that runs on this PR is the old one, and the new block is not exercised by it. Repointing that caller at this branch would defeat the tamper-proofing AGENTS.md requires, so it was not done. Someone should run that sequence on a throwaway PR once this is merged and the pin has moved, and in particular confirm the two things static analysis cannot: that a called workflow's workflow-levelconcurrencycancels the panel matrix jobs (not merely the caller'suses:job), and that the cancelled run posts no review. Thepr-size.yml/pr-area-label.ymlprecedent in this repo is the evidence this shape works; it is precedent, not a run of this expression.The 4108-run / 29-caller history behind the plan was not re-measured. Those callers are private and their roster lives in the
CURSOR_REVIEW_CALLERSsecret, which is write-only and unreadable from here, so the "no incident found" premise is carried forward from the spike rather than re-verified. Nothing in this PR depends on it beyond priority.The portion not fixed, swept here: 16 reusable workflows in this repo declare
on: workflow_call; 6 now own a workflow-levelconcurrency:group (cursor-review,pr-size,pr-area-label,groom,linear-ticket, and — pre-existing — none other), leaving 10 that do not. That is not a latent copy of this bug: none of the 10 pairs a minutes-long job with a mid-flight veto label, which is the combination that makes a split group observable. Checked by shape (askip-/bypassgrep over each), not by run history.Explicitly out of scope, per the plan: the thread-resolve-cancels-panel wart for widened-
types:callers, the over-cap comment race, and theworkflows_refguard. Draft PR #58 was not rebased on or merged into this; if it is ever revived it must adopt the slot expression added here rather than routingskip-cursor-reviewto a slot of its own.Provenance
python3 -m unittest discover -s .github/cursor-review/tests -p 'test_*.py': 600 passed, 0 failed (7 in the new concurrency suite);python3 -m unittest discover -s .github/workflow-pins/tests -p 'test_*.py': 341 passed, 0 failed;check_workflow_pins.py: OK (11 workflows declareworkflows_ref, none with a default, every ref checkout guarded);check_agents_md.py --root .: passed (2 pre-existing warnings, unchanged);cursor-review.ymlre-parsed as YAML and the group expression paren-balanced after the edit. The hardened test was mutation-checked four ways, each reverted: veto demoted into the nestedrun_without_labelarm → fails;||between the two label comparisons swapped for&&→ fails; theinputs.review_label != ''guard removed → fails; veto given its own slot (the PR Add native cursor-review XL mode #58 shape) → fails. Commit 1'sactionlintv1.7.7 baseline (2 findings, byte-identical tomain, none in the new block) was not re-run — no actionlint binary on this host — so the guard against a new lint finding in the edited expression rests on the YAML parse and the paren check.run_without_labeldisjunct uses an explicit action allowlist rather than the plan's empty-label.nametest, and the new test parses the workflow with the sibling suite's stdlib regex approach rather than PyYAML (this suite's CI installs no requirements) — both under "Judgment calls" above. The plan's live end-to-end verification step is unmet and is written up under "Residual". Commit 2 departs from the panel's recommendation on one finding —blocking-gatekeepsalways()rather than moving to!cancelled()— for the reason its own comment gives; see "Review round 1".