Skip to content

fix(review-gate-sweep): re-check ally_has_reviewed_head before the write (BLO-32044) - #1667

Open
allyblockcast[bot] wants to merge 2 commits into
BLO-31908-sweep-refire-reread-guardfrom
BLO-32044-guard-rechecks-ally-reviewed-head
Open

fix(review-gate-sweep): re-check ally_has_reviewed_head before the write (BLO-32044)#1667
allyblockcast[bot] wants to merge 2 commits into
BLO-31908-sweep-refire-reread-guardfrom
BLO-32044-guard-rechecks-ally-reviewed-head

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 5, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • review-gate-sweep.yml is its reconciler for lost Ally review wakes: it finds PRs stranded awaiting review and re-fires the request (BLO-22892)
  • A sweep decides during a scan and writes minutes later, so every precondition it checks is check-then-act. #1661 added refire_still_permitted() to re-read one of them — the cooldown — immediately before the write
  • The scan has a second precondition, ally_has_reviewed_head(), and it was left at its scan-time value. So the guard would withhold a write for a concurrent re-ask while happily re-asking for a review that had just landed
  • That interleaving needs no concurrent sweep at all — one run whose scan and write straddle Ally answering is enough, which arguably makes it the likelier of the two
  • This pull request re-applies ally_has_reviewed_head against freshly-read state inside the same guard, on both review surfaces
  • The benefit is that the sweep stops producing the stacked-review-request pathology it exists to prevent, arriving through the front door

Linked Issues or Issue Description

Fixes: BLO-32044
Refs BLO-31908 / #1661this PR is stacked on that branch, because refire_still_permitted() does not exist on master yet. Base is BLO-31908-sweep-refire-reread-guard; GitHub will re-target to master when #1661 merges.
Refs BLO-31818 — the dropped concurrency group whose live cost the new summary block makes measurable.

Raised by Ally reviewing #1661 (native-codex lens) and verified independently against the script on that branch. Explicitly out of scope for BLO-31908 and not a defect it introduced; the guard is simply the natural home for it.

What Changed

The fix (commit 1)

  • refire_still_permitted() now re-applies ally_has_reviewed_head() as well as the cooldown, and withholds the DELETE, the POST and the marker comment when Ally has reviewed the current head.
  • Both surfaces are consulted. Either alone yields false negatives — verified live 2026-08-04: #952 carried 4 comment-shaped reviews with an empty pulls/952/reviews, while #937 carried 4 formal review objects and no comment-shaped one.
  • Ordered by cost. The cooldown and the comment surface are free (the guard already holds the comments); the one paid GET .../reviews is reached only when both free checks pass. Every branch that declines is a branch that will not write, so may_refire=False and dry_run still pay for nothing.
  • New REVIEWED_SKIP_REASON_PREFIX, held distinct from the cooldown prefix: one says "somebody re-asked too recently", the other says "the review we were about to ask for has already landed".
  • MAX_REFIRES_PER_RUN semantics unchanged — the guard returns refire=False and sweep() decrements on that flag, so a guard skip consumes no slot.

Observability (commit 2) — two non-blocking items Ally raised on #1661, deferred there rather than staling a clean review at head bc73d71e:

  • Guard skips now get a GITHUB_STEP_SUMMARY block, counting contended and answered separately. failed/deferred/alarming each already had one; the contended count is the only direct evidence that dropping the concurrency group (BLO-31818) has a live cost, and it was the one signal you had to grep logs to find. The block is conditional, so a clean run carries no permanent "0 withheld" line.
  • The cooldown reason no longer renders a negative age in exactly the case the guard exists for. now is sampled once per run, so a marker posted mid-run by a concurrent sweep is genuinely newer than the run's clock and the message read re-asked -3s ago < cooldown 7200s. Reworded to name the skew rather than clamp it with max(0, ...), which would print re-asked 0s ago and hide the informative part — that the marker postdates this run. The clamp is kept out of the comparison, pinned by a test.

Verification

python3 -m unittest discover -s .github/scripts -p 'test_sweep_*.py'118 passed (99 before this PR, +19). This is the workflow's own Unit tests for the sweep's pure decision logic step (review-gate-sweep.yml:184, pr.yml:314).

The interleaving needs Ally to answer inside a ~2min window against a measured 5m–74m response latency, so it is not reproducible on demand. The unit tests stand in for it, and they verify the guard rather than the absence of the race. They simulate the straddle the same way #1661's do — by serving the scan and the pre-write re-read different pages of the same surface.

Because "these tests pass" is weak evidence for a guard, each new behaviour was confirmed against a deliberately broken copy:

mutant result
the new check removed entirely 5 fail, including both positive cases
head-exactness dropped — the naive "any Ally report → skip" only the stale-head control fails
the negative-age rewording reverted 2 fail
the summary block removed 3 fail

The second row is the load-bearing one. An over-broad check passes both positive tests while disabling the reconciler outright — a PR whose head has moved past an older review carries an Ally report on both surfaces permanently, and that is precisely the state this sweep exists to re-fire. Suppressing it would reinstate BLO-22892 silently: no re-fire, no alarm. test_a_report_against_a_stale_head_does_not_block_the_refire is the single test separating a correct guard from that, and it is the only one the over-broad mutant fails.

Risks

Low, and the risk is asymmetric in the direction this change is careful about.

  • A false "reviewed" suppresses recovery — no re-fire and no alarm, the BLO-22892 defect. A false "not reviewed" merely costs one redundant review request. So the check demands positive evidence: ally_has_reviewed_head requires the consolidated envelope plus an exact head attestation, and ignores the mutable review.commit_id. That bias is pre-existing and inherited deliberately, not loosened here.
  • One extra GET .../pulls/{n}/reviews per re-firing PR — at most MAX_REFIRES_PER_RUN (5) per run against 1,000/hour/repository, negligible beside the ~270 reads the scan already issues, and short-circuited on every non-writing path.
  • CI-only change; no runtime, API, or persistence surface.
  • BLO-31908's stated residual is left intact and is not rewritten to imply the race is closed. This narrows a different window with the same mechanism; there is still no compare-and-set on the GitHub comment API, so the concurrent-sweep race remains open and the docstring still says so (pinned by TestRereadGuardResidualIsStated).

Model Used

Claude Opus 4.5 (claude-opus-4-5, 1M context) via Claude Code, with extended thinking and tool use (file editing, git, gh, local test execution).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, CI script only
  • I have updated relevant documentation to reflect my changes — the behaviour is documented in the module's own docstrings, which are where this script keeps its rationale
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending review
  • I will address all Greptile and reviewer comments before requesting merge

Release Engineer added 2 commits September 5, 2026 16:13
…ite (BLO-32044)

The pre-write guard added in BLO-31908 re-read the PR's comments and
re-applied only the cooldown. The scan has a second precondition --
_consider_pr skips a PR when ally_has_reviewed_head() is true -- and that
read was left at its scan-time value, so the guard could withhold a write
for a concurrent re-ask while happily re-asking for a review that had just
landed.

That interleaving needs no concurrent sweep at all, which arguably makes it
the likelier one: a single run whose scan and write straddle Ally answering
is enough. Measured request->response latency is 5m-74m against a ~2min
run, so the window is small but not exotic.

Both surfaces are re-checked, because either alone yields false negatives
(verified live 2026-08-04: #952 carried 4 comment-shaped reviews with an
empty pulls/952/reviews; #937 carried 4 formal review objects and no
comment-shaped one). Ordered by cost -- the cooldown and the comment
surface are free, since the guard already holds the comments, and the one
paid read is reached only when both free checks pass. Every branch that
declines is a branch that will not write, so may_refire=False and dry_run
still pay for nothing.

The check is head-exact, not "has Ally reviewed at all". A report against a
superseded head must NOT block: reconciling that case is the entire purpose
of the sweep, and a coarser test would silently disable the reconciler
while still passing the two positive tests. The stale-head negative control
is the load-bearing one and was confirmed against two mutants -- with the
check removed, 5 tests fail; with head-exactness dropped for a naive "any
Ally report -> skip", the positive tests still pass and only that control
fails.

Skips carry a distinct REVIEWED_SKIP_REASON_PREFIX so an operator can tell
"Ally answered mid-run" from "re-asked too recently" off the log line, and
a guard skip still returns refire=False, so it consumes no
MAX_REFIRES_PER_RUN slot.

BLO-31908's residual is left intact: this narrows a different window with
the same mechanism and closes neither race.
…a negative age (BLO-32044)

Two non-blocking items Ally raised reviewing #1661, deliberately deferred
there so a clean review at head bc73d71 was not staled for changes with no
correctness gain. Both are observability, and both sit in the code this
issue already touches.

1. The guard's skips were the least visible of the run's outcomes, which
   inverts the priority. `failed`, `deferred` and `alarming` each get a
   GITHUB_STEP_SUMMARY section; the guard's reached an operator only through
   the per-PR stdout line. The contended count is the only direct evidence
   that carrying no concurrency group (BLO-31818) has a live cost -- i.e.
   that sweeps genuinely overlap and the residual on refire_still_permitted
   is real rather than theoretical -- so it was the one signal you had to
   grep logs to find. It now gets a summary block, counting the two kinds
   separately: contended means a concurrent sweep beat us to it, answered
   means Ally replied mid-run and the PR is simply no longer stranded. The
   block is conditional, so a clean run does not carry a permanent "0
   withheld" line that nobody reads.

2. The cooldown reason rendered a negative age in exactly the case the guard
   exists for. `now` is sampled once at the top of the run, so a marker
   posted by a concurrent sweep mid-run is genuinely newer than this run's
   clock and the message read "re-asked -3s ago < cooldown 7200s". The
   decision was right, but it reads as an arithmetic bug on the one line
   operators use as evidence. Reworded to name the skew rather than clamp it
   with max(0, ...), which would print "re-asked 0s ago" and hide the
   informative part -- that the marker POSTDATES this run. The clamp is kept
   out of the comparison, pinned by a test.
@allyblockcast
allyblockcast Bot requested a review from kkroo as a code owner September 5, 2026 16:22
@allyblockcast

allyblockcast Bot commented Sep 5, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-22892
🔗 Paperclip issue: BLO-32044
🔗 Paperclip issue: BLO-31908
🔗 Paperclip issue: BLO-31818

@allyblockcast

allyblockcast Bot commented Sep 5, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • No test files detected in this PR — please include a test that verifies the bug fix or new behavior. If this PR genuinely doesn't need a test (e.g. a refactor), please retitle with refactor: prefix.

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast

allyblockcast Bot commented Sep 5, 2026

Copy link
Copy Markdown
Author

The red review gate is a known false positive — do not retitle this PR

commitperclip reports "No test files detected in this PR — please include a test... If this PR genuinely doesn't need a test (e.g. a refactor), please retitle with refactor: prefix."

That is wrong here. This PR adds 19 tests (99 → 118) in .github/scripts/test_sweep_stalled_ally_reviews.py, +398 lines. The gate simply cannot see them: check-pr-test-coverage.mjs's TEST_PATTERNS matches only JS/TS conventions, and none of them match unittest's test_*.py — which is the exact pattern review-gate-sweep.yml:184 discovers with.

This is already fixed in #1666 (fix(ci): let the test-coverage gate see Python test files (BLO-31908)), which adds /(?:^|\/)test_[^/]*\.py$/. Verified empirically against this PR's exact file set rather than by reading the diff — I ran checkTestCoverage from both revisions over [sweep-stalled-ally-reviews.py, test_sweep_stalled_ally_reviews.py] with this PR's title:

check-pr-test-coverage.mjs revision result
current (master / this base) passed=false — "No test files detected"
with #1666 passed=true

So the gate goes green on this PR once #1666 lands; no change is needed here.

Retitling to refactor: would be the wrong fix and is worth naming explicitly, because it is what the gate's own message pushes you toward: it would silence a correct-in-intent gate by mislabelling a behavioural bug fix that ships with tests. BLO-19152 recorded that same inversion for the .mjs case.

Ordering

Tracking on BLO-32044; both prerequisites belong to BLO-31908.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f392d08

The core change is right and the negative control (test_a_report_against_a_stale_head_does_not_block_the_refire) is exactly the test this class of guard needs. All 118 tests pass locally at this head. One finding, in the summary/exit surface rather than the guard itself.

Critical Issues (0)

Important Issues (1)

  • [native-codex / gstack-review] .github/scripts/sweep-stalled-ally-reviews.py:998 — the REVIEWED_SKIP_REASON_PREFIX path returns pending_since unchanged, so a PR the guard just proved is not stranded still trips is_alarming and fails the run red.

    The scan path handles the identical discovery differently. At sweep-stalled-ally-reviews.py:962, ally_has_reviewed_head() being true sets pending_since = None, with the comment "Either way treat as not-pending so should_refire / is_alarming both skip it." The new guard discovers the same fact minutes later and skips that normalization, so main()'s alarming list at :1119-1127 (which reads r[2]) counts it.

    Reproduced at this head, using the guard's own fixtures (PR pending 10h, Ally report served only to the pre-write re-read):

    refire: False
    reason: skip: Ally reviewed this head between scan and write -- consolidated report on the comment surface
    pending_since is None? False
    ALARM_THRESHOLD_SECONDS: 19800 = 5.5 h
    is_alarming -> True
    

    Consequence: one GITHUB_STEP_SUMMARY reports the same PR twice, in contradictory terms — the new section says "1 answered … the PR is no longer stranded. This one is healthy", while the :rotating_light: section below it says "still pending past 5.5h despite an automated re-fire cycle" — and sys.exit(EXIT_ALARM) turns the scheduled job red on the healthiest outcome the guard can produce. The contended path is different and should not be normalized: there the PR genuinely is still stranded.

    Why the new tests don't catch it: test_sweep_stalled_ally_reviews.py:1634's _skip() helper hardcodes None in the pending_since slot, so TestGuardSkipsAreVisibleInTheStepSummary never exercises the real tuple _consider_pr returns. TestPreWriteAllyReviewedGuard builds the real tuple but only asserts on refire and reason.

    • Return None for pending_since on the REVIEWED_SKIP_REASON_PREFIX branch only, mirroring :962 — e.g. have refire_still_permitted signal which precondition declined (it already returns prefix) and normalize at :998 when prefix is REVIEWED_SKIP_REASON_PREFIX. Then pin it with an assertion that an answered outcome yields is_alarming(...) is False, and add a _skip() variant carrying a real stale pending_since so the summary test can distinguish the two.

Suggestions (2)

  • [pr-review-toolkit/code] .github/scripts/sweep-stalled-ally-reviews.py:694 — the stated cost bound isn't exact. "only the reviews surface costs a request, bounded at MAX_REFIRES_PER_RUN per run" holds for the write path, but sweep() at :1051 decrements refires_left only on outcome[3], and test_an_already_reviewed_skip_does_not_consume_a_refire_budget_slot (correctly) pins that a withheld write leaves the slot free. So each PR withheld on the reviews surface pays a request without consuming budget, and the true bound is the eligible-PR count. The practical exposure is small — cooldown-blocked PRs short-circuit before the paid read, and the answered-via-reviews case is rare — but given how carefully the rest of this file states its residuals rather than claiming them away, the bound is worth rewording to match.

  • [pr-review-toolkit/code] .github/scripts/sweep-stalled-ally-reviews.py:432int(-since_last) truncates, so a sub-second skew renders as re-asked 0s AFTER this run's scan clock. The AFTER … (concurrent writer) wording still carries the informative part, so this is cosmetic, but it's the same "reads as an arithmetic bug" failure the change set out to fix. Rounding, or a <1s floor, would close it.

Strengths

  • test_a_report_against_a_stale_head_does_not_block_the_refire is the load-bearing control, and its docstring names the exact failure a coarser any Ally review present -> skip would cause (silently reinstating BLO-22892). It serves the stale report to both the scan and the re-read, which is what makes it a real control rather than a restatement.
  • The two-surface check is grounded in measured evidence (#952 comment-shaped with empty pulls/reviews; #937 the inverse) rather than asserted, and test_the_guard_reads_the_reviews_surface_again_rather_than_trusting_the_scan pins the extra read so a refactor can't reopen half the race while the other tests still pass.
  • Keeping REVIEWED_SKIP_REASON_PREFIX distinct from REREAD_SKIP_REASON_PREFIX, with test_the_two_skip_reasons_are_distinguishable asserting neither is a prefix of the other, is the right call for startswith()-based classification — and the summary text explicitly tells the operator that one is contention evidence and the other is healthy.
  • The residual is inherited explicitly ("BLO-32044 narrows a DIFFERENT window with the same mechanism and inherits the same residual") instead of the guard being described as closing the race.

Recommended Action

  1. Normalize pending_since on the answered branch before merge — as written, the guard's success case fails the job red.
  2. Consider the two suggestions opportunistically.

Formal COMMENTED review: this PR is authored by app/allyblockcast, so GitHub bars the App from APPROVE on it. The consolidated result is delivered as a formal review (not a plain comment) so it carries a commit_id and is visible to the exact-head gate.

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.

0 participants