fix(queue): dedupe concurrent AI reviews for the same PR head - #2429
Conversation
A webhook pass and an agent-regate-pr sweep pass can independently miss the AI review cache for the same (repo, PR, head SHA, mode) and each fire a real LLM call, potentially landing different verdicts at the same head. Add a per-key advisory lock (mirroring the existing per-PR maintenance lock) around the review call; a pass that loses the race returns the existing ai_review_inconclusive shape so the gate holds for human review instead of racing an independent verdict.
|
Warning 🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨 ⏸️ Gittensory review result - manual review recommendedReview updated: 2026-07-01 23:42:15 UTC
⏸️ Suggested Action - Manual Review
Review summary Nits — 6 non-blocking
Review context
Contributor next steps
Signal definitions
🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed 💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →. Checked by Gittensory, a quiet PR intelligence layer for OSS maintainers.
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #2429 +/- ##
==========================================
- Coverage 95.88% 95.88% -0.01%
==========================================
Files 224 224
Lines 25158 25165 +7
Branches 9150 9150
==========================================
+ Hits 24124 24130 +6
- Misses 421 422 +1
Partials 613 613
🚀 New features to boost your workflow:
|
…/set fallback claimAiReviewLock's fallback path (used when the cache adapter has no atomic claim()) was a plain get-then-set pair: two concurrent callers can both observe an absent key before either writes, and both believe they claimed the lock -- defeating the dedupe guarantee for exactly the double-LLM-call race this lock exists to prevent. The same latent gap already existed in the sibling claimAgentMaintenanceLock fallback. Extract a shared claimTransientLock helper used by both locks. Its fallback now writes a token unique to the attempt, then reads the key back -- since a correctly-behaved key-value store serializes writes to a single key, only the caller whose token survives the final read actually won; every other concurrent caller reads a different (later) token and correctly backs off. Still fails open on a missing cache or any read/write error, matching the existing defense-in-depth design.
… without atomic claim() The prior fallback (write a unique token, then re-read to verify) does not close the race: caller A can write its token, read it back, and return true entirely before caller B's later write/read also returns true -- both callers can still "win" under real concurrent interleavings, which defeats the whole point of the lock. There is no way to build real mutual exclusion out of separate get/set calls without an atomic primitive. Rather than keep pretending to serialize, claimTransientLock now requires the cache adapter's native claim() for any exclusivity at all; without it, every caller proceeds (fail open), matching this lock family's existing defense-in-depth philosophy. Self-host's Redis-backed cache always implements claim(), so this is a documented limitation for a hypothetical future adapter, not a live production gap.
Summary
agent-regate-prsweep pass can independently miss the AI review cache for the same (repo, PR, head SHA, review mode) and each fire a real LLM call, potentially landing different verdicts (e.g. one call flags a consensus defect, the other doesn't) for the identical head — since both share a head SHA, freshness guards elsewhere never catch this.claimAiReviewLock/releaseAiReviewLock, a per-(repo, PR, head SHA, mode) advisory lock mirroring the existing per-PR maintenance lock (fix(queue): serialize concurrent webhook/sweep passes for the same PR #2368), claimed immediately before the expensive grounding/RAG/enrichment/LLM section ofrunAiReviewForAdvisoryand released in afinallycovering every exit path.ai_review_inconclusiveshape (non-cacheable,reviewerCount: 0), which the gate already treats as "held for human review" (neutral) rather than success/failure — reusing the established fail-safe instead of inventing new gate logic.Test plan
npx tsc --noEmit -p .cleanqueue.test.ts,ai-review-advisory.test.ts,gate-check-policy.test.ts): 380/380 passingnpm run test:coverage: 312 files / 5831 tests passing, 0 failuressrc/queue/processors.ts— zero uncovered added lines/branchesclaimAiReviewLock is not a function); restoring makes them pass againgit diff --checkclean;npm audit --audit-level=moderate: 0 vulnerabilities