Skip to content

fix: collapse duplicate reviews of the same PR head - #109

Open
Zaimwa9 wants to merge 7 commits into
mainfrom
fix/dedup-reviews-by-head-sha
Open

Zaimwa9 wants to merge 7 commits into
mainfrom
fix/dedup-reviews-by-head-sha

Conversation

@Zaimwa9

@Zaimwa9 Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #77.

Problem

Review jobs were deduplicated by trigger comment id, so two people mentioning the bot on the same PR enqueued two full reviews of byte-identical code (observed on Flagsmith/flagsmith#8052: ~10 min of wasted engine time, cancelled by restarting the service). With THEMIS_CONCURRENCY > 1 the duplicates would also run simultaneously and double-post.

Change

Two keys instead of one, because the id was doing two jobs badly:

  • Serialization — every review trigger for a PR now shares review:{repo}#{pr}, so at most one review of a PR is in flight regardless of concurrency.
  • Redundancy — a new revision on queue.enqueue names what the job would process. A revision already in flight under that id (the running job's, or the follow-up already stored behind it) is dropped instead of queued.

Review revisions:

trigger revision second trigger while one is in flight
push / mention on the same commit sha:<head> dropped
push moving the head sha:<new head> stored, runs when the slot frees
steered mention (@themis review <context>) context:<comment id> stored — the request text is part of what gets reviewed
head unresolvable comment:<id> re-deliveries dropped, distinct comments stored
neither (degraded /api/review) None never matches, always queued

None never counts as a duplicate: guessing "same" would drop real work, and the existing rule is that an event arriving mid-run must not vanish.

The head sha is free on pull_request payloads. Mentions and /api/review resolve it via get_pr, reusing the installation token the eyes-ack already mints (_ack became _prepare_trigger, one token for both). Resolution failure is best-effort: the job keeps head_sha=None and degrades to per-comment dedup rather than blocking the trigger.

Notes for review

  • The delta follow-up path is preserved by construction: it depended on the id colliding, and the id still collides for every review of a PR — only the drop-vs-store decision moved to the revision. tests/test_queue.py::test_new_revision_still_runs_after_the_active_job pins it.
  • A collapsed second mention still gets its 👀 ack; the in-flight review's summary is the answer to it. It gets no 🚀 (that reaction belongs to the run that did happen).
  • A mention re-running the same commit after the previous review finished is still honoured — the revision frees with the id.

Verify

uv run pytest -q (876 passed), uv run ruff check src tests.

Regression tests fail on main:

uv run pytest -q \
  tests/test_router.py::test_webhook_second_mention_on_the_same_head_is_a_duplicate \
  tests/test_queue.py::test_same_revision_is_dropped_even_when_followup

Manually: mention the bot twice on one PR without pushing in between — the second webhook answers duplicate and the logs show themis_job_duplicate id=review:… revision=sha:… with a single engine run. Push a commit while a review runs, then confirm the follow-up review starts on the new head once the first finishes.

🤖 Generated with Claude Code

Two people mentioning the bot on one PR bought two full engine runs over
byte-identical code, because jobs were deduplicated by trigger comment id.
Under THEMIS_CONCURRENCY they would also run at once and double-post.

Reviews now share one queue id per PR, so at most one is ever in flight,
and a `revision` decides which of the colliding triggers is redundant:
same head sha (or the same re-delivered comment) is dropped, anything else
is kept as a follow-up and runs when the PR's slot frees. Steered mentions
key on their own comment - the request text is part of what gets reviewed,
so it is never the review already running. An unknown revision never
counts as a duplicate; guessing "same" would drop real work.

The head sha comes free on pull_request payloads; mentions and /api/review
resolve it with the installation token the ack already mints. Resolution
failure degrades to per-comment dedup rather than blocking the trigger.

Closes #77

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/themis/router.py Resolved
@themis-lezgo

themis-lezgo Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

The SHA-based queue key is a solid direction, but it currently describes the trigger rather than the checkout the worker actually reviews. A request captured at commit A can begin after commit B is pushed, review B, and still leave a queued request for B to run a second full review. CI is still running; local tests could not start because this environment cannot download a required dependency.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 3/5
📐 Code quality 3/5
🚀 Product impact 3/5

🟠 Majors

📝 Walkthrough
  • ReviewJob / trigger preparation — captures the payload head or resolves it for mentions and API requests.
  • Queue — serializes reviews by PR and suppresses matching stored or active revisions.
  • Review execution — still resolves and fetches the PR's current head only when the worker starts.
  • Headless docs — describe the new duplicate and deferred-review behavior.
🧪 How to verify
  1. Queue a manual review for head A behind another running job, then push head B before the queued review begins.
  2. Submit a second manual review request at B and let both queued jobs drain; assert that only one full engine run reviews B.
  3. Repeat with a synchronize delivery and confirm the delta path still reviews a genuinely unreviewed head.
  4. Run uv run pytest -q tests/test_queue.py tests/test_router.py tests/test_review_service.py.
  5. Run uv run ruff check src tests.
    Automate: add an execution-level regression that advances the mocked PR head after the first revision is captured.

Product take: This prevents duplicated agent runs and duplicate PR output, directly addressing a costly review-loop failure.
The fix is important for busy repositories and any deployment using more than one worker.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

A neat queue key needs a checkout to match it · reviewed at 98d0a3b

The revision captured at trigger time names the head as it looked then.
The worker resolves the real head when it starts, so a review requested at
A can begin after B is pushed, review B, and still leave the follow-up
stored for B to run a second full review of code already reviewed.

A finished run now reports the commit it posted a review of, and the queue
corrects the in-flight revision to it: a follow-up waiting for that commit
is superseded, and later triggers naming it are duplicates. A run that
posted nothing (closed PR, auto-review off, title skip, no delta base)
reports nothing, so a skip can never masquerade as coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Round 1 finding fixed — you were right, and the hole was the one the PR claims to close.

src/themis/router.py:103 — a newer-head follow-up can still produce a duplicate full review. The revision named the head at trigger time; the worker resolves the real head when it starts. Requested at A, started after B was pushed → reviews B, and the follow-up stored for B then runs a second full review of B.

Closed by making the queue learn what actually happened rather than pinning the checkout:

  • ReviewService.review() now returns the commit it posted a review of, and None from every early return (closed, draft, auto-review off, title skip, no delta base) — a skip must never look like coverage, or it would silence a duplicate that deserves to run.
  • The run closure reports that commit via a new queue.reviewed(job_id, revision), which corrects the in-flight revision.
  • At completion the queue drops a stored follow-up whose revision matches what the job turned out to cover (themis_job_followup_superseded), and any trigger arriving after the correction for that same commit is a plain duplicate.

I chose correction over pinning the clone to the captured SHA: pinning would change what gets reviewed (a review requested before a push would deliberately ignore that push) and would need the workspace to fetch a specific commit rather than refs/pull/<n>/head. Correcting keeps today's "review the current head" semantics and only removes the redundant second pass.

Regression tests, all failing before this commit:

  • tests/test_queue.py::test_followup_superseded_when_the_running_job_covered_its_revision — the exact scenario from the finding: queued at A, running job reports B, follow-up for B never runs.
  • tests/test_queue.py::test_reviewed_revision_deduplicates_later_triggers — same correction seen from the other side.
  • tests/test_queue.py::test_reviewed_is_ignored_once_the_job_is_gone — a late report cannot resurrect a freed id.
  • tests/test_router.py::test_review_run_corrects_the_revision_to_what_it_reviewed and …_that_posted_nothing_leaves_the_revision_alone.
  • tests/test_review_service.py::test_review__returns_the_commit_it_posted_a_review_of and …__skipped_pr__reports_no_reviewed_commit.

uv run pytest -q → 883 passed. uv run ruff check src tests → clean.

@themis-lezgo review

@themis-lezgo

themis-lezgo Bot commented Jul 29, 2026

Copy link
Copy Markdown

No findings.

@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

@themis-lezgo review

@themis-lezgo

themis-lezgo Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚖️ Themis review: ✅ Ship it

TL;DR: Review requests for the same PR head now collapse while a newer head is retained for one follow-up run. The completed test and lockfile checks passed; the focused queue and router coverage exercises the new revision and head-reconciliation paths.

Area Score
🎯 Correctness 5/5
🧪 Test coverage 5/5
📐 Code quality 5/5
🚀 Product impact 4/5

⚖️ Acknowledged

  • Revision drift no longer schedules a duplicate review — thread resolved by @themis-lezgo
📝 Walkthrough
  • Queue revisions - records the requested revision, coalesces duplicate triggers, and retains one newer follow-up.
  • Trigger preparation - resolves missing PR heads before enqueueing while preserving best-effort acknowledgements.
  • Review completion - reports the workspace commit so a waiting job for that commit is suppressed.
  • Headless API - documents the serialized, per-head review behavior.
🧪 How to verify
  1. Run uv run pytest -q tests/test_queue.py tests/test_router.py tests/test_review_service.py.
  2. Run uv run ruff check src tests.
  3. Send two review mentions without changing the PR head and confirm only one engine run starts.
  4. Push a commit during a review and confirm exactly one follow-up runs for the new head.
    Automate: Keep the same-head, newer-head, and actual-workspace-head regression cases in the queue/router suites.

Product take: A solid operational improvement: duplicate agent runs and duplicate review posts are avoided without dropping a later push.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

One PR, one head, one review — much less déjà vu · reviewed at aedbc15

The revision named the commit but not the kind of review. A delta job and a
mention resolving the same head produced the same string, so the mention was
dropped as a duplicate - and a delta re-review covers only the commits pushed
since the last review, and may decline to run at all (delta disabled, no prior
review, no checkpoint key). The user who asked for a review got a narrower one,
or silence.

Revisions are now scope-qualified (`delta:<sha>` vs `sha:<sha>`), reported back
in the same form when a run reports what it covered. Two triggers on one commit
still collapse when they are the same kind of work, which is the case the issue
is about. A delta follow-up left alive behind a full review re-checks the
checkpoint marker and exits cheaply, which is the documented shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Post-ship-it correction — I found the round-1 finding had a sibling I had not closed, so I would rather land it than merge around it.

The class, not just the instance: round 1 was "the revision names the trigger, not the work". I fixed the commit half (report what was actually reviewed) and left the scope half open. _revision returned sha:<head> for a delta job and sha:<head> for a mention — the same string — and job.delta never entered it.

  • pull_request.synchronize at head A → delta job, revision sha:A, active.
  • @themis review arrives, resolves the same head A → revision sha:A.
  • The revision check runs before the follow-up branch → dropped, no follow-up stored.
  • The delta job then finds delta_review disabled (or no prior review, or no checkpoint key) and returns without posting.

Someone asked for a review and got silence. Even when the delta does post, it is explicitly scoped ("Do not re-review unchanged code the earlier review already covered") — a full-review request answered by a narrower review.

Same root cause, other instances of the class, all closed by the same change: an auto job that early-exits on auto_review: false, and one that early-exits on skip_titles — whose courtesy comment literally says "mention me to request one anyway", which an in-flight auto job's revision would have swallowed.

Fix: revisions are scope-qualified — delta:<sha> vs sha:<sha> — and a run reports back in the same form. Same-kind triggers on one commit still collapse, which is issue #77's actual case. I deliberately did not build a covers() relation between scopes: a full review of B leaves a delta:B follow-up alive, that follow-up re-checks the checkpoint marker and exits cheaply, and that is the shape _enqueue already documents. One cheap job is the right price for not losing a real request.

New regression tests: test_webhook_mention_is_not_swallowed_by_a_queued_delta_on_the_same_head (fails before this commit) and test_webhook_redelivered_synchronize_is_a_duplicate (pins that same-scope collapsing is untouched).

uv run pytest -q → 885 passed. uv run ruff check src tests → clean.

@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

@themis-lezgo review

Comment thread src/themis/router.py Resolved
@themis-lezgo

themis-lezgo Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚖️ Themis review: 🔴 Hold the merge

TL;DR: Collapsing equivalent heads is the right direction, but a single follow-up slot now overwrites distinct focused review requests while a review is active. That silently drops an explicit request, and the new pre-enqueue GitHub calls can make webhook delivery fail before any job exists. CI is still running.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 2/5
📐 Code quality 2/5
🚀 Product impact 3/5

🔴 Blockers

  • src/themis/router.py:123 — distinct focused review requests can overwrite one another, leaving an explicit request unreviewed.

🟠 Majors

  • src/themis/router.py:233 — synchronous preparation can fail a webhook delivery before the review is queued.

⚖️ Acknowledged

  • Reconcile the processed revision when the PR head moves — thread resolved by @themis-lezgo[bot]
📝 Walkthrough
  • ReviewJob - records the trigger-time PR head for revision-aware deduplication.
  • Router ingress - serializes all reviews for a PR and resolves heads absent from comment payloads.
  • In-memory queue - tracks active and pending revisions, including post-run reconciliation.
  • Review service - returns the checked-out commit after posting so the queue can reconcile it.
  • Headless documentation - explains the revised duplicate response semantics.
🧪 How to verify
  1. Delay installation-token and PR-head requests beyond 10 seconds; confirm a webhook still returns 2xx promptly and the review is retained.
  2. Hold one full review open, then send two trusted @themis review <different focus> comments; confirm both requests run in order.
  3. Send two ordinary mentions for the same unchanged head; confirm only one engine run occurs.
  4. Push a new head during a review; confirm one follow-up reviews the new head after the active run.
  5. Run uv run pytest -q tests/test_queue.py tests/test_router.py tests/test_review_service.py && uv run ruff check src tests.
    Automate: Add a router/queue regression that queues two distinct steered mentions behind an active review and asserts neither is replaced.

Product take: This removes wasteful duplicate reviews and double-posting, a solid operational improvement.
The request-loss path means it is not yet safe for reviewers relying on focused follow-up reviews.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

One follow-up slot is neat until two humans ask for two things · reviewed at 96c9c3d

1 finding(s) anchored outside the diff (not posted inline)
  • src/themis/router.py:123 🔴 Blocker · 🏗️ Heavy lift

Do not coalesce distinct explicit review requests.

Observed: every ReviewJob uses followup=True here, while the queue keeps one follow-up per PR and replaces it for each new distinct revision. Two trusted @themis review <different focus> comments received during a running review therefore leave only the later request queued; the earlier one returns duplicate but never starts. Predicted: a reviewer’s requested focus area would be silently skipped. Keep distinct manual/steered requests in a FIFO (or merge their contexts) and reserve newest-wins coalescing for supersedable push/delta jobs; add a regression covering two delayed steered mentions.

@Zaimwa9

Zaimwa9 commented Jul 29, 2026 •

Copy link
Copy Markdown
Owner Author

Round 3 lands at the review budget I was given for this PR (3 rounds), so I am stopping here rather than pushing an unreviewed fourth fix. Recording both findings so they are not lost, with my read of each.

🔴 router.py:123 — distinct focused review requests can overwrite one another. Real, and a regression this PR introduced. On main each mention had a comment-scoped job id, so two owners each asking @themis review <different angle> both ran. Here they share the PR key and differ only by revision, so the second is stored in the single follow-up slot and displaces the first: one explicit, steered request is silently dropped.

The fix looks small and local — give a steered request its own job id again (review:{repo}#{pr}:comment:{id} when extra_context is present), since two different asks are genuinely two reviews and were never the duplicate this issue is about. The unsteered collapse, which is #77's actual case, is untouched by that.

🟠 router.py:233 — synchronous preparation can fail a webhook delivery before the review is queued. Also real. One refinement, stated as a prediction rather than something I ran: _prepare_trigger catches Exception, so a GitHub error cannot propagate out of it — but asyncio.CancelledError is a BaseException, so if the handler is cancelled mid-await (server-dependent; I did not test which conditions produce that here) the request aborts before _enqueue, where previously the enqueue had already happened. The trigger would be lost rather than degraded.

The direction I would take: enqueue first with the head the payload carried (possibly None), then resolve and correct the revision through the queue.reviewed() seam this PR already added. That restores "enqueue can never be skipped by anything that happens on the network" while keeping the dedup revision accurate a moment later.

Neither is written, neither is reviewed. Over to you on whether to take this PR with them fixed in a follow-up, or to have me continue.

A single follow-up slot per PR meant the newest held trigger replaced
whatever was already waiting. That is right for pushes and plain mentions -
each asks for "review the current state", so the newest subsumes the rest -
and wrong for a steered request, whose instruction no later review answers.
Two owners each asking for a different focus area during one running review
left only the second queued.

The queue now takes an `on_conflict` policy instead of a followup flag:
"drop" (unchanged default), "coalesce" (supersedable: replaces the first
coalescing job held, in place, so a push stream cannot reorder itself ahead
of an explicit ask), and "queue" (held in arrival order, never replaced).
Held jobs are a list: the revision check reads all of them, completion drops
only the ones the finished run covered, and requeueing the first keeps the
rest.

`_prepare_trigger` also runs before the enqueue on every ingress path, and
resolving a head talks to GitHub. It swallows its own failures, but a
cancelled handler raises through it - CancelledError is a BaseException -
and GitHub does not redeliver a delivery whose connection it dropped. The
enqueue moves into a `finally` on all three paths, so the trigger is queued
either way and degrades to the head it arrived with.
@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Round 4. Both round-3 findings are fixed — and in fixing them I rejected both of the fix directions I published last round, because working them through showed each one trading the blocker for a different one.

🔴 router.py:123 — distinct focused review requests overwrite one another.

What I said I would do: give a steered request its own job id again (review:{repo}#{pr}:comment:{id}). That is wrong. The job id is what serializes reviews of a PR, and a separate id puts a steered review back in a position to run concurrently with the full review of the same PR under THEMIS_CONCURRENCY>1 — two engine runs, two summary comments, and potentially two verdicts on one commit. That is exactly the failure this PR exists to remove; #111 produced it in the wild (🔴 Hold and 🧹 Ship it on the same commit, minutes apart). I would have closed the finding by re-opening the issue.

Your suggestion was the right one and I took it: FIFO for what cannot be superseded, newest-wins only for what can. Concretely, enqueue takes an on_conflict policy instead of a followup bool:

  • "drop" — discard (unchanged; discussion jobs, and the default).
  • "coalesce" — supersedable. A push, an auto review and a plain mention all ask for "review the PR as it stands", so the newest subsumes the others.
  • "queue" — not supersedable. Held in arrival order, never replaced.

_on_conflict picks "queue" exactly when the job carries extra_context, i.e. when a human attached an instruction the next review would not answer.

Three details that are where this would go wrong if I got them wrong, each with its own test:

  • The replacement is in place, not append. A coalescing job takes the position of the coalescing job it supersedes, so a stream of pushes cannot reorder itself ahead of a request that was already waiting (test_a_coalescing_job_replaces_its_own_kind_in_place).
  • The revision check reads every held job, not the next one, so a re-delivered steered mention still collapses however deep its original sits (test_a_revision_held_behind_another_still_deduplicates).
  • Completion drops only the held jobs whose revision the finished run covered, and requeueing the first one retains the rest (test_superseding_drops_only_the_held_jobs_the_run_covered, test_queued_jobs_are_never_replaced_and_run_in_order). Losing the tail there would be this same blocker wearing a different hat.

Your Automate: line is tests/test_router.py::test_webhook_two_steered_mentions_behind_a_review_both_survive, and its push-side sibling is …_pushes_coalesce_around_a_waiting_steered_mention. The queue-level version is three deep on purpose: a two-entry test passes even if requeueing drops the tail.

I did not add a cap on the held list. It is unbounded, and I considered bounding it, but a cap can only be enforced by dropping a request — which is the blocker again, just rarer and with a log line. asyncio.Queue is already unbounded and on main every distinct steered mention got its own id and queued without limit, so a cap would be a new restriction rather than a fix. What I did instead is make the depth observable: themis_job_followup_stored id=%s pending=%d.

🟠 router.py:233 — preparation can fail a delivery before the review is queued.

What I said I would do: enqueue first with the payload's head and correct the revision afterwards through queue.reviewed(). Also wrong, and for a reason I should have seen when I wrote it — the dedup decision happens at enqueue. Enqueueing with revision=None means the second trigger is compared against nothing and is held rather than recognised as a duplicate; correcting it afterwards would mean reaching into an already-held entry and re-running the redundancy check against its neighbours, which is machinery invented to work around the ordering rather than fix it.

The enqueue does not need to move — it needs to be unskippable. It is now in a finally:

try:
    job = await _prepare_trigger(settings, job, ack)
finally:
    enqueued = _enqueue(settings, queue, slug, job)

finally runs during CancelledError propagation and _enqueue never awaits, so the job reaches the queue and the cancellation still propagates. The job keeps the head it arrived with — usually None — which degrades to per-comment dedup, the same degradation an unreachable GitHub already produces.

Applied to all three ingress paths, not just the webhook. The webhook is the severe one (GitHub does not redeliver a delivery whose connection it dropped) but /api/review and /api/discuss have the same shape, and I would rather cover the class than argue the asymmetry.

Last round I flagged the CancelledError-is-a-BaseException part as a prediction I had not run. test_webhook_cancelled_preparation_still_queues_the_review now runs it: _prepare_trigger raises CancelledError, and the assertion is that the job is in the queue anyway. It fails without the finally. It drives the app through httpx.ASGITransport on the loop rather than TestClient, whose blocking portal converts the cancellation into concurrent.futures.CancelledError and hides what is being tested.

One thing I looked at and deliberately left alone. The revision a run reports back is scope-qualified with the trigger's scope (_scope(job)), but a delta job can fall back to a full review (themis_delta_base_unknown_fallback_full, themis_delta_fallback_full) and would then report delta:X for work that was actually full. Every consequence of that mislabel is extra work, never lost work: a later sha:X mention does not match delta:X, so it runs a redundant full review; and the one direction that does drop — a held delta:X superseded by a run that reported delta:X but did a full review — is correct, because the full review strictly covers the delta. Threading an effective scope back out of review() would buy one avoided cheap job for a wider return type, so I left it and am stating it rather than hiding it.

uv run pytest -q → 892 passed. uv run ruff check src tests → clean.

@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

@themis-lezgo review

@themis-lezgo

themis-lezgo Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚖️ Themis review: 🟠 Fix before merge

TL;DR: The revision-aware queue collapses same-head review requests while retaining changed heads, but webhook handling still waits on GitHub API work before returning. GitHub requires a webhook 2XX within 10 seconds and records slower deliveries as failures; the external calls here have 30-second timeouts. The completed test and lockfile-freshness checks passed.

Area Score
🎯 Correctness 3/5
🧪 Test coverage 4/5
📐 Code quality 3/5
🚀 Product impact 3/5

🟠 Majors

  • src/themis/router.py — webhook delivery can be marked failed before the review request is retained.

⚖️ Acknowledged

  • src/themis/router.py — Revisions are corrected to the commit actually reviewed, allowing redundant follow-ups for that commit to be removed — thread resolved by @themis-lezgo[bot]
📝 Walkthrough
  • ReviewJob - carries the trigger-time head SHA for automatic review events.
  • router - resolves missing heads for mentions and trigger-API reviews, then maps review type and revision to queue behavior.
  • queue - serializes a PR, drops in-flight matching revisions, and retains distinct or steered requests in order.
  • review_service - returns the checked-out commit so the queue can remove a follow-up that the running review already covered.
🧪 How to verify
  1. Run uv run pytest -q and uv run ruff check src tests.
  2. Deliver two mention webhooks for one PR head and confirm one engine run and two eyes reactions.
  3. Push a new commit during a running review and confirm exactly one follow-up runs for the new head.
  4. Delay installation-token or PR-head resolution beyond 10 seconds and confirm the webhook still returns a timely 2XX while retaining the request.
    Automate: add a delayed-GitHub-client route test that asserts a prompt webhook response and a retained job.

Product take: This removes wasted duplicate review runs for active PRs, a solid operational improvement. The webhook response path still needs a deadline-safe handoff so explicit review requests are not lost during GitHub API slowness.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

The queue is finally learning the difference between deja vu and a new commit · reviewed at f925231

Trigger preparation is the only GitHub work on the webhook response path,
and GitHub records a delivery as failed when the endpoint has not answered
within 10 seconds. Three calls at a 30-second client timeout could sit well
past that, and a delivery marked failed is one GitHub does not retry.

Preparation now runs under a 5-second deadline that also governs its HTTP
clients, and assigns as it goes so a deadline landing mid-ack keeps a head
resolved a moment earlier. What it produces is enrichment - an eyes
reaction, a sharper dedup key - never the trigger itself, so passing the
deadline costs those and nothing else: the job enqueues with the head it
arrived with, which degrades to per-comment dedup.
@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Round 4 finding fixed.

🟠 src/themis/router.py — webhook delivery can be marked failed before the review request is retained. Real, and worth stating precisely: this is not something this PR introduced. On main the handler already awaited _ack, which mints an App JWT, exchanges it for an installation token and posts a reaction — two GitHub calls at a 30-second client timeout, all before the response returned. This PR added a third call (get_pr) inside the same token-scoped block. So the exposure predates it and the PR widened it; either way the response path was never deadline-bounded, and it should be.

Preparation now runs under a deadline:

TRIGGER_PREPARE_TIMEOUT = 5.0
...
try:
    await asyncio.wait_for(prepare(), TRIGGER_PREPARE_TIMEOUT)
except TimeoutError:
    logger.warning("themis_trigger_prepare_timeout repo=%s pr=%s seconds=%s", ...)

Five seconds against GitHub's ten, and the same number is the httpx client timeout inside, so one value governs the whole path rather than a 30-second inner limit hiding under a shorter outer one.

Two details that make the degradation clean rather than merely fast:

  • prepare() assigns through a nonlocal rather than returning a value. The deadline can land between resolving the head and posting the reaction; assigning as it goes keeps the head that was already resolved instead of throwing away work the timeout arrived just after. Without that, a slow reaction would silently cost the dedup key.
  • What preparation produces is enrichment — an eyes reaction, a sharper dedup key — never the trigger itself. Past the deadline the job still enqueues with the head it arrived with, which degrades to per-comment dedup. That is the same degradation an unreachable GitHub already produced, now reached on a bounded clock.

Your Automate: line is test_webhook_slow_github_still_answers_promptly_and_keeps_the_trigger: get_installation_token never answers, and the assertions are that the webhook returns queued promptly, that the job is in the queue, and that its revision fell back to comment:501. It hangs without the deadline.

This composes with the finally from the last round rather than replacing it — they close different holes. The deadline stops a slow GitHub from failing the delivery; the finally stops a cancelled handler from losing the trigger, and cancellation propagates through wait_for untouched.

uv run pytest -q → 893 passed. uv run ruff check src tests → clean.

@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

@themis-lezgo review

Comment thread src/themis/router.py
being replaced. Two owners asking for two different focus areas are two
reviews, and always were: before issue #77 each mention had its own job id.
"""
return "queue" if job.extra_context else "coalesce"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocker · ⚡ Quick win

Keep unresolved-head review requests instead of coalescing them.

Observed: _revision assigns a failed head lookup a distinct comment:<id> revision (or None), but this function still selects coalesce; _hold then replaces an existing coalescing follow-up. Predicted: if head resolution is unavailable while a review runs, a later explicit request could replace an earlier request and the earlier review would never run. Return queue whenever head_sha is absent, so only requests with a known head are coalesced, and add a regression for two distinct unresolved-head mentions.

Suggested change
return "queue" if job.extra_context else "coalesce"
return "queue" if job.extra_context or job.head_sha is None else "coalesce"

@themis-lezgo

themis-lezgo Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚖️ Themis review: 🔴 Hold the merge

The known-head path correctly serializes a PR and collapses duplicate work, but the fallback for a failed head lookup can discard a distinct explicit review request. The test check is still running.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 3/5
📐 Code quality 3/5
🚀 Product impact 4/5

🔴 Blockers

  • src/themis/router.py: unresolved-head requests can replace a distinct pending review request.

⚖️ Acknowledged

  • Pin the revision that the queue uses to deduplicate — thread resolved by @themis-lezgo[bot]
📝 Walkthrough
  • events - carries the pull request head SHA from pull-request events.
  • router - resolves missing heads, derives review revisions, and serializes all reviews for one PR.
  • queue - tracks in-flight revisions and retains/coalesces follow-up work.
  • review_service - returns the checkout SHA after posting so the queue can suppress an already-covered follow-up.
🧪 How to verify
  1. Simulate a running review, then make two plain review requests while head lookup fails; both requests should remain pending and run in order.
  2. Confirm two mentions resolved to the same head produce one engine run and both receive the acknowledgement reaction.
  3. Push a new commit during a review and verify one follow-up runs for the new head after the active review completes.
  4. Run uv run pytest -q and uv run ruff check src tests.
    Automate: Add a router regression covering two distinct unresolved-head mentions behind an active review.

Product take: This is a solid reduction in duplicate agent work, especially for busy PRs.
The unresolved-head fallback needs to preserve requests before that reliability benefit is safe to ship.

🧭 Assumptions & unverified claims

No unverified assumptions or claims.

The queue is learning that “unknown” is not a synonym for “latest” · reviewed at 6e9e579

…redundant

A round of review read `_on_conflict` as depending on the head commit, and
the docstring invited that: it explained itself in terms of what a review
covers. It does not depend on the head. `_revision` answers "is this
provably the same work?" and needs the head; `_on_conflict` answers "does a
later ask of this kind answer this one?" and the trigger states that itself.

Behavior is unchanged; a test pins the pair of outcomes the two keys produce
for the same two mentions, resolved head and unresolved.
@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

🔴 src/themis/router.py — unresolved-head requests can replace a distinct pending review request. I am not making this change, and I want to be direct about why rather than quietly not doing it.

The decisive fact is that the unresolved-head path is strictly more conservative than the resolved-head path it falls back from. Same two plain mentions during one running review:

head second mention what is held
resolved (sha:cafe1234) duplicate nothing — dropped outright
unresolved (comment:502) duplicate one job, which runs when the slot frees

The resolved row is issue #77's actual case and this reviewer accepted it in rounds 1–4. The unresolved row keeps more work than that, not less. A degraded path that preserves less than the happy path would be a bug; one that preserves more is the fallback doing its job.

That is now pinned as one test with both halves side by side — test_webhook_unresolved_head_keeps_more_work_than_a_resolved_one — rather than argued in prose.

Why the head does not enter this decision at all. The two keys answer different questions, and I had not said so clearly enough:

  • _revision asks is this provably the same work? That needs the head commit, so when GitHub cannot be reached the answer is "cannot tell" — and cannot-tell never matches, so nothing is dropped.
  • _on_conflict asks does a later ask of this kind answer this one? The trigger states that itself. A plain mention asks for the PR as it stands whenever the queue reaches it — not for a particular commit — so a later plain mention answers it whatever either one's head was. A steered mention carries an instruction no later review answers, which is why steered requests are "queue", and that check reads extra_context, not the head.

So an unresolved head changes the revision and not the conflict policy. The alternative — treating an unknown head as "not supersedable" — is consistent with the letter of the unknown-never-matches rule and costs the outcome that rule exists to protect: during a GitHub outage, five mentions on one PR would become five sequential full engine runs over identical code, which is issue #77 reappearing in exactly the degraded path where the engine budget matters most.

The finding did surface a real defect, and I fixed that. If a careful reader read _on_conflict as head-dependent, the code was not saying otherwise clearly enough — the docstring explained itself in terms of what a review covers, which invites exactly that reading. It now states the two-keys separation outright, as does the OnConflict block comment in queue.py. No behavior change; c3ed2e2 is docs plus the test.

One correction to my own round-4 comment while I am here: I said the latency exposure predates this PR. What I verified is the call ordering on ea235a4 — _enqueue, then await _ack, both before the response returns — so the response path was already blocked on two GitHub calls. That is the extent of the claim; I did not measure real-world delivery times on main.

uv run pytest -q → 894 passed. uv run ruff check src tests → clean.

@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

@themis-lezgo review

@themis-lezgo

themis-lezgo Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚖️ Themis review: 🔴 Hold the merge

TL;DR: Same-head requests now share one review slot and the queue records the commit actually reviewed, which addresses the duplicate-work goal. An open blocker remains when GitHub cannot resolve a PR head: successive plain review requests can still replace one another. CI is still running; the completed lockfile-fresh check passed.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 3/5
📐 Code quality 3/5
🚀 Product impact 3/5

🔴 Blockers

  • src/themis/router.py:106 — Unresolved-head review requests can still overwrite each other; this remains open in the existing thread.

⚖️ Acknowledged

  • Record the revision actually reviewed before suppressing its follow-up — thread resolved by @themis-lezgo[bot]
  • Bound trigger preparation so webhook delivery is not held on GitHub round trips — thread resolved by @themis-lezgo[bot]
📝 Walkthrough
  • Queue - serializes every review for a PR and distinguishes redundant revisions from work that must wait.
  • Trigger preparation - resolves missing heads and adds the queued reaction within a short best-effort deadline.
  • Review completion - reports the checked-out commit back to the queue so an already-covered follow-up can be removed.
  • Headless API documentation - describes the shared review slot and duplicate response semantics.
🧪 How to verify
  1. Run uv run pytest -q tests/test_queue.py tests/test_router.py tests/test_review_service.py.
  2. Deliver two plain review mentions for the same resolved head and confirm only one engine run starts.
  3. While a review is active, make head resolution fail and send multiple plain review mentions; confirm each distinct request is retained rather than replaced.
  4. Push a new head during a review and confirm the latest follow-up runs after the active review.
  5. Run uv run ruff check src tests.
    Automate: add the unresolved-head multi-request regression before merging.

Product take: Avoiding duplicate full reviews is a solid operational improvement for busy PRs.
The unresolved-head path can still discard a requested review during a GitHub API outage, so it needs fixing before that benefit is dependable.

🧭 Assumptions & unverified claims
  • The full test suite and Ruff could not be run locally because the isolated environment lacks the locked dependencies and cannot download them.

The queue has learned the difference between déjà vu and a new commit; it just needs to keep every note when GitHub goes quiet. · reviewed at c3ed2e2

@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Round 6 is the last of the budget for this PR, and it returns the round-5 finding unchanged. Recording where that leaves things rather than spending another round.

Rounds 4 and 5 fixed the things I agreed with, both of them regressions this PR introduced:

  • 🔴 distinct focused review requests overwriting one another → on_conflict policy (drop / coalesce / queue) with a held list instead of a single slot.
  • 🟠 preparation blocking the response path → a 5-second deadline that also governs the HTTP clients, on top of the finally that makes the enqueue unskippable by a cancelled handler.

The remaining 🔴 I decline, and round 6 did not add anything to change that. The verify step asks to confirm each distinct unresolved-head request is "retained rather than replaced". The reason I am not doing that is the comparison in the previous comment, which this round did not engage:

head second plain mention what is held
resolved duplicate nothing — dropped outright
unresolved duplicate one job, which runs

The unresolved path already keeps strictly more work than the resolved path it falls back from, and the resolved path is issue #77's actual case — accepted in rounds 1 through 4 of this same review. "Retaining each request" would mean N sequential full engine runs over identical code during a GitHub outage, which is the waste this PR exists to remove, reappearing in the degraded path where the engine budget matters most.

tests/test_router.py::test_webhook_unresolved_head_keeps_more_work_than_a_resolved_one pins both rows, so if this reasoning is wrong the test is where to prove it — a case where a mention is genuinely lost, not merely coalesced with one that answers it.

Note for whoever picks this up: the assumptions block on this round says the suite could not be run in the review sandbox. Locally, uv run pytest -q → 894 passed, uv run ruff check src tests → clean, and CI runs exactly those two.

Over to a human on the last point. Everything else in this PR is reviewed and closed.

Coalescing replaced any held coalescing job with any other, and both a plain
mention and a push-delta are coalescing. So: a review is running, someone
mentions the bot asking for a review of newer code, the author then pushes -
and the delta job takes the mention's place in the held list. The delta may
decline outright (delta_review off, no prior review, no checkpoint key) and
post nothing, and it skips the ack, so the request that was acknowledged with
an eyes reaction is never answered. On main the mention had its own job id
and always ran.

96c9c3d closed the same pairing in the other arrival order, where the
scope-qualified revision keeps a mention from being read as the queued
delta's duplicate. This is that fix's sibling on the coalescing side: jobs
now coalesce within a group, and `_scope` is the group, so a delta only ever
supersedes another delta. A burst of pushes still collapses to one
re-review; a mention and a push are two.

Found by an independent review pass, not by the review loop on this PR.
@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

An independent review pass over the whole branch found a lost-work regression that six rounds here did not, and I have fixed it. Recording it, plus its verdicts on the things this review and I disagreed about.

🔴 A push takes the place of a review someone asked for. _hold replaced any held coalescing job with any other, and both a plain mention and a push-delta are coalescing. Sequence:

  1. A review of the PR is running.
  2. Someone mentions the bot asking for a review of newer code → held, and acknowledged with 👀.
  3. The author pushes → synchronize → delta job → replaces the held mention.
  4. The delta declines — delta_review off, no prior review, or no checkpoint key — and posts nothing. It also skips the ack, so there is no second signal.
  5. The acknowledged request is never answered.

This is a regression from main, where the mention had its own job id and always ran. It is the sibling of the finding I closed in 96c9c3d: that one stopped a queued delta from being read as a duplicate of a mention on the same head; this one stops a delta from superseding a mention on the held list. Same pairing, other arrival order, other mechanism. I checked one direction and not the other, and mention-then-push is the more common one.

Fixed in b7bab17: coalescing is now scoped to a group, and _scope is the group, so a delta only ever supersedes another delta. Regressions, both failing before the commit:

  • test_webhook_push_does_not_replace_a_mention_already_waiting — the sequence above; the mention and the push are both held.
  • test_webhook_pushes_still_coalesce_with_each_other — three pushes behind a running review are still one re-review, so separating the kinds did not cost the collapsing the follow-up slot exists for.

On the finding I declined twice: the independent pass reached the same conclusion I did and could not construct a case where a plain mention goes unanswered — the surviving follow-up re-resolves the PR and reviews its current state, which answers both requests. It also made the point that the instinct behind the finding was sound and pointed at something real; it just lived in the delta-versus-mention pairing above, not the plain-versus-plain one. So: the specific finding stays declined, and the thing it was reaching for is now fixed.

It also checked the three things I said I was leaving alone and agreed with all three, having verified the delta-fallback claim against review_service.py in both directions: a sha:X mention after a fallback run is held and costs one extra full run, and a delta:X push is dropped as covered because the posted full review re-seeds the checkpoint. No lost work either way.

Two smaller things it raised that I am not changing here, so they are on the record rather than in a commit:

  • /api/review answers duplicate for a request that was held and will run. docs/headless.md says so, but the status genuinely cannot distinguish "dropped" from "held". A third status is the real fix and is out of scope for this PR.
  • A same-revision trigger dropped while a review is in flight is not revived if that review then crashes or times out. The failure comment is the recovery signal, which is consistent with the restart doctrine, but it is undocumented.

uv run pytest -q → 896 passed. uv run ruff check src tests → clean.

This is past the review budget I set for this PR, and I am asking for one more round anyway: the change is to the queue's core replacement rule, and shipping a blocker fix with no review at all is the wrong trade.

@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

@themis-lezgo review

@themis-lezgo

themis-lezgo Bot commented Jul 29, 2026

Copy link
Copy Markdown

⚖️ Themis review: 🔴 Hold the merge

The SHA-based deduplication handles known heads and reconciles the commit actually reviewed, but an unavailable head lookup can still replace an earlier explicit request with a later one. That means a transient GitHub failure can discard review work rather than retaining it. CI is still running; the completed lockfile-fresh check passed.

Area Score
🎯 Correctness 2/5
🧪 Test coverage 3/5
📐 Code quality 3/5
🚀 Product impact 3/5

🔴 Blockers

  • src/themis/router.py:113 — unresolved-head review requests still coalesce and replace an earlier request.

⚖️ Acknowledged

  • Reconcile the revision actually reviewed after the PR head moves — thread resolved by @themis-lezgo[bot]
  • Keep webhook triggers when GitHub preparation is slow — thread resolved by @themis-lezgo[bot]
📝 Walkthrough
  • Review ingress - all review triggers now serialize on a per-PR key and enrich comment-triggered requests with a head SHA when possible.
  • Queue - revisions distinguish redundant work from work that must wait, with ordering for stored follow-ups.
  • Review service - completed reviews report the checkout SHA back to the queue so a waiting duplicate can be dropped.
  • Headless API - documents the revised duplicate response and deferred review behavior.
🧪 How to verify
  1. Simulate a running review plus two distinct mention requests whose head lookups fail; both pending requests must execute after the running job.
  2. Post two mentions resolved to the same head and confirm only one engine run is queued.
  3. Push a new head while a review runs and confirm exactly one follow-up reviews the newest head.
  4. Run uv run pytest -q tests/test_queue.py tests/test_router.py tests/test_review_service.py tests/test_app.py and uv run ruff check src tests.
    Automate: add the unresolved-head multiple-mention regression, asserting neither pending request is replaced.

Product take: Avoiding duplicate agent runs is a solid operational improvement.
Dropping an explicit request during a GitHub outage undermines the reliability users expect from that improvement.

🧭 Assumptions & unverified claims
  • The full local test run could not start because this environment cannot download the locked dependencies.

The duplicate-run net is nearly there; it just needs to hold onto requests when GitHub blinks · reviewed at b7bab17

@Zaimwa9

Zaimwa9 commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Round 7: the coalescing-group fix is reviewed and uncontested, and the two earlier findings are marked resolved. The only remaining objection is the unresolved-head one, now raised a third time in the same terms, again from a sandbox whose assumptions block says it could not run the suite — so it has not been able to execute test_webhook_unresolved_head_keeps_more_work_than_a_resolved_one, which is where the disagreement is decided. An independent review pass reached the same conclusion I did and could not construct a case where a plain mention goes unanswered.

I am not spending another round re-arguing it. The branch is at b7bab17: 896 passing, ruff clean, both CI checks green, every other finding across seven rounds either fixed or acknowledged. The merge call, and the call on that one finding, are a human's.

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.

Dedup queued reviews by PR head SHA, not trigger comment

1 participant