fix: reviewer publishes against stale head_sha on mid-run re-review (#1393)

* fix: resolve reviewer head_sha from thread metadata, not frozen run config

A push that lands while a reviewer run is in flight is delivered as a
queued message into that run. The run's configurable is frozen at
creation, so its head_sha still names the commit the run was created for
— not the commit just pushed. publish_review then anchored the GitHub
review to the stale commit and regressed last_reviewed_sha to it, and
add_finding/update_finding stamped findings with the stale SHA.

Persist the current head in thread metadata at every reviewer dispatch
(both the ready-for-review and push paths, before they branch to create
a run or queue a message), and add resolve_review_head_sha() which
prefers the metadata head over the run config. Wire it into
publish_review (review commit_id + last_reviewed_sha), add_finding
(first_seen_sha) and update_finding (last_confirmed_sha). Falls back to
the run config when metadata carries no head (first review, eval, tests).

* fix: persist head_sha in manual review dispatch (trigger_pr_review_from_ref)

resolve_review_head_sha prefers metadata[head_sha] over the run config,
and the push/ready dispatchers write it — but trigger_pr_review_from_ref
(Slack/GitHub @open-swe review, request_pr_review tool) created a run
with a freshly-fetched config head while leaving metadata's head stale
from a prior dispatch. A manual re-review at a newer commit would then
resolve to the old head and publish/advance findings against it.

Persist head_sha in that dispatch's metadata write too, so every
run-creating reviewer dispatch keeps metadata in sync with the head its
run targets. Caught by the Open SWE reviewer on this PR.
This commit is contained in:
Johannes du Plessis 2026-06-03 11:38:56 -07:00 • committed by GitHub
parent 94c44a9642
commit 1d4f1aed33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 219 additions and 12 deletions

View file

@ -294,6 +294,24 @@ async def get_thread_metadata(thread_id: str) -> dict[str, Any]:
return metadata if isinstance(metadata, dict) else {} return metadata if isinstance(metadata, dict) else {}
async def resolve_review_head_sha(thread_id: str, configurable: dict[str, Any]) -> str:
"""Return the current PR head SHA for a reviewer run.
A push that lands while a reviewer run is in flight is delivered as a queued
message into that run, whose frozen ``configurable`` still names the head the
run was created for. The dispatching webhook records the current head in
thread metadata, so prefer that; fall back to the run's config when metadata
carries no head (first review, eval, tests).
"""
config_head = configurable.get("head_sha") if isinstance(configurable, dict) else None
config_head = config_head if isinstance(config_head, str) else ""
if not thread_id:
return config_head
metadata = await get_thread_metadata(thread_id)
meta_head = metadata.get("head_sha")
return meta_head if isinstance(meta_head, str) and meta_head else config_head
async def list_findings(thread_id: str) -> list[Finding]: async def list_findings(thread_id: str) -> list[Finding]:
"""Return all findings persisted on the reviewer thread.""" """Return all findings persisted on the reviewer thread."""
metadata = await get_thread_metadata(thread_id) metadata = await get_thread_metadata(thread_id)
@ -438,6 +456,7 @@ async def set_reviewer_thread_metadata(
*, *,
pr: ReviewerPRMeta | None = None, pr: ReviewerPRMeta | None = None,
last_reviewed_sha: str | None = None, last_reviewed_sha: str | None = None,
head_sha: str | None = None,
watch: bool | None = None, watch: bool | None = None,
findings: list[Finding] | None = None, findings: list[Finding] | None = None,
slack_thread: ReviewerSlackThread | None = None, slack_thread: ReviewerSlackThread | None = None,
@ -448,6 +467,11 @@ async def set_reviewer_thread_metadata(
Always sets ``kind=reviewer`` so the future UI can list reviewer threads by Always sets ``kind=reviewer`` so the future UI can list reviewer threads by
filtering on metadata. Only includes the fields the caller passed in filtering on metadata. Only includes the fields the caller passed in
(langgraph metadata updates merge rather than overwrite). (langgraph metadata updates merge rather than overwrite).
``head_sha`` records the current PR head the dispatching webhook is acting
on. A push that lands mid-run is queued into the still-running run, whose
frozen config can't be updated; persisting the head here lets the reviewer
tools resolve the live head via ``resolve_review_head_sha``.
""" """
client = get_client() client = get_client()
metadata: dict[str, Any] = {"kind": REVIEWER_THREAD_KIND} metadata: dict[str, Any] = {"kind": REVIEWER_THREAD_KIND}
@ -455,6 +479,8 @@ async def set_reviewer_thread_metadata(
metadata["pr"] = pr metadata["pr"] = pr
if last_reviewed_sha is not None: if last_reviewed_sha is not None:
metadata["last_reviewed_sha"] = last_reviewed_sha metadata["last_reviewed_sha"] = last_reviewed_sha
if head_sha is not None:
metadata["head_sha"] = head_sha
if watch is not None: if watch is not None:
metadata["watch"] = watch metadata["watch"] = watch
if findings is not None: if findings is not None:

View file

@ -20,6 +20,7 @@ from ..reviewer_findings import (
get_thread_id_from_runtime, get_thread_id_from_runtime,
new_finding, new_finding,
normalize_finding_title, normalize_finding_title,
resolve_review_head_sha,
) )
@ -112,7 +113,6 @@ def add_finding(
config = get_config() config = get_config()
configurable = config.get("configurable", {}) if isinstance(config, dict) else {} configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
diff_line_set = configurable.get("diff_line_set") if isinstance(configurable, dict) else None diff_line_set = configurable.get("diff_line_set") if isinstance(configurable, dict) else None
head_sha = configurable.get("head_sha", "") if isinstance(configurable, dict) else ""
diff_text = configurable.get("diff_text", "") if isinstance(configurable, dict) else "" diff_text = configurable.get("diff_text", "") if isinstance(configurable, dict) else ""
if isinstance(diff_line_set, dict) and not is_range_in_diff( if isinstance(diff_line_set, dict) and not is_range_in_diff(
@ -134,6 +134,9 @@ def add_finding(
clipped_suggestion, suggestion_dropped = clip_suggestion(suggestion) clipped_suggestion, suggestion_dropped = clip_suggestion(suggestion)
thread_id = get_thread_id_from_runtime()
head_sha = asyncio.run(resolve_review_head_sha(thread_id, configurable))
finding: Finding = new_finding( finding: Finding = new_finding(
severity=_cast_severity(severity), severity=_cast_severity(severity),
confidence=_cast_confidence(confidence), confidence=_cast_confidence(confidence),
@ -142,14 +145,13 @@ def add_finding(
start_line=start_line, start_line=start_line,
end_line=end_line, end_line=end_line,
description=description, description=description,
sha=str(head_sha) if isinstance(head_sha, str) else "", sha=head_sha,
title=normalized_title, title=normalized_title,
side=_cast_side(side), side=_cast_side(side),
suggestion=clipped_suggestion, suggestion=clipped_suggestion,
diff_hunk=diff_hunk, diff_hunk=diff_hunk,
) )
thread_id = get_thread_id_from_runtime()
asyncio.run(append_finding(thread_id, finding)) asyncio.run(append_finding(thread_id, finding))
result: dict[str, Any] = {"success": True, "finding_id": finding["id"]} result: dict[str, Any] = {"success": True, "finding_id": finding["id"]}
if suggestion_dropped: if suggestion_dropped:

View file

@ -18,6 +18,7 @@ from ..reviewer_findings import (
get_thread_metadata, get_thread_metadata,
get_thread_slack_ref, get_thread_slack_ref,
replace_findings, replace_findings,
resolve_review_head_sha,
set_reviewer_thread_metadata, set_reviewer_thread_metadata,
) )
from ..reviewer_findings import ( from ..reviewer_findings import (
@ -202,6 +203,11 @@ async def _publish_review_async(
trace_link_config_override: object = None, trace_link_config_override: object = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
thread_id = get_thread_id_from_runtime() thread_id = get_thread_id_from_runtime()
# The run config's head_sha is frozen at run creation; a push that arrived
# mid-run updated the live head in thread metadata. Prefer that so the
# review anchors to (and last_reviewed_sha advances to) the commit actually
# reviewed, not the stale one this run was created for.
head_sha = await resolve_review_head_sha(thread_id, {"head_sha": head_sha})
review_trace_url = await _resolve_review_trace_url(thread_id, trace_link_config_override) review_trace_url = await _resolve_review_trace_url(thread_id, trace_link_config_override)
findings = await _backfill_findings_from_pr_threads( findings = await _backfill_findings_from_pr_threads(
thread_id=thread_id, thread_id=thread_id,

View file

@ -15,6 +15,7 @@ from ..reviewer_findings import (
get_thread_id_from_runtime, get_thread_id_from_runtime,
list_findings, list_findings,
normalize_finding_title, normalize_finding_title,
resolve_review_head_sha,
update_finding_fields, update_finding_fields,
) )
from ..utils.reviewer_outcomes import emit_finding_status_outcome from ..utils.reviewer_outcomes import emit_finding_status_outcome
@ -127,9 +128,10 @@ def update_finding(
config = get_config() config = get_config()
configurable = config.get("configurable", {}) if isinstance(config, dict) else {} configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
head_sha = configurable.get("head_sha", "") if isinstance(configurable, dict) else "" if status == "open":
if status == "open" and isinstance(head_sha, str) and head_sha: head_sha = asyncio.run(resolve_review_head_sha(get_thread_id_from_runtime(), configurable))
updates["last_confirmed_sha"] = head_sha if head_sha:
updates["last_confirmed_sha"] = head_sha
if not updates: if not updates:
if suggestion_dropped: if suggestion_dropped:

View file

@ -1781,7 +1781,7 @@ async def trigger_pr_review_from_ref(
"thread_ts": slack_thread_ts, "thread_ts": slack_thread_ts,
} }
await set_reviewer_thread_metadata( await set_reviewer_thread_metadata(
thread_id, pr=pr_meta, watch=True, slack_thread=slack_thread_meta thread_id, pr=pr_meta, watch=True, slack_thread=slack_thread_meta, head_sha=head_sha
) )
prompt = build_github_pr_review_prompt(repo_config, pr_ref.number, pr_url, base_sha, head_sha) prompt = build_github_pr_review_prompt(repo_config, pr_ref.number, pr_url, base_sha, head_sha)
@ -1959,7 +1959,7 @@ async def _dispatch_first_review_from_pr_payload(payload: dict[str, Any], *, sou
logger.warning("Could not persist bot token for reviewer thread %s", thread_id) logger.warning("Could not persist bot token for reviewer thread %s", thread_id)
return return
await set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True) await set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
is_re_review = bool(last_reviewed_sha) is_re_review = bool(last_reviewed_sha)
if is_re_review: if is_re_review:
@ -2400,7 +2400,7 @@ async def process_github_push_event(payload: dict[str, Any]) -> None:
"head_ref": head_ref, "head_ref": head_ref,
"base_ref": base_ref, "base_ref": base_ref,
} }
await set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True) await set_reviewer_thread_metadata(thread_id, pr=pr_meta, watch=True, head_sha=head_sha)
re_review_prompt = ( re_review_prompt = (
f"A new commit has been pushed to PR #{pr_number}. The new HEAD is " f"A new commit has been pushed to PR #{pr_number}. The new HEAD is "

View file

@ -911,8 +911,9 @@ def test_process_github_pr_review_request_creates_reviewer_run(monkeypatch) -> N
runs = _FakeRunsClient() runs = _FakeRunsClient()
threads = _FakeThreadsClient() threads = _FakeThreadsClient()
async def fake_set_reviewer_thread_metadata(thread_id: str, **_kwargs: object) -> None: async def fake_set_reviewer_thread_metadata(thread_id: str, **kwargs: object) -> None:
captured["set_metadata_thread_id"] = thread_id captured["set_metadata_thread_id"] = thread_id
captured["set_metadata_kwargs"] = kwargs
monkeypatch.setattr( monkeypatch.setattr(
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
@ -1011,8 +1012,9 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
runs = _FakeRunsClient() runs = _FakeRunsClient()
threads = _FakeThreadsClient() threads = _FakeThreadsClient()
async def fake_set_reviewer_thread_metadata(thread_id: str, **_kwargs: object) -> None: async def fake_set_reviewer_thread_metadata(thread_id: str, **kwargs: object) -> None:
captured["set_metadata_thread_id"] = thread_id captured["set_metadata_thread_id"] = thread_id
captured["set_metadata_kwargs"] = kwargs
monkeypatch.setattr( monkeypatch.setattr(
webapp, "get_github_app_installation_token", fake_get_github_app_installation_token webapp, "get_github_app_installation_token", fake_get_github_app_installation_token
@ -1065,6 +1067,9 @@ def test_trigger_pr_review_from_ref_creates_reviewer_run(monkeypatch) -> None:
"channel_id": "C123", "channel_id": "C123",
"thread_ts": "1700000000.000100", "thread_ts": "1700000000.000100",
} }
# The live head must be persisted to metadata so resolve_review_head_sha
# doesn't return a stale head left by a prior push/ready dispatch.
assert captured["set_metadata_kwargs"]["head_sha"] == "head-sha"
def test_trigger_pr_review_from_ref_respects_dashboard_opt_in(monkeypatch) -> None: def test_trigger_pr_review_from_ref_respects_dashboard_opt_in(monkeypatch) -> None:

View file

@ -191,6 +191,12 @@ async def test_pr_ready_for_review_uses_re_review_after_previous_review(
assert configurable["last_reviewed_sha"] == "oldsha" assert configurable["last_reviewed_sha"] == "oldsha"
assert configurable["head_sha"] == "headsha" assert configurable["head_sha"] == "headsha"
assert "marked ready for review" in kwargs["input"]["messages"][0]["content"] assert "marked ready for review" in kwargs["input"]["messages"][0]["content"]
head_sha_writes = [
c.kwargs.get("head_sha")
for c in webapp.set_reviewer_thread_metadata.await_args_list
if c.kwargs.get("head_sha") is not None
]
assert "headsha" in head_sha_writes
@pytest.mark.asyncio @pytest.mark.asyncio

View file

@ -16,6 +16,7 @@ from agent.reviewer_findings import (
new_finding, new_finding,
new_finding_id, new_finding_id,
replace_findings, replace_findings,
resolve_review_head_sha,
set_reviewer_thread_metadata, set_reviewer_thread_metadata,
update_finding_fields, update_finding_fields,
) )
@ -181,3 +182,41 @@ async def test_set_reviewer_thread_metadata_includes_kind() -> None:
assert metadata["last_reviewed_sha"] == "sha" assert metadata["last_reviewed_sha"] == "sha"
assert "pr" not in metadata assert "pr" not in metadata
assert "findings" not in metadata assert "findings" not in metadata
@pytest.mark.asyncio
async def test_set_reviewer_thread_metadata_persists_head_sha() -> None:
fake_client = AsyncMock()
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
await set_reviewer_thread_metadata("tid", head_sha="newhead")
metadata = fake_client.threads.update.await_args.kwargs["metadata"]
assert metadata["head_sha"] == "newhead"
@pytest.mark.asyncio
async def test_resolve_review_head_sha_prefers_metadata_over_config() -> None:
"""A mid-run push records the live head in thread metadata; it must win over
the stale head frozen in the run's config."""
fake_client = AsyncMock()
fake_client.threads.get.return_value = {"metadata": {"head_sha": "metahead"}}
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
head = await resolve_review_head_sha("tid", {"head_sha": "confighead"})
assert head == "metahead"
@pytest.mark.asyncio
async def test_resolve_review_head_sha_falls_back_to_config_when_metadata_empty() -> None:
fake_client = AsyncMock()
fake_client.threads.get.return_value = {"metadata": {}}
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
head = await resolve_review_head_sha("tid", {"head_sha": "confighead"})
assert head == "confighead"
@pytest.mark.asyncio
async def test_resolve_review_head_sha_falls_back_without_thread_id() -> None:
fake_client = AsyncMock()
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
head = await resolve_review_head_sha("", {"head_sha": "confighead"})
assert head == "confighead"
fake_client.threads.get.assert_not_called()

View file

@ -45,6 +45,12 @@ def _isolate_publish_review_pr_state() -> Iterator[None]:
patch("agent.tools.publish_review.fetch_pr_review_threads", AsyncMock(return_value=[])), patch("agent.tools.publish_review.fetch_pr_review_threads", AsyncMock(return_value=[])),
patch("agent.tools.publish_review.replace_findings", AsyncMock()), patch("agent.tools.publish_review.replace_findings", AsyncMock()),
patch("agent.tools.publish_review.open_swe_review_exists", AsyncMock(return_value=False)), patch("agent.tools.publish_review.open_swe_review_exists", AsyncMock(return_value=False)),
patch(
"agent.tools.publish_review.resolve_review_head_sha",
AsyncMock(
side_effect=lambda thread_id, configurable: configurable.get("head_sha") or ""
),
),
): ):
yield yield
@ -562,6 +568,56 @@ async def test_publish_review_skips_duplicate_empty_summary_when_open_swe_alread
set_metadata.assert_awaited_once_with("tid", last_reviewed_sha="newsha") set_metadata.assert_awaited_once_with("tid", last_reviewed_sha="newsha")
@pytest.mark.asyncio
async def test_publish_review_uses_resolved_head_sha_for_commit_and_last_reviewed() -> None:
"""A push that landed mid-run updates the live head in thread metadata.
publish_review must anchor the GitHub review to that head and advance
last_reviewed_sha to it, not the stale head frozen in the run config."""
from agent.tools.publish_review import _publish_review_async
finding = _f(id="f_new", file="b.py", start_line=2, end_line=2)
post_review = AsyncMock(return_value={"id": 4242})
set_metadata = AsyncMock()
with (
patch("agent.tools.publish_review.get_thread_id_from_runtime", return_value="tid"),
patch("agent.tools.publish_review.list_findings_async", AsyncMock(return_value=[finding])),
patch(
"agent.tools.publish_review.resolve_review_head_sha",
AsyncMock(return_value="freshhead"),
),
patch("agent.tools.publish_review.post_pull_request_review", post_review),
patch("agent.tools.publish_review.fetch_review_comments", AsyncMock(return_value=[])),
patch(
"agent.tools.publish_review._resolve_threads_for_resolved_findings",
new_callable=AsyncMock,
return_value=0,
),
patch("agent.tools.publish_review.set_reviewer_thread_metadata", set_metadata),
patch(
"agent.tools.publish_review._maybe_post_slack_completion_reply",
new_callable=AsyncMock,
),
):
result = await _publish_review_async(
owner="o",
repo="r",
pr_number=7,
head_sha="stalehead",
token="t",
severity_threshold="medium",
cap=15,
is_re_review=False,
langgraph_run_id="run-x",
)
assert result["success"] is True
assert post_review.await_args.kwargs["head_sha"] == "freshhead"
final = set_metadata.await_args_list[-1]
assert final.args[0] == "tid"
assert final.kwargs["last_reviewed_sha"] == "freshhead"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_publish_review_skips_review_existence_check_on_re_review() -> None: async def test_publish_review_skips_review_existence_check_on_re_review() -> None:
"""When re_review is already True we know a prior review exists, so the """When re_review is already True we know a prior review exists, so the

View file

@ -2,15 +2,38 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterator
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest
from agent.tools.add_finding import add_finding from agent.tools.add_finding import add_finding
from agent.tools.list_findings import list_findings from agent.tools.list_findings import list_findings
from agent.tools.resolve_finding_thread import resolve_finding_thread from agent.tools.resolve_finding_thread import resolve_finding_thread
from agent.tools.update_finding import update_finding from agent.tools.update_finding import update_finding
@pytest.fixture(autouse=True)
def _stub_resolve_review_head_sha() -> Iterator[None]:
"""Resolve the review head from the run config (no thread-metadata fetch).
Mirrors the production fallback when metadata carries no head, keeping these
unit tests offline. Tests that exercise the metadata-override path patch
``resolve_review_head_sha`` themselves.
"""
def _head(thread_id: str, configurable: dict[str, Any]) -> str:
head = configurable.get("head_sha")
return head if isinstance(head, str) else ""
with (
patch("agent.tools.add_finding.resolve_review_head_sha", AsyncMock(side_effect=_head)),
patch("agent.tools.update_finding.resolve_review_head_sha", AsyncMock(side_effect=_head)),
):
yield
def _config(**configurable_overrides: Any) -> dict[str, Any]: def _config(**configurable_overrides: Any) -> dict[str, Any]:
base: dict[str, Any] = { base: dict[str, Any] = {
"configurable": { "configurable": {
@ -200,6 +223,40 @@ def test_add_finding_persists_to_thread_metadata() -> None:
assert persisted["confidence"] == "high" assert persisted["confidence"] == "high"
def test_add_finding_uses_resolved_head_sha_for_provenance() -> None:
"""A net-new finding filed during a mid-run re-review must record the live
head (from thread metadata), not the stale head frozen in the run config."""
captured: list[Any] = []
async def fake_append(thread_id: str, finding: Any) -> Any:
captured.append(finding)
return finding
with (
patch("agent.tools.add_finding.get_config", return_value=_config()),
patch("agent.tools.add_finding.get_thread_id_from_runtime", return_value="tid-1"),
patch(
"agent.tools.add_finding.resolve_review_head_sha",
AsyncMock(return_value="freshhead"),
),
patch("agent.tools.add_finding.append_finding", side_effect=fake_append),
):
result = add_finding(
severity="medium",
confidence="high",
category="style",
file="foo.py",
title="Rename breaks reference",
description="rename",
start_line=11,
end_line=12,
)
assert result["success"] is True
assert captured[0]["first_seen_sha"] == "freshhead"
assert captured[0]["last_confirmed_sha"] == "freshhead"
def test_add_finding_allows_file_level_with_no_lines() -> None: def test_add_finding_allows_file_level_with_no_lines() -> None:
with ( with (
patch("agent.tools.add_finding.get_config", return_value=_config()), patch("agent.tools.add_finding.get_config", return_value=_config()),

View file

@ -265,7 +265,7 @@ async def test_push_event_triggers_re_review_run_when_watching() -> None:
patch( patch(
"agent.webapp.set_reviewer_thread_metadata", "agent.webapp.set_reviewer_thread_metadata",
new_callable=AsyncMock, new_callable=AsyncMock,
), ) as set_meta,
patch("agent.webapp.is_thread_active", new_callable=AsyncMock, return_value=False), patch("agent.webapp.is_thread_active", new_callable=AsyncMock, return_value=False),
patch("agent.webapp.get_client", return_value=fake_client), patch("agent.webapp.get_client", return_value=fake_client),
): ):
@ -278,6 +278,14 @@ async def test_push_event_triggers_re_review_run_when_watching() -> None:
assert configurable["re_review"] is True assert configurable["re_review"] is True
assert configurable["last_reviewed_sha"] == "oldsha" assert configurable["last_reviewed_sha"] == "oldsha"
assert configurable["head_sha"] == "newsha" assert configurable["head_sha"] == "newsha"
# The live head is persisted to thread metadata so a re-review queued into
# an in-flight run can resolve it despite the run's frozen config.
head_sha_writes = [
c.kwargs.get("head_sha")
for c in set_meta.await_args_list
if c.kwargs.get("head_sha") is not None
]
assert "newsha" in head_sha_writes
@pytest.mark.asyncio @pytest.mark.asyncio