Skip to content

fix: stop the reviewer re-triggering itself on its own replies [OSWE-267] - #144

Merged
ericlitman merged 4 commits into
mainfrom
fix/reviewer-self-reply-loop-oswe267
Aug 8, 2026
Merged

fix: stop the reviewer re-triggering itself on its own replies [OSWE-267]#144
ericlitman merged 4 commits into
mainfrom
fix/reviewer-self-reply-loop-oswe267

Conversation

@ericlitman

Copy link
Copy Markdown
Owner

The bug

Every re-triggered review on mobilyze-llc/mastra-pilot (#23, #24) concluded its check as failure — "Review did not complete" ~45 s in, then a replacement run succeeded 30 s later and never re-concluded the check, leaving a red gate on a green PR. First-review-per-PR always worked. Reproduced ≥7 times, including on quiet heads with zero fleet load and a freshly restarted API.

The reviewer was answering itself. This deployment posts as openswebot[bot], but the finding-reply guard hard-coded sender_login == "open-swe[bot]":

if sender_login == "open-swe[bot]":   # never true here
    return

So every reply the reviewer wrote to its own finding thread came back through the webhook as a human reply ("kind": "human_reply", needs_reassessment: True). That path settles the in-flight check and dispatches a run with multitask_strategy="interrupt", which killed the review still publishing — and that run replied again.

One webhook handler, from the logs:

11:32:50.270  run 019fe125…  POST /pulls/24/comments/3739957182/replies  201   ← reviewer replies to its OWN thread
11:32:51.536  POST /webhooks/github 200                                        ← GitHub echoes it back
11:32:52.415  PATCH check-runs/93097424025                                     ← concludes FAILURE
11:32:52.474  "…for finding reply dispatch; forcing reviewer"                  ← guard missed openswebot[bot]
11:32:52.504  Created run 019fe125-b480  multitask_strategy=interrupt          ← the "replacement"

The reported "conclude-then-requeue race" is not a race: those are two sequential statements in one handler, which is why load made no difference. Why first reviews work: a first review has no existing finding threads to reply to, so it never emits the comment that triggers itself.

What changed

1. Match every login we post as. GITHUB_BOT_LOGINS adds the deployment's own login(s) to the built-in set, read from the env rather than baked in, matched case-insensitively across both the bare slug (GraphQL authors) and the [bot] form (REST senders). Applied to the finding-reply guard, the public-repo org gate, and finding reconciliation — all three shared the same assumption that the bot is named open-swe[bot].

2. A preemption is not a review failure. New superseded_review_check_result() concludes neutral regardless of REVIEW_CHECK_BLOCKING, used by the finding-reply handoff and by the run-completion handler when a newer reviewer run already owns the thread. Genuine crashes still fail a blocking check via incomplete_review_check_result(), so the merge gate is not weakened — only infrastructure preemption stops being reported as a failed review.

3. The superseding run re-concludes the HEAD check. settle_review_check_run clears review_check_run_id, so the successor previously had nothing to update and is_finding_reply blocked it anyway. The handoff now records the check it closed; the successor consumes that marker and reports the real result on the head SHA (creating the check, since the old id is completed). The marker is only written when the PATCH actually succeeded, so a failed settle still goes through the existing retry paths instead of opening a second check. A finding reply that preempted nothing keeps today's behavior, and never advances last_reviewed_sha.

On the regression-window hypothesis in OSWE-267

The ticket pinned this on control-plane image 5bf5f962 (deployed 2026-08-07 23:09 ET). That is wrong, and the evidence is in this repo:

  • git diff 625054fc..5bf5f962 touches three files, +81/−0 — canonicalize_repo_config and its test. Zero reviewer, check-run, webhook-handler, or pool changes. The GitHub webhook path is byte-identical across the window. The 23:09 deploy was a config change (adding GITHUB_REPO_ALIASES for the mastra-pilot transfer).
  • The failure-concluding call site came from 1dea596e (2026-08-04), first shipped in the Aug 5 01:35 ET image — ~46 h earlier. The self-trigger is older still (209132d3, 2026-06-26).
  • "Re-reviews worked on earlier builds — PR chore(open-swe): sync fork with upstream (13 commits, 2026-07-21) #18 had 27 reviews" does not hold: all 39 review comments on PR chore(open-swe): sync fork with upstream (13 commits, 2026-07-21) #18 are openswebot[bot] with zero human replies, and the loop is plainly visible on 2026-08-06 (three parents replied to at 07:23:14, the same three again at 07:23:47). Those reviews were substantially the loop generating its own runs.

The reported wedged DB pool (requests_queued=145 flat) was also a misread: it climbs monotonically 1 → 145 over 8 h and resets on restart — a cumulative counter, not a gauge — while pool_available == pool_size on every sample, i.e. zero connections in use. No wedge; no pool change here. That metric is emitted by langgraph_api, not this repo.

Testing

make lint clean; full unit suite green (2509 passed). New coverage:

  • tests/utils/test_internal_bot_logins.py — identity matching, incl. that openswebot[bot] matches only when configured, and that third-party bots and humans never do.
  • test_finding_reply_ignores_replies_authored_by_our_own_bot — the loop's regression test.
  • test_finding_reply_never_settles_failure_under_blocking, test_preempted_reviewer_settles_neutral_even_when_blocking.
  • test_finding_reply_publish_reconcludes_check_it_superseded.
  • Updated test_finding_reply_ignores_pending_result_from_superseded_check for the neutral conclusion; its original intent (a pending result belonging to a different check can't speak for this one) is preserved.

Deploy note: set GITHUB_BOT_LOGINS=openswebot[bot] in the control-plane env alongside this image — without it the guard still misses and the loop continues.

Unrelated pre-existing flake observed: tests/api/test_logging_redaction.py::test_asgi_access_log_query_string_is_redacted_and_fields_are_preserved[code] asserts output.count("401") == 2 against a timestamped log line, so it fails whenever the timestamp happens to contain 401 (~1 run in 3). Not touched here.

Refs OSWE-267, OSWE-265, OSWE-264.

…267]

Every re-review on mastra-pilot concluded its check as failure ("Review
did not complete") ~45s in, then a replacement run succeeded without ever
re-concluding the check. First-review-per-PR always worked.

The reviewer was answering itself. This deployment posts as
`openswebot[bot]`, but the finding-reply guard hard-coded
`sender_login == "open-swe[bot]"`, so every reply the reviewer wrote to
its own finding thread came back through the webhook as a *human* reply.
That path settles the in-flight check and dispatches a run with
`multitask_strategy="interrupt"` — which killed the review still
publishing, which replied again. The whole "conclude then requeue race"
is two sequential statements in one webhook handler, which is why it
reproduced on quiet heads with zero load.

- Match every login we post as. `GITHUB_BOT_LOGINS` adds the deployment's
  own login(s) to the built-in set, read from the env rather than baked
  in, and matching is case-insensitive across both the bare slug and the
  `[bot]` form. Applied to the finding-reply guard, the public-repo org
  gate, and finding reconciliation, which shared the same assumption.
- A preemption is not a review failure. Superseded checks now conclude
  `neutral` regardless of `REVIEW_CHECK_BLOCKING`, in both the
  finding-reply handoff and the run-completion handler. Genuine crashes
  keep failing a blocking check, so the merge gate is unchanged.
- A run that preempted a review re-concludes on the head SHA. The
  handoff records the check it closed and the successor reports the real
  result there, instead of leaving the head with no full-review check.

Fixes the failure half introduced by 1dea596 (shipped 2026-08-05); the
self-trigger predates it.

@openswebot openswebot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Open SWE Review found 6 potential issues.

Open in Web

Comment thread agent/tools/publish_review.py Outdated
Comment thread agent/utils/github_org_membership.py
Comment thread agent/webhooks/common.py
Comment thread agent/webhooks/github.py Outdated
Comment thread agent/tools/publish_review.py Outdated
Comment thread agent/utils/github_org_membership.py
…-267]

Review of the first pass found three real defects, all in the check
handling rather than the self-trigger fix:

- Matching the bare app slug in `is_internal_bot_login` was a privilege
  bug. REST reports `name[bot]` for apps and the bare login for users,
  and GitHub lets both exist — `open-swe` is a User, `open-swe[bot]` is
  the App — so a human owning an app's slug was handed the
  PUBLIC_REPO_ORG_GATE bypass. Sender matching is now exact; the bare
  form is accepted only by `is_internal_bot_author`, for GraphQL comment
  authors, where it decides comment ownership and nothing else.

- Concluding the preempted check as neutral freed the gate: `neutral`
  is non-blocking here, and the finding-reply path opens no replacement
  in-progress check, so a blocking PR was mergeable for the whole
  successor run — permanently if the successor never published. The
  check is no longer concluded at all. It stays in progress and is
  handed to the successor, which keeps the gate closed throughout, and
  the after-agent hook still settles it if that run dies.

- A finding reply reassesses one thread and never reads the diff, so it
  must not answer for a review it interrupted. It now concludes the
  inherited check with the real result only when a full review of this
  head completed; otherwise the honest incomplete result stands.

Also: the handoff marker is no longer consumed before the settle
succeeds — it is matched against the check the thread tracks, so it
lapses on its own once that check is settled or replaced, and a failed
settle stays retryable. And `GITHUB_BOT_LOGINS` is documented in
INSTALLATION.md, without which a custom app slug reintroduces the loop.
@ericlitman

Copy link
Copy Markdown
Owner Author

Reviewed all six findings. Three were real defects and are fixed in 3fccdc2; the other three follow from them.

🔴 bare-slug matching (github_org_membership.py:49, common.py:582) — correct, and my regression. REST reports name[bot] for Apps and the bare login for users, and GitHub lets both exist independently. Normalizing open-sweopen-swe[bot] handed an unrelated human every exemption the predicate grants, including the PUBLIC_REPO_ORG_GATE bypass. is_internal_bot_login is now exact (case-insensitive only). The bare form survives solely in a new is_internal_bot_author, used by GraphQL reconciliation, where it answers "is this comment ours" and never an authorization question. No sender.type == "Bot" check was added because it is now redundant: [ is invalid in GitHub usernames, so a suffixed login cannot be a user.

🔴 neutral supersede frees the gate (github.py:63) — correct. neutral is non-blocking here and the finding-reply path opens no replacement in-progress check, so the head was unguarded for the whole successor run and permanently if it never published. Concluding the check at handoff is gone entirely: it now stays in progress and is handed to the successor. The gate stays closed for the duration, which is also a better answer to the "preemption must not be a failure" ask in OSWE-267 than any conclusion would be — no failure, and no false green either. If the successor dies, the after-agent hook settles it, so it cannot dangle.

🔴 finding reply concluding the full-review check (publish_review.py:614) — correct. A reply reassesses one thread and never reads the diff. The inherited check is now concluded with the finding-count result only when a full review of this head completed (last_reviewed_sha == head_sha); otherwise it settles as incomplete, which is the honest state and keeps a blocking check closed. Your dismissal-of-the-last-finding scenario now settles incomplete rather than success.

🟠 marker deleted before the settle succeeded (publish_review.py:345) — correct. No mutation happens at claim time at all. The marker is matched against the check the thread currently tracks, so it lapses on its own once that check is settled (id cleared) or replaced by a newer review (ids diverge), and a failed settle stays retryable. This also removes the create_if_missing interaction you flagged, since the inherited id is passed rather than None.

🟡 undocumented GITHUB_BOT_LOGINS — added to docs/INSTALLATION.md, both the env template and a short section explaining that the wrong value reintroduces exactly this loop.

Full suite green (2511) and lint clean.

@openswebot openswebot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Open SWE Review found 2 potential issues.

Open in Web

Comment thread agent/middleware/settle_review_check.py
Comment thread agent/tools/publish_review.py Outdated
Leaving the preempted check in progress moved the risk onto the run that
inherits it, and review found two ways it could be stranded or stolen.

- The after-agent hook only runs when the graph exits normally. A run
  killed by a platform timeout or a hard error skipped it, and the
  completion handler could not help because the finding-reply dispatch
  passed `_AGENT_VERSION_METADATA` with no check id. With blocking on,
  that stranded the check in progress and left the PR unmergeable until
  someone started another review by hand. The handoff now returns the
  check it handed over and the dispatch records it on the run, which is
  what `_settle_failed_reviewer_check` reads.

- Two replies arriving in quick succession left both runs seeing the same
  marker, so the first could settle the check on its way out and leave
  the run that actually owns the handoff with nothing to report. Claiming
  now also requires being the thread's current reviewer run.
@ericlitman

Copy link
Copy Markdown
Owner Author

Second round reviewed. Two of the three are real and fixed in 52ee0e3; the 🔴 is a false positive against the current diff.

🟠 settle_review_check.py:67 — failed successor strands the check. Correct, and the most important one so far. Leaving the preempted check in progress moved the risk onto the successor, and after_agent only fires when the graph exits normally — a platform timeout or hard error skips it, and handle_run_completion could not recover because the finding-reply dispatch passed _AGENT_VERSION_METADATA with no check id. With blocking on that strands the check and the PR. Fixed as you suggested: the handoff now returns the check it handed over, and the dispatch records it via _review_run_metadata(...), which is exactly what _review_check_id_for_run_settle_failed_reviewer_check reads.

🟡 publish_review.py:368 — old reply run can claim the successor's check. Correct. Claiming now also requires being the thread's current reviewer run (current_reviewer_run_id), so a run that was itself superseded mid-flight no longer settles the check out from under its successor. I used the existing current_reviewer_run_id rather than adding a run id to the marker, since the dispatch writes the marker before the run exists and that field already tracks exactly this.

🔴 github_org_membership.py:65 — false positive on this diff. The finding quotes the bare-slug line and cites common.py:582 and github.py:1087 as the affected call sites, but those two call is_internal_bot_login, which was made exact in 3fccdc2 — precisely the fix for the identical finding in round one. Line 65 is the separate is_internal_bot_author, whose only callers are the two GraphQL reconciliation sites in agent/review/reconcile.py; it answers "is this comment ours" and gates no authorization. grep -rn "is_internal_bot_author" agent/ returns only reconcile.py. Preserving both spellings there is also non-regressive: the code it replaced was already author in {"open-swe", "open-swe[bot]"}. test_sender_matching_never_accepts_a_bare_slug pins the distinction.

Full suite green (2513) and lint clean.


Aside worth recording: this PR's own re-review reproduced the bug live at 13:15, which is about as direct a confirmation as the root-cause analysis could get. The reviewer posted five replies to its own finding threads at 13:15:35 (comments 3740734776-83, all openswebot[bot], in reply to its own 3740698470-76); six seconds later a run was interrupted and check 93105696104 concluded failure / "Review did not complete". No pushes, no load — just the bot answering itself, exactly as described. The Open SWE Review gate on this PR is therefore currently red for the bug the PR fixes.

@ericlitman
ericlitman marked this pull request as draft August 8, 2026 13:31
@ericlitman
ericlitman marked this pull request as ready for review August 8, 2026 13:31
@ericlitman

Copy link
Copy Markdown
Owner Author

Resolved the three remaining review threads: the 🟠 (settle_review_check.py) and 🟡 (publish_review.py) are fixed in 52ee0e3, and the 🔴 (github_org_membership.py) is the false positive answered in the comment above — its cited call sites use the strict is_internal_bot_login, and is_internal_bot_author is reached only from agent/review/reconcile.py. Re-triggering review so the check reflects the resolved state.

@ericlitman
ericlitman marked this pull request as draft August 8, 2026 13:33
@ericlitman
ericlitman marked this pull request as ready for review August 8, 2026 13:33
@ericlitman
ericlitman merged commit 492da07 into main Aug 8, 2026
12 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