This is an automated email from the ASF dual-hosted git repository.

hello-stephen pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 05a280e0860 [fix](ci) Stop superseded reviews when the PR head changes 
(#68981)
05a280e0860 is described below

commit 05a280e08601ad4707a2e564b66b3061485e4b95
Author: shuke <[email protected]>
AuthorDate: Tue Oct 13 11:44:24 2026 +0800

    [fix](ci) Stop superseded reviews when the PR head changes (#68981)
    
    When a PR receives another commit, its existing AI review can keep
    consuming model capacity until Codex notices the stale head. It then
    fails final submission verification with `No final review submission was
    declared`. This also happens while the outer runner waits five minutes
    to resume after a capacity error.
    
    Poll the authoritative PR head every 60 seconds during execution and
    capacity backoff, and check at attempt boundaries. A confirmed different
    SHA stops the old attempt through the existing
    SIGINT/grace-period/descendant-reaping path. API errors, timeouts, and
    malformed responses only warn; they do not establish supersession.
    Existing strict resume and submission checks remain in place.
    
    Record `review.superseded`, both SHAs, and a final diagnostic. The
    workflow validates the distinct helper exit code/result, treats
    supersession as a handled outcome, skips verdict/status publication and
    obsolete status aggregation, and retains Litefuse, session, and auth
    finalization. A successful workflow in this case does **not** mean a
    passed review. Litefuse verification also permits a review stopped
    before its first model/tool request. New commits still require the
    existing `/review` trigger; this does not add automatic reviews on
    pushes.
---
 .github/scripts/emit_litefuse_otel_io.py       |   8 +
 .github/scripts/run_review_with_resume.py      | 110 ++++++++++-
 .github/scripts/test_emit_litefuse_otel_io.py  |  17 ++
 .github/scripts/test_run_review_with_resume.py | 255 ++++++++++++++++++++++++-
 .github/scripts/test_submit_review.py          |   3 +-
 .github/workflows/code-review-runner.yml       |  34 +++-
 6 files changed, 409 insertions(+), 18 deletions(-)

diff --git a/.github/scripts/emit_litefuse_otel_io.py 
b/.github/scripts/emit_litefuse_otel_io.py
index 4da5db39950..3c6cf3846cb 100644
--- a/.github/scripts/emit_litefuse_otel_io.py
+++ b/.github/scripts/emit_litefuse_otel_io.py
@@ -389,6 +389,14 @@ def build_ingestion_payload(args, input_text, output_text, 
events):
             if key not in ("type", "_line_number")
         }
 
+    superseded = next((event for event in reversed(events)
+                       if event.get("type") == "review.superseded"), None)
+    if superseded is not None:
+        trace_metadata["review_superseded"] = {
+            key: value for key, value in superseded.items()
+            if key not in ("type", "_line_number")
+        }
+
     completed_items = [
         event
         for event in events
diff --git a/.github/scripts/run_review_with_resume.py 
b/.github/scripts/run_review_with_resume.py
old mode 100755
new mode 100644
index 43fe87a4079..b0dfd51ee36
--- a/.github/scripts/run_review_with_resume.py
+++ b/.github/scripts/run_review_with_resume.py
@@ -22,6 +22,7 @@ import argparse
 import ctypes
 import json
 import os
+import re
 import shutil
 import signal
 import subprocess
@@ -39,6 +40,65 @@ RETRY_DELAYS = (300,) * 6
 CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different 
model."
 UNSUPPORTED_CHATGPT_MODEL = "model is not supported when using Codex with a 
ChatGPT account."
 PROCESS_EXIT_GRACE_SECONDS = 5
+HEAD_POLL_SECONDS = 60
+HEAD_API_TIMEOUT_SECONDS = 10
+SUPERSEDED_EXIT_CODE = 75
+
+
+class ReviewSuperseded(Exception):
+    def __init__(self, head_sha):
+        self.head_sha = head_sha
+        super().__init__(f"PR head changed to {head_sha}; stopping superseded 
review")
+
+
+def check_head(pr, expected):
+    """Only a complete, valid API head can supersede the frozen review 
target."""
+    head = pr.get("head") if isinstance(pr, dict) else None
+    sha = head.get("sha") if isinstance(head, dict) else None
+    if not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}", sha):
+        raise ValueError("PR API returned no valid head SHA")
+    if sha != expected:
+        raise ReviewSuperseded(sha)
+
+
+class HeadWatchdog:
+    def __init__(self, args, remaining):
+        self.args = args
+        self.remaining = remaining
+        self.next_check = 0
+
+    def seconds_until_check(self):
+        return max(0, self.next_check - time.monotonic())
+
+    def check(self, *, force=False):
+        if not force and self.seconds_until_check() > 0:
+            return
+        timeout = min(HEAD_API_TIMEOUT_SECONDS, self.remaining())
+        # Poll synchronously: gh is reaped before Codex cleanup/ChildReaper 
runs.
+        # Include API time in the existing shared budget and polling interval.
+        self.next_check = time.monotonic() + HEAD_POLL_SECONDS
+        try:
+            response = subprocess.run(
+                ["gh", "api", 
f"repos/{self.args.repository}/pulls/{self.args.pr_number}"],
+                check=True, capture_output=True, text=True, timeout=timeout,
+            )
+            check_head(json.loads(response.stdout), self.args.head_sha)
+        except (ValueError, OSError, subprocess.SubprocessError) as exc:
+            # An unavailable API is not evidence of a new commit. Strict target
+            # checks before resume/submission still apply independently.
+            print(
+                f"PR head check unavailable ({type(exc).__name__}); keeping 
current review",
+                file=sys.stderr, flush=True,
+            )
+
+    def wait(self, seconds):
+        deadline = time.monotonic() + seconds
+        while True:
+            self.check()
+            delay = min(deadline - time.monotonic(), self.remaining())
+            if delay <= 0:
+                return
+            time.sleep(min(delay, self.seconds_until_check()))
 
 
 class ChildReaper:
@@ -77,7 +137,7 @@ class ChildReaper:
 
     def reap(self):
         # Called only after Popen.wait() reaps Codex. This dedicated helper has
-        # no other concurrent subprocesses; gh/help commands run between 
attempts.
+        # no other concurrent subprocesses; synchronous gh/help calls have 
returned.
         deadline = time.monotonic() + PROCESS_EXIT_GRACE_SECONDS
         while children := self.children.read_text().split():
             if time.monotonic() >= deadline:
@@ -204,7 +264,7 @@ def stop_process(process):
         process.wait()
 
 
-def run_attempt(command, events_path, stderr_path, timeout, reaper=None):
+def run_attempt(command, events_path, stderr_path, timeout, reaper=None, 
watchdog=None):
     with events_path.open("w") as stdout, stderr_path.open("w") as stderr:
         process = None
         copier = None
@@ -237,7 +297,19 @@ def run_attempt(command, events_path, stderr_path, 
timeout, reaper=None):
                 copier.start()
             finally:
                 signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask)
-            return process.wait(timeout=timeout)
+            if watchdog is None:
+                return process.wait(timeout=timeout)
+            deadline = time.monotonic() + timeout
+            while True:
+                watchdog.check()
+                remaining = deadline - time.monotonic()
+                if remaining <= 0:
+                    raise subprocess.TimeoutExpired(command, timeout)
+                try:
+                    return process.wait(timeout=min(remaining, 
watchdog.seconds_until_check()))
+                except subprocess.TimeoutExpired:
+                    if time.monotonic() >= deadline:
+                        raise
         finally:
             # Finish bounded cleanup before delivering cancellation, including 
a
             # second signal while an earlier cancellation is already unwinding.
@@ -298,9 +370,9 @@ def check_resume_target(args, started_at, remaining):
         return json.loads(result.stdout)
 
     pr = api(f"repos/{args.repository}/pulls/{args.pr_number}")
+    check_head(pr, args.head_sha)
     if (
         pr["state"] != "open"
-        or pr["head"]["sha"] != args.head_sha
         or pr["base"]["sha"] != args.base_sha
     ):
         raise ValueError(
@@ -388,13 +460,15 @@ def run_review(args, reaper=None):
             )
         return 1
 
+    watchdog = HeadWatchdog(args, remaining)
     try:
         while capacity_retry <= len(RETRY_DELAYS):
+            (context / "codex-review-model.txt").write_text(model + "\n")
+            watchdog.check(force=True)
             attempt_number += 1
             events_path = attempts / f"{attempt_number}.jsonl"
             stderr_path = attempts / f"{attempt_number}.stderr.log"
             output_path = attempts / f"{attempt_number}.final.txt"
-            (context / "codex-review-model.txt").write_text(model + "\n")
             final_message.write_text("")
             command = [
                 "codex",
@@ -426,7 +500,7 @@ def run_review(args, reaper=None):
             )
             try:
                 status = run_attempt(
-                    command, events_path, stderr_path, remaining(), 
reaper=reaper
+                    command, events_path, stderr_path, remaining(), 
reaper=reaper, watchdog=watchdog
                 )
             finally:
                 # Preserve failed attempts and their child-thread IDs for 
Litefuse.
@@ -439,6 +513,9 @@ def run_review(args, reaper=None):
                 if output_path.exists():
                     shutil.copyfile(output_path, final_message)
 
+            # Catch a head change even if Codex exited before the next poll,
+            # including its own stale-head refusal without a final submission.
+            watchdog.check(force=True)
             events = read_events(events_path)
             if status < 0 or status in (124, 130, 137, 143):
                 return fail(
@@ -478,7 +555,12 @@ def run_review(args, reaper=None):
                 # Final submission declares all review work complete. Only an
                 # exact GitHub readback can recover a subsequent capacity 
error;
                 # another run's review or a partially posted review cannot 
pass.
-                result = verify_completion(context, run, remaining)
+                try:
+                    result = verify_completion(context, run, remaining)
+                except ValueError:
+                    # The head can move between the exit check and readback.
+                    watchdog.check(force=True)
+                    raise
                 result["recovered_after_capacity"] = message == 
CAPACITY_MESSAGE
                 write_json(context / RESULT_FILE, result)
                 with aggregate.open("a") as handle:
@@ -520,9 +602,21 @@ def run_review(args, reaper=None):
                     )
                     + "\n"
                 )
-            time.sleep(delay)
+            watchdog.wait(delay)
             check_resume_target(args, started_at, remaining)
             capacity_retry += 1
+    except ReviewSuperseded as exc:
+        result = {
+            "state": "superseded", "repository": args.repository,
+            "pr_number": args.pr_number, "head_sha": args.head_sha,
+            "superseding_head_sha": exc.head_sha, "message": str(exc),
+        }
+        write_json(context / RESULT_FILE, result)
+        with aggregate.open("a") as handle:
+            handle.write(json.dumps({"type": "review.superseded", **result}) + 
"\n")
+        final_message.write_text(str(exc) + "\n")
+        print(str(exc), file=sys.stderr, flush=True)
+        return SUPERSEDED_EXIT_CODE
     except KeyboardInterrupt:
         fail("Review cancelled; not resuming")
         return 130
diff --git a/.github/scripts/test_emit_litefuse_otel_io.py 
b/.github/scripts/test_emit_litefuse_otel_io.py
index 866b3c09ead..6c7b31960ac 100644
--- a/.github/scripts/test_emit_litefuse_otel_io.py
+++ b/.github/scripts/test_emit_litefuse_otel_io.py
@@ -192,6 +192,23 @@ class LitefuseOtelExporterTest(unittest.TestCase):
         self.assertEqual("failed", turn["output"]["status"])
         self.assertEqual("ERROR", turn["level"])
 
+    def test_superseded_outcome_is_distinct_from_verified_completion(self):
+        args = SimpleNamespace(
+            repository="apache/doris", workflow="Code Review", run_id="123", 
pr_number="123",
+            head_sha="a" * 40, base_sha="b" * 40, reasoning_effort="xhigh",
+            max_json_chars=20000, max_context_json_chars=0, 
trace_name="review",
+            session_id="123", environment="test", model="gpt-6-sol",
+        )
+        outcome = {"state": "superseded", "head_sha": "a" * 40, 
"superseding_head_sha": "c" * 40}
+        _, payload, _ = MODULE.build_ingestion_payload(args, "review", 
"Superseded by new head", [
+            {"type": "turn.failed", "error": {"message": "capacity"}},
+            {"type": "review.superseded", **outcome},
+        ])
+        trace = payload["batch"][0]["body"]
+        self.assertEqual(outcome, trace["metadata"]["review_superseded"])
+        self.assertNotIn("review_completion", trace["metadata"])
+        self.assertIn("Superseded by new head", trace["output"])
+
     def test_bounds_and_shrinks_failed_turn_status_message(self):
         args = SimpleNamespace(
             repository="apache/doris",
diff --git a/.github/scripts/test_run_review_with_resume.py 
b/.github/scripts/test_run_review_with_resume.py
old mode 100755
new mode 100644
index ea06e64284e..e58d35d36c7
--- a/.github/scripts/test_run_review_with_resume.py
+++ b/.github/scripts/test_run_review_with_resume.py
@@ -20,6 +20,7 @@ import io
 import json
 import os
 import signal
+import shutil
 import subprocess
 import sys
 import tempfile
@@ -51,6 +52,19 @@ def completed():
     return {"type": "turn.completed", "usage": {"input_tokens": 20, 
"output_tokens": 3}}
 
 
+def write_fake_gh(root):
+    # CLI lifecycle tests must never use real GitHub credentials or the 
network.
+    gh = root / "gh"
+    gh.write_text(
+        f"#!{sys.executable}\n"
+        "import json\nfrom pathlib import Path\n"
+        f"head_file = Path({str(root / 'new-head')!r})\n"
+        f"head = head_file.read_text() if head_file.exists() else {'a' * 
40!r}\n"
+        "print(json.dumps({'head': {'sha': head}}))\n"
+    )
+    gh.chmod(0o700)
+
+
 class ResumeReviewTest(unittest.TestCase):
     def setUp(self):
         self.temp = tempfile.TemporaryDirectory()
@@ -98,6 +112,8 @@ class ResumeReviewTest(unittest.TestCase):
         self.target_check = self.enterContext(
             mock.patch.object(runner, "check_resume_target")
         )
+        self.watchdog = self.enterContext(mock.patch.object(runner, 
"HeadWatchdog")).return_value
+        self.watchdog.wait.side_effect = lambda seconds: 
runner.time.sleep(seconds)
         self.help = self.enterContext(mock.patch.object(runner.subprocess, 
"run"))
         self.verification = self.enterContext(mock.patch.object(
             runner, "verify_completion", return_value={
@@ -123,7 +139,7 @@ class ResumeReviewTest(unittest.TestCase):
     def execute(self, attempts):
         pending = iter(attempts)
 
-        def fake_attempt(command, events_path, stderr_path, timeout, 
reaper=None):
+        def fake_attempt(command, events_path, stderr_path, timeout, 
reaper=None, watchdog=None):
             self.commands.append(command)
             self.timeouts.append(timeout)
             spec = next(pending)
@@ -152,6 +168,62 @@ class ResumeReviewTest(unittest.TestCase):
     def last_error(self):
         return self.events()[-1]["error"]["message"]
 
+    def assert_superseded(self):
+        result = json.loads((self.context / runner.RESULT_FILE).read_text())
+        self.assertEqual("superseded", result["state"])
+        self.assertEqual("c" * 40, result["superseding_head_sha"])
+        self.assertEqual(self.args.head_sha, result["head_sha"])
+        self.assertEqual("review.superseded", self.events()[-1]["type"])
+        self.assertIn("superseded", (self.context / 
"codex-final-message.txt").read_text())
+
+    def test_stale_before_start_does_not_launch_codex(self):
+        self.watchdog.check.side_effect = runner.ReviewSuperseded("c" * 40)
+        self.assertEqual(runner.SUPERSEDED_EXIT_CODE, self.execute([]))
+        self.assertEqual([], self.commands)
+        self.assertEqual(self.args.model, (self.context / 
"codex-review-model.txt").read_text().strip())
+        self.verification.assert_not_called()
+        self.assert_superseded()
+
+    def 
test_superseded_attempt_preserves_logs_without_verification_or_resume(self):
+        self.assertEqual(runner.SUPERSEDED_EXIT_CODE, self.execute([{
+            "events": [thread_event()], "stderr": "attempt diagnostic",
+            "output": "interrupted partial review",
+            "raises": runner.ReviewSuperseded("c" * 40),
+        }]))
+        self.assertEqual(thread_event(), self.events()[0])
+        self.assertIn("attempt diagnostic", (self.context / 
"codex-stderr.log").read_text())
+        self.assertEqual("interrupted partial review",
+                         (self.context / 
"codex-attempts/1.final.txt").read_text())
+        self.verification.assert_not_called()
+        self.target_check.assert_not_called()
+        self.assertEqual([], self.sleeps)
+        self.assert_superseded()
+
+    def test_stale_at_exit_does_not_report_missing_submission(self):
+        self.watchdog.check.side_effect = [None, runner.ReviewSuperseded("c" * 
40)]
+        self.assertEqual(runner.SUPERSEDED_EXIT_CODE, self.execute([
+            {"events": [thread_event(), completed()], "status": 0},
+        ]))
+        self.verification.assert_not_called()
+        self.assert_superseded()
+
+    def test_stale_during_verification_is_superseded(self):
+        self.verification.side_effect = ValueError("PR head moved during 
readback")
+        self.watchdog.check.side_effect = [None, None, 
runner.ReviewSuperseded("c" * 40)]
+        self.assertEqual(runner.SUPERSEDED_EXIT_CODE, self.execute([
+            {"events": [thread_event(), completed()], "status": 0},
+        ]))
+        self.assert_superseded()
+
+    def test_superseded_in_backoff_never_resumes(self):
+        self.watchdog.wait.side_effect = runner.ReviewSuperseded("c" * 40)
+        self.assertEqual(runner.SUPERSEDED_EXIT_CODE, self.execute([
+            {"events": [thread_event(), failed()]},
+        ]))
+        self.assertEqual(1, len(self.commands))
+        self.target_check.assert_not_called()
+        self.assert_superseded()
+
     def test_success_does_not_retry(self):
         self.assertEqual(
             0, self.execute([{"events": [thread_event(), completed()], 
"status": 0}])
@@ -733,6 +805,162 @@ class ResumeReviewTest(unittest.TestCase):
         )
 
 
+
+class HeadWatchdogTest(unittest.TestCase):
+    def setUp(self):
+        self.clock = 0
+        self.args = SimpleNamespace(repository="apache/doris", 
pr_number="123", head_sha="a" * 40)
+        self.enterContext(mock.patch.object(runner.time, "monotonic", 
side_effect=lambda: self.clock))
+        self.sleep = self.enterContext(mock.patch.object(runner.time, "sleep", 
side_effect=self.advance))
+        self.api = self.enterContext(mock.patch.object(runner.subprocess, 
"run"))
+        self.api.return_value = SimpleNamespace(stdout=json.dumps({"head": 
{"sha": "a" * 40}}))
+        self.stderr = self.enterContext(redirect_stderr(io.StringIO()))
+        self.watchdog = runner.HeadWatchdog(self.args, lambda: 1000 - 
self.clock)
+
+    def advance(self, seconds):
+        self.clock += seconds
+
+    def test_unchanged_head_is_polled_once_per_minute(self):
+        self.watchdog.check()
+        self.clock = 59
+        self.watchdog.check()
+        self.assertEqual(1, self.api.call_count)
+        self.clock = 60
+        self.watchdog.check()
+        self.assertEqual(2, self.api.call_count)
+        self.assertEqual(10, self.api.call_args.kwargs["timeout"])
+
+    def test_only_head_changes_cancel_not_base_or_open_state(self):
+        self.api.return_value.stdout = json.dumps({
+            "head": {"sha": "a" * 40}, "base": {"sha": "c" * 40}, "state": 
"closed",
+        })
+        self.watchdog.check()
+        self.api.return_value.stdout = json.dumps({"head": {"sha": "c" * 40}})
+        with self.assertRaises(runner.ReviewSuperseded) as raised:
+            self.watchdog.check(force=True)
+        self.assertEqual("c" * 40, raised.exception.head_sha)
+
+    def test_invalid_response_does_not_cancel(self):
+        for payload in ("not json", "null", "[]", "{}", '{"head":null}',
+                        '{"head":{"sha":null}}', '{"head":{"sha":"short"}}',
+                        '{"head":{"sha":123}}'):
+            with self.subTest(payload=payload):
+                self.api.return_value.stdout = payload
+                self.watchdog.check(force=True)
+        self.assertIn("head check unavailable", self.stderr.getvalue())
+
+    def test_api_failures_keep_review_and_allow_later_detection(self):
+        for error in (subprocess.TimeoutExpired("gh", 10),
+                      subprocess.CalledProcessError(1, "gh"), 
OSError("unavailable")):
+            with self.subTest(error=type(error)):
+                self.api.side_effect = error
+                self.watchdog.check(force=True)
+        self.api.side_effect = None
+        self.api.return_value.stdout = json.dumps({"head": {"sha": "c" * 40}})
+        with self.assertRaises(runner.ReviewSuperseded):
+            self.watchdog.check(force=True)
+
+    def test_cancellation_is_not_swallowed_as_api_failure(self):
+        self.api.side_effect = KeyboardInterrupt
+        with self.assertRaises(KeyboardInterrupt):
+            self.watchdog.check()
+
+    def test_api_timeout_is_bounded_by_remaining_budget(self):
+        self.clock = 998
+        self.watchdog.check()
+        self.assertEqual(2, self.api.call_args.kwargs["timeout"])
+
+    def test_backoff_polls_and_stops_before_another_model_request(self):
+        self.api.side_effect = [
+            SimpleNamespace(stdout=json.dumps({"head": {"sha": "a" * 40}})),
+            SimpleNamespace(stdout=json.dumps({"head": {"sha": "c" * 40}})),
+        ]
+        with self.assertRaises(runner.ReviewSuperseded):
+            self.watchdog.wait(300)
+        self.assertEqual(60, self.clock)
+
+    def test_backoff_api_time_counts_toward_wait(self):
+        def slow_api(*args, **kwargs):
+            self.clock += 5
+            return SimpleNamespace(stdout=json.dumps({"head": {"sha": "a" * 
40}}))
+        self.api.side_effect = slow_api
+        self.watchdog.wait(125)
+        self.assertEqual(125, self.clock)
+        self.assertEqual([mock.call(55), mock.call(55)], 
self.sleep.call_args_list)
+
+    def test_running_process_keeps_shared_deadline_despite_poll_time(self):
+        self.watchdog.remaining = lambda: 65 - self.clock
+        process = mock.Mock(stderr=io.StringIO())
+        def wait(timeout=None):
+            if timeout is None or timeout == runner.PROCESS_EXIT_GRACE_SECONDS:
+                return 0  # cleanup
+            self.clock += timeout
+            raise subprocess.TimeoutExpired("codex", timeout)
+        process.wait.side_effect = wait
+        def slow_api(*args, **kwargs):
+            self.clock += min(10, kwargs["timeout"])
+            raise subprocess.TimeoutExpired("gh", kwargs["timeout"])
+        self.api.side_effect = slow_api
+        reaper = mock.Mock()
+        with tempfile.TemporaryDirectory() as tmp, 
mock.patch.object(runner.subprocess, "Popen", return_value=process):
+            with self.assertRaises(subprocess.TimeoutExpired):
+                runner.run_attempt(["codex"], Path(tmp) / "events", Path(tmp) 
/ "stderr", 65,
+                                   reaper=reaper, watchdog=self.watchdog)
+        self.assertEqual(65, self.clock)
+        process.send_signal.assert_called_once_with(signal.SIGINT)
+        reaper.reap.assert_called_once()
+
+
[email protected](shutil.which("jq"), "workflow shell test requires jq")
+class SupersededWorkflowTest(unittest.TestCase):
+    def test_shell_accepts_only_matching_superseded_result(self):
+        workflow = (Path(__file__).resolve().parents[1] / 
"workflows/code-review-runner.yml").read_text()
+        step = workflow.split("      - name: Run automated code review\n", 
1)[1].split("\n      - name:", 1)[0]
+        script = textwrap.dedent(step.split("          set -e\n\n", 1)[1])
+        for case, status, state, head, expected in (
+            ("superseded", 75, "superseded", "a" * 40, 0),
+            ("wrong head", 75, "superseded", "b" * 40, 1),
+            ("wrong state", 75, "success", "a" * 40, 1),
+            ("generic failure", 1, "superseded", "a" * 40, 1),
+        ):
+            with self.subTest(case=case), tempfile.TemporaryDirectory() as tmp:
+                root = Path(tmp)
+                (root / "review-result.json").write_text(json.dumps({
+                    "state": state, "repository": "apache/doris", "pr_number": 
"123",
+                    "head_sha": head, "superseding_head_sha": "c" * 40,
+                    "message": "PR head changed; stopping superseded review",
+                }))
+                (root / "codex-events.jsonl").touch()
+                (root / "codex-stderr.log").touch()
+                result = subprocess.run(["bash", "-e", "-c", script], 
capture_output=True, text=True, env={
+                    **os.environ, "status": str(status), "REPO": 
"apache/doris", "PR_NUMBER": "123",
+                    "HEAD_SHA": "a" * 40, "REVIEW_CONTEXT_DIR": tmp,
+                    "GITHUB_OUTPUT": str(root / "output"), 
"GITHUB_STEP_SUMMARY": str(root / "summary"),
+                })
+                self.assertEqual(expected, result.returncode, result.stderr)
+                output = (root / "output").read_text() if (root / 
"output").exists() else ""
+                if expected == 0:
+                    self.assertEqual("review_state=superseded\n", output)
+                    self.assertIn("superseded", (root / "summary").read_text())
+                else:
+                    self.assertNotIn("review_state=superseded", output)
+
+
+class SupersededCleanupTest(unittest.TestCase):
+    def test_cleanup_failure_does_not_become_handled_supersession(self):
+        process = mock.Mock(stderr=io.StringIO())
+        watchdog = mock.Mock()
+        watchdog.check.side_effect = runner.ReviewSuperseded("c" * 40)
+        reaper = mock.Mock()
+        reaper.reap.side_effect = OSError("descendant cleanup failed")
+        with tempfile.TemporaryDirectory() as tmp, 
mock.patch.object(runner.subprocess, "Popen", return_value=process):
+            with self.assertRaisesRegex(OSError, "cleanup failed"):
+                runner.run_attempt(["codex"], Path(tmp) / "events", Path(tmp) 
/ "stderr", 60,
+                                   reaper=reaper, watchdog=watchdog)
+        process.send_signal.assert_called_once_with(signal.SIGINT)
+        process.kill.assert_called_once()
+
+
 class WorkflowBudgetTest(unittest.TestCase):
     def test_cli_requires_the_workflow_budget(self):
         with (
@@ -872,6 +1100,11 @@ class ResumeTargetTest(unittest.TestCase):
         with self.assertRaisesRegex(ValueError, "changed"):
             self.check([[]])
 
+    def test_new_head_before_resume_has_explicit_outcome(self):
+        self.pr["head"]["sha"] = "c" * 40
+        with self.assertRaises(runner.ReviewSuperseded):
+            self.check([[]])
+
     def test_unavailable_api_does_not_blindly_resume(self):
         with (
             mock.patch.object(
@@ -1067,6 +1300,7 @@ class ProcessLifecycleTest(unittest.TestCase):
     def test_real_process_capacity_then_resume_preserves_state(self):
         with tempfile.TemporaryDirectory() as tmp, 
redirect_stderr(io.StringIO()):
             root = Path(tmp)
+            write_fake_gh(root)
             (root / "codex_goal_prompt.txt").write_text("test review")
             ledger = root / "subagent_review_findings.md"
             ledger.write_text("completed round 1")
@@ -1173,6 +1407,7 @@ else:
     def test_cli_sigterm_cleans_up_child_and_does_not_restart(self):
         with tempfile.TemporaryDirectory() as tmp:
             root = Path(tmp)
+            write_fake_gh(root)
             (root / "codex_goal_prompt.txt").write_text(
                 "test only; do not call a service"
             )
@@ -1243,7 +1478,7 @@ else:
 
     @unittest.skipUnless(sys.platform == "linux", "requires Linux 
subreaper/pidfd")
     def 
test_cli_reaps_detached_descendants_without_touching_other_processes(self):
-        for stop in ("cancel", "timeout", "exit"):
+        for stop in ("cancel", "timeout", "exit", "superseded"):
             for graceful in (True, False):
                 with self.subTest(stop=stop, graceful=graceful):
                     self.check_detached_descendants(stop, graceful)
@@ -1257,6 +1492,7 @@ else:
     def check_detached_descendants(self, stop, graceful):
         with tempfile.TemporaryDirectory() as tmp:
             root = Path(tmp)
+            write_fake_gh(root)
             (root / "codex_goal_prompt.txt").write_text("local process test 
only")
             fake_codex = root / "codex"
             fake_codex.write_text(
@@ -1301,7 +1537,7 @@ while time.monotonic() < deadline:
                 (
                     "import sys; sys.path.insert(0, sys.argv.pop(1)); "
                     "import run_review_with_resume as r; "
-                    "r.PROCESS_EXIT_GRACE_SECONDS = 0.5; "
+                    "r.PROCESS_EXIT_GRACE_SECONDS = 0.5; r.HEAD_POLL_SECONDS = 
0.05; "
                     + (
                         """
 import os, signal
@@ -1370,11 +1606,14 @@ r.os.pidfd_open = interrupt_cleanup
                     self.assertTrue(
                         (root / "pids.json").exists(), "fake Codex did not 
start"
                     )
+                    if stop == "superseded":
+                        (root / "new-head").write_text("c" * 40)
                     if stop.startswith("cancel"):
                         process.send_signal(signal.SIGTERM)
                     _, stderr = process.communicate(timeout=10)
                     self.assertEqual(
-                        130 if "cancel" in stop else 1, process.returncode, 
stderr
+                        75 if stop == "superseded" else 130 if "cancel" in 
stop else 1,
+                        process.returncode, stderr
                     )
                     for pid in json.loads((root / "pids.json").read_text()):
                         with self.assertRaises(ProcessLookupError):
@@ -1391,7 +1630,13 @@ r.os.pidfd_open = interrupt_cleanup
                             "Review cancelled; not resuming",
                             events[-1]["error"]["message"],
                         )
-                    if stop in ("cancel", "timeout", "cancel_reap_repeat"):
+                    if stop == "superseded":
+                        result = json.loads((root / 
runner.RESULT_FILE).read_text())
+                        self.assertEqual("superseded", result["state"])
+                        self.assertEqual("c" * 40, 
result["superseding_head_sha"])
+                        self.assertEqual("review.superseded",
+                                         runner.read_events(root / 
"codex-events.jsonl")[-1]["type"])
+                    if stop in ("cancel", "timeout", "cancel_reap_repeat", 
"superseded"):
                         self.assertEqual(graceful, (root / 
"interrupted").exists())
                 finally:
                     if process.poll() is None:
diff --git a/.github/scripts/test_submit_review.py 
b/.github/scripts/test_submit_review.py
index f40ad82d05b..702f2ceb558 100644
--- a/.github/scripts/test_submit_review.py
+++ b/.github/scripts/test_submit_review.py
@@ -247,7 +247,7 @@ class FinalReviewTest(unittest.TestCase):
                 }, model="gpt-6-sol", effort="xhigh", budget_seconds=60)
                 self.reviews.clear()
 
-                def attempt(command, events, stderr, timeout, reaper=None):
+                def attempt(command, events, stderr, timeout, reaper=None, 
watchdog=None):
                     run = json.loads((context / 
submitter.RUN_FILE).read_text())
                     submitter.verify_completion(context, run, self.remaining, 
submit=True,
                                                 
submission=self.submission(level))
@@ -259,6 +259,7 @@ class FinalReviewTest(unittest.TestCase):
 
                 with mock.patch.object(runner, "run_attempt", 
side_effect=attempt), \
                      mock.patch.object(runner, "RETRY_DELAYS", ()), \
+                     mock.patch.object(runner, "HeadWatchdog"), \
                      mock.patch.object(runner, "check_resume_target") as 
guard, \
                      mock.patch.object(runner.time, "sleep") as sleep, 
redirect_stderr(io.StringIO()):
                     self.assertEqual(0, runner.run_review(args))
diff --git a/.github/workflows/code-review-runner.yml 
b/.github/workflows/code-review-runner.yml
index 51b84852fb6..23ffcefa2c9 100644
--- a/.github/workflows/code-review-runner.yml
+++ b/.github/workflows/code-review-runner.yml
@@ -205,6 +205,7 @@ jobs:
       base_sha: ${{ steps.review_inputs.outputs.base_sha }}
       head_sha: ${{ steps.review_inputs.outputs.head_sha }}
       manage_status: ${{ steps.review_inputs.outputs.manage_status }}
+      review_state: ${{ steps.review.outputs.review_state }}
       pr_number: ${{ steps.review_inputs.outputs.pr_number }}
     # Pre-finalization steps can use 218 minutes and auth sync can use 8 more,
     # leaving 12 minutes for runner setup and post-job cleanup.
@@ -895,6 +896,20 @@ jobs:
           status=$?
           set -e
 
+          # The runner has stopped Codex and reaped descendants. Treat an
+          # obsolete head as a handled outcome, without publishing a verdict.
+          if [ "$status" -eq 75 ]; then
+            jq -e --arg repo "$REPO" --arg pr "$PR_NUMBER" --arg head 
"$HEAD_SHA" '
+              .state == "superseded" and .repository == $repo and
+              (.pr_number | tostring) == $pr and .head_sha == $head and
+              (.superseding_head_sha | test("^[0-9a-f]{40}$")) and
+              .superseding_head_sha != $head' \
+              "$REVIEW_CONTEXT_DIR/review-result.json" > /dev/null
+            echo "review_state=superseded" >> "$GITHUB_OUTPUT"
+            jq -r '.message' "$REVIEW_CONTEXT_DIR/review-result.json" | tee -a 
"$GITHUB_STEP_SUMMARY"
+            exit 0
+          fi
+
           failure_reason=""
           if [ "$status" -ne 0 ]; then
             if [ -s "$REVIEW_CONTEXT_DIR/codex-events.jsonl" ]; then
@@ -1080,7 +1095,8 @@ jobs:
           ${{
             always() &&
             steps.review_inputs.outcome == 'success' &&
-            steps.review_inputs.outputs.manage_status == 'true'
+            steps.review_inputs.outputs.manage_status == 'true' &&
+            steps.review.outputs.review_state != 'superseded'
           }}
         timeout-minutes: 2
         env:
@@ -1127,6 +1143,7 @@ jobs:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PK }}
           LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SK }}
+          REVIEW_STATE: ${{ steps.review.outputs.review_state }}
           REPO: ${{ github.repository }}
           PR_NUMBER: ${{ steps.review_inputs.outputs.pr_number }}
           HEAD_SHA: ${{ steps.review_inputs.outputs.head_sha }}
@@ -1145,6 +1162,14 @@ jobs:
             > "$helper"
           chmod 700 "$helper"
 
+          min_observations=4
+          min_step_observations=2
+          if [ "$REVIEW_STATE" = "superseded" ]; then
+            # The head may have changed before the first model/tool request.
+            min_observations=1
+            min_step_observations=0
+          fi
+
           python3 "$helper" \
             --input-file "$REVIEW_CONTEXT_DIR/codex_goal_prompt.txt" \
             --events-file "$REVIEW_CONTEXT_DIR/codex-events.jsonl" \
@@ -1163,8 +1188,8 @@ jobs:
             --reasoning-effort "xhigh" \
             --environment "github-actions" \
             --max-payload-bytes 4000000 \
-            --min-observations 4 \
-            --min-step-observations 2 \
+            --min-observations "$min_observations" \
+            --min-step-observations "$min_step_observations" \
             --verify-attempts 24 \
             --verify-sleep-seconds 5 \
             --verify-timeout-seconds 120 \
@@ -1255,7 +1280,8 @@ jobs:
     if: >-
       always() &&
       needs.code-review.outputs.manage_status == 'true' &&
-      needs.code-review.outputs.head_sha != ''
+      needs.code-review.outputs.head_sha != '' &&
+      needs.code-review.outputs.review_state != 'superseded'
     uses: ./.github/workflows/code-review-aggregate-status.yml
     with:
       head_sha: ${{ needs.code-review.outputs.head_sha }}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to