Skip to content

fix(review): stop repeat reviews of an unchanged head - #699

Merged
neubig merged 2 commits into
mainfrom
fix/stop-repeat-reviews
Sep 29, 2026
Merged

neubig merged 2 commits into
mainfrom
fix/stop-repeat-reviews

Conversation

@neubig

@neubig neubig commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

HUMAN:
No human has tested these changes. The checkbox below is intentionally left unchecked, and all evidence in this description comes from agent-run automated tests and generated-artifact checks. This description was written by an AI agent at the maintainer's request.

  • A human has tested these changes.

AGENT:

Why

The reviewer published the same review up to three times on one unchanged head. On #698, all-hands-bot posted three separate APPROVED reviews against the identical head c9a6c7d. Re-reviewing identical code costs a full agent runtime per duplicate, and the repeated reviews bury any genuine follow-up in noise.

Timeline on #698, from the issue timeline API:

Time (UTC) Event
02:14:51 reviewed — commented
02:25:53 review_requested (neubig)
03:05:09 reviewed — approved
03:15:02 review_requested (neubig)
03:26:05 reviewed — approved
03:26:21 review_requested (all-hands-bot)
03:41:48 review_requested (neubig)
03:43:35 reviewed — approved

Two independent causes:

  1. The delivery key counted the trigger, not the revision. The key was "{pr_number}:label:{label_event_id}" (or the review_requested event id), so every fresh trigger at the same head produced a distinct delivery key. Each request in the table above started its own review of c9a6c7d, with no dedup on the revision under review. An in-flight review of the same head was likewise unrecognized, so a second conversation could run beside the first.
  2. The reviewer did not recognize its own pre-trigger review. _finish_completed_review only treated a review as this run's result when the review followed the selected trigger, so a review that predated a re-applied label or repeat request re-dispatched.

Summary

  • AgentConversationDispatcher.deliver() now takes the revision head and persists it in the KV record. A conversation already reviewing that head reports in_progress and is left alone, so a second delivery key at an unchanged head cannot start a second review beside a live one.
  • PullRequestReviewer gained _reviewed_current_head_without_clarification() / _clarified_since(). Before dispatch, an account-published review on the current head with no later comment from anyone else is treated as done, and the worker logs disposition: "review-already-published". A moved head, or a comment from another account after the review, makes the head eligible again under the normal rules.
  • The reviewer prompt carries the same rule, since the agent is what reads GitHub before publishing: do not publish a second review of an already-reviewed head unless the head moved or someone else commented after that review.
  • Regenerated automations/bundle-index.js (npm run build:automations) — the bundled dispatcher is inlined there, and the repo's own staleness test fails without this.

Callers with no revision identity (issue triage, issue-to-PR) pass no head and keep the previous delivery-key-only behavior, so this is backward compatible.

Issue Number

Fixes #700

How to Test

End-to-end evidence, not only unit tests:

1. The generated bundle is in sync with the sources (this is a real drift gate, and it fails if bundle-index.js is stale):

$ npm run build:automations
$ git diff --stat automations/bundle-index.js
(no output — regenerating produces no further change)

2. The full suite, on the rebased branch cut from current main:

$ uv run pytest -q
1074 passed, 23 skipped, 3 warnings in 34.33s

1074 passed here is the count on current main. The five added tests are the regression coverage:

$ uv run pytest -q tests/test_github_reviewer_delivery.py tests/test_agent_conversation_dispatch.py
  • test_reviewer_does_not_re_review_a_head_after_a_repeat_request — replays the fix(ci): adopt HUMAN/AGENT sections in the PR template #698 sequence (a review lands, then a later request arrives at the same head) and asserts exactly one dispatch.
  • test_reviewer_does_not_redeliver_a_label_for_an_already_reviewed_head — a label re-applied after the bot's own handoff dispatches nothing.
  • test_new_delivery_on_a_running_same_head_is_not_a_second_review — a fresh delivery key against a head already under review returns in_progress.
  • test_new_delivery_on_a_running_new_head_still_sends_a_turn — a moved head still resumes the conversation with a new turn.
  • test_reviewer_redelivers_a_label_when_a_human_clarified_since — a human comment after the review re-enables the head.

3. Each new test was confirmed to fail against the pre-change source, so the tests pin the defect rather than the implementation. Reverting agent_conversation.py and worker.py to origin/main and re-running the five names produces failures on all five; restoring the change makes them pass.

Video/Screenshots

No UI change; the evidence above is terminal output. The observable behavior is a JSON log line and a dispatch count, both asserted in the tests.

Notes

Behavior change worth an explicit decision. The fix makes a repeat review_requested on an unchanged, already-reviewed head a no-op. That is unambiguously correct for the bot's own re-requests and for re-applied labels, and it is what stops #698's duplicate reviews. It also means a maintainer cannot force a re-review of an unmoved head by re-requesting; a clarifying comment does re-enable it. If forcing a re-review deliberately should stay possible, that needs a separate escape hatch and this PR should be adjusted — flagged rather than assumed.

Related, not overlapping. #646 addresses the delayed pull_request_review.submitted completion path (PR #647); #687 addresses bounded dispatcher state. This PR touches neither.

Rollout follow-up. The reviewer automations running on the oss-agent-canvas VM execute the previously-synced code, so duplicate reviews continue there until that deployment picks up this change. I could not reach that VM from this environment to verify or trigger the sync.

A review was published up to three times on one head (#698
got three APPROVED reviews on c9a6c7d). Two independent causes:

1. The delivery key counts the trigger event, so every fresh
   review_requested or re-applied label produced a new key and started
   another review at the same head. The dispatcher now also dedupes on the
   head it records, and a conversation already running that head is reported
   in-progress rather than restarted.

2. The reviewer itself only recognized a completed review as its own result
   when the review followed the trigger event. A review that predated the
   event re-dispatched. The worker now treats a review on the current head
   with no clarifying comment since as done, and says so in the log.

The prompt carries the same rule, since the agent is what reads GitHub before
publishing: do not publish a second review of a head already reviewed, unless
the head moved or someone else commented after that review. Callers with no
revision identity pass no head and keep the delivery-key-only behavior.

Regenerate automations/bundle-index.js for the inlined dispatcher.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Scope: This change belongs in OpenHands/extensions - it fixes the bundled github-pr-reviewer automation's dispatcher/worker, the extension content this repository owns. No product/architecture decision is required.

What I verified on head e4e4dce

  • Required status checks for the main ruleset all pass: test, sync-extensions, validate-claude-code.
  • npm run build:automations is a no-op, and the inlined agent_conversation.py / worker.py in automations/bundle-index.js match the sources byte-for-byte, so the staleness test is satisfied.
  • Full suite in the workspace: 1074 passed, 23 skipped (your stated count differs; the delta is environmental, not a failure).
  • The new regression tests fail against the pre-change source (TypeError: deliver() got an unexpected keyword argument 'head' and wrong-disposition assertions) and pass after, so they pin the fix rather than the implementation.

Assessment
The fix is correct and well-targeted. Both root causes are addressed: the revision is now part of the KV record so a repeated delivery key at an unchanged head reports in_progress instead of starting a second turn, and the pre-dispatch guard stops a fresh trigger from re-reviewing a head this account already reviewed. The backward-compatible head="" default leaves the issue triage / issue-to-PR callers on their previous behavior, which I confirmed at skills/github-issue-triage/scripts/worker.py and skills/github-issue-to-pr/scripts/worker.py.

One behavioral gap is worth addressing before merge - see the inline comment: the guard treats a GitHub-dismissed review as a standing review, so an unchanged head can become permanently unreviewable.

Non-blocking note: the Validate PR description check is red on this head because the description does not open with the HUMAN: section now required by the template on main (added by #698). It is not one of the ruleset's required checks, so it does not block merge, but you may want to add the human/agent sections for consistency.

Verdict:
🔄 CHANGES REQUESTED

Comment thread skills/github-pr-reviewer/scripts/worker.py

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Re-established state at head e4e4dceff1b70ecc4e4b2d341a5bf02be79d808c: PR open, label type: fix, no prior reviews or comments, and every CI check green (test, sync-extensions, validate-claude-code, Validate PR description, etc.). Linked issue #700 is open with ready-for-dev and priority:high. In the workspace I ran the focused tests (uv run pytest -q tests/test_github_reviewer_delivery.py tests/test_agent_conversation_dispatch.py -> 98 passed) and the full suite (uv run pytest -q -> 1074 passed, 23 skipped), and npm run build:automations produced no drift in automations/bundle-index.js.

The two root causes from #700 are addressed: the dispatcher persists the revision head and returns in_progress for a head already under review, and the worker's pre-dispatch _reviewed_current_head_without_clarification gate suppresses a repeat review_requested / re-applied label on an already-reviewed head, while _clarified_since re-enables it on another account's later comment. Backward compatibility for no-head callers is preserved, and the new tests pin the #698 sequence.

One correctness/state issue in the new gate (see inline comment):

  • The guard's call to _finish_completed_review(pr, trigger, trigger_label) is annotated as clearing the label, but it cannot do that in the exact scenario the guard targets. _finish_completed_review only treats a review as this trigger's result when submitted_at > trigger.created_at; the #698 ordering is a review that predates the re-applied trigger, so it computes no completed review and returns before the DELETE /issues/{number}/labels/{label}. The guard then continues, so on a label deployment the label is never removed and every later scan re-reads the PR and re-logs review-already-published indefinitely.

Non-blocking: skills/github-pr-reviewer/SKILL.md ("Each explicit review request - a re-applied trigger label, or a new reviewer request in event mode - is a fresh review") and README.md ("Supports re-review by requesting the bot again or re-applying the label") now contradict the enforced behavior for an unchanged, already-reviewed head. The linked issue's acceptance criteria call for these docs to describe the same-head dedup rule and the clarifying-comment path, so that documentation update is still outstanding.

🔄 CHANGES REQUESTED

Comment thread skills/github-pr-reviewer/scripts/worker.py Outdated
Two gaps in the unchanged-head guard, both raised in review of #699:

- The guard counted a dismissed review as standing. GitHub keeps a
  dismissed review in /pulls/{n}/reviews with its commit_id, so a
  maintainer who dismissed the verdict and re-requested a review on the
  same head got `review-already-published` forever. Skip DISMISSED
  reviews, matching maintainer_handoff._reviewer_states.

- The `review-already-published` branch claimed to clear the label but
  delegated to _finish_completed_review, which returns early when the
  review predates the trigger (#698 ordering) and never reaches its
  DELETE. Clear the label directly in that case so a later scan does not
  re-read the same head forever.

Co-authored-by: openhands <openhands@all-hands.dev>

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This review was posted by an AI agent (OpenHands).

Scope: This change belongs in OpenHands/extensions — it fixes the bundled github-pr-reviewer automation's dispatcher and worker, the extension content this repository owns. No product/architecture decision is required.

What I verified on head 74d4878

  • Required main ruleset checks all pass: test, sync-extensions, validate-claude-code.
  • npm run build:automations is a no-op, and the inlined agent_conversation.py / worker.py in automations/bundle-index.js match the sources byte-for-byte, so the drift gate is satisfied.
  • Full suite: 1076 passed, 23 skipped (the description's 1074 is the pre-rebase count; not a failure).
  • Both new tests fail against e4e4dce (the prior head) and pass on 74d4878, so they pin the fixes rather than the implementation.

Prior findings are resolved. My earlier review on the previous head raised two issues, and both are fixed in this head:

  1. The dismissed-review gap — _reviewed_current_head_without_clarification now skips reviews whose state is DISMISSED, matching maintainer_handoff._reviewer_states. I reproduced a dismissed ✅ APPROVED review on an unchanged head: the previous head suppressed dispatch; this head reviews again.
  2. The stale-trigger label leak — when the review predates the trigger, _finish_completed_review returns False and no longer leaves the label on the PR; the guard now deletes it directly, so later scans do not re-read and re-log the same head forever.

Assessment. The core fix is correct and well-targeted. The revision is now part of the KV record, so a repeated delivery key at an unchanged head reports in_progress rather than starting a second turn, and the pre-dispatch guard prevents a fresh trigger from re-reviewing an already-reviewed head. The head="" default keeps the issue triage / issue-to-PR callers (skills/github-issue-triage/scripts/worker.py, skills/github-issue-to-pr/scripts/worker.py) on their prior delivery-key-only behavior. The two follow-up fixes are properly tested.

Non-blocking notes (no action required for merge):

  • The _has_current_head_review predicate on the unrequested scheduled-scan path (line ~791) still counts a dismissed review as a standing review, so an unrequested PR whose only bot review was dismissed is skipped by the scan. Impact is bounded: the dismissed state does not persist on GitHub — a dismissal is followed either by a new review request/label (handled by the guard) or by a new head on the next push (the old-commit review no longer matches), so the head is not permanently stuck. Aligning this predicate with the same DISMISSED rule would make the two paths consistent.
  • The description's HUMAN: section explicitly notes no human has tested the change. The gate behavior is exercised by tests, but the live duplicate-review symptom is only observable in a real deployment, so the rollout follow-up on the oss-agent-canvas VM matters for confirming the fix end-to-end.

Verdict:
✅ APPROVED

@neubig
neubig merged commit 41593ce into main Sep 29, 2026
14 checks passed
@neubig
neubig deleted the fix/stop-repeat-reviews branch September 29, 2026 13:34
hieptl added a commit that referenced this pull request Oct 1, 2026
…ared scripts

The previous commit gave a minor version to every bundle that ships the
shared GitHub scripts. A bundle's version records the shape of its files and
config, not their contents, and #699 changed the same scripts without a
bump. Restore those five versions and the fixture that pins one of them.
hieptl added a commit that referenced this pull request Oct 2, 2026
…d Enterprise (#712)

* feat(jira-issue-to-pr): run the Jira to GitHub PR automation on OpenHands Cloud

The skill's deploy instructions hardcoded http://localhost:8000 with the
local session key, and its poller script only worked on a local agent
server: it read AGENT_SERVER_URL, which the automation service injects in
local mode only, authenticated its completion callback with
AUTOMATION_CALLBACK_API_KEY (also local-only), fetched LLM settings from
the sandbox's agent server, and started conversations inside the run's
sandbox, which the service tears down once the run reports completion.

- Branch the script on AGENT_SERVER_URL, the signal the service's presets
  use. On OpenHands Cloud and Enterprise it reads secrets from
  /api/v1/sandboxes/{SANDBOX_ID}/settings/secrets/{name}, authenticates
  the callback with OPENHANDS_API_KEY, requires the KV store (each run
  starts in a fresh sandbox), and starts each conversation through
  POST /api/v1/app-conversations, which runs it in its own sandbox with
  the user's settings, secrets and connected git provider. The Jira
  comment links the canvas conversation URL. The local path is unchanged.
- Document the cloud host and auth in SKILL.md and switch the curl steps
  to OPENHANDS_HOST and AUTH_HEADER; regenerate skills/index.js.
- Add tests that run the script against a stub server in both modes.

Refs OHE-3160

* feat(automations): run the template bundles and skills on OpenHands Cloud

The automation templates were written against a local Agent Canvas run:
their scripts read AGENT_SERVER_URL and a local callback key, the three
agent-profile bundles import the SDK, and the skills tell the agent to
find the automation backend in <RUNTIME_SERVICES>. A run on OpenHands
Cloud or Enterprise gets OPENHANDS_CLOUD_API_URL, OPENHANDS_API_KEY and
SANDBOX_ID instead, executes on a bare sandbox Python, and its sandbox is
removed once it reports completion.

- agent_conversation.py: when no Agent Server URL is given, deliver
  through the OpenHands API. Conversations start in a sandbox of their own
  with the automation's agent profile, a paused one is resumed before the
  next turn is sent, and one whose sandbox is gone is replaced, with the
  KV record following the current id. The SDK imports are optional so the
  module loads on a sandbox without it.
- github_client.py: read secrets from the run's sandbox on cloud, fall
  back to the connected GitHub integration when the named secret is not
  saved (and name GITHUB_TOKEN to the agent), and default the workspace.
- AGENTS.md maintainer, GitLab issue to MR and news digest: on cloud,
  start and follow conversations through the OpenHands API, authenticate
  the callback with the run's API key, require the KV store, and link the
  Canvas conversation. The agent clones the project itself; a conversation
  that ends without a pull or merge request is reported rather than
  finished from a clone the run no longer has. GitLab requests carry the
  token as a bearer token there, which an integration's OAuth token needs.
- Skills that create a prompt-preset automation, and the three bundle
  skills above, pick the API host and auth for Cloud (<HOST> and
  Authorization: Bearer $OPENHANDS_API_KEY) or a local Agent Canvas.
- Bump the three changed bundles to 1.1.0 and regenerate the indexes.

The local path is unchanged.

Refs OHE-3160

* docs(automations): document Jira Cloud webhooks for event-triggered automations

An agent asked for an event-based Jira automation had nothing to go on:
the automation skill listed no Jira settings, so it fell back to the generic
signature header and wrote a label filter for a payload shape Jira does not
send. The result was a webhook Jira could not deliver to and a trigger that
never matched.

- openhands-automation: add a Jira Cloud example and table row. Jira signs
  with X-Hub-Signature, names the event in webhookEvent, sends labels as
  plain strings, and only an admin-registered webhook can carry a secret.
  The example filter fires when an issue is created with the label or when
  an update changes its labels, so later edits do not start another run.
- jira-issue-to-pr: add an event-based alternative to the poller, pointing
  at that example and stating what the poller's script no longer does.

* feat(jira-issue-to-pr): set up the Jira webhook for the user

Setting up the event-based automation meant opening Jira and pasting a
webhook URL and secret by hand. Jira Cloud has a REST endpoint for the same
admin webhook (POST /rest/webhooks/1.0/webhook), so the skill can do it.

scripts/setup_webhook.py registers the custom webhook source in OpenHands
and the matching webhook in Jira, limited to the label by JQL, and sends one
signed request to prove the pair works. The signing secret is generated or
rotated inside the script and never printed, so it stays out of the
conversation. Running it again updates both sides in place; `delete` removes
the Jira webhook. An account without the Administer Jira permission exits
with code 3 and the skill falls back to the manual steps, which are kept.

The skill has the agent show a dry run and wait for the user's confirmation
before it changes their Jira site. The automation skill's rule against
registering webhooks gets an exception for skills that ship such a script.

* fix(automations): correct the cloud paths found in review

Conversations on OpenHands Cloud and Enterprise:

- OpenHands initializes a Git repository in a conversation's working
  directory, so `git clone ... .` and `gh repo clone <repo> .` fail there.
  The GitLab, AGENTS.md and issue-to-PR prompts now clone into a new
  directory, and the shared GitHub instructions stop naming the run's own
  workspace path, which means nothing in the conversation's sandbox.
- The dispatcher started a second conversation for a subject when a new
  delivery arrived before the API listed the first. While a start is in its
  grace window every delivery now waits, with the record left for the next
  trigger.
- The Jira poller marked an issue done as soon as the API accepted the start
  request, so a start that failed afterwards was lost. The next run now
  confirms each start, makes a failed one again up to three times, and then
  reports it on the issue.
- `is_cloud_run()` now means what `IS_CLOUD` means in the bundle scripts.

Jira webhook setup script:

- An existing pair keeps its signing secret: Jira is updated without one, so a
  failure half way no longer leaves the two sides with different secrets.
  `--rotate-secret` replaces it on request.
- The deployment URL is checked for HTTPS and Jira's webhook API is queried
  before anything is written.

The bundles that ship the shared GitHub scripts get a minor version, and the
changed behavior is covered by tests.

* docs(automations): say what changes on OpenHands Cloud and Enterprise

A conversation started through the OpenHands API runs with the user's own
settings, so the news digest's empty secret allow-list and the GitLab
template's single-secret allow-list hold only on a local Agent Canvas. The
skill text, READMEs and catalog entry now say so, and describe the other
cloud differences: the agent clones for itself, an unfinished conversation is
reported rather than completed by the script, and a connected git integration
stands in for a missing token secret.

Also corrects the Jira label filter's description: it still fires when
another label changes on an issue that carries the trigger label.

* chore(automations): keep the versions of bundles that only pick up shared scripts

The previous commit gave a minor version to every bundle that ships the
shared GitHub scripts. A bundle's version records the shape of its files and
config, not their contents, and #699 changed the same scripts without a
bump. Restore those five versions and the fixture that pins one of them.

* fix(jira-issue-to-pr): be conservative where the OpenHands API says nothing

- A start task the API no longer has is settled like a started one. Treating
  it as a failure would start a second conversation for an issue whose first
  one may well have run.
- An answer that is not the expected list leaves the pending starts for the
  next run.
- The setup script asks Jira for its webhooks before it registers anything in
  OpenHands, also when the OpenHands webhook does not exist yet, so an
  account Jira does not serve the webhook API leaves nothing behind.

* fix(github-stale-ci-pr-closer): report completion on cloud runs

The closer reported its run with AUTOMATION_CALLBACK_API_KEY, which a run on
OpenHands Cloud or Enterprise is not given. The callback was refused, the
entrypoint raised, and a run that had done its work ended as failed. It now
falls back to the run's API key, as the other bundles that report their own
completion do.

* fix(jira-issue-to-pr): keep polling when earlier starts cannot be confirmed

A failing call to the start-task API ended the run before it polled Jira, so
one pending start could stall the automation for as long as the call failed.
The run now warns and leaves the pending starts for the next one.

---------

Co-authored-by: openhands <openhands@all-hands.dev>
@openhands-release-bot openhands-release-bot Bot added the released: v0.26.0 Shipped in v0.26.0 label Oct 2, 2026
@openhands-release-bot

Copy link
Copy Markdown
Contributor

🚀 Released in v0.26.0.

enyst added a commit that referenced this pull request Oct 4, 2026
…nt main

Port the fix for #646 onto the restructured worker. A submitted delivery
now passes the review it reports into both completion paths (the
already-reviewed-head path added in #699 and the regular path), which
match that review by id regardless of timing, and a submitted delivery
with no visible review_requested event is still reconciled instead of
skipped. Unrequested scheduled scans keep the any-head-review rule.

The follow-up request test now uses a moved head: since #699 an explicit
request on an unchanged, already-reviewed head intentionally does not
start a second review. Bump the reviewer bundle to 1.9.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
enyst added a commit that referenced this pull request Oct 4, 2026
Resolve the hand-written conflicts against main's Cloud/local dispatcher
split (#712), unchanged-head dedupe (#699), and full-backlog scan (#703):

- agent_conversation.py: keep main's `deliver` -> `_deliver_cloud` /
  `_deliver_local` structure. The PR's admission gate moves into
  `_deliver_local` (create, resume of an idle/paused conversation, and a
  new-revision turn), which now returns a "deferred" disposition that
  `deliver` reports without writing a delivery record. `deliver` keeps the
  PR's registry bookkeeping (`_remember`) and reads/writes through
  `_kv_value` / `_kv_put` because `_kv_request` now returns the whole body.
  The Cloud path is unchanged here; how the cap applies to it follows in a
  separate commit.
- test_agent_conversation_dispatch.py: keep both new sections, the PR's
  in-flight/admission tests and main's Cloud tests.
- SKILL.md: troubleshooting rows describe the cap on top of main's
  full-backlog scan (no rotating window).
- manifest.json: main's descriptions plus the PR's in-flight cap clause.
- A deferred-drain test stub accepts main's `head=` keyword; the
  ReviewIntake blank line left by main's ScanCursor removal is dropped;
  fixture `max_in_flight` lines take their neighbours' indentation.
- skills/index.js and automations/bundle-index.js regenerated with
  `npm run build`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released: v0.26.0 Shipped in v0.26.0 type: fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(review): do not review an unchanged head twice

3 participants