Skip to content

fix(ally-guard): classify a same-lane duplicate review by body, not by timing - #1738

Merged
kkroo merged 1 commit into
masterfrom
fix/pen-2865-classify-duplicate-review-mode
Sep 15, 2026
Merged

kkroo merged 1 commit into
masterfrom
fix/pen-2865-classify-duplicate-review-mode

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 10, 2026

Copy link
Copy Markdown

Refs PEN-2865, BLO-19778. Related (not superseded by this PR): #1687, #1141.

  • I searched GitHub for a duplicate PR/issue before opening this.

Searched open PRs and gh search prs for check-ally-review-consistency / ally / duplicate / idempotency. Closest neighbours, all complementary rather than overlapping:

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Ally is its PR reviewer, and scripts/check-ally-review-consistency.mjs guards the invariant that one PR head carries one operative verdict (BLO-19778)
  • The guard reliably detects the violation, but reports only that a head has two reviews — never which of two different defects produced them
  • Those two defects need opposite fixes, so the finding is not actionable until someone hand-diffs the bodies; and because timing is the intuitive discriminator, the intuitive reading is wrong
  • On PEN-2865 that misread happened: a re-measurement classified 3 of 4 instances as submit retries on the basis of sub-60s gaps, and recommended submit idempotency — but one of those three is a genuine double-compute
  • This pull request makes the guard compare the bodies and name the implied mechanism in the I1 message
  • The benefit is that each occurrence self-classifies, so nobody has to hand-diff four PRs to know which control is missing — and nobody repeats the misread

Linked Issues or Issue Description

  • Refs PEN-2865 — the duplicate-review defect this diagnoses (Paperclip issue, not a GitHub issue)
  • Refs BLO-19778 — the incident class the guard exists to prevent

What Changed

  • scripts/check-ally-review-consistency.mjs — new exported sameLaneBodyRelation(operative) returning resubmit (all bodies identical) / recompute (bodies differ) / mixed (>2 reviews, both shapes) / null (not a duplicate, or any body empty). Reuses the existing normalizedBody() so the trim semantics stay shared with duplicateBodyAcrossIdentities.
  • scripts/check-ally-review-consistency.mjs — the I1 message now appends the mechanism the relation implies (SAME_LANE_RELATION_NOTES). Nothing else about I1 changes: same trigger, same count, same fingerprint.
  • scripts/check-ally-review-consistency.test.mjs — 11 new tests across the classifier, the I1 wording, the empty-body abstention, and fingerprint stability.
  • Docblock updates recording why body-comparison rather than elapsed time, with the counterexample.

Verification

node --test scripts/check-ally-review-consistency.test.mjs     # 124 pass / 0 fail (11 new)
node --test scripts/ally-agent-idempotency-contract.test.mjs   #  11 pass / 0 fail (adjacent, untouched)
node scripts/check-ally-review-consistency.mjs                 # live, against Blockcast/paperclip

The live run classified all four then-current violations exactly as they had been independently hand-diffed from GET /pulls/{n}/reviews:

PR head gap bodies classified
#1731 772616cb 46 s 1895 / 1895 resubmit
#1730 a17fba0d 30 s 2197 / 2197 resubmit
#1220 a9ee094a 10 s 8513 / 6564 recompute
#1721 a8096107 2.4 h 2534 / 2865 recompute

#1220 is the counterexample that motivates the whole change: 10 seconds apart with two independently composed reviews (one opens ### Prior Findings Dispositioned (2), the other with a rebase/mergeStateStatus head-context paragraph). A timing threshold files that as a retry; submit idempotency keys on the payload and these are two different payloads, so it would not fix it.

Mutation-checked both new invariants, i.e. I confirmed the tests fail when the behaviour is wrong:

  • forcing the classifier to always return resubmit → 3 tests fail
  • injecting an account id into the classification clause → the fingerprint test fails

Relationship to #1687

#1687 adds prevention (a pre-wake allyReviewAlreadyAttestsHead() check) and is the right shape for it. This PR is diagnosis, and the two compose: the classification says which instances #1687 closes.

A pre-wake check fires before any review is computed or submitted, so on the four above it closes #1721 (2.4 h — by the second wake the first review already attests the head) but structurally cannot close:

  • #1731 / #1730 — one wake, one computed body, submitted twice. The duplicate happens after the wake decision.
  • #1220 — two wakes both arriving before either review posts. Both legitimately read not_attested; a full review takes minutes, so a 10-second submission gap means both were already in flight. Same shape as the 43 ms #876 pair in this script's docblock.

So the remaining gap after #1687 is a once-only submit, and resubmit in the guard output is what names it. Nothing here changes or duplicates #1687.

Risks

Low risk, and deliberately bounded:

  • Diagnostic only. No change to which findings fire, their count, or the exit code. Only the human-readable I1 string grows.
  • The one real hazard is fingerprint drift, and it is tested. violationFingerprint() derives the baseline key from the message text by harvesting every 6-digit-or-longer token, so a byte count or an account id in the new clause would change every I1 fingerprint and silently void the matching scripts/ally-review-consistency-baseline.json suppressions. The clause is digit-free by construction, a comment on the helper says why, and a test pins the fingerprint across all three relations.
  • Not a CI-behaviour change: pr.yml already runs this test file, so the new tests gate on the existing job.

Separately noted, not changed here: all six baseline entries are currently stale (the guard warns on each) because the violations they pinned rotated out. Pruning them changes no verdict; they belong to PEN-2847 and I left them to that row's owner rather than mixing an unrelated edit into this diff.

Model Used

Claude Opus 4.5 (claude-opus-5[1m] as configured in this agent's adapter), 1M context, extended thinking, with tool use (shell, file edit, GitHub API) via the Claude Agent SDK.

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 script's own docblocks)
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending; will address
  • 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

…y timing

I1 reports that one PR head carries two operative Ally reviews. It did not
report which of two different defects produced them, and the two need
opposite fixes, so every finding had to be hand-diffed to be actionable.

Add sameLaneBodyRelation() and name the mechanism in the I1 message:

  resubmit   every body identical -> one computed verdict reached GitHub more
             than once; the submit step is at-least-once.
  recompute  bodies differ -> two full reviews were computed for one head and
             both submitted; the missing control is reviewer exclusion, not
             submit idempotency.
  mixed      >2 reviews showing both shapes at once.

A body is left unclassified when any of them is empty: that is an attestation
defect (I3), and inferring a mechanism from it would assert more than the
evidence carries.

Timing was the previous discriminator and it is wrong. On paperclip#1220 two
reviews 10 s apart carry different bodies (8513 vs 6564 bytes) -- a genuine
double-compute inside the window a timing rule reserves for retries. Measured
against live GitHub, the four current violations split 2 resubmit
(#1731, #1730) / 2 recompute (#1721, #1220), where the timing rule scores
three of them as retries.

The classification clause is deliberately free of any 6-digit-or-longer
number. violationFingerprint() harvests every such token out of the message
text, so a count or an account id here would change an I1 fingerprint and
silently void the matching baseline suppression. A test pins that, and it was
mutation-checked: injecting an account id into the clause fails it.

Refs PEN-2865, BLO-19778.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 10, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19778
🔗 Paperclip issue: PEN-2865
🔗 Paperclip issue: PEN-2847

1 similar comment
@allyblockcast

allyblockcast Bot commented Sep 10, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19778
🔗 Paperclip issue: PEN-2865
🔗 Paperclip issue: PEN-2847

@allyblockcast

allyblockcast Bot commented Sep 10, 2026

Copy link
Copy Markdown
Author

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

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

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

— commitperclip

@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: 4b1bc3a

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The body-based classifier cleanly separates identical resubmissions, substantive recomputations, and mixed duplicate sets without relying on timing.
  • Empty-body handling fails closed rather than inferring a mechanism from incomplete evidence.
  • The tests cover whitespace normalization, all relation classes, fingerprint stability, and the committed baseline; the targeted suite passes 124/124.

Recommended Action

  1. No Critical or Important review findings. The implementation is suitable for merge once the repository's required checks complete.

@kkroo
kkroo added this pull request to the merge queue Sep 14, 2026
Merged via the queue into master with commit 9ceb60f Sep 15, 2026
22 of 23 checks passed
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.

1 participant