mirror of
https://github.com/apache/doris.git
synced 2026-11-04 07:31:17 +00:00
### 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.
1836 lines
76 KiB
Python
1836 lines
76 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.
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import signal
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
import time
|
|
import unittest
|
|
from contextlib import redirect_stderr
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest import mock
|
|
|
|
import emit_litefuse_otel_io as exporter
|
|
import run_review_with_resume as runner
|
|
|
|
THREAD = "0199a213-81c0-7800-8aa1-bbab2a035a53"
|
|
CHILD = "0199a213-81c0-7800-8aa1-bbab2a035a54"
|
|
OTHER = "0199a213-81c0-7800-8aa1-bbab2a035a55"
|
|
|
|
|
|
def thread_event(thread_id=THREAD):
|
|
return {"type": "thread.started", "thread_id": thread_id}
|
|
|
|
|
|
def failed(message=runner.CAPACITY_MESSAGE):
|
|
return {"type": "turn.failed", "error": {"message": message}}
|
|
|
|
|
|
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()
|
|
self.addCleanup(self.temp.cleanup)
|
|
root = Path(self.temp.name)
|
|
self.context = root / "context"
|
|
self.context.mkdir()
|
|
self.cwd = root / "checkout"
|
|
self.cwd.mkdir()
|
|
self.codex_home = root / "codex-home"
|
|
self.sessions = self.codex_home / "sessions" / "2026" / "09" / "07"
|
|
self.sessions.mkdir(parents=True)
|
|
self.args = SimpleNamespace(
|
|
context_dir=self.context,
|
|
cwd=self.cwd,
|
|
repository="apache/doris",
|
|
pr_number="123",
|
|
head_sha="a" * 40,
|
|
base_sha="b" * 40,
|
|
model="gpt-5.6-sol",
|
|
fallback_model=None,
|
|
capacity_fallback_model="gpt-6-sol",
|
|
effort="xhigh",
|
|
budget_seconds=1000,
|
|
)
|
|
(self.context / "codex_goal_prompt.txt").write_text(
|
|
"Read review_prompt.txt; complete the review."
|
|
)
|
|
self.ledger = self.context / "subagent_review_findings.md"
|
|
self.ledger.write_text("Completed risk scan and round 1; keep these findings.")
|
|
self.write_rollout()
|
|
self.clock = 0
|
|
self.commands = []
|
|
self.timeouts = []
|
|
self.sleeps = []
|
|
self.enterContext(
|
|
mock.patch.dict(os.environ, {"CODEX_HOME": str(self.codex_home)})
|
|
)
|
|
self.enterContext(redirect_stderr(io.StringIO()))
|
|
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.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={
|
|
"state": "success", "p0": 0, "p1": 0, "review_id": 123,
|
|
"converged": True, "rounds": 1, "incomplete_reason": "",
|
|
}
|
|
))
|
|
|
|
def write_rollout(self, *, thread_id=THREAD, cwd=None):
|
|
(self.sessions / f"rollout-2026-09-07-{thread_id}.jsonl").write_text(
|
|
json.dumps(
|
|
{
|
|
"type": "session_meta",
|
|
"payload": {"id": thread_id, "cwd": str(cwd or self.cwd)},
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
def advance(self, delay):
|
|
self.sleeps.append(delay)
|
|
self.clock += delay
|
|
|
|
def execute(self, attempts):
|
|
pending = iter(attempts)
|
|
|
|
def fake_attempt(command, events_path, stderr_path, timeout, reaper=None, watchdog=None):
|
|
self.commands.append(command)
|
|
self.timeouts.append(timeout)
|
|
spec = next(pending)
|
|
elapsed = spec.get("elapsed", 0)
|
|
if elapsed > timeout:
|
|
raise subprocess.TimeoutExpired(command, timeout)
|
|
self.clock += elapsed
|
|
events_path.write_text(
|
|
spec.get("raw", "".join(json.dumps(e) + "\n" for e in spec["events"]))
|
|
)
|
|
stderr_path.write_text(spec.get("stderr", ""))
|
|
if "output" in spec:
|
|
Path(command[command.index("--output-last-message") + 1]).write_text(
|
|
spec["output"]
|
|
)
|
|
if "raises" in spec:
|
|
raise spec["raises"]
|
|
return spec.get("status", 1)
|
|
|
|
with mock.patch.object(runner, "run_attempt", side_effect=fake_attempt):
|
|
return runner.run_review(self.args)
|
|
|
|
def events(self):
|
|
return runner.read_events(self.context / "codex-events.jsonl")
|
|
|
|
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}])
|
|
)
|
|
self.help.assert_not_called()
|
|
self.target_check.assert_not_called()
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_completed_turn_without_verified_delivery_fails(self):
|
|
self.verification.side_effect = ValueError("No final review submission was declared")
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), completed()], "status": 0}]))
|
|
self.assertIn("No final review submission", self.last_error())
|
|
self.assertFalse((self.context / "review-result.json").exists())
|
|
|
|
def test_partial_submission_at_capacity_does_not_resume_or_pass(self):
|
|
(self.context / "review-submission.json").write_text("{}")
|
|
self.verification.side_effect = ValueError("Final review inline comments were not completely verified")
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed()]}]))
|
|
self.assertEqual([], self.sleeps)
|
|
self.target_check.assert_not_called()
|
|
self.assertFalse((self.context / "review-result.json").exists())
|
|
|
|
def test_only_capacity_can_recover_after_submission(self):
|
|
(self.context / "review-submission.json").write_text("{}")
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed("Other failure")]}]))
|
|
self.verification.assert_not_called()
|
|
|
|
def test_cancellation_after_submission_is_still_failure(self):
|
|
(self.context / "review-submission.json").write_text("{}")
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed()], "status": 143}]))
|
|
self.verification.assert_not_called()
|
|
|
|
def test_unsupported_model_falls_back_before_review_work(self):
|
|
self.args.model = "gpt-6-sol"
|
|
self.args.fallback_model = "gpt-5.6-sol"
|
|
rejection = (
|
|
"The 'gpt-6-sol' model is not supported when using Codex "
|
|
"with a ChatGPT account."
|
|
)
|
|
server_error = json.dumps(
|
|
{
|
|
"type": "error",
|
|
"status": 400,
|
|
"error": {"type": "invalid_request_error", "message": rejection},
|
|
}
|
|
)
|
|
self.assertEqual(
|
|
0,
|
|
self.execute(
|
|
[
|
|
{
|
|
"events": [
|
|
thread_event(),
|
|
{"type": "turn.started"},
|
|
failed(server_error),
|
|
]
|
|
},
|
|
{"events": [thread_event(OTHER), completed()], "status": 0},
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual(
|
|
["gpt-6-sol", "gpt-5.6-sol"],
|
|
[command[command.index("--model") + 1] for command in self.commands],
|
|
)
|
|
self.assertNotIn("resume", self.commands[1])
|
|
self.assertEqual(
|
|
"gpt-5.6-sol\n", (self.context / "codex-review-model.txt").read_text()
|
|
)
|
|
self.assertEqual([], self.sleeps)
|
|
self.target_check.assert_called_once()
|
|
|
|
def test_unsupported_model_does_not_restart_after_item(self):
|
|
self.args.model = "gpt-6-sol"
|
|
self.args.fallback_model = "gpt-5.6-sol"
|
|
rejection = (
|
|
"The 'gpt-6-sol' model is not supported when using Codex "
|
|
"with a ChatGPT account."
|
|
)
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{
|
|
"events": [
|
|
thread_event(),
|
|
{"type": "item.started"},
|
|
failed(rejection),
|
|
]
|
|
}
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual(1, len(self.commands))
|
|
self.target_check.assert_not_called()
|
|
|
|
def test_both_models_rejected_stops_after_one_fallback(self):
|
|
self.args.model = "gpt-6-sol"
|
|
self.args.fallback_model = "gpt-5.6-sol"
|
|
|
|
def rejection(model):
|
|
return (
|
|
f"The '{model}' model is not supported when using Codex "
|
|
"with a ChatGPT account."
|
|
)
|
|
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{"events": [thread_event(), failed(rejection("gpt-6-sol"))]},
|
|
{"events": [thread_event(OTHER), failed(rejection("gpt-5.6-sol"))]},
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual(2, len(self.commands))
|
|
self.assertEqual(rejection("gpt-5.6-sol"), self.last_error())
|
|
|
|
def test_capacity_resume_uses_the_fallback_model(self):
|
|
self.args.model = "gpt-6-sol"
|
|
self.args.fallback_model = "gpt-5.6-sol"
|
|
self.write_rollout(thread_id=OTHER)
|
|
rejection = (
|
|
"The 'gpt-6-sol' model is not supported when using Codex "
|
|
"with a ChatGPT account."
|
|
)
|
|
self.assertEqual(
|
|
0,
|
|
self.execute(
|
|
[
|
|
{"events": [thread_event(), failed(rejection)]},
|
|
{"events": [thread_event(OTHER), failed()]},
|
|
{"events": [thread_event(OTHER), completed()], "status": 0},
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual([300], self.sleeps)
|
|
self.assertEqual(
|
|
["gpt-6-sol", "gpt-5.6-sol", "gpt-5.6-sol"],
|
|
[command[command.index("--model") + 1] for command in self.commands],
|
|
)
|
|
self.assertEqual(["resume", OTHER], self.commands[2][-3:-1])
|
|
self.assertEqual(
|
|
"gpt-5.6-sol\n", (self.context / "codex-review-model.txt").read_text()
|
|
)
|
|
|
|
def test_unsupported_model_during_resume_never_starts_another_review(self):
|
|
self.args.model = "gpt-6-sol"
|
|
self.args.fallback_model = "gpt-5.6-sol"
|
|
rejection = (
|
|
"The 'gpt-6-sol' model is not supported when using Codex "
|
|
"with a ChatGPT account."
|
|
)
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{"events": [thread_event(), failed()]},
|
|
{"events": [thread_event(), failed(rejection)]},
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual(2, len(self.commands))
|
|
self.assertEqual([300], self.sleeps)
|
|
self.assertEqual(rejection, self.last_error())
|
|
|
|
def test_capacity_resumes_exact_session_with_same_settings_and_ledger(self):
|
|
before = self.ledger.read_text()
|
|
self.assertEqual(
|
|
0,
|
|
self.execute(
|
|
[
|
|
{
|
|
"events": [thread_event(), failed()],
|
|
"elapsed": 5,
|
|
"output": "partial",
|
|
},
|
|
{
|
|
"events": [thread_event(), completed()],
|
|
"status": 0,
|
|
"output": "done",
|
|
},
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual([300], self.sleeps)
|
|
self.assertEqual([1000, 695], self.timeouts)
|
|
command = self.commands[1]
|
|
self.assertEqual(["resume", THREAD], command[-3:-1])
|
|
self.assertNotIn("--last", command)
|
|
for option in ("--goal", "--cd", "--model", "--config", "--sandbox", "--json"):
|
|
self.assertIn(option, command)
|
|
self.assertEqual("gpt-5.6-sol", command[command.index("--model") + 1])
|
|
self.assertIn("do not restart the review", command[-1])
|
|
self.assertIn("initial snapshots may be stale", command[-1])
|
|
self.assertIn("do not assume child agents", command[-1])
|
|
self.assertEqual(before, self.ledger.read_text())
|
|
self.assertEqual("done", (self.context / "codex-final-message.txt").read_text())
|
|
self.target_check.assert_called_once()
|
|
|
|
def test_capacity_fallback_resumes_same_session_after_five_failures(self):
|
|
self.args.model = "gpt-6.1-sol"
|
|
self.args.budget_seconds = 3600
|
|
before = self.ledger.read_text()
|
|
self.assertEqual(0, self.execute(
|
|
[{"events": [thread_event(), failed()], "elapsed": 5}] * 5
|
|
+ [{"events": [thread_event(), completed()], "status": 0}]
|
|
))
|
|
self.assertEqual(
|
|
["gpt-6.1-sol"] * 5 + ["gpt-6-sol"],
|
|
[c[c.index("--model") + 1] for c in self.commands],
|
|
)
|
|
self.assertEqual([3600, 3295, 2990, 2685, 2380, 2075], self.timeouts)
|
|
self.assertEqual([300] * 5, self.sleeps)
|
|
for command in self.commands[1:]:
|
|
self.assertEqual(["resume", THREAD], command[-3:-1])
|
|
self.assertIn("model_reasoning_effort=xhigh", command)
|
|
self.assertNotIn("Capacity recovery switched", self.commands[4][-1])
|
|
self.assertIn("Capacity recovery switched to gpt-6-sol", self.commands[5][-1])
|
|
self.assertIn("including child agents", self.commands[5][-1])
|
|
self.assertIn("cumulative round count", self.commands[5][-1])
|
|
self.assertEqual(before, self.ledger.read_text())
|
|
self.assertEqual("gpt-6-sol\n", (self.context / "codex-review-model.txt").read_text())
|
|
self.verification.assert_called_once()
|
|
|
|
def test_policy_capacity_resumes_same_session_with_shared_budget(self):
|
|
before = self.ledger.read_text()
|
|
message = "Policy-restricted capacity is unavailable. Please try again later."
|
|
self.assertEqual(0, self.execute([
|
|
{"events": [thread_event(), failed(message)], "elapsed": 5},
|
|
{"events": [thread_event(), completed()], "status": 0},
|
|
]))
|
|
self.assertEqual([300], self.sleeps)
|
|
self.assertEqual([1000, 695], self.timeouts)
|
|
self.assertEqual(["resume", THREAD], self.commands[1][-3:-1])
|
|
self.assertEqual(before, self.ledger.read_text())
|
|
self.target_check.assert_called_once()
|
|
|
|
def test_mixed_capacity_errors_share_fallback_and_retry_limit(self):
|
|
self.args.budget_seconds = 3600
|
|
policy_message = "Policy-restricted capacity is unavailable. Please try again later."
|
|
messages = [policy_message, runner.CAPACITY_MESSAGE] * 3 + [policy_message]
|
|
self.assertEqual(1, self.execute([
|
|
{"events": [thread_event(), failed(message)]} for message in messages
|
|
]))
|
|
self.assertEqual([300] * 6, self.sleeps)
|
|
self.assertEqual(
|
|
[self.args.model] * 5 + [self.args.capacity_fallback_model] * 2,
|
|
[command[command.index("--model") + 1] for command in self.commands],
|
|
)
|
|
for command in self.commands[1:]:
|
|
self.assertEqual(["resume", THREAD], command[-3:-1])
|
|
self.assertEqual(policy_message, self.last_error())
|
|
|
|
def test_policy_capacity_error_event_with_zero_exit_resumes(self):
|
|
message = "Policy-restricted capacity is unavailable. Please try again later."
|
|
self.assertEqual(0, self.execute([
|
|
{"events": [thread_event(), {"type": "error", "message": message}], "status": 0},
|
|
{"events": [thread_event(), completed()], "status": 0},
|
|
]))
|
|
self.assertEqual([300], self.sleeps)
|
|
|
|
def test_policy_capacity_after_submission_requires_verified_delivery(self):
|
|
(self.context / runner.SUBMISSION_FILE).write_text("{}")
|
|
message = "Policy-restricted capacity is unavailable. Please try again later."
|
|
self.assertEqual(0, self.execute([{"events": [thread_event(), failed(message)]}]))
|
|
self.verification.assert_called_once()
|
|
self.assertTrue(json.loads((self.context / runner.RESULT_FILE).read_text())["recovered_after_capacity"])
|
|
self.assertEqual([], self.sleeps)
|
|
self.target_check.assert_not_called()
|
|
|
|
def test_policy_capacity_with_partial_submission_does_not_resume(self):
|
|
(self.context / runner.SUBMISSION_FILE).write_text("{}")
|
|
self.verification.side_effect = ValueError("Final review inline comments were not completely verified")
|
|
message = "Policy-restricted capacity is unavailable. Please try again later."
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed(message)]}]))
|
|
self.verification.assert_called_once()
|
|
self.assertIn("not completely verified", self.last_error())
|
|
self.assertEqual([], self.sleeps)
|
|
self.assertFalse((self.context / runner.RESULT_FILE).exists())
|
|
|
|
def test_policy_capacity_does_not_override_cancellation(self):
|
|
message = "Policy-restricted capacity is unavailable. Please try again later."
|
|
self.assertEqual(1, self.execute([
|
|
{"events": [thread_event(), failed(message)], "status": 143},
|
|
]))
|
|
self.assertEqual([], self.sleeps)
|
|
self.verification.assert_not_called()
|
|
|
|
def test_policy_capacity_does_not_exceed_shared_budget(self):
|
|
self.args.budget_seconds = 300
|
|
message = "Policy-restricted capacity is unavailable. Please try again later."
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed(message)]}]))
|
|
self.assertIn("Insufficient shared review budget", self.last_error())
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_policy_capacity_text_in_tool_output_does_not_override_safety_failure(self):
|
|
event = {"type": "item.completed", "item": {
|
|
"type": "command_execution",
|
|
"aggregated_output": "Policy-restricted capacity is unavailable. Please try again later.",
|
|
}}
|
|
self.assertEqual(1, self.execute([
|
|
{"events": [thread_event(), event, failed("Request blocked by safety policy")]},
|
|
]))
|
|
self.assertEqual("Request blocked by safety policy", self.last_error())
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_capacity_fallback_is_optional(self):
|
|
self.args.capacity_fallback_model = None
|
|
self.args.budget_seconds = 3600
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed()]}] * 7))
|
|
self.assertEqual(
|
|
[self.args.model] * 7,
|
|
[c[c.index("--model") + 1] for c in self.commands],
|
|
)
|
|
|
|
def test_fifth_attempt_non_capacity_failure_does_not_switch(self):
|
|
self.args.budget_seconds = 3600
|
|
self.assertEqual(1, self.execute(
|
|
[{"events": [thread_event(), failed()]}] * 4
|
|
+ [{"events": [thread_event(), failed("HTTP 422")]}]
|
|
))
|
|
self.assertEqual(5, len(self.commands))
|
|
self.assertEqual("HTTP 422", self.last_error())
|
|
self.assertEqual(self.args.model + "\n", (self.context / "codex-review-model.txt").read_text())
|
|
|
|
def test_capacity_fallback_preserves_budget_and_target_checks(self):
|
|
self.args.budget_seconds = 1500
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed()]}] * 5))
|
|
self.assertEqual(5, len(self.commands))
|
|
self.assertEqual([300] * 4, self.sleeps)
|
|
self.assertIn("Insufficient shared review budget", self.last_error())
|
|
self.assertEqual(self.args.model + "\n", (self.context / "codex-review-model.txt").read_text())
|
|
|
|
def test_target_change_before_capacity_switch_stops_same_review(self):
|
|
self.args.budget_seconds = 3600
|
|
self.target_check.side_effect = [None] * 4 + [runner.ReviewSuperseded("c" * 40)]
|
|
self.assertEqual(75, self.execute([{"events": [thread_event(), failed()]}] * 5))
|
|
self.assertEqual(5, len(self.commands))
|
|
self.assertEqual(self.args.model + "\n", (self.context / "codex-review-model.txt").read_text())
|
|
self.assert_superseded()
|
|
|
|
def test_submission_at_fifth_capacity_is_verified_without_switch(self):
|
|
self.args.budget_seconds = 3600
|
|
read_events = runner.read_events
|
|
def submitted(path):
|
|
if path.name == "5.jsonl":
|
|
(self.context / runner.SUBMISSION_FILE).write_text("{}")
|
|
return read_events(path)
|
|
with mock.patch.object(runner, "read_events", side_effect=submitted):
|
|
self.assertEqual(0, self.execute([{"events": [thread_event(), failed()]}] * 5))
|
|
self.assertEqual(5, len(self.commands))
|
|
self.assertEqual([300] * 4, self.sleeps)
|
|
self.verification.assert_called_once()
|
|
self.assertTrue(json.loads((self.context / runner.RESULT_FILE).read_text())["recovered_after_capacity"])
|
|
|
|
def test_unsupported_capacity_fallback_does_not_restart_or_switch_back(self):
|
|
self.args.budget_seconds = 3600
|
|
rejection = "The 'gpt-6-sol' " + runner.UNSUPPORTED_CHATGPT_MODEL
|
|
self.assertEqual(1, self.execute(
|
|
[{"events": [thread_event(), failed()]}] * 5
|
|
+ [{"events": [thread_event(), failed(rejection)]}]
|
|
))
|
|
self.assertEqual(6, len(self.commands))
|
|
self.assertEqual(["resume", THREAD], self.commands[-1][-3:-1])
|
|
self.assertEqual(rejection, self.last_error())
|
|
|
|
def test_zero_exit_capacity_can_complete_on_the_sixth_resume(self):
|
|
self.args.budget_seconds = 3600
|
|
self.assertEqual(
|
|
0,
|
|
self.execute(
|
|
[{"events": [thread_event(), failed()]}]
|
|
+ [{"events": [thread_event(), failed()], "status": 0}] * 5
|
|
+ [{"events": [thread_event(), completed()], "status": 0}]
|
|
),
|
|
)
|
|
self.assertEqual(7, len(self.commands))
|
|
self.assertEqual([300] * 6, self.sleeps)
|
|
self.assertEqual([3600, 3300, 3000, 2700, 2400, 2100, 1800], self.timeouts)
|
|
self.assertEqual(6, self.target_check.call_count)
|
|
for command in self.commands[1:]:
|
|
self.assertEqual(["resume", THREAD], command[-3:-1])
|
|
retries = [e for e in self.events() if e["type"] == "review.capacity_retry"]
|
|
self.assertEqual([2, 3, 4, 5, 6, 7], [e["next_attempt"] for e in retries])
|
|
self.assertEqual([300] * 6, [e["delay_seconds"] for e in retries])
|
|
self.assertEqual("completed", exporter.latest_turn_result(self.events())[0])
|
|
|
|
def test_zero_exit_capacity_stops_at_the_retry_limit(self):
|
|
self.args.budget_seconds = 3600
|
|
self.assertEqual(
|
|
1, self.execute([{"events": [thread_event(), failed()], "status": 0}] * 7)
|
|
)
|
|
self.assertEqual([300] * 6, self.sleeps)
|
|
self.assertEqual(7, len(self.commands))
|
|
self.assertEqual(runner.CAPACITY_MESSAGE, self.last_error())
|
|
|
|
def test_zero_exit_error_event_can_identify_capacity(self):
|
|
self.assertEqual(
|
|
0,
|
|
self.execute(
|
|
[
|
|
{
|
|
"events": [
|
|
thread_event(),
|
|
{"type": "error", "message": runner.CAPACITY_MESSAGE},
|
|
],
|
|
"status": 0,
|
|
},
|
|
{"events": [thread_event(), completed()], "status": 0},
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual([300], self.sleeps)
|
|
|
|
def test_zero_exit_auth_usage_and_generic_failures_do_not_retry(self):
|
|
for message in (
|
|
"refresh_token_reused",
|
|
"You've hit your usage limit.",
|
|
"HTTP 500",
|
|
):
|
|
with self.subTest(message=message), tempfile.TemporaryDirectory() as tmp:
|
|
self.args.context_dir = Path(tmp)
|
|
(self.args.context_dir / "codex_goal_prompt.txt").write_text("review")
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[{"events": [thread_event(), failed(message)], "status": 0}]
|
|
),
|
|
)
|
|
events = runner.read_events(
|
|
self.args.context_dir / "codex-events.jsonl"
|
|
)
|
|
self.assertEqual(message, events[-1]["error"]["message"])
|
|
self.assertEqual([], self.sleeps)
|
|
self.target_check.assert_not_called()
|
|
|
|
def test_zero_exit_without_terminal_event_fails_closed(self):
|
|
self.assertEqual(1, self.execute([{"events": [thread_event()], "status": 0}]))
|
|
self.assertIn("without a terminal turn event", self.last_error())
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_empty_resume_does_not_reuse_the_previous_capacity_error(self):
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{"events": [thread_event(), failed()]},
|
|
{"events": [thread_event()], "status": 0},
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual(2, len(self.commands))
|
|
self.assertEqual([300], self.sleeps)
|
|
self.assertIn("without a terminal turn event", self.last_error())
|
|
self.assertNotEqual(runner.CAPACITY_MESSAGE, self.last_error())
|
|
|
|
def test_later_completion_supersedes_earlier_error_in_the_same_attempt(self):
|
|
self.assertEqual(
|
|
0,
|
|
self.execute(
|
|
[{"events": [thread_event(), failed(), completed()], "status": 0}]
|
|
),
|
|
)
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_later_failure_supersedes_earlier_completion(self):
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{
|
|
"events": [thread_event(), completed(), failed("auth failed")],
|
|
"status": 0,
|
|
}
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual("auth failed", self.last_error())
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_unfinished_new_turn_does_not_reuse_an_earlier_completion(self):
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{
|
|
"events": [
|
|
thread_event(),
|
|
completed(),
|
|
{"type": "turn.started"},
|
|
],
|
|
"status": 0,
|
|
}
|
|
]
|
|
),
|
|
)
|
|
self.assertIn("without a terminal turn event", self.last_error())
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_completion_with_nonzero_exit_does_not_retry_old_stderr_capacity(self):
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{
|
|
"events": [thread_event(), completed()],
|
|
"status": 1,
|
|
"stderr": runner.CAPACITY_MESSAGE,
|
|
}
|
|
]
|
|
),
|
|
)
|
|
self.assertIn("status 1 after turn.completed", self.last_error())
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_retry_count_is_bounded(self):
|
|
self.args.budget_seconds = 3600
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed()]}] * 7))
|
|
self.assertEqual([300] * 6, self.sleeps)
|
|
self.assertEqual(7, len(self.commands))
|
|
self.assertEqual(
|
|
[self.args.model] * 5 + ["gpt-6-sol"] * 2,
|
|
[c[c.index("--model") + 1] for c in self.commands],
|
|
)
|
|
self.assertEqual(runner.CAPACITY_MESSAGE, self.last_error())
|
|
|
|
def test_no_retry_for_auth_usage_or_generic_errors_even_before_session_starts(self):
|
|
for message in (
|
|
"You've hit your usage limit. Try again at 9:00 PM.",
|
|
"refresh_token_reused",
|
|
"HTTP 500",
|
|
"permission denied",
|
|
):
|
|
with self.subTest(message=message), tempfile.TemporaryDirectory() as tmp:
|
|
self.args.context_dir = Path(tmp)
|
|
(self.args.context_dir / "codex_goal_prompt.txt").write_text("review")
|
|
self.assertEqual(1, self.execute([{"events": [failed(message)]}]))
|
|
self.assertEqual(
|
|
message,
|
|
runner.read_events(self.args.context_dir / "codex-events.jsonl")[
|
|
-1
|
|
]["error"]["message"],
|
|
)
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_stderr_auth_error_is_preserved(self):
|
|
message = "You've hit your usage limit. Try again at 9:00 PM."
|
|
self.assertEqual(1, self.execute([{"events": [], "stderr": message}]))
|
|
self.assertEqual(message, self.last_error())
|
|
|
|
def test_capacity_text_inside_tool_output_does_not_trigger_retry(self):
|
|
event = {
|
|
"type": "item.completed",
|
|
"item": {
|
|
"type": "command_execution",
|
|
"aggregated_output": runner.CAPACITY_MESSAGE,
|
|
},
|
|
}
|
|
self.assertEqual(
|
|
1,
|
|
self.execute([{"events": [thread_event(), event, failed("auth failed")]}]),
|
|
)
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_missing_session_id_fails_closed(self):
|
|
self.assertEqual(1, self.execute([{"events": [failed()]}]))
|
|
self.assertIn("thread.started", self.last_error())
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_thread_name_is_not_accepted_as_resume_id(self):
|
|
self.assertEqual(
|
|
1, self.execute([{"events": [thread_event("latest-review"), failed()]}])
|
|
)
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_multiple_bootstrap_ids_fail_closed(self):
|
|
self.assertEqual(
|
|
1,
|
|
self.execute([{"events": [thread_event(), thread_event(CHILD), failed()]}]),
|
|
)
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_missing_or_wrong_workspace_rollout_fails_closed(self):
|
|
self.write_rollout(cwd=self.context)
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed()]}]))
|
|
self.assertIn("rollout is missing", self.last_error())
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_resuming_another_session_is_rejected(self):
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{"events": [thread_event(), failed()]},
|
|
{"events": [thread_event(OTHER), completed()], "status": 0},
|
|
]
|
|
),
|
|
)
|
|
self.assertIn("different session", self.last_error())
|
|
|
|
def test_unsupported_goal_resume_fails_without_new_request(self):
|
|
self.help.side_effect = subprocess.CalledProcessError(
|
|
2, ["codex", "exec", "--help"]
|
|
)
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed()]}]))
|
|
self.assertEqual(1, len(self.commands))
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_budget_is_not_reset_between_attempts(self):
|
|
self.args.budget_seconds = 400
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{"events": [thread_event(), failed()], "elapsed": 10},
|
|
{"events": [thread_event(), failed()], "elapsed": 10},
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual([400, 90], self.timeouts)
|
|
self.assertEqual([300], self.sleeps)
|
|
self.assertIn("Insufficient shared review budget", self.last_error())
|
|
|
|
def test_normal_review_can_run_past_the_old_89_minute_limit(self):
|
|
self.args.budget_seconds = 120 * 60 - 120
|
|
self.assertEqual(
|
|
0,
|
|
self.execute(
|
|
[
|
|
{
|
|
"events": [thread_event(), completed()],
|
|
"status": 0,
|
|
"elapsed": 95 * 60,
|
|
}
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual([7080], self.timeouts)
|
|
self.help.assert_not_called()
|
|
|
|
def test_exhausted_setup_budget_does_not_start_codex(self):
|
|
self.args.budget_seconds = -1
|
|
self.assertEqual(1, self.execute([]))
|
|
self.assertEqual([], self.commands)
|
|
self.assertIn("shared time budget was exhausted", self.last_error())
|
|
|
|
def test_timeout_preserves_raw_partial_json_but_aggregate_stays_parseable(self):
|
|
raw = json.dumps(thread_event()) + '\n{"type":"item.'
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{
|
|
"events": [],
|
|
"raw": raw,
|
|
"raises": subprocess.TimeoutExpired(["codex"], 1),
|
|
}
|
|
]
|
|
),
|
|
)
|
|
self.assertIn("timeout exhausted", self.last_error())
|
|
self.assertEqual(raw, (self.context / "codex-attempts/1.jsonl").read_text())
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_non_object_json_is_rejected(self):
|
|
self.assertEqual(1, self.execute([{"events": [], "raw": "[]\n"}]))
|
|
self.assertIn("non-object event", self.last_error())
|
|
|
|
def test_cancellation_while_waiting_never_resumes(self):
|
|
self.sleep.side_effect = KeyboardInterrupt
|
|
self.assertEqual(130, self.execute([{"events": [thread_event(), failed()]}]))
|
|
self.assertEqual(1, len(self.commands))
|
|
self.target_check.assert_not_called()
|
|
|
|
def test_interrupted_process_is_not_retried_even_with_capacity_event(self):
|
|
self.assertEqual(
|
|
1, self.execute([{"events": [thread_event(), failed()], "status": -15}])
|
|
)
|
|
self.assertEqual([], self.sleeps)
|
|
|
|
def test_existing_review_or_stale_pr_stops_resume_and_keeps_failure(self):
|
|
self.target_check.side_effect = ValueError("review already submitted")
|
|
self.assertEqual(1, self.execute([{"events": [thread_event(), failed()]}]))
|
|
self.assertEqual(1, len(self.commands))
|
|
self.assertIn("review already submitted", self.last_error())
|
|
|
|
def test_failed_attempt_output_is_not_reused_as_final_response(self):
|
|
self.assertEqual(
|
|
1,
|
|
self.execute(
|
|
[
|
|
{"events": [thread_event(), failed()], "output": "old partial"},
|
|
{"events": [thread_event(), failed("auth failed")]},
|
|
]
|
|
),
|
|
)
|
|
self.assertEqual("", (self.context / "codex-final-message.txt").read_text())
|
|
self.assertEqual(
|
|
"old partial", (self.context / "codex-attempts/1.final.txt").read_text()
|
|
)
|
|
|
|
def test_litefuse_retains_failed_attempt_items_and_child_ids_after_recovery(self):
|
|
first_item = {
|
|
"type": "item.completed",
|
|
"item": {
|
|
"type": "collab_tool_call",
|
|
"id": "item_0",
|
|
"tool": "spawn_agent",
|
|
"receiver_thread_ids": [CHILD],
|
|
},
|
|
}
|
|
last_item = {
|
|
"type": "item.completed",
|
|
"item": {"type": "agent_message", "id": "item_0", "text": "done"},
|
|
}
|
|
self.assertEqual(
|
|
0,
|
|
self.execute(
|
|
[
|
|
{"events": [thread_event(), first_item, failed()]},
|
|
{"events": [thread_event(), last_item, completed()], "status": 0},
|
|
]
|
|
),
|
|
)
|
|
events = exporter.load_jsonl(str(self.context / "codex-events.jsonl"))
|
|
self.assertEqual({CHILD}, exporter.receiver_thread_ids(events))
|
|
self.assertEqual("completed", exporter.latest_turn_result(events)[0])
|
|
args = SimpleNamespace(
|
|
**vars(self.args),
|
|
reasoning_effort="xhigh",
|
|
run_id="123",
|
|
workflow="review",
|
|
trace_name="review",
|
|
session_id="123",
|
|
environment="test",
|
|
max_json_chars=4000,
|
|
max_context_json_chars=4000,
|
|
)
|
|
_, payload, _ = exporter.build_ingestion_payload(args, "review", "done", events)
|
|
items = [
|
|
event["body"]
|
|
for event in payload["batch"]
|
|
if event["body"].get("metadata", {}).get("item_id") == "item_0"
|
|
]
|
|
self.assertEqual(2, len(items))
|
|
self.assertNotEqual(items[0]["id"], items[1]["id"])
|
|
self.assertNotEqual(
|
|
items[0]["metadata"]["event_line"], items[1]["metadata"]["event_line"]
|
|
)
|
|
|
|
|
|
|
|
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(stderr="", 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(stderr="", stdout=json.dumps({"head": {"sha": "a" * 40}})),
|
|
SimpleNamespace(stderr="", 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(stderr="", 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()
|
|
|
|
|
|
@unittest.skipUnless(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 (
|
|
mock.patch.object(
|
|
sys,
|
|
"argv",
|
|
[
|
|
"runner",
|
|
"--context-dir",
|
|
"/tmp",
|
|
"--cwd",
|
|
"/tmp",
|
|
"--repository",
|
|
"apache/doris",
|
|
"--pr-number",
|
|
"123",
|
|
"--head-sha",
|
|
"a",
|
|
"--base-sha",
|
|
"b",
|
|
"--model",
|
|
"test",
|
|
"--effort",
|
|
"xhigh",
|
|
],
|
|
),
|
|
mock.patch.object(runner, "ChildReaper") as reaper,
|
|
redirect_stderr(io.StringIO()) as stderr,
|
|
self.assertRaises(SystemExit) as error,
|
|
):
|
|
runner.main()
|
|
self.assertEqual(2, error.exception.code)
|
|
self.assertIn("--budget-seconds", stderr.getvalue())
|
|
reaper.assert_not_called()
|
|
|
|
def test_workflow_passes_remaining_budget_after_helper_download(self):
|
|
workflow = (
|
|
Path(__file__).resolve().parents[1] / "workflows" / "code-review-runner.yml"
|
|
).read_text()
|
|
review = workflow.split(" - name: Run automated code review\n", 1)[1]
|
|
self.assertIn(
|
|
"timeout-minutes: ${{ fromJSON(env.REVIEW_TIMEOUT_MINUTES) }}",
|
|
review.split(" run: |", 1)[0],
|
|
)
|
|
default_minutes = next(
|
|
line.split(":", 1)[1].strip()
|
|
for line in workflow.splitlines()
|
|
if line.strip().startswith("REVIEW_TIMEOUT_MINUTES:")
|
|
)
|
|
self.assertEqual("120", default_minutes)
|
|
script = textwrap.dedent(
|
|
review.split(" run: |\n", 1)[1].split(" status=$?", 1)[0]
|
|
)
|
|
# A shell gh stub writes the downloaded helper and advances Bash's elapsed
|
|
# clock without waiting minutes. The helper reports the actual CLI args.
|
|
gh = """python3() {
|
|
if [ "$1" != "$REVIEW_GH_HELPER" ]; then command python3 "$@"; return; fi
|
|
shift
|
|
SECONDS=$((SECONDS + SETUP_SECONDS))
|
|
cat <<'HELPER'
|
|
import json, sys
|
|
print(json.dumps(sys.argv[1:]))
|
|
HELPER
|
|
}
|
|
"""
|
|
for minutes, setup, expected in (
|
|
(default_minutes, 12, 7056),
|
|
("150", 30, 8820),
|
|
("120", 7201, -7322),
|
|
):
|
|
with (
|
|
self.subTest(minutes=minutes, setup=setup),
|
|
tempfile.TemporaryDirectory() as tmp,
|
|
):
|
|
result = subprocess.run(
|
|
["bash", "-e", "-c", gh + script],
|
|
env={
|
|
**os.environ,
|
|
"RUNNER_TEMP": tmp,
|
|
"REVIEW_CONTEXT_DIR": tmp,
|
|
"GITHUB_WORKSPACE": tmp,
|
|
"REPO": "apache/doris",
|
|
"PR_NUMBER": "123",
|
|
"HEAD_SHA": "a",
|
|
"BASE_SHA": "b",
|
|
"HELPER_REF": "pinned",
|
|
"REVIEW_TIMEOUT_MINUTES": minutes,
|
|
"SETUP_SECONDS": str(setup),
|
|
},
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
args = json.loads(result.stdout)
|
|
self.assertEqual("gpt-6-sol", args[args.index("--capacity-fallback-model") + 1])
|
|
actual = int(args[args.index("--budget-seconds") + 1])
|
|
self.assertLessEqual(actual, expected)
|
|
self.assertGreaterEqual(actual, expected - 2)
|
|
|
|
|
|
class ResumeTargetTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.args = SimpleNamespace(
|
|
repository="apache/doris",
|
|
pr_number="123",
|
|
head_sha="a" * 40,
|
|
base_sha="b" * 40,
|
|
)
|
|
self.pr = {
|
|
"state": "open",
|
|
"head": {"sha": self.args.head_sha},
|
|
"base": {"sha": self.args.base_sha},
|
|
}
|
|
self.review = {
|
|
"user": {"login": "github-actions[bot]"},
|
|
"commit_id": self.args.head_sha,
|
|
"submitted_at": "2026-09-07T11:30:00Z",
|
|
}
|
|
|
|
def check(self, pages):
|
|
def api(command, **kwargs):
|
|
data = pages if "--paginate" in command else self.pr
|
|
return SimpleNamespace(stderr="", stdout=json.dumps(data))
|
|
|
|
with mock.patch.object(runner.subprocess, "run", side_effect=api):
|
|
runner.check_resume_target(self.args, "2026-09-07T11:00:00Z", lambda: 500)
|
|
|
|
def test_published_review_on_later_page_prevents_duplicate_resume(self):
|
|
with self.assertRaisesRegex(ValueError, "already submitted"):
|
|
self.check([[], [self.review]])
|
|
|
|
def test_other_heads_humans_and_old_reviews_do_not_block(self):
|
|
old = {**self.review, "submitted_at": "2026-09-07T10:00:00Z"}
|
|
human = {**self.review, "user": {"login": "reviewer"}}
|
|
other = {**self.review, "commit_id": "c" * 40}
|
|
self.check([[old, human, other]])
|
|
|
|
def test_changed_pr_prevents_resume(self):
|
|
self.pr["base"]["sha"] = "c" * 40
|
|
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(
|
|
runner.subprocess,
|
|
"run",
|
|
side_effect=subprocess.CalledProcessError(1, ["gh"]),
|
|
),
|
|
self.assertRaises(subprocess.CalledProcessError),
|
|
):
|
|
runner.check_resume_target(self.args, "2026-09-07T11:00:00Z", lambda: 500)
|
|
|
|
|
|
class ChildReaperTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.reaper = runner.ChildReaper.__new__(runner.ChildReaper)
|
|
self.reaper.children = mock.Mock()
|
|
self.reaper.children.read_text.side_effect = ["10", "20", ""]
|
|
self.open = self.enterContext(mock.patch.object(os, "pidfd_open", create=True))
|
|
self.open.side_effect = [110, 120]
|
|
self.close = self.enterContext(mock.patch.object(os, "close"))
|
|
self.wait = self.enterContext(mock.patch.object(os, "waitid", create=True))
|
|
self.enterContext(mock.patch.object(os, "P_PIDFD", 3, create=True))
|
|
self.send = self.enterContext(
|
|
mock.patch.object(signal, "pidfd_send_signal", create=True)
|
|
)
|
|
self.enterContext(mock.patch.object(runner.time, "sleep"))
|
|
|
|
def test_unsupported_platform_fails_before_starting_codex(self):
|
|
with (
|
|
mock.patch.object(runner.sys, "platform", "darwin"),
|
|
self.assertRaisesRegex(OSError, "requires Linux"),
|
|
):
|
|
runner.ChildReaper()
|
|
self.open.assert_not_called()
|
|
|
|
def test_subreaper_setup_failure_is_not_silenced(self):
|
|
with (
|
|
mock.patch.object(runner.sys, "platform", "linux"),
|
|
mock.patch.object(Path, "read_text", return_value=""),
|
|
mock.patch.object(runner.ctypes, "CDLL") as libc,
|
|
mock.patch.object(runner.ctypes, "get_errno", return_value=1),
|
|
):
|
|
libc.return_value.prctl.return_value = -1
|
|
with self.assertRaises(OSError):
|
|
runner.ChildReaper()
|
|
libc.return_value.prctl.assert_called_once_with(36, 1, 0, 0, 0)
|
|
self.close.assert_called_once_with(110)
|
|
|
|
def test_reaps_newly_adopted_descendants_with_stable_handles(self):
|
|
self.reaper.reap()
|
|
self.assertEqual([mock.call(10), mock.call(20)], self.open.call_args_list)
|
|
self.assertEqual(
|
|
[mock.call(110, signal.SIGKILL), mock.call(120, signal.SIGKILL)],
|
|
self.send.call_args_list,
|
|
)
|
|
self.assertEqual([mock.call(110), mock.call(120)], self.close.call_args_list)
|
|
self.assertEqual(
|
|
mock.call(3, 110, os.WEXITED | os.WNOHANG | os.WNOWAIT),
|
|
self.wait.call_args_list[0],
|
|
)
|
|
|
|
def test_non_child_pid_is_never_signalled(self):
|
|
self.reaper.children.read_text.side_effect = ["10", ""]
|
|
self.wait.side_effect = ChildProcessError()
|
|
self.reaper.reap()
|
|
self.send.assert_not_called()
|
|
self.close.assert_called_once_with(110)
|
|
|
|
def test_already_exited_child_is_safe(self):
|
|
self.reaper.children.read_text.side_effect = ["10", ""]
|
|
self.open.side_effect = ProcessLookupError()
|
|
self.reaper.reap()
|
|
self.send.assert_not_called()
|
|
self.close.assert_not_called()
|
|
|
|
def test_cleanup_deadline_fails_closed(self):
|
|
with (
|
|
mock.patch.object(runner.time, "monotonic", side_effect=[0, 6]),
|
|
self.assertRaisesRegex(OSError, "cleanup did not finish"),
|
|
):
|
|
self.reaper.reap()
|
|
self.send.assert_not_called()
|
|
|
|
|
|
class ProcessLifecycleTest(unittest.TestCase):
|
|
def test_copier_construction_failure_cleans_process(self):
|
|
self.check_startup_cleanup("construct")
|
|
|
|
def test_copier_start_failure_cleans_process(self):
|
|
self.check_startup_cleanup("start")
|
|
|
|
def test_cancellation_before_copier_starts_cleans_process(self):
|
|
self.check_startup_cleanup("cancel_before")
|
|
|
|
def test_cancellation_after_copier_starts_cleans_process(self):
|
|
self.check_startup_cleanup("cancel_after")
|
|
|
|
def check_startup_cleanup(self, stage):
|
|
processes = []
|
|
copiers = []
|
|
original_mask = signal.pthread_sigmask(signal.SIG_BLOCK, set())
|
|
real_popen = subprocess.Popen
|
|
real_thread = runner.threading.Thread
|
|
real_start = real_thread.start
|
|
reaper = mock.Mock()
|
|
|
|
def create_process(*args, **kwargs):
|
|
process = real_popen(*args, **kwargs)
|
|
processes.append(process)
|
|
return process
|
|
|
|
def create_thread(*args, **kwargs):
|
|
if stage == "construct":
|
|
raise RuntimeError("copier construction failed")
|
|
thread = real_thread(*args, **kwargs)
|
|
copiers.append(thread)
|
|
return thread
|
|
|
|
def start_thread(thread):
|
|
if stage == "start":
|
|
raise RuntimeError("copier start failed")
|
|
if stage == "cancel_before":
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
real_start(thread)
|
|
if stage == "cancel_after":
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
|
|
def cancelled(_signum, _frame):
|
|
raise KeyboardInterrupt
|
|
|
|
original_handler = signal.signal(signal.SIGTERM, cancelled)
|
|
try:
|
|
with (
|
|
tempfile.TemporaryDirectory() as tmp,
|
|
redirect_stderr(io.StringIO()),
|
|
mock.patch.object(
|
|
runner.subprocess, "Popen", side_effect=create_process
|
|
),
|
|
mock.patch.object(
|
|
runner.threading, "Thread", side_effect=create_thread
|
|
),
|
|
mock.patch.object(real_thread, "start", start_thread),
|
|
):
|
|
expected = (
|
|
KeyboardInterrupt if stage.startswith("cancel") else RuntimeError
|
|
)
|
|
with self.assertRaises(expected) as error:
|
|
runner.run_attempt(
|
|
[sys.executable, "-c", "import time; time.sleep(20)"],
|
|
Path(tmp) / "events",
|
|
Path(tmp) / "stderr",
|
|
5,
|
|
reaper=reaper,
|
|
)
|
|
if expected is RuntimeError:
|
|
self.assertIn("copier", str(error.exception))
|
|
self.assertIsNotNone(
|
|
processes[0].poll(), "Codex survived startup failure"
|
|
)
|
|
self.assertTrue(processes[0].stderr.closed)
|
|
reaper.reap.assert_called_once_with()
|
|
self.assertTrue(all(not thread.is_alive() for thread in copiers))
|
|
self.assertEqual(
|
|
original_mask, signal.pthread_sigmask(signal.SIG_BLOCK, set())
|
|
)
|
|
finally:
|
|
signal.signal(signal.SIGTERM, original_handler)
|
|
signal.pthread_sigmask(signal.SIG_SETMASK, original_mask)
|
|
for process in processes:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
for thread in copiers:
|
|
if thread.ident is not None:
|
|
thread.join(timeout=5)
|
|
for process in processes:
|
|
process.stderr.close()
|
|
|
|
def test_interrupted_popen_still_calls_reaper(self):
|
|
reaper = mock.Mock()
|
|
with (
|
|
tempfile.TemporaryDirectory() as tmp,
|
|
mock.patch.object(
|
|
runner.subprocess, "Popen", side_effect=KeyboardInterrupt
|
|
),
|
|
self.assertRaises(KeyboardInterrupt),
|
|
):
|
|
runner.run_attempt(
|
|
["codex"], Path(tmp) / "events", Path(tmp) / "stderr", 5, reaper=reaper
|
|
)
|
|
reaper.reap.assert_called_once_with()
|
|
|
|
def test_real_process_capacity_then_resume_preserves_state(self):
|
|
self.assert_real_process_capacity_then_resume(runner.CAPACITY_MESSAGE)
|
|
|
|
def test_real_process_policy_capacity_then_resume_preserves_state(self):
|
|
self.assert_real_process_capacity_then_resume(
|
|
"Policy-restricted capacity is unavailable. Please try again later."
|
|
)
|
|
|
|
def assert_real_process_capacity_then_resume(self, message):
|
|
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")
|
|
fake_codex = root / "codex"
|
|
fake_codex.write_text(
|
|
f"#!{sys.executable}\n"
|
|
+ r"""
|
|
import json, os, sys
|
|
from pathlib import Path
|
|
if "--help" in sys.argv:
|
|
print("exec --goal resume SESSION_ID PROMPT")
|
|
sys.exit(0)
|
|
root = Path(os.environ["FAKE_REVIEW_ROOT"])
|
|
thread_id = os.environ["FAKE_THREAD_ID"]
|
|
print(json.dumps({"type": "thread.started", "thread_id": thread_id}), flush=True)
|
|
if "resume" in sys.argv:
|
|
assert sys.argv[sys.argv.index("resume") + 1] == thread_id
|
|
assert (root / "subagent_review_findings.md").read_text() == "completed round 1"
|
|
assert (root / "request-count").read_text() == "1"
|
|
(root / "request-count").write_text("2")
|
|
Path(sys.argv[sys.argv.index("--output-last-message") + 1]).write_text("review complete")
|
|
print(json.dumps({"type": "turn.completed", "usage": {"input_tokens": 123}}))
|
|
else:
|
|
sessions = Path(os.environ["CODEX_HOME"]) / "sessions"
|
|
sessions.mkdir(parents=True)
|
|
(sessions / f"rollout-{thread_id}.jsonl").write_text(json.dumps({
|
|
"type": "session_meta", "payload": {"id": thread_id, "cwd": str(root)}
|
|
}) + "\n")
|
|
(root / "request-count").write_text("1")
|
|
print(json.dumps({"type": "turn.failed", "error": {
|
|
"message": os.environ["FAKE_CAPACITY_MESSAGE"]
|
|
}}))
|
|
sys.exit(1)
|
|
"""
|
|
)
|
|
fake_codex.chmod(0o700)
|
|
args = SimpleNamespace(
|
|
context_dir=root,
|
|
cwd=root,
|
|
repository="apache/doris",
|
|
pr_number="123",
|
|
head_sha="a" * 40,
|
|
base_sha="b" * 40,
|
|
model="gpt-5.6-sol",
|
|
effort="xhigh",
|
|
budget_seconds=30,
|
|
)
|
|
with (
|
|
mock.patch.dict(
|
|
os.environ,
|
|
{
|
|
"PATH": str(root) + os.pathsep + os.environ["PATH"],
|
|
"CODEX_HOME": str(root / "isolated-home"),
|
|
"FAKE_REVIEW_ROOT": str(root),
|
|
"FAKE_THREAD_ID": THREAD,
|
|
"FAKE_CAPACITY_MESSAGE": message,
|
|
},
|
|
),
|
|
mock.patch.object(runner, "RETRY_DELAYS", (0, 0, 0)),
|
|
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))
|
|
self.assertEqual("2", (root / "request-count").read_text())
|
|
self.assertEqual(
|
|
"review complete", (root / "codex-final-message.txt").read_text()
|
|
)
|
|
self.assertEqual(2, len(list((root / "codex-attempts").glob("*.jsonl"))))
|
|
|
|
def test_actual_process_captures_stdout_and_stderr(self):
|
|
with tempfile.TemporaryDirectory() as tmp, redirect_stderr(io.StringIO()):
|
|
root = Path(tmp)
|
|
status = runner.run_attempt(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import sys; print('output'); print('error', file=sys.stderr)",
|
|
],
|
|
root / "events",
|
|
root / "stderr",
|
|
5,
|
|
)
|
|
self.assertEqual(0, status)
|
|
self.assertEqual("output\n", (root / "events").read_text())
|
|
self.assertEqual("error\n", (root / "stderr").read_text())
|
|
|
|
def test_timeout_terminates_codex_process(self):
|
|
with tempfile.TemporaryDirectory() as tmp, redirect_stderr(io.StringIO()):
|
|
root = Path(tmp)
|
|
program = "import os,time; print(os.getpid(), flush=True); time.sleep(60)"
|
|
with self.assertRaises(subprocess.TimeoutExpired):
|
|
runner.run_attempt(
|
|
[sys.executable, "-c", program],
|
|
root / "events",
|
|
root / "stderr",
|
|
0.2,
|
|
)
|
|
pid = int((root / "events").read_text())
|
|
with self.assertRaises(ProcessLookupError):
|
|
os.kill(pid, 0)
|
|
|
|
@unittest.skipUnless(sys.platform == "linux", "CLI supervision requires Linux")
|
|
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"
|
|
)
|
|
fake_codex = root / "codex"
|
|
fake_codex.write_text(
|
|
f"#!{sys.executable}\n"
|
|
+ "import json,os,time\nfrom pathlib import Path\n"
|
|
+ f"print(json.dumps({thread_event()!r}), flush=True)\n"
|
|
+ "Path(os.environ['FAKE_CHILD_PID']).write_text(str(os.getpid()))\n"
|
|
+ "time.sleep(60)\n"
|
|
)
|
|
fake_codex.chmod(0o700)
|
|
pid_file = root / "child.pid"
|
|
command = [
|
|
sys.executable,
|
|
str(Path(runner.__file__).resolve()),
|
|
"--context-dir",
|
|
str(root),
|
|
"--cwd",
|
|
str(root),
|
|
"--repository",
|
|
"apache/doris",
|
|
"--pr-number",
|
|
"123",
|
|
"--head-sha",
|
|
"a" * 40,
|
|
"--base-sha",
|
|
"b" * 40,
|
|
"--model",
|
|
"gpt-5.6-sol",
|
|
"--effort",
|
|
"xhigh",
|
|
"--budget-seconds",
|
|
"30",
|
|
]
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env={
|
|
**os.environ,
|
|
"PATH": str(root) + os.pathsep + os.environ["PATH"],
|
|
"CODEX_HOME": str(root / "isolated-home"),
|
|
"FAKE_CHILD_PID": str(pid_file),
|
|
},
|
|
)
|
|
try:
|
|
deadline = time.monotonic() + 5
|
|
while not pid_file.exists() and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
self.assertTrue(pid_file.exists(), "fake Codex did not start")
|
|
process.send_signal(signal.SIGTERM)
|
|
process.communicate(timeout=10)
|
|
self.assertEqual(130, process.returncode)
|
|
self.assertEqual(
|
|
1, len(list((root / "codex-attempts").glob("*.jsonl")))
|
|
)
|
|
events = runner.read_events(root / "codex-events.jsonl")
|
|
self.assertEqual(
|
|
"Review cancelled; not resuming", events[-1]["error"]["message"]
|
|
)
|
|
with self.assertRaises(ProcessLookupError):
|
|
os.kill(int(pid_file.read_text()), 0)
|
|
finally:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.communicate(timeout=10)
|
|
|
|
@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", "superseded"):
|
|
for graceful in (True, False):
|
|
with self.subTest(stop=stop, graceful=graceful):
|
|
self.check_detached_descendants(stop, graceful)
|
|
|
|
@unittest.skipUnless(sys.platform == "linux", "requires Linux subreaper/pidfd")
|
|
def test_cancellation_during_reaping_finishes_cleanup_without_retry(self):
|
|
for stop in ("reap_cancel", "reap_cancel_repeat", "cancel_reap_repeat"):
|
|
with self.subTest(stop=stop):
|
|
self.check_detached_descendants(stop, True)
|
|
|
|
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(
|
|
f"#!{sys.executable}\n"
|
|
+ r"""
|
|
import json, os, signal, subprocess, sys, time
|
|
from pathlib import Path
|
|
root = Path(os.environ["TREE_ROOT"])
|
|
role = sys.argv[1]
|
|
if role == "worker":
|
|
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
|
elif role == "shell":
|
|
worker = subprocess.Popen([sys.executable, __file__, "worker"], start_new_session=True)
|
|
print(worker.pid, flush=True)
|
|
else:
|
|
shell = subprocess.Popen(
|
|
[sys.executable, __file__, "shell"], start_new_session=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
|
)
|
|
worker_pid = int(shell.stdout.readline())
|
|
def interrupted(_signum, _frame):
|
|
(root / "interrupted").touch()
|
|
shell.terminate()
|
|
shell.wait()
|
|
sys.exit(0)
|
|
signal.signal(signal.SIGINT, interrupted if os.environ["TREE_GRACEFUL"] == "1" else signal.SIG_IGN)
|
|
(root / "pids.json").write_text(json.dumps([shell.pid, worker_pid]))
|
|
print(json.dumps({"type": "thread.started", "thread_id": os.environ["TREE_THREAD"]}), flush=True)
|
|
if os.environ["TREE_STOP"] in ("exit", "reap_cancel", "reap_cancel_repeat"):
|
|
print(json.dumps({"type": "turn.failed", "error": {"message": "test failure"}}), flush=True)
|
|
sys.exit(1)
|
|
deadline = time.monotonic() + 20
|
|
while time.monotonic() < deadline:
|
|
time.sleep(1)
|
|
"""
|
|
)
|
|
fake_codex.chmod(0o700)
|
|
command = [
|
|
sys.executable,
|
|
"-c",
|
|
(
|
|
"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.HEAD_POLL_SECONDS = 0.05; "
|
|
+ (
|
|
"""
|
|
import os, signal
|
|
original_open = r.os.pidfd_open
|
|
def interrupt_cleanup(pid):
|
|
fd = original_open(pid)
|
|
if pid != os.getpid():
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
if os.environ["TREE_STOP"].endswith("repeat"):
|
|
for _ in range(3):
|
|
os.kill(os.getpid(), signal.SIGINT)
|
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
return fd
|
|
r.os.pidfd_open = interrupt_cleanup
|
|
"""
|
|
if "reap" in stop
|
|
else ""
|
|
)
|
|
+ "sys.exit(r.main())"
|
|
),
|
|
str(Path(runner.__file__).parent),
|
|
"--context-dir",
|
|
str(root),
|
|
"--cwd",
|
|
str(root),
|
|
"--repository",
|
|
"apache/doris",
|
|
"--pr-number",
|
|
"123",
|
|
"--head-sha",
|
|
"a" * 40,
|
|
"--base-sha",
|
|
"b" * 40,
|
|
"--model",
|
|
"test",
|
|
"--effort",
|
|
"xhigh",
|
|
"--budget-seconds",
|
|
"2" if stop == "timeout" else "30",
|
|
]
|
|
with subprocess.Popen(
|
|
[sys.executable, "-c", "import time; time.sleep(30)"],
|
|
start_new_session=True,
|
|
) as unrelated:
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
env={
|
|
**os.environ,
|
|
"PATH": str(root) + os.pathsep + os.environ["PATH"],
|
|
"TREE_ROOT": str(root),
|
|
"TREE_THREAD": THREAD,
|
|
"TREE_STOP": stop,
|
|
"TREE_GRACEFUL": str(int(graceful)),
|
|
"CODEX_HOME": str(root / "isolated-home"),
|
|
},
|
|
)
|
|
try:
|
|
deadline = time.monotonic() + 5
|
|
while (
|
|
not (root / "pids.json").exists()
|
|
and time.monotonic() < deadline
|
|
):
|
|
time.sleep(0.01)
|
|
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(
|
|
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):
|
|
os.kill(pid, 0)
|
|
self.assertIsNone(
|
|
unrelated.poll(), "cleanup killed an unrelated process"
|
|
)
|
|
self.assertEqual(
|
|
1, len(list((root / "codex-attempts").glob("*.jsonl")))
|
|
)
|
|
if "cancel" in stop:
|
|
events = runner.read_events(root / "codex-events.jsonl")
|
|
self.assertEqual(
|
|
"Review cancelled; not resuming",
|
|
events[-1]["error"]["message"],
|
|
)
|
|
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:
|
|
process.kill()
|
|
process.communicate(timeout=5)
|
|
unrelated.kill()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|