fix(approval-queue,lifecycle): reopen expired staged actions, restore accept-path protections, and stop honest iteration escalating (#9481, #9482, #9483) - #9508
Merged
Conversation
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 |
Contributor
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #9508 +/- ##
==========================================
+ Coverage 89.55% 95.27% +5.71%
==========================================
Files 843 741 -102
Lines 110135 60483 -49652
Branches 26207 21314 -4893
==========================================
- Hits 98635 57623 -41012
+ Misses 10238 1618 -8620
+ Partials 1262 1242 -20
Flags with carried forward coverage won't be shown. Click here to find out more.
|
… accept-path protections, and stop honest iteration escalating (#9481, #9482, #9483) The unique index on agent_pending_actions has no status predicate and the insert used onConflictDoNothing, so once #9032's sweep expired a row every later re-plan conflicted with it permanently: created:false, so stageForApproval returned before notifying and decidePendingAgentAction reported already_decided. Nothing deleted or reopened an expired row, making an auto_with_approval merge/close whose maintainer was away for the expiry window permanently unexecutable via the queue -- strictly worse than the pre-#9032 behaviour, and the opposite of what the sweep's own doc promises. An expired row is now reopened in place, guarded on status='expired' so a concurrent decision cannot be clobbered. The step-4 audit is also conditional on staging having actually happened; it used to assert "awaiting maintainer approval" on every later sweep even when staging no-op'd. The accept-replay path built a thinner executor context than the live path, silently voiding three protections. Without authorLogin, step 8c claimed the EMPTY-author cap-lock key, so #9159's mutex shared no namespace with the live path's author-keyed one and provided no mutual exclusion at all, while maybeEscalateModeration early-returned and staged-then-accepted enforcement closes recorded no moderation violation toward the warning/ban ladder. Without expectedBaseRef, #9055's base-retarget guard could never fire on a replay. The verdict-flip guard counted a flip on any direction change, keyed per PR with no content dimension -- so honest iteration produced the same signal as the exploit (two fix cycles hit the threshold), and because flipCount never decreased the PR then escalated forever, an absorbing state whose own advice to "push a substantive fix" could not clear it. Flips now count only at an unchanged content fingerprint; genuinely changed content resets. Re-rolling the same content still escalates exactly as before.
…ms (#9481, #9483) Restructures the flip guard's content check as an early return so TypeScript narrows the fingerprint, removing a nullish fallback whose null arm was unreachable on the counting path. Drops the redundant re-read after a lost CAS -- reporting the row already in hand with created:false is the same answer -- and annotates the remaining race arm with its reason. Adds coverage for the nullish arms: reopening with no reason, a first observation and a counted flip both persisting null rather than undefined.
…he real exploit (#9483) The test inserted a prior flip row with a null fingerprint, which under #9483 correctly resets rather than escalating -- honest iteration on changed content is not the abuse pattern. It now runs one real pass to establish the fingerprint for this exact content, forces the counter to the threshold with the opposite prior verdict, clears the review cache so the second pass is a genuinely FRESH verdict rather than a reuse, and re-rolls the SAME content. That is precisely the exploit the guard exists to stop, and it still escalates.
JSONbored
force-pushed
the
fix/approval-queue-and-lifecycle-stranding
branch
from
July 28, 2026 02:19
342c128 to
2135db9
Compare
…0196 collision Both branches took 0196 from the same base, so the contiguity check passed on each in isolation. The retention index migration merged first, so this one moves.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three states a PR could enter and never leave, plus a set of protections that were silently inert on the approval-queue replay path.
Closes #9481
Closes #9482
Partially addresses #9483 (verdict-flip half; see Scope)
#9481 — an expired staged action could never be re-staged
agent_pending_actions_target_uniqueis a full unique index on(repo_full_name, pull_number, action_class)— no status predicate — andcreatePendingAgentActionIfAbsentusedonConflictDoNothingagainst it. So once #9032's sweep expired a row, every later re-plan conflicted with it permanently:created: false, sostageForApprovalreturned before notifying, anddecidePendingAgentActionreportedalready_decidedfor a non-pending row. Nothing anywhere deletes or reopens an expired row.Net effect: any
auto_with_approvalmerge/close whose maintainer was away for the expiry window became permanently unexecutable via the queue for that PR + action class. That is strictly worse than the pre-#9032 behaviour (rows waited forever but stayed acceptable), and it is the opposite of what the sweep's own doc promises — "a later pass that re-plans the same action stages a fresh row with a fresh notification".An expired row is now reopened in place, guarded on
status = 'expired'so a concurrent accept/reject can never be clobbered, and reported ascreated: trueso the caller notifies exactly as for a new row. Onlyexpiredreopens:pendingis legitimately sticky and accepted/rejected are terminal by design.Also fixed: the step-4 audit fired unconditionally, so when staging no-op'd against an existing row every later sweep still recorded
"awaiting maintainer approval"— an audit trail asserting a notification that was never sent. It is now conditional on staging having actually happened.#9482 — the accept path built a thinner context, voiding three protections
decidePendingAgentAction's executor context omittedauthorLogin,expectedBaseRefandmoderationSettings. All three fields are optional, so nothing forced parity with the live path's context and nothing failed loudly:claimContributorCapLock(env, repo, ctx.authorLogin ?? ""), so an absent author produced the keycontributor-cap-lock:<repo>:— the empty-author key, sharing no namespace with the live path's...:<author>. The mutex that shipped specifically so "the two can never both act on a stale view" provided zero mutual exclusion on this path, leaving the feat(miner): add tenant create/list/destroy control-plane admin CLI #7284 TOCTOU open, while also making every accept-path merge in a repo contend on one shared key.maybeEscalateModerationearly-returns on a falsyauthorLogin, so a staged-then-accepted blacklist / contributor-cap / review-nag close recorded no moderation violation. A contributor whose enforcement closes always route through approval accumulated zero standing violations toward the warning/ban threshold.fetchPullRequestFreshnessonly evaluates the base whenexpectedBaseRefis set, so a contributor retargeting the PR base between staging and accept — head unchanged, so the head-SHA pin still matched — merged into a base the reviewed diff and CI never targeted.#9483 (verdict-flip half) — honest iteration escalated, permanently
The flip guard counted a flip on any direction change, keyed per
(repo, pull)with no content dimension. The exploit it defends against is re-rolling a non-deterministic reviewer over the same diff until a lucky clean roll lands — but honest iteration produces an identical signal: defect(A) → fix → clean(B) → new issue(C) → fixed(D) is three flips, i.e. two honest fix cycles reach the threshold of 3.Worse,
flipCountnever decreased, so once escalated the PR escalated on every future fresh verdict — an absorbing state whose own user-facing advice ("push a substantive fix so the next review reflects real content change") was structurally unable to clear it, because a substantive fix produces a fresh verdict that re-escalates.Flips now count only when the review's content fingerprint matches the prior verdict's; genuinely changed content resets the count. Migration
0196adds the nullable column. Re-rolling unchanged content still escalates exactly as before.Scope
#9483 also covers the merge-train stranding half (a waiting PR is never re-driven when its blocker resolves by any means other than a merge). That needs a new wake path and its own tests, and it interacts with the ordering work in #9499 — it stays open rather than being half-done here.
Validation
npx tsc --noEmit,npm run db:migrations:check(contiguous 0001..0196),npm run db:schema-drift:check— all cleanverdict-flip-guard,approval-queue-staleness,agent-approval-queue,routes-agent-approval,mcp-automation-stateRegressions, each verified to fail against the unfixed code:
expectedBaseRefreaches the freshness check; with it reverted the assertion fails.authorLoginreverted the accept proceeded (it claimed the empty-author key), so the test genuinely pins the fix.created: true, a fresh reason, cleared decision metadata, and still exactly one row for the target.Invariants:
pendingrow stays sticky — a re-plan must not reset an outstanding decision.acceptedandrejectedrows stay terminal (parameterised over both) — only expiry reopens.Merge-order note
This adds
migrations/0196_verdict_flip_fingerprint.sql. #9503 also adds a0196(retention_column_indexes_round_two) — both branch frommain, so the contiguity check passes on each in isolation but whichever merges second needs renumbering to0197. #9503 is already fully green, so it will likely land first; flagging so the collision is expected rather than surprising.