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 3c060af5da7 [fix](ci) Deliver non-converged reviews at the round limit
(#68996)
3c060af5da7 is described below
commit 3c060af5da766b4483edbfc974369e42a3372a72
Author: shuke <[email protected]>
AuthorDate: Tue Oct 13 16:15:46 2026 +0800
[fix](ci) Deliver non-converged reviews at the round limit (#68996)
Problem Summary:
The review runner simultaneously required unanimous
`NO_NEW_VALUABLE_FINDINGS` before submission and required submission
after at most three rounds. When round 3 still produced candidates, the
agent could mark its goal blocked and withhold verified findings. This
happened in the reviews of
[#67549](https://github.com/apache/doris/actions/runs/38222336278) and
[#68499](https://github.com/apache/doris/actions/runs/38271916203).
Separate verified delivery from convergence:
- Submit after convergence or the third round. At the cap, publish
verified findings and disclose remaining questions or coverage gaps;
unverified candidates must not become inline findings.
- Require `converged`, `rounds`, and `incomplete_reason` in the
submission. Non-convergence requires round 3 and a nonempty reason. The
trusted helper renders this declaration into the review body and binds
it to the existing digest and exact API readback.
- A verified delivery completes execution, but non-convergence produces
a failing review status even with zero P0/P1. Both the source and
aggregate status explain non-convergence. Reviews without P0/P1 remain
GitHub comments; no artificial blocking finding is created.
- Preserve head/base validation, submission deduplication, capacity
recovery, stale-head cancellation, and existing authorized local/skip
overrides.
The prompt and helpers are loaded from the same workflow revision.
Existing invocations retain their old schema; new invocations reject
missing convergence fields rather than defaulting to a pass. This change
does not update the agent binary or model.
---
.github/scripts/resolve_code_review_status.py | 15 ++-
.github/scripts/run_review_with_resume.py | 1 +
.github/scripts/submit_review.py | 33 +++++-
.github/scripts/test_resolve_code_review_status.py | 30 ++++++
.../scripts/test_review_convergence_workflow.py | 117 +++++++++++++++++++++
.github/scripts/test_run_review_with_resume.py | 2 +
.github/scripts/test_submit_review.py | 99 +++++++++++++++--
.github/workflows/code-review-runner.yml | 37 ++++---
8 files changed, 303 insertions(+), 31 deletions(-)
diff --git a/.github/scripts/resolve_code_review_status.py
b/.github/scripts/resolve_code_review_status.py
index a2d54302577..5bc50f32417 100644
--- a/.github/scripts/resolve_code_review_status.py
+++ b/.github/scripts/resolve_code_review_status.py
@@ -73,19 +73,30 @@ def resolve_status(
approved_contexts: set[tuple[int, str]] = set()
blocked_contexts: set[tuple[int, str]] = set()
+ incomplete_contexts: set[tuple[int, str]] = set()
for context, item in latest_by_context.items():
match = SOURCE_CONTEXT_RE.fullmatch(context)
if match is not None and item["state"] == "success":
approved_contexts.add((int(match.group(1)),
match.group(2).casefold()))
elif (match is not None and item["state"] == "failure"
and context.startswith("code-review/source/automated/")):
- blocked_contexts.add((int(match.group(1)),
match.group(2).casefold()))
+ key = (int(match.group(1)), match.group(2).casefold())
+ blocked_contexts.add(key)
+ # State remains the sole gating signal. Use the runner's
description
+ # only to explain an already-blocking result, never to grant a
pass.
+ description = item.get("description")
+ if isinstance(description, str) and description.startswith("Review
did not converge after "):
+ incomplete_contexts.add(key)
# Preserve the authorized local PASS / skip override. Otherwise a completed
- # automated review with P0/P1 blocks, rather than appearing to still be
running.
+ # automated review with P0/P1 or incomplete coverage blocks instead of
pending.
blocked = sorted((open_contexts - approved_contexts) & blocked_contexts)
if blocked:
number, base_sha = blocked[0]
+ if (number, base_sha) in incomplete_contexts:
+ return Resolution(
+ "failure", f"Review did not converge for PR #{number} at
{base_sha[:12]}.",
+ )
return Resolution(
"failure", f"Review completed with P0/P1 for PR #{number} at
{base_sha[:12]}.",
)
diff --git a/.github/scripts/run_review_with_resume.py
b/.github/scripts/run_review_with_resume.py
index b0dfd51ee36..7f8bbf1589d 100644
--- a/.github/scripts/run_review_with_resume.py
+++ b/.github/scripts/run_review_with_resume.py
@@ -568,6 +568,7 @@ def run_review(args, reaper=None):
print(
f"Verified final review {result['review_id']}: "
f"P0={result['p0']}, P1={result['p1']}, "
+ f"converged={result['converged']},
rounds={result['rounds']}, "
f"recovered_after_capacity={result['recovered_after_capacity']}",
file=sys.stderr, flush=True,
)
diff --git a/.github/scripts/submit_review.py b/.github/scripts/submit_review.py
index edd8fe9cf18..6db8997b3b9 100644
--- a/.github/scripts/submit_review.py
+++ b/.github/scripts/submit_review.py
@@ -49,9 +49,25 @@ def priority(body):
def validate_submission(value):
if not isinstance(value, dict) or set(value) != {
- "body", "comments", "existing_blocking_comment_ids"
+ "body", "comments", "existing_blocking_comment_ids",
+ "converged", "rounds", "incomplete_reason"
}:
- raise ValueError("Final review requires body, comments and
existing_blocking_comment_ids")
+ raise ValueError(
+ "Final review requires body, comments,
existing_blocking_comment_ids, "
+ "converged, rounds and incomplete_reason"
+ )
+ if type(value["converged"]) is not bool:
+ raise ValueError("converged must be a boolean")
+ if type(value["rounds"]) is not int or not 1 <= value["rounds"] <= 3:
+ raise ValueError("rounds must be an integer from 1 to 3")
+ reason = value["incomplete_reason"]
+ if not isinstance(reason, str):
+ raise ValueError("incomplete_reason must be text")
+ if value["converged"]:
+ if reason.strip():
+ raise ValueError("A converged review cannot have an
incomplete_reason")
+ elif value["rounds"] != 3 or not reason.strip():
+ raise ValueError("A non-converged review requires three rounds and an
incomplete_reason")
if not isinstance(value["body"], str) or not value["body"].strip():
raise ValueError("Final review summary is empty")
if not isinstance(value["comments"], list):
@@ -119,6 +135,15 @@ def review_payload(submission, run, root, remaining):
digest = hashlib.sha256(json.dumps(submission,
sort_keys=True).encode()).hexdigest()
marker = f"<!-- code-review-completion:{run['token']}:{digest} -->"
body = submission["body"]
+ # Render the declared convergence state ourselves and bind it to the same
+ # digest/readback as the findings. A delivered review can still be
incomplete.
+ if submission["converged"]:
+ body += f"\n\nReview converged after {submission['rounds']} round(s)."
+ else:
+ body += (
+ "\n\n**Review did not converge after 3 rounds. This automated
review cannot pass the review check.**"
+ f"\n\n{submission['incomplete_reason']}"
+ )
if ids:
body += "\n\nExisting P0/P1 findings confirmed for this head: " + ",
".join(
f"https://github.com/{run['repository']}/pull/{run['pr_number']}#discussion_r{i}"
@@ -187,7 +212,9 @@ def verify_completion(context, run, remaining, *,
submit=False, submission=None)
# Recheck after the API reads so a moving PR cannot pass an obsolete
review.
check_target(run, remaining)
return {
- "state": "failure" if counts[0] + counts[1] else "success",
+ "state": "failure" if counts[0] + counts[1] or not
submission["converged"] else "success",
+ "converged": submission["converged"], "rounds": submission["rounds"],
+ "incomplete_reason": submission["incomplete_reason"],
"p0": counts[0], "p1": counts[1], "review_id": review["id"],
"review_url": review["html_url"],
}
diff --git a/.github/scripts/test_resolve_code_review_status.py
b/.github/scripts/test_resolve_code_review_status.py
index c9576ab5085..1f1f4f30ce2 100644
--- a/.github/scripts/test_resolve_code_review_status.py
+++ b/.github/scripts/test_resolve_code_review_status.py
@@ -115,6 +115,36 @@ class ResolveCodeReviewStatusTest(unittest.TestCase):
self.assertEqual("failure", result.state)
self.assertIn("P0/P1", result.description)
+ def test_nonconverged_result_blocks_with_an_explicit_description(self) ->
None:
+ incomplete = dict(status(1, source="automated", state="failure"),
+ description="Review did not converge after 3 rounds
for PR #123: P0=0, P1=0.")
+ result = resolve_status([pull(123)], [incomplete], head_sha=HEAD_SHA)
+ self.assertEqual("failure", result.state)
+ self.assertIn("did not converge", result.description)
+ self.assertNotIn("P0/P1", result.description)
+
+ def test_description_does_not_decide_whether_review_passes(self) -> None:
+ for state in ("success", "pending", "failure"):
+ with self.subTest(state=state):
+ source = dict(status(1, source="automated", state=state),
+ description="Review did not converge after 3
rounds")
+ self.assertEqual(state, resolve_status([pull(123)], [source],
head_sha=HEAD_SHA).state)
+
+ def test_incomplete_result_can_be_replaced_or_explicitly_overridden(self)
-> None:
+ incomplete = dict(status(1, source="automated", state="failure"),
+ description="Review did not converge after 3 rounds")
+ for source in ("automated", "local", "skip"):
+ with self.subTest(source=source):
+ result = resolve_status([pull(123)], [incomplete, status(2,
source=source)], head_sha=HEAD_SHA)
+ self.assertEqual("success", result.state)
+
+ def test_incomplete_result_does_not_follow_other_heads_or_bases(self) ->
None:
+ incomplete = dict(status(1, source="automated", state="failure"),
+ description="Review did not converge after 3 rounds")
+ for pulls in ([pull(124)], [pull(123, base=OTHER_BASE_SHA)],
[pull(123, head=OTHER_HEAD_SHA)]):
+ with self.subTest(pulls=pulls):
+ self.assertEqual("pending", resolve_status(pulls,
[incomplete], head_sha=HEAD_SHA).state)
+
def test_local_or_skip_success_can_override_blocking_review(self) -> None:
for source in ("local", "skip"):
with self.subTest(source=source):
diff --git a/.github/scripts/test_review_convergence_workflow.py
b/.github/scripts/test_review_convergence_workflow.py
new file mode 100644
index 00000000000..97953d01b77
--- /dev/null
+++ b/.github/scripts/test_review_convergence_workflow.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import json
+import os
+import re
+import shutil
+import subprocess
+import tempfile
+import textwrap
+import unittest
+from pathlib import Path
+
+from resolve_code_review_status import resolve_status
+from submit_review import validate_submission
+
+
+WORKFLOW = Path(__file__).resolve().parents[1] /
"workflows/code-review-runner.yml"
+
+
+def step_script(name):
+ step = WORKFLOW.read_text().split(f" - name: {name}\n",
1)[1].split("\n - name:", 1)[0]
+ script = textwrap.dedent(step.split(" run: |\n", 1)[1])
+ return re.sub(r"\$\{\{.*?\}\}", "test", script)
+
+
+class ReviewPromptContractTest(unittest.TestCase):
+ def test_prompt_schema_supports_both_delivery_outcomes(self):
+ prompt = WORKFLOW.read_text().split("<<'PROMPT'\n", 1)[1].split("
PROMPT\n", 1)[0]
+ example = next(line.strip() for line in prompt.splitlines() if
line.strip().startswith('{"body":'))
+ submission = json.loads(example.replace("<diff_position>", "1"))
+ validate_submission(submission)
+ validate_submission(dict(submission, converged=False, rounds=3,
+ incomplete_reason="Round 3 still found
candidates."))
+ # Regression guard for the clauses that blocked #67549 and #68499.
+ for obsolete in (
+ "can only be submitted after all subagents return",
+ "MUST remain incomplete until every suspicious point",
+ "Only finish when there are no unresolved suspicious points",
+ "declare the COMPLETE final review",
+ "Submit only after all review completion criteria are satisfied",
+ ):
+ self.assertNotIn(obsolete, prompt)
+ self.assertIn("do not start a fourth round", prompt)
+ self.assertIn("MUST NOT prevent delivery of verified findings", prompt)
+
+
[email protected](shutil.which("jq"), "workflow shell test requires jq")
+class ReviewConvergenceWorkflowTest(unittest.TestCase):
+ def test_verified_result_flows_through_source_and_aggregate_status(self):
+ output_script = step_script("Run automated code
review").split("status=$?\n", 1)[1]
+ source_script = step_script("Sync Code Review check for current head")
+ # Replace the only external mutation with an argument capture. Run the
+ # actual Bash/jq blocks, then feed their source into the real resolver.
+ capture = """gh() {
+ python3 -c 'import json, sys; print(json.dumps(sys.argv[1:]))' "$@"
+}
+"""
+ for converged, p0, p1, expected in (
+ (True, 0, 0, "success"),
+ (True, 1, 0, "failure"),
+ (True, 0, 1, "failure"),
+ (False, 0, 0, "failure"),
+ (False, 0, 1, "failure"),
+ ):
+ with self.subTest(converged=converged, p0=p0, p1=p1),
tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ (root / "review-result.json").write_text(json.dumps({
+ "state": expected, "p0": p0, "p1": p1, "converged":
converged,
+ "rounds": 3, "incomplete_reason": "" if converged else
"Coverage incomplete",
+ }))
+ env = {**os.environ, "status": "0", "REVIEW_CONTEXT_DIR": tmp,
+ "GITHUB_OUTPUT": str(root / "output"),
"GITHUB_STEP_SUMMARY": str(root / "summary")}
+ subprocess.run(["bash", "-e", "-c", output_script], env=env,
+ capture_output=True, text=True, check=True)
+ outputs = dict(line.split("=", 1) for line in (root /
"output").read_text().splitlines())
+ self.assertEqual(str(converged).lower(), outputs["converged"])
+ result = subprocess.run(["bash", "-e", "-c", capture +
source_script], env={
+ **env, "BASE_SHA": "b" * 40, "HEAD_SHA": "a" * 40,
+ "PR_NUMBER": "123", "REPO": "apache/doris", "JOB_STATUS":
"success",
+ "REVIEW_CONTEXT_OUTCOME": "success", "REVIEW_OUTCOME":
"success",
+ "REVIEW_STATE": outputs["review_state"], "REVIEW_P0":
outputs["p0"],
+ "REVIEW_P1": outputs["p1"], "REVIEW_CONVERGED":
outputs["converged"],
+ "REVIEW_ROUNDS": outputs["rounds"],
+ }, capture_output=True, text=True, check=True)
+ args = json.loads(result.stdout)
+ posted = dict(args[i + 1].split("=", 1) for i in
range(len(args)) if args[i] == "-f")
+ self.assertEqual(expected, posted["state"])
+ self.assertIn("repos/apache/doris/statuses/" + "a" * 40, args)
+ resolution = resolve_status([{
+ "state": "open", "number": 123, "head": {"sha": "a" * 40},
"base": {"sha": "b" * 40},
+ }], [{"id": 1, **posted}], head_sha="a" * 40)
+ self.assertEqual(expected, resolution.state)
+ if not converged:
+ self.assertIn("did not converge", posted["description"])
+ self.assertIn("did not converge", resolution.description)
+ else:
+ self.assertNotIn("did not converge",
resolution.description)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/test_run_review_with_resume.py
b/.github/scripts/test_run_review_with_resume.py
index e58d35d36c7..6651d65b145 100644
--- a/.github/scripts/test_run_review_with_resume.py
+++ b/.github/scripts/test_run_review_with_resume.py
@@ -118,6 +118,7 @@ class ResumeReviewTest(unittest.TestCase):
self.verification = self.enterContext(mock.patch.object(
runner, "verify_completion", return_value={
"state": "success", "p0": 0, "p1": 0, "review_id": 123,
+ "converged": True, "rounds": 1, "incomplete_reason": "",
}
))
@@ -1362,6 +1363,7 @@ else:
mock.patch.object(runner, "check_resume_target"),
mock.patch.object(runner, "verify_completion", return_value={
"state": "success", "p0": 0, "p1": 0, "review_id": 123,
+ "converged": True, "rounds": 1, "incomplete_reason": "",
}),
):
self.assertEqual(0, runner.run_review(args))
diff --git a/.github/scripts/test_submit_review.py
b/.github/scripts/test_submit_review.py
index 702f2ceb558..50dbfed228d 100644
--- a/.github/scripts/test_submit_review.py
+++ b/.github/scripts/test_submit_review.py
@@ -18,6 +18,7 @@
import copy
import io
+import itertools
import json
import subprocess
import tempfile
@@ -53,10 +54,11 @@ class FinalReviewTest(unittest.TestCase):
self.api = self.enterContext(mock.patch.object(submitter, "api",
side_effect=self.fake_api))
self.remaining = lambda: 60
- def submission(self, priority=1):
+ def submission(self, priority=1, *, converged=True, rounds=1):
return {"body": "All checkpoints reviewed.", "comments": [] if
priority is None else [
{"path": "file.py", "position": 12, "body": f"[P{priority}] Fix
the verified issue"}
- ], "existing_blocking_comment_ids": []}
+ ], "existing_blocking_comment_ids": [], "converged": converged,
"rounds": rounds,
+ "incomplete_reason": "" if converged else "Round 3 found another
candidate; coverage is not settled."}
def fake_api(self, path, remaining, *, payload=None, paginated=False):
if path == self.root:
@@ -105,6 +107,77 @@ class FinalReviewTest(unittest.TestCase):
self.assertEqual("success", result["state"])
self.assertEqual("COMMENT", self.posts[-1]["event"])
+ def test_round_limit_delivers_verified_findings_without_passing(self):
+ submission = self.submission(2, converged=False, rounds=3)
+ result = self.submit(submission)
+ self.assertEqual(("failure", 0, 0), (result["state"], result["p0"],
result["p1"]))
+ self.assertFalse(result["converged"])
+ self.assertEqual(3, result["rounds"])
+ self.assertEqual(submission["incomplete_reason"],
result["incomplete_reason"])
+ self.assertEqual(submission["comments"], self.posts[0]["comments"])
+ self.assertEqual("COMMENT", self.posts[0]["event"])
+ self.assertIn("Review did not converge after 3 rounds",
self.posts[0]["body"])
+ self.assertIn(submission["incomplete_reason"], self.posts[0]["body"])
+ self.assertEqual(result, self.verify())
+ self.assertEqual(result, self.submit(submission))
+ self.assertEqual(1, len(self.posts))
+
+ def test_convergence_metadata_is_bound_to_frozen_submission(self):
+ original = self.submission(None, converged=False, rounds=3)
+ self.submit(original)
+ changes = (
+ {"converged": True, "incomplete_reason": ""},
+ {"incomplete_reason": "A different gap"},
+ )
+ for changeset in changes:
+ with self.subTest(changeset=changeset),
self.assertRaisesRegex(ValueError, "already declared"):
+ self.submit(dict(original, **changeset))
+ self.assertEqual(1, len(self.posts))
+
+ def test_convergence_statement_is_verified_by_api_readback(self):
+ self.submit(self.submission(None, converged=False, rounds=3))
+ self.reviews[0]["body"] = self.reviews[0]["body"].replace("did not
converge", "converged")
+ with self.assertRaisesRegex(ValueError, "summary did not match"):
+ self.verify()
+
+ def test_nonconverged_submission_still_checks_live_head(self):
+ self.pr["head"]["sha"] = "c" * 40
+ with self.assertRaisesRegex(ValueError, "stale"):
+ self.submit(self.submission(None, converged=False, rounds=3))
+ self.assertEqual([], self.posts)
+
+ def test_nonconverged_post_with_lost_response_is_not_duplicated(self):
+ submission = self.submission(None, converged=False, rounds=3)
+ def lost_response(path, *args, **kwargs):
+ result = self.fake_api(path, *args, **kwargs)
+ if kwargs.get("payload") is not None:
+ raise subprocess.TimeoutExpired("gh", 30)
+ return result
+ self.api.side_effect = lost_response
+ with self.assertRaises(subprocess.TimeoutExpired):
+ self.submit(submission)
+ self.api.side_effect = self.fake_api
+ self.assertEqual("failure", self.submit(submission)["state"])
+ self.assertEqual(1, len(self.posts))
+
+ def test_convergence_schema_rejects_ambiguous_or_premature_results(self):
+ invalid = [
+ {"converged": "false"}, {"converged": 1}, {"converged": None},
+ {"rounds": True}, {"rounds": 0}, {"rounds": 4}, {"rounds": "3"},
{"rounds": 3.0},
+ {"incomplete_reason": None}, {"incomplete_reason": "still
unresolved"},
+ {"converged": False, "rounds": 3, "incomplete_reason": " "},
+ {"converged": False, "rounds": 2, "incomplete_reason": "not
settled"},
+ ]
+ for changes in invalid:
+ with self.subTest(changes=changes), self.assertRaises(ValueError):
+ self.submit(dict(self.submission(None), **changes))
+ for field in ("converged", "rounds", "incomplete_reason"):
+ value = self.submission(None)
+ del value[field]
+ with self.subTest(missing=field), self.assertRaises(ValueError):
+ self.submit(value)
+ self.assertEqual([], self.posts)
+
def
test_existing_confirmed_blocker_is_counted_without_duplicate_inline(self):
self.existing = [{"id": 101, "body": "[P0] Previously reported
blocker"}]
submission = self.submission(None)
@@ -235,11 +308,11 @@ class FinalReviewTest(unittest.TestCase):
self.submit()
self.assertEqual(1, len(self.posts))
- def
test_capacity_after_submission_completes_execution_and_gates_findings(self):
+ def
test_delivery_completes_execution_and_gates_findings_and_convergence(self):
# Reproduce #66227: final write/readback, then capacity (also on the
last
# allowed attempt). Run the real verifier and SHA-wide status resolver.
- for level in (None, 0, 1, 2):
- with self.subTest(priority=level), tempfile.TemporaryDirectory()
as tmp:
+ for level, converged, capacity in itertools.product((None, 0, 1, 2),
(True, False), (True, False)):
+ with self.subTest(priority=level, converged=converged,
capacity=capacity), tempfile.TemporaryDirectory() as tmp:
context = Path(tmp)
(context / "codex_goal_prompt.txt").write_text("Review the PR")
args = SimpleNamespace(context_dir=context, cwd=context, **{
@@ -250,12 +323,13 @@ class FinalReviewTest(unittest.TestCase):
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))
+
submission=self.submission(level, converged=converged, rounds=3))
events.write_text(json.dumps({"type": "thread.started",
"thread_id":
"0199a213-81c0-7800-8aa1-bbab2a035a53"}) + "\n" +
json.dumps({
- "type": "turn.failed", "error": {"message":
runner.CAPACITY_MESSAGE}}) + "\n")
+ "type": "turn.failed" if capacity else
"turn.completed",
+ "error": {"message": runner.CAPACITY_MESSAGE} if
capacity else {}}) + "\n")
stderr.write_text("")
- return 1
+ return 1 if capacity else 0
with mock.patch.object(runner, "run_attempt",
side_effect=attempt), \
mock.patch.object(runner, "RETRY_DELAYS", ()), \
@@ -266,8 +340,13 @@ class FinalReviewTest(unittest.TestCase):
guard.assert_not_called()
sleep.assert_not_called()
result = json.loads((context /
submitter.RESULT_FILE).read_text())
- self.assertTrue(result["recovered_after_capacity"])
- state = "failure" if level in (0, 1) else "success"
+ self.assertEqual(capacity, result["recovered_after_capacity"])
+ self.assertEqual(converged, result["converged"])
+ self.assertEqual(3, result["rounds"])
+ completion = runner.read_events(context /
"codex-events.jsonl")[-1]
+ self.assertEqual("review.completed", completion["type"])
+ self.assertEqual(converged, completion["converged"])
+ state = "failure" if level in (0, 1) or not converged else
"success"
self.assertEqual(state, result["state"])
resolution = resolve_status([self.pr], [{"id": 1, "state":
result["state"],
"context":
f"code-review/source/automated/pr-123/base-{self.run['base_sha']}"}],
diff --git a/.github/workflows/code-review-runner.yml
b/.github/workflows/code-review-runner.yml
index dc8826ac6cc..fc227900abc 100644
--- a/.github/workflows/code-review-runner.yml
+++ b/.github/workflows/code-review-runner.yml
@@ -690,8 +690,8 @@ jobs:
This environment is only used for review operations, so do not
attempt any builds or code modifications. However, temporary scripts you use
for review purposes are exceptions.
You MUST NOT attempt to access any files outside the current
directory. and you DO NOT need to. But this does not prevent you from normally
using any skill or web fetch tools.
You can comment on the pull request.
- This review task is executed in Codex goal mode. Subsequent prompts
and skill indicate all the review and commenting actions you need to perform.
The current round can only be concluded after completing the main agent risk
scan, the normal full-review subagent review for each round, and the
risk-focused subagent scan (if applicable). The final GitHub review can only be
submitted after all subagents return NO_NEW_VALUABLE_FINDINGS, and all
candidate points from the subagents hav [...]
- In addition, the number of iterations shall not exceed 3 rounds. If
new subagents identify valuable candidate points after 3 rounds, the main agent
must indicate in the final summary that the review is incomplete.
+ This review task is executed in Codex goal mode. The goal is to
deliver one verified GitHub review within at most 3 review rounds, with an
explicit convergence result. Delivery and convergence are separate: a review
that reaches the round limit MUST still deliver its verified findings and
describe its remaining limits. Follow this bounded delivery contract for
stopping and submission.
+ A review is converged only when all assigned subagents in the latest
round return NO_NEW_VALUABLE_FINDINGS, the required coverage is complete, and
every candidate and risk focus has been verified and accepted, deduplicated, or
dismissed with evidence. Otherwise continue until round 3. At round 3, perform
the final sweep and submit with converged=false if any of these conditions
still fail; do not start a fourth round, wait for an override, or mark the goal
blocked solely becaus [...]
Context:
- Repository: PLACEHOLDER_REPO
@@ -715,11 +715,11 @@ jobs:
The active review goal's progress tracking MUST include, and must
stay current throughout the review:
1. Read the review prompt, code-review skill, required AGENTS.md
files, existing review threads, user focus, changed-file list, and the shared
subagent review ledger.
2. Perform a main-agent initial risk scan before spawning review
subagents. You must thoroughly read all the changes in this PR and understand
all the involved mechanisms, pointing out: if there is a problem with this PR,
where is the problem most likely to be? Or are there any points you suspect to
be risky? write the result under `## Main Initial Risk Scan` in the shared
ledger.
- 3. Based on the requirements of the code-review skill regarding the
coverage points of the review spawn 1-3 subagents (depends on the complexity of
the PR), each focusing on certain aspects. The sum of their focus points must
fully cover the entire content of PR, as well as the points required in skill
and any additional review points you consider necessary. They must complete the
review of their own aspects and identify all possible issues before they can
finish their work. Yo [...]
+ 3. Based on the requirements of the code-review skill regarding the
coverage points of the review spawn 1-3 subagents (depends on the complexity of
the PR), each focusing on certain aspects. The sum of their focus points must
fully cover the entire content of PR, as well as the points required in skill
and any additional review points you consider necessary. Each subagent must
investigate its assigned scope within the bounded review process, report every
substantiated finding, [...]
4. If the main initial risk scan identifies one or more suspicious
mechanisms, spawn additional risk-focused subagent(s), separate from the normal
complete-review subagents, to specially investigate those specific mechanisms
and their upstream/downstream interactions.
- 5. Read the shared ledger after every subagent result, then
independently verify, deduplicate, accept, or dismiss every candidate in the
main merged section. The status of each candidate must be clarified after this
stage.
- 6. If all subagents return `NO_NEW_VALUABLE_FINDINGS`, or if this
loop has already executed 3 rounds, conduct the necessary review to ensure that
all current candidate points have been verified, deduplicated, accepted, or
rejected, and then submit the final GitHub comment. Otherwise, continue with
the next round of the same review process from the beginning.
- 7. Submit the final GitHub review and verify that all accepted
comments landed before marking the goal complete. You must address all
checkpoints required by the skill, user concerns (if any), and indicate the
status of the review completion.
+ 5. Read the shared ledger after every subagent result, independently
verify and deduplicate candidates, and classify each as accepted, already
covered, dismissed with evidence, or unresolved with a concrete remaining
question. Keep unresolved candidates out of the inline findings until verified.
+ 6. After each round, evaluate the convergence conditions above. If
they hold, or if 3 rounds have executed, perform the final sweep and deliver
the review using the trusted submission helper. At the round limit, publish all
verified findings even if the review did not converge, and explicitly describe
unresolved candidates or coverage limits. Otherwise continue the next round
without resetting the shared ledger or round count.
+ 7. Submit the GitHub review and verify that all accepted findings
landed before marking the bounded delivery goal complete. Address the required
skill checkpoints and user concerns, and report convergence separately. A
verified non-converged delivery completes this execution while leaving the
review check blocked.
Subagent Constraints:
- Every subagent appends only to its assigned ledger section, while
reading the whole ledger to avoid duplicate candidates.
@@ -729,13 +729,13 @@ jobs:
- Before returning, a further thought must be made—what problem, if
any, might I have missed just now, and where? Then a thorough recheck should be
conducted to confirm whether this potential issue is real and whether it needs
to be reported.
Main-agent Constraints:
- - The active review goal MUST remain incomplete until every
suspicious point found during review has a clear conclusion: submitted as an
inline issue, dismissed as already covered by existing review context, or
dismissed with concrete code evidence explaining why it is not a bug.
+ - Before delivery, every suspicious point must have an explicit
disposition in the ledger: a verified finding, already covered by an existing
review, dismissed with concrete evidence, or unresolved. At round 3, disclose
unresolved points as remaining questions in the summary and incomplete_reason;
they prevent convergence but MUST NOT prevent delivery of verified findings.
- The main initial risk scan MUST be written under `## Main Initial
Risk Scan` in the shared ledger before any subagent is spawned. For every risk
focus item, include: ID, changed files/lines involved, related mechanisms to
inspect, why it is suspicious, required upstream/downstream files or paths, and
the exact question the risk-focused subagent must answer.
- The shared subagent review ledger is
PLACEHOLDER_CONTEXT_DIR/subagent_review_findings.md. Before spawning any
subagent, the main agent MUST read this ledger. Every subagent prompt MUST
include this ledger path and the exact ledger section assigned to that subagent.
- The main agent must read the shared ledger after each subagent
result, merge duplicate candidates into the main merged section, update
candidate statuses, and keep a proposed final comment set in the main-owned
ledger sections. It must also update the status of every main risk focus item.
- - Before submitting the final review, do one explicit final sweep
over the changed-file list and your unresolved candidate list. Only finish when
there are no unresolved suspicious points and all possible substantiated bugs
have been pointed out in the GitHub review. At this stage, you can conduct
final research on any part you still have doubts about or which may not have
been fully investigated, ensuring that all potential issues have been
thoroughly investigated and reported.
+ - Before submitting, do one final sweep over the changed-file list,
candidate dispositions, and risk focus items. Confirm the evidence and anchors
for every accepted finding and record any remaining coverage gaps. This sweep
must respect the three-round limit. Remaining uncertainty at the cap sets
converged=false and must be disclosed; do not claim it has been resolved or
withhold the verified review.
- Any agent MUST NOT stop after finding the first blocking issue. Keep
reviewing changed files, related control flow, tests, and parallel/special-case
paths until all plausible correctness, lifecycle, configuration, compatibility,
performance, and coverage bugs have been investigated and every bug you can
substantiate has been reported.
+ Any agent MUST NOT stop after finding the first blocking issue.
Within the three-round limit, keep reviewing changed files, related control
flow, tests, and parallel/special-case paths. Publish every verified bug and
explicitly disclose remaining uncertainty at the limit.
Before inspecting the PR diff or related code, you MUST read the
contents of every AGENTS.md file listed below. These paths are computed from
the PR changed file ancestors in this checkout. Searching for or listing paths
is not sufficient; read each listed file directly.
Required AGENTS.md files for this PR:
@@ -747,15 +747,15 @@ jobs:
In addition, you can perform any desired review operations to
observe suspicious code and details in order to identify issues as much as
possible.
## Final response format
- - After completing the review, you MUST provide a final summary
opinion based on the rules defined in AGENTS.md and the code-review skill. The
summary must include conclusions for each applicable critical checkpoint.
- - After the required convergence rounds and final sweep, declare the
COMPLETE final review in a JSON file with exactly these fields:
- {"body":"<comprehensive
summary>","comments":[{"path":"<file>","position":<diff_position>,"body":"[P1]
<finding title and explanation>"}],"existing_blocking_comment_ids":[]}
+ - On delivery, provide a final summary opinion addressing every
applicable critical checkpoint, user concerns, and any remaining coverage
limits. State whether the review converged.
+ - After convergence or the third round and final sweep, declare the
full set of verified findings for this invocation in a JSON file with exactly
these fields:
+ {"body":"<comprehensive
summary>","comments":[{"path":"<file>","position":<diff_position>,"body":"[P1]
<finding title and
explanation>"}],"existing_blocking_comment_ids":[],"converged":true,"rounds":1,"incomplete_reason":""}
- Every new finding must be inline and start with [P0], [P1], [P2]
or [P3]. Suggested changes are welcome for precise, self-contained fixes.
- Include ALL accepted new findings in comments. Use an empty array
when there are none. In existing_blocking_comment_ids, list the numeric IDs of
every already-reported P0/P1 inline finding you independently confirmed still
applies to this head. Do not duplicate those findings. Explain their
disposition in the summary; use an empty array only if none still apply.
- - Submit only after all review completion criteria are satisfied,
using the trusted helper: python3 "$RUNNER_TEMP/submit_review.py" --context-dir
PLACEHOLDER_CONTEXT_DIR --input-file <json_file>
- - The helper pins the head, adds this invocation's completion
marker, submits the review and verifies the entire summary and inline comment
set. P0/P1 results request changes; other completed reviews are comments. This
is the ONLY final GitHub submission path. Do not submit reviews or inline
comments directly with gh.
+ - Set rounds to the actual total (1 through 3), never reset it on
resume. Set converged=true only when all convergence conditions hold and use an
empty incomplete_reason. At the cap without convergence, set converged=false,
rounds=3, and a nonempty incomplete_reason explaining which conditions failed,
remaining questions, and uncovered scope. Deliver in either case using the
trusted helper: python3 "$RUNNER_TEMP/submit_review.py" --context-dir
PLACEHOLDER_CONTEXT_DIR --input-fi [...]
+ - The helper pins the head, adds this invocation's delivery marker
and convergence statement, and verifies the summary and inline set. P0/P1
results request changes; other deliveries are comments. A non-converged
delivery blocks the review check even with zero P0/P1. This is the ONLY GitHub
submission path; do not submit reviews or inline comments directly with gh.
- If the helper reports an error, inspect it and verify the outcome
before proceeding. Do not change or delete its review-run.json or
review-submission.json files. A retry with the identical JSON reads back a
submitted review instead of posting it again.
- - Mark the goal complete only after the helper reports the verified
review ID. A completed review with P0/P1 is still a completed execution; the
workflow applies the separate blocking verdict.
+ - Mark the bounded delivery goal complete only after the helper
reports the verified review ID, including a non-converged delivery after round
3. The workflow applies the separate blocking verdict. A changed PR head, an
unverified submission, or a helper error still prevents successful delivery.
PROMPT
sed -i "s|PLACEHOLDER_REPO|${REPO}|g"
"$REVIEW_CONTEXT_DIR/review_prompt.txt"
@@ -970,7 +970,7 @@ jobs:
# Execution succeeded even when verified findings block the PR check.
# This result is written only after the runner's own API readback.
jq -er 'select(.state == "success" or .state == "failure") |
- "review_state=\(.state)\np0=\(.p0)\np1=\(.p1)"' \
+
"review_state=\(.state)\np0=\(.p0)\np1=\(.p1)\nconverged=\(.converged)\nrounds=\(.rounds)"'
\
"$REVIEW_CONTEXT_DIR/review-result.json" >> "$GITHUB_OUTPUT"
- name: Record invalid Codex auth
@@ -1111,6 +1111,8 @@ jobs:
REVIEW_STATE: ${{ steps.review.outputs.review_state }}
REVIEW_P0: ${{ steps.review.outputs.p0 }}
REVIEW_P1: ${{ steps.review.outputs.p1 }}
+ REVIEW_CONVERGED: ${{ steps.review.outputs.converged }}
+ REVIEW_ROUNDS: ${{ steps.review.outputs.rounds }}
run: |
state="pending"
summary="Automated review did not pass for PR #${PR_NUMBER} at
${BASE_SHA:0:12}."
@@ -1122,6 +1124,9 @@ jobs:
success|failure)
state="$REVIEW_STATE"
summary="Review completed for PR #${PR_NUMBER}:
P0=${REVIEW_P0}, P1=${REVIEW_P1}."
+ if [ "$REVIEW_CONVERGED" = "false" ]; then
+ summary="Review did not converge after ${REVIEW_ROUNDS}
rounds for PR #${PR_NUMBER}: P0=${REVIEW_P0}, P1=${REVIEW_P1}."
+ fi
;;
*) echo "::error::Missing verified review verdict"; exit 1 ;;
esac
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]