Skip to content

fix(gate): never plan an add and a remove of the manual-review label together - #10167

Merged
JSONbored merged 1 commit into
mainfrom
fix/label-add-remove-flap
Jul 31, 2026
Merged

fix(gate): never plan an add and a remove of the manual-review label together#10167
JSONbored merged 1 commit into
mainfrom
fix/label-add-remove-flap

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Closes #10164

P1 — live in production. #10155 on orb-v3.7.0-beta.7:

10:08:31 labeled   manual-review by loopover-orb[bot]
10:09:31 unlabeled manual-review     10:09:33 labeled manual-review
10:11:18 unlabeled manual-review     10:11:20 labeled manual-review
10:13:01 unlabeled manual-review     10:13:03 labeled manual-review
10:14:43 unlabeled manual-review     10:14:44 labeled manual-review

Four add/remove cycles in eight minutes on one PR — a GitHub write and a subscriber notification each. The audit trail shows both operations in the same pass, two seconds apart:

10:16:26  agent.action.label | close-audit holdout drew this PR — would-close held for human adjudication (#8831)
10:16:24  agent.action.label | manual-review hold resolved — clearing the "manual-review" label the bot applied

Root cause

Three post-plan transforms surface this hold — downgradeMergeToHold, downgradeCloseToHold, and applyCloseAuditHoldout (#8831). All three carried the identical idempotency check:

const alreadyNeedsReview = labels.manualReview !== null && next.some(
  (a) => a.actionClass === "label" && a.label === labels.manualReview && a.labelOp !== "remove");

It looks for an existing add, so by construction it cannot see a planned remove. When the planner has already scheduled a release, the transform appends an add beside it. One plan, both operations, same label — forever.

Why now, and why #10116 is not to blame

Latent until #10116. noManualReviewHoldWanted used to include !mergeableStateUnstable, which suppressed the release on exactly these PRs and accidentally masked the missing case. Removing that term — correctly; it was the latch keeping #10098 stuck — let the release fire and exposed the contradiction. The label was previously a sticky latch: quieter, but strictly more broken.

Why the planner can't fix it

Section 1b's doc calls noManualReviewHoldWanted "the complete condition: every reason that would ADD this label, in one place." It is not, and cannot be — these three run after planning, so their reasons are not knowable there, and #8831 landed long after that comment was written. Extending the planner's list is one more thing to remember on the next transform.

Fix

One shared withManualReviewHoldLabel(planned, labelSettings, action) that drops a planned release of the same label before adding, used by all three sites. The contradiction becomes unrepresentable where the add happens.

The remove is dropped rather than the add skipped: a transform only reaches there by having just diverted a merge or a close, so its hold is strictly newer information than the release the planner decided before that diversion.

Scoped to this one label — dropping every label remove would silently defeat the stale-disposition-label cleanup.

Verification

  • Full suite: 26,306 passed, 0 failed. agent-actions + close-audit-holdout suites: 357 pass.
  • 6 new regression tests, including the exact production plan shape.
  • Mutation-tested:
mutation tests failed
stop dropping the planned release (the production bug) 3
drop the idempotency guard 1
over-broad: drop every label remove 1

That last one is why the filter is label-scoped: the naive fix breaks the stale-label cleanup.

Note

This also removes three copies of the same 8-line block, which is what let one of them (#8831, added later) diverge from the other two without anyone noticing.

gate.closeAuditHoldoutPct is 20 — the maximum — in the global Orb config, so roughly one in five would-close PRs is eligible for the path that triggered this. Per maintainer decision the holdout stays enabled and we ship the fix rather than zeroing it, so the close-precision measurement series keeps no gap.

@loopover-orb

loopover-orb Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Important

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

⏳ LoopOver is waiting…

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

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

@superagent-security

Copy link
Copy Markdown
Contributor

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

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

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.85714% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 91.34%. Comparing base (55cbeb9) to head (64f7dee).
⚠️ Report is 9 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/settings/agent-actions.ts 92.30% 0 Missing and 1 partial ⚠️

❌ Your patch status has failed because the patch coverage (92.85%) is below the target coverage (99.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #10167      +/-   ##
==========================================
- Coverage   92.21%   91.34%   -0.88%     
==========================================
  Files         931      931              
  Lines      114071   114069       -2     
  Branches    27548    27547       -1     
==========================================
- Hits       105188   104193     -995     
- Misses       7584     8773    +1189     
+ Partials     1299     1103     -196     
Flag Coverage Δ
backend 94.13% <92.85%> (-1.55%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/review/close-audit-holdout.ts 100.00% <100.00%> (ø)
src/settings/agent-actions.ts 98.31% <92.30%> (+0.35%) ⬆️

... and 3 files with indirect coverage changes

…together

#10155 is flapping in production on beta.7: the label is
removed and re-added roughly every 90 seconds -- four cycles in eight minutes,
each pair a GitHub write and a subscriber notification. The audit trail shows
both operations in the SAME pass, two seconds apart.

Three post-plan transforms surface the manual-review hold -- the merge
circuit-breaker, the close circuit-breaker, and the close-audit holdout (#8831).
All three carried the identical idempotency check, which looks for an existing
ADD (`labelOp !== "remove"`) and therefore cannot see a planned REMOVE. When the
planner has already scheduled a release -- section 1b does, whenever nothing IT
knows about still wants a hold -- the transform appended an add next to that
remove, and the executor performed both.

Latent until #10116. `noManualReviewHoldWanted` used to include
`!mergeableStateUnstable`, which suppressed the release on exactly these PRs and
accidentally masked the missing case. Removing that term (correctly -- it was the
latch keeping #10098 stuck) let the release fire and exposed the contradiction.
#10116 did not create this; the label was previously a sticky latch, quieter but
strictly more broken.

The planner cannot fix it from its side. Section 1b's doc calls
noManualReviewHoldWanted "every reason that would ADD this label, in one place";
it is not, and cannot be, because these three run AFTER planning -- #8831 landed
long after that comment was written. Extending the planner's list would just be
one more thing to remember on the next transform.

So one shared withManualReviewHoldLabel() drops a planned release of the same
label before adding, and all three call sites use it. The contradiction becomes
unrepresentable where the add happens. The remove is dropped rather than the add
skipped: a transform only gets there by having just diverted a merge or a close,
so its hold is strictly newer than the release decided before that diversion.

Scoped to this label only -- dropping every label remove would silently defeat
the stale-disposition-label cleanup, which is its own mutation test.

gate.closeAuditHoldoutPct is 20 (the maximum) in the global Orb config, so about
one in five would-close PRs is eligible for the path that triggered this.

Closes #10164
@JSONbored
JSONbored force-pushed the fix/label-add-remove-flap branch from 69a6e37 to 64f7dee Compare July 31, 2026 10:53
@JSONbored
JSONbored merged commit 456a55c into main Jul 31, 2026
6 checks passed
@JSONbored
JSONbored deleted the fix/label-add-remove-flap branch July 31, 2026 10:57
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.

P1: manual-review label flaps every pass — a plan can contain both an add and a remove of it

1 participant