Files
doris/.github/scripts/run_review_with_resume.py
shuke 173a0e2064 [improvement](ci) Upgrade review runtime to Codex 0.166.0 (#69990)
### What problem does this PR solve?

Related PR: https://github.com/shuke987/codex/pull/6

Upgrade the Code Review Runner and its runtime canary from Codex 0.162.1
to the validated [0.166.0 fork
release](https://github.com/shuke987/codex/releases/tag/exec-goal-v0.166.0),
retaining goal completion and same-session resume support.

Codex 0.166.0 distinguishes `policy_capacity_unavailable` from ordinary
overloads. Accept both canonical terminal capacity messages so the
existing bounded recovery policy continues to apply. Preserve the
original diagnostic and exact submission verification; unrelated
failures remain non-retryable.

Update the archive, version and CLI checksum pins together. The workflow
changes are limited to seven package pin replacements. Existing model
selection and delivery checks are preserved.
2026-11-03 17:45:20 +08:00

680 lines
28 KiB
Python

#!/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.
"""Resume the same review after capacity failures, never restart the workflow."""
import argparse
import ctypes
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from review_github_api import decode_json, run_gh
from submit_review import RUN_FILE, RESULT_FILE, SUBMISSION_FILE, verify_completion, write_json
# Six same-session capacity retries, each after a fixed five-minute wait.
RETRY_DELAYS = (300,) * 6
CAPACITY_FALLBACK_AFTER = 5
CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model."
# Codex 0.166 distinguishes policy capacity from ordinary overloads. Both
# retain the same bounded recovery; other policy/safety failures must not retry.
CAPACITY_MESSAGES = (
CAPACITY_MESSAGE,
"Policy-restricted capacity is unavailable. Please try again later.",
)
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:
command = ["gh", "api", f"repos/{self.args.repository}/pulls/{self.args.pr_number}"]
response = run_gh(
command,
check=True, capture_output=True, text=True, timeout=timeout,
)
check_head(decode_json(response, command), 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:
"""Own orphaned commands in the standalone Linux helper, not the whole runner."""
def __init__(self):
if sys.platform != "linux":
raise OSError("Review process supervision requires Linux")
# Fail before starting Codex if the runner cannot provide safe cleanup.
for module, name in (
(os, "pidfd_open"),
(os, "P_PIDFD"),
(signal, "pidfd_send_signal"),
):
if not hasattr(module, name):
raise OSError(f"Review process supervision requires {name}")
fd = os.pidfd_open(os.getpid())
try:
signal.pidfd_send_signal(fd, 0)
try:
os.waitid(os.P_PIDFD, fd, os.WEXITED | os.WNOHANG | os.WNOWAIT)
except ChildProcessError:
pass # Expected: this process is not its own child.
finally:
os.close(fd)
self.children = Path(f"/proc/self/task/{os.getpid()}/children")
self.children.read_text()
prctl = ctypes.CDLL(None, use_errno=True).prctl
prctl.argtypes = [ctypes.c_int] + [ctypes.c_ulong] * 4
prctl.restype = ctypes.c_int
# PR_SET_CHILD_SUBREAPER: descendants that outlive Codex are reparented
# to this helper, even if shell/PTY commands created new sessions.
if prctl(36, 1, 0, 0, 0) != 0:
error = ctypes.get_errno()
raise OSError(error, os.strerror(error))
def reap(self):
# Called only after Popen.wait() reaps Codex. This dedicated helper has
# 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:
raise OSError("Codex descendant cleanup did not finish; not resuming")
for child in children:
try:
fd = os.pidfd_open(int(child))
except ProcessLookupError:
continue
try:
# Kernel-verified parenthood plus a stable pidfd prevents
# signalling an unrelated process if a PID was recycled.
os.waitid(os.P_PIDFD, fd, os.WEXITED | os.WNOHANG | os.WNOWAIT)
signal.pidfd_send_signal(fd, signal.SIGKILL)
os.waitid(os.P_PIDFD, fd, os.WEXITED | os.WNOHANG)
except (ChildProcessError, ProcessLookupError):
pass
finally:
os.close(fd)
# Killing one orphan can reparent its children to us on the next pass.
time.sleep(0.01)
def read_events(path):
with path.open() as handle:
events = [json.loads(line) for line in handle if line.strip()]
if any(not isinstance(event, dict) for event in events):
raise ValueError("Codex JSONL contains a non-object event")
return events
def attempt_result(events, status, stderr_path):
"""Use the latest turn in this attempt, never a previous attempt's failure."""
terminal = next(
(
event
for event in reversed(events)
if event.get("type")
in ("turn.started", "turn.completed", "turn.failed", "error")
),
{},
)
event_type = terminal.get("type", "missing")
if event_type == "turn.completed":
message = (
None
if status == 0
else f"Codex exited with status {status} after turn.completed"
)
return event_type, message
if event_type in ("turn.failed", "error"):
error = terminal.get("error") or terminal
message = (
error.get("message")
or f"Codex reported {event_type} (exit status {status})"
)
return event_type, message
# A zero exit after only thread.started (or an unfinished new turn) does
# not prove recovery. Do not reuse an older capacity event to retry it.
if status == 0:
return (
event_type,
"Codex exited with status 0 without a terminal turn event; review is incomplete",
)
lines = stderr_path.read_text(errors="replace").splitlines()
return event_type, next(
(line for line in reversed(lines) if line.strip()),
f"Codex exited with status {status}",
)
def can_fallback_model(events, message, model):
"""Only a rejected first model request may start a new review session."""
return (
message is not None
and f"The '{model}' {UNSUPPORTED_CHATGPT_MODEL}" in message
and not any(
event.get("type") == "turn.completed"
or event.get("type", "").startswith("item.")
for event in events
)
)
def session_id(events):
ids = [
event.get("thread_id")
for event in events
if event.get("type") == "thread.started"
]
if len(ids) != 1 or not isinstance(ids[0], str):
raise ValueError("Expected exactly one main thread.started event")
# Names and --last can fall back to a new thread in codex exec. A UUID uses
# thread/resume directly and fails if the persisted thread cannot be loaded.
if str(uuid.UUID(ids[0])) != ids[0]:
raise ValueError("Main session ID is not a canonical UUID")
return ids[0]
def require_rollout(codex_home, thread_id, cwd):
for path in (codex_home / "sessions").rglob(f"*{thread_id}.jsonl"):
with path.open() as handle:
meta = json.loads(handle.readline())
payload = meta.get("payload") or {}
if (
meta.get("type") == "session_meta"
and payload.get("id") == thread_id
and payload.get("cwd")
and Path(payload["cwd"]).resolve() == cwd.resolve()
):
return
raise ValueError("Main session rollout is missing; refusing to start a new review")
def stop_process(process):
try:
# Codex handles SIGINT through its graceful turn-interrupt/shutdown path.
process.send_signal(signal.SIGINT)
process.wait(timeout=PROCESS_EXIT_GRACE_SECONDS)
except subprocess.TimeoutExpired:
pass
finally:
process.kill()
process.wait()
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
try:
process = subprocess.Popen(
command,
stdout=stdout,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
start_new_session=True,
)
def copy_stderr():
for line in process.stderr:
stderr.write(line)
stderr.flush()
print(line, end="", file=sys.stderr, flush=True)
copier = threading.Thread(target=copy_stderr, daemon=True)
# Do not interrupt Thread.start() between the native thread being
# created and its ident being published. The copier inherits this
# mask; pending cancellation reaches the main thread on restoration,
# inside the cleanup-protected region. Codex was spawned unmasked.
previous_mask = signal.pthread_sigmask(
signal.SIG_BLOCK, {signal.SIGINT, signal.SIGTERM}
)
try:
copier.start()
finally:
signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask)
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.
# The stderr copier inherited a blocked mask at startup, so pending
# SIGINT/SIGTERM can only reach this thread after everything is closed.
previous_mask = signal.pthread_sigmask(
signal.SIG_BLOCK, {signal.SIGINT, signal.SIGTERM}
)
try:
try:
try:
if process is not None:
stop_process(process)
finally:
# Popen itself may be interrupted before returning a handle.
if reaper is not None:
reaper.reap()
finally:
if copier is not None and copier.ident is not None:
copier.join(timeout=5)
if copier.is_alive():
raise OSError(
"Codex stderr remained open after descendant cleanup"
)
if process is not None:
process.stderr.close()
finally:
signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask)
def append_events(source, target):
# Interrupted writes can leave a partial JSON line. Retain raw bytes in the
# attempt file, but keep the aggregate parseable by jq and the trace uploader.
with source.open() as src, target.open("a") as dst:
for line in src:
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(event, dict):
dst.write(json.dumps(event) + "\n")
def check_resume_target(args, started_at, remaining):
def api(path, paginated=False):
command = ["gh", "api", path]
if paginated:
command += ["--paginate", "--slurp"]
result = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
timeout=min(30, 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["base"]["sha"] != args.base_sha
):
raise ValueError(
"PR base/head or open state changed; refusing to resume stale context"
)
pages = api(
f"repos/{args.repository}/pulls/{args.pr_number}/reviews", paginated=True
)
for page in pages:
for review in page:
if (
review.get("user", {}).get("login") == "github-actions[bot]"
and review.get("commit_id") == args.head_sha
and (review.get("submitted_at") or "") >= started_at
):
# A capacity error can arrive after the final GitHub write.
# Keep the failure visible for manual verification instead of
# replaying a possibly already-completed external side effect.
raise ValueError(
"A bot review was already submitted during this run; "
"refusing automatic resume to avoid duplicate submission"
)
def resume_prompt(goal_prompt):
return (
goal_prompt
+ "\n\n"
+ (
"Recovery after a temporary model-capacity error in this SAME review session. "
"Continue unfinished work using the existing conversation and shared subagent review ledger; "
"do not restart the review, reset the ledger, or reset the three-round limit. "
"The previous process exited: do not assume child agents or shell processes are still running. "
"Recover unfinished child-agent work using their recorded IDs and ledger sections where possible. "
"Keep completed findings and replace only work that could not be recovered. "
"Before any GitHub write, fetch the current PR reviews and inline comments again; "
"the initial snapshots may be stale. Verify already-submitted work and never submit it twice. "
"Mark the goal complete only after the original review completion criteria are satisfied."
)
)
def run_review(args, reaper=None):
context = args.context_dir
aggregate = context / "codex-events.jsonl"
stderr_log = context / "codex-stderr.log"
final_message = context / "codex-final-message.txt"
attempts = context / "codex-attempts"
attempts.mkdir()
aggregate.touch()
stderr_log.touch()
goal_prompt = (context / "codex_goal_prompt.txt").read_text()
deadline = time.monotonic() + args.budget_seconds
started_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
run = {
"repository": args.repository, "pr_number": args.pr_number,
"head_sha": args.head_sha, "base_sha": args.base_sha,
"started_at": started_at, "token": str(uuid.uuid4()), "deadline": deadline,
}
write_json(context / RUN_FILE, run)
thread_id = None
model = args.model
fallback_model = getattr(args, "fallback_model", None)
capacity_fallback_model = getattr(args, "capacity_fallback_model", None)
capacity_retry = 0
attempt_number = 0
def remaining():
seconds = deadline - time.monotonic()
if seconds <= 0:
raise TimeoutError("Review's shared time budget was exhausted")
return seconds
def fail(message):
print(message, file=sys.stderr, flush=True)
with aggregate.open("a") as handle:
handle.write(
json.dumps(
{
"type": "turn.failed",
"source": "review-resume-runner",
"error": {"message": message},
}
)
+ "\n"
)
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"
final_message.write_text("")
command = [
"codex",
"exec",
"--goal",
"--cd",
str(args.cwd),
"--model",
model,
"--config",
f"model_reasoning_effort={args.effort}",
"--sandbox",
"danger-full-access",
"--color",
"never",
"--json",
"--output-last-message",
str(output_path),
]
if thread_id:
command += ["resume", thread_id, resume_prompt(goal_prompt)]
else:
command += [goal_prompt]
print(
f"Starting Codex review attempt {attempt_number} "
f"(model={model}, session={thread_id or 'new'}, remaining={remaining():.0f}s)",
file=sys.stderr,
flush=True,
)
try:
status = run_attempt(
command, events_path, stderr_path, remaining(), reaper=reaper, watchdog=watchdog
)
finally:
# Preserve failed attempts and their child-thread IDs for Litefuse.
if events_path.exists():
append_events(events_path, aggregate)
if stderr_path.exists():
with stderr_path.open("rb") as src, stderr_log.open("ab") as dst:
shutil.copyfileobj(src, dst)
dst.write(b"\n")
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(
f"Codex was interrupted or timed out (status {status}); not resuming"
)
terminal, message = attempt_result(events, status, stderr_path)
print(
f"Finished Codex review attempt {attempt_number} "
f"(exit_status={status}, terminal_event={terminal}, events={len(events)})",
file=sys.stderr,
flush=True,
)
if (
fallback_model
and fallback_model != model
and thread_id is None
and can_fallback_model(events, message, model)
):
check_resume_target(args, started_at, remaining)
print(
f"Model {model} is unavailable for this ChatGPT account; "
f"retrying with {fallback_model} before review work began",
file=sys.stderr,
flush=True,
)
model = fallback_model
continue
if message is not None and message not in CAPACITY_MESSAGES:
return fail(message)
current_id = session_id(events)
if thread_id and current_id != thread_id:
return fail(
"Codex resumed a different session; refusing further attempts"
)
thread_id = current_id
if message is None or (context / SUBMISSION_FILE).exists():
# 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.
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 in CAPACITY_MESSAGES
write_json(context / RESULT_FILE, result)
with aggregate.open("a") as handle:
handle.write(json.dumps({"type": "review.completed", **result}) + "\n")
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,
)
return 0
if capacity_retry == len(RETRY_DELAYS):
return fail(message)
require_rollout(Path(os.environ["CODEX_HOME"]), thread_id, args.cwd)
# Check parser support without authenticating or starting a model request.
subprocess.run(
["codex", "exec", "--goal", "resume", "--help"],
check=True,
capture_output=True,
timeout=min(10, remaining()),
)
delay = RETRY_DELAYS[capacity_retry]
if remaining() <= delay:
return fail("Insufficient shared review budget for capacity backoff")
print(
f"Capacity unavailable; waiting {delay}s before resuming {thread_id}",
file=sys.stderr,
flush=True,
)
with aggregate.open("a") as handle:
handle.write(
json.dumps(
{
"type": "review.capacity_retry",
"thread_id": thread_id,
"next_attempt": attempt_number + 1,
"delay_seconds": delay,
}
)
+ "\n"
)
watchdog.wait(delay)
check_resume_target(args, started_at, remaining)
capacity_retry += 1
if capacity_retry == CAPACITY_FALLBACK_AFTER and capacity_fallback_model:
print(
f"Switching capacity recovery from {model} to {capacity_fallback_model} "
f"after {CAPACITY_FALLBACK_AFTER} capacity failures; keeping session {thread_id}",
file=sys.stderr, flush=True,
)
model = capacity_fallback_model
goal_prompt += (
f"\n\nCapacity recovery switched to {model}. Use this model for remaining "
"review work, including child agents. If an unfinished child cannot resume "
"with this model, replace only its unfinished work using its recorded ledger "
"section; preserve completed findings and the cumulative round count."
)
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
except subprocess.TimeoutExpired:
return fail(
"Review recovery stopped: shared time budget or helper timeout exhausted"
)
except (ValueError, OSError, subprocess.SubprocessError) as exc:
return fail(f"Review recovery stopped: {exc}")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--context-dir", type=Path, required=True)
parser.add_argument("--cwd", type=Path, required=True)
parser.add_argument("--repository", required=True)
parser.add_argument("--pr-number", required=True)
parser.add_argument("--head-sha", required=True)
parser.add_argument("--base-sha", required=True)
parser.add_argument("--model", required=True)
parser.add_argument("--fallback-model")
parser.add_argument("--capacity-fallback-model", help="Resume with this model after five capacity failures")
parser.add_argument("--effort", required=True)
# The workflow owns the total timeout and deducts setup/finalization time.
parser.add_argument("--budget-seconds", type=int, required=True)
args = parser.parse_args()
def cancelled(_signum, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, cancelled)
return run_review(args, reaper=ChildReaper())
if __name__ == "__main__":
sys.exit(main())