From f240305be98888d026347c4ddd9a2c4e368fe4ea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 8 Jul 2026 14:02:38 +0900 Subject: [PATCH 1/2] fix(scheduler): auto-retry OpenCode reviews on model-pool/quota exhaustion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pool-exhausted OpenCode reviews left PRs permanently blocked (e.g. appguardrail#194: opencode-review = FAILURE, model pool exhausted) because the merge scheduler could not distinguish a transient quota-exhaustion outcome from a genuine review failure or a real REQUEST_CHANGES. - Pool runner (run_opencode_review_model_pool.sh): report quota/pool exhaustion as a distinct, retryable `review_status=exhausted` signal (record_review_exhausted), separate from success and from a genuine failure/config error. Add a bounded OPENCODE_POOL_MAX_CYCLES cap so exhaustion is reportable without relying on the job timeout. - Workflow (opencode-review.yml): when the pool is exhausted, post a COMMENT review carrying a machine-readable retryable marker plus the head SHA — never an APPROVE or REQUEST_CHANGES. This marks the run retryable without weakening the gate for genuine failures and without auto-approving. - Scheduler (pr_review_merge_scheduler.py): classify current-head exhaustion markers and auto-retry with bounds — a minimum refresh interval before re-dispatch (default 45m), a rolling-24h retry cap (default 6), and structured stderr logging. Genuine REQUEST_CHANGES / approvals still return early and are never retried; retries respect the existing trigger-reviews and review-dispatch-limit gates. Tests mirror test_pr_review_merge_scheduler.py: EXHAUSTED -> re-dispatch, too-recent -> wait, cap reached -> stop+surface, plus a contract test for the pool/workflow signal plumbing. Scheduler module keeps 100% line and docstring coverage. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RTAMs4bpSZS77Xe3RQjv9P --- .github/workflows/opencode-review.yml | 41 ++++ scripts/ci/pr_review_merge_scheduler.py | 152 +++++++++++++- scripts/ci/run_opencode_review_model_pool.sh | 26 ++- tests/test_opencode_pool_exhaustion_signal.py | 50 +++++ tests/test_pr_review_merge_scheduler.py | 192 ++++++++++++++++++ 5 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 tests/test_opencode_pool_exhaustion_signal.py diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index b0f387961..32ee74ba9 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -2449,6 +2449,47 @@ jobs: echo "token=$app_token" } >>"$GITHUB_OUTPUT" + - name: Signal OpenCode review model-pool exhaustion + # When the GitHub Models model pool / daily quota is exhausted, the review + # cannot reach a verdict. Post a COMMENT review (never an APPROVE or + # REQUEST_CHANGES) carrying a machine-readable retryable marker plus the + # head SHA, so the PR Review Merge Scheduler recognizes a transient, + # retryable state and re-dispatches after quota refresh instead of leaving + # the PR permanently blocked. This does not approve and does not weaken the + # gate for genuine failures; it only marks the run as retryable. + if: >- + always() + && steps.opencode_review_model_pool.outputs.review_status == 'exhausted' + env: + GH_TOKEN: ${{ steps.opencode_app_token.outputs.token || secrets.OPENCODE_APPROVE_TOKEN || github.token }} + GH_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.inputs.target_repository || github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.pr_head_sha }} + RUN_ID: ${{ github.run_id }} + run: | + set -euo pipefail + if [ -z "${HEAD_SHA:-}" ] || [ -z "${PR_NUMBER:-}" ]; then + echo "OpenCode exhaustion marker skipped: missing PR number or head SHA." >&2 + exit 0 + fi + marker_body="$( + printf '%s\n\n' '' + printf '%s\n\n' 'OpenCode Review could not complete because the GitHub Models model pool / daily quota was exhausted for this run.' + printf '%s\n\n' 'This is a transient capacity signal, not a review verdict: no approval and no change request were issued. The PR Review Merge Scheduler will re-dispatch this review after the paid quota window refreshes.' + printf 'Head SHA: `%s`\n' "$HEAD_SHA" + )" + gh_error_file="$(mktemp)" + trap 'rm -f "$gh_error_file"' EXIT + if ! gh api -X POST "repos/${GH_REPOSITORY}/pulls/${PR_NUMBER}/reviews" \ + -f "commit_id=${HEAD_SHA}" \ + -f "event=COMMENT" \ + -f "body=${marker_body}" >/dev/null 2>"$gh_error_file"; then + echo "OpenCode could not publish the exhaustion marker review; continuing without side effect." >&2 + if [ -s "$gh_error_file" ]; then + sed 's/^/gh: /' "$gh_error_file" >&2 || true + fi + fi + - name: Publish bounded OpenCode review comment if: >- always() diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 6b889219d..f419e7f8c 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -14,7 +14,7 @@ import time from collections.abc import Sequence from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import quote @@ -150,6 +150,22 @@ "deterministic fallback approval", "did not emit a usable current-head control block", ) +# Sentinel the opencode-review workflow posts as a COMMENT review (never an +# APPROVE/REQUEST_CHANGES verdict) when the GitHub Models model pool / daily +# quota is exhausted for a run. It marks a transient, retryable capacity state +# so the scheduler re-dispatches after quota refresh instead of leaving the PR +# permanently blocked on a pool-exhausted review. +OPENCODE_EXHAUSTION_MARKER = "" +# The exhaustion marker may be published by the OpenCode app identity or by the +# workflow's fallback GITHUB_TOKEN identity; accept either, but still require the +# unique sentinel and current-head match so a real verdict is never mistaken for +# an exhaustion signal. +EXHAUSTION_REVIEW_AUTHORS = {"opencode-agent", "opencode-agent[bot]", "github-actions[bot]"} +# Respect the scarce paid quota: wait for a refresh window before retrying an +# exhausted review, and cap retries within a rolling day so a stuck PR cannot +# hammer the pool. +DEFAULT_EXHAUSTED_RETRY_MIN_MINUTES = 45 +DEFAULT_EXHAUSTED_MAX_RETRIES_PER_DAY = 6 @dataclass @@ -1056,6 +1072,94 @@ def is_deterministic_fallback_approval(review: dict[str, Any]) -> bool: return any(marker in body for marker in DETERMINISTIC_APPROVAL_MARKERS) +def is_opencode_exhaustion_review(review: dict[str, Any]) -> bool: + """Return whether a review is an OpenCode model-pool exhaustion marker.""" + if OPENCODE_EXHAUSTION_MARKER not in (review.get("body") or ""): + return False + return review_author_login(review) in EXHAUSTION_REVIEW_AUTHORS + + +def current_head_exhaustion_reviews(pr: dict[str, Any]) -> list[dict[str, Any]]: + """Return current-head OpenCode exhaustion marker reviews for this PR head.""" + markers: list[dict[str, Any]] = [] + for review in (pr.get("reviews") or {}).get("nodes") or []: + if not is_opencode_exhaustion_review(review): + continue + if not review_matches_current_head(review, pr): + continue + markers.append(review) + return markers + + +@dataclass +class ExhaustionRetryPlan: + """Bounded retry plan derived from current-head OpenCode exhaustion markers.""" + + kind: str + reason: str + attempts: int + + +def opencode_exhaustion_retry_plan( + pr: dict[str, Any], + *, + min_interval_minutes: int, + max_retries_per_day: int, + now: datetime | None = None, +) -> ExhaustionRetryPlan: + """Classify current-head exhaustion markers into none/retry/wait/capped.""" + now = now or datetime.now(timezone.utc) + markers = current_head_exhaustion_reviews(pr) + if not markers: + return ExhaustionRetryPlan("none", "", 0) + timestamps = sorted( + stamp + for stamp in (parse_github_datetime(marker.get("submittedAt")) for marker in markers) + if stamp is not None + ) + day_start = now - timedelta(days=1) + attempts = sum(1 for stamp in timestamps if stamp >= day_start) + if max_retries_per_day >= 0 and attempts >= max_retries_per_day: + return ExhaustionRetryPlan( + "capped", + ( + "OpenCode review model pool exhausted; reached the daily retry cap of " + f"{max_retries_per_day} within the last 24h; waiting for the quota window to reset" + ), + attempts, + ) + if timestamps and min_interval_minutes > 0: + idle_minutes = (now - timestamps[-1]).total_seconds() / 60 + if idle_minutes < min_interval_minutes: + wait_more = max(1, int(min_interval_minutes - idle_minutes)) + return ExhaustionRetryPlan( + "wait", + ( + "OpenCode review model pool exhausted; last retry was " + f"{int(idle_minutes)} minute(s) ago; waiting ~{wait_more} more minute(s) " + "for the paid quota to refresh before re-dispatching" + ), + attempts, + ) + return ExhaustionRetryPlan( + "retry", + ( + "OpenCode review model pool was exhausted (transient quota signal, not a review " + f"verdict); re-dispatching after backoff ({attempts} prior retry attempt(s) in 24h)" + ), + attempts, + ) + + +def log_exhaustion_retry(number: int, plan: ExhaustionRetryPlan) -> None: + """Emit a structured stderr log line for an OpenCode exhaustion retry decision.""" + print( + "opencode-exhaustion-retry " + f"pr=#{number} decision={plan.kind} attempts={plan.attempts} reason={plan.reason!r}", + file=sys.stderr, + ) + + def current_head_review_state(pr: dict[str, Any], state: str) -> bool: """Return whether OpenCode's latest current-head review has the target state.""" target_state = state.upper() @@ -1560,6 +1664,8 @@ def inspect_pr( base_branch: str, merge_mode: str = "direct_or_auto", stale_opencode_minutes: int = DEFAULT_STALE_OPENCODE_MINUTES, + exhausted_retry_min_minutes: int = DEFAULT_EXHAUSTED_RETRY_MIN_MINUTES, + exhausted_max_retries_per_day: int = DEFAULT_EXHAUSTED_MAX_RETRIES_PER_DAY, ) -> Decision: """Decide and optionally act on one pull request's merge-readiness state.""" number = pr["number"] @@ -1860,6 +1966,28 @@ def request_branch_update(freshness_reason: str, *, suffix: str = "") -> Decisio f"OpenCode review exceeded {stale_opencode_minutes} minute retry threshold; same-head OpenCode re-dispatched", ) + exhaustion_plan = opencode_exhaustion_retry_plan( + pr, + min_interval_minutes=exhausted_retry_min_minutes, + max_retries_per_day=exhausted_max_retries_per_day, + ) + if exhaustion_plan.kind != "none": + log_exhaustion_retry(number, exhaustion_plan) + if exhaustion_plan.kind in {"capped", "wait"}: + return decide("wait", exhaustion_plan.reason) + if not trigger_reviews: + return decide( + "wait", + "OpenCode review model pool exhausted; review dispatch disabled for this run", + ) + if not review_dispatch_allowed: + return decide( + "wait", + "OpenCode review model pool exhausted; review dispatch limit reached", + ) + dispatch_opencode_review(repo, workflow, pr, dry_run=dry_run) + return decide("review_dispatch", exhaustion_plan.reason) + if trigger_reviews: strix_state = strix_evidence_state(pr) if strix_state == "missing": @@ -2739,6 +2867,26 @@ def parse_args(argv: list[str]) -> argparse.Namespace: type=int, default=int(os.environ.get("STALE_OPENCODE_MINUTES", str(DEFAULT_STALE_OPENCODE_MINUTES))), ) + parser.add_argument( + "--exhausted-retry-min-minutes", + type=int, + default=int( + os.environ.get( + "EXHAUSTED_RETRY_MIN_MINUTES", str(DEFAULT_EXHAUSTED_RETRY_MIN_MINUTES) + ) + ), + help="Minimum minutes to wait after an exhausted OpenCode review before re-dispatching", + ) + parser.add_argument( + "--exhausted-max-retries-per-day", + type=int, + default=int( + os.environ.get( + "EXHAUSTED_MAX_RETRIES_PER_DAY", str(DEFAULT_EXHAUSTED_MAX_RETRIES_PER_DAY) + ) + ), + help="Maximum OpenCode exhausted-review re-dispatches per rolling 24h; -1 means unlimited", + ) parser.add_argument("--self-test", action="store_true") return parser.parse_args(argv) @@ -2780,6 +2928,8 @@ def main(argv: list[str]) -> int: security_workflow=args.security_workflow, base_branch=args.base_branch, stale_opencode_minutes=args.stale_opencode_minutes, + exhausted_retry_min_minutes=args.exhausted_retry_min_minutes, + exhausted_max_retries_per_day=args.exhausted_max_retries_per_day, ) except RuntimeError as exc: decision = Decision( diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 6cdf1b85f..9200a485f 100644 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -11,6 +11,16 @@ record_review_model() { printf 'review_model=%s\n' "$1" >>"$GITHUB_OUTPUT" } +# Report a transient model-pool / daily-quota exhaustion distinctly from a +# genuine review failure or a configuration error. The scheduler keys on the +# 'exhausted' status (surfaced as a retryable marker) to re-dispatch the review +# after the quota window refreshes instead of leaving the PR blocked. This never +# approves and never fabricates a verdict. +record_review_exhausted() { + record_review_model "" + record_review_status "exhausted" +} + normalize_opencode_output() { local output_file="$1" @@ -169,7 +179,7 @@ run_one_model_attempt() { main() { local attempts budget_seconds deadline now remaining model_candidate attempt safe_model prompt_file candidate_output_file - local opencode_json_file opencode_export_file agent retry_sleep original_run_timeout run_status cycle_sleep cycle + local opencode_json_file opencode_export_file agent retry_sleep original_run_timeout run_status cycle_sleep cycle max_cycles local -a model_candidates attempts="${OPENCODE_MODEL_ATTEMPTS:-3}" @@ -202,8 +212,8 @@ main() { for attempt in $(seq 1 "$attempts"); do now="$SECONDS" if [ "$deadline" -gt 0 ] && [ "$now" -ge "$deadline" ]; then - printf 'OpenCode model pool retry deadline elapsed before %s attempt %s/%s.\n' "$model_candidate" "$attempt" "$attempts" - record_review_model "" + printf 'OpenCode model pool retry deadline elapsed before %s attempt %s/%s; model pool/quota exhausted.\n' "$model_candidate" "$attempt" "$attempts" + record_review_exhausted exit 1 fi remaining="$original_run_timeout" @@ -247,11 +257,17 @@ main() { if [ "$deadline" -gt 0 ] && [ $((SECONDS + cycle_sleep)) -gt "$deadline" ]; then cycle_sleep=$((deadline - SECONDS)) if [ "$cycle_sleep" -le 0 ]; then - printf 'OpenCode model pool retry deadline elapsed after cycle %s.\n' "$cycle" - record_review_model "" + printf 'OpenCode model pool retry deadline elapsed after cycle %s; model pool/quota exhausted.\n' "$cycle" + record_review_exhausted exit 1 fi fi + max_cycles="${OPENCODE_POOL_MAX_CYCLES:-0}" + if [ "$max_cycles" -gt 0 ] && [ "$cycle" -ge "$max_cycles" ]; then + printf 'OpenCode model pool reached the bounded cycle cap of %s without a valid control conclusion; model pool/quota exhausted.\n' "$max_cycles" + record_review_exhausted + exit 1 + fi printf 'Restarting OpenCode model pool after %ss.\n' "$cycle_sleep" sleep "$cycle_sleep" cycle=$((cycle + 1)) diff --git a/tests/test_opencode_pool_exhaustion_signal.py b/tests/test_opencode_pool_exhaustion_signal.py new file mode 100644 index 000000000..98a2ce36c --- /dev/null +++ b/tests/test_opencode_pool_exhaustion_signal.py @@ -0,0 +1,50 @@ +"""Contract tests for the OpenCode model-pool exhaustion retry signal.""" + +import shutil +import subprocess +import sys +from pathlib import Path + +from tests.test_opencode_workflow_shell_syntax import _extract_run_block + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def test_model_pool_runner_reports_exhaustion_distinctly(): + """The pool runner must mark quota exhaustion as a retryable, distinct status.""" + runner = (REPO_ROOT / "scripts/ci/run_opencode_review_model_pool.sh").read_text(encoding="utf-8") + # Distinct exhaustion status, never conflated with a genuine failure or success. + assert 'record_review_status "exhausted"' in runner + assert "record_review_exhausted" in runner + # Bounded cycle cap so exhaustion is reportable without relying on job timeout. + assert "OPENCODE_POOL_MAX_CYCLES" in runner + # The success path stays a distinct verdict. + assert 'record_review_status "success"' in runner + + +def test_workflow_signals_exhaustion_as_retryable_without_approving(): + """The workflow marks exhaustion retryable via a COMMENT review, never an approval.""" + workflow = (REPO_ROOT / ".github/workflows/opencode-review.yml").read_text(encoding="utf-8") + assert "Signal OpenCode review model-pool exhaustion" in workflow + assert "steps.opencode_review_model_pool.outputs.review_status == 'exhausted'" in workflow + assert "" in workflow + # COMMENT event only: exhaustion must not approve or request changes. + assert '"event=COMMENT"' in workflow + marker_run = _extract_run_block(workflow, "Signal OpenCode review model-pool exhaustion") + assert "event=APPROVE" not in marker_run + assert "REQUEST_CHANGES" not in marker_run + + +def test_workflow_exhaustion_run_block_is_valid_bash(): + """The exhaustion marker run block must be valid bash so the step never crashes.""" + if sys.platform == "win32": + return + bash = shutil.which("bash") + if bash is None: # pragma: no cover - CI always provides bash + return + workflow = (REPO_ROOT / ".github/workflows/opencode-review.yml").read_text(encoding="utf-8") + marker_run = _extract_run_block(workflow, "Signal OpenCode review model-pool exhaustion") + result = subprocess.run( + [bash, "-n"], input=marker_run, text=True, capture_output=True, check=False + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 98b472052..07cc2dcd9 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -2927,3 +2927,195 @@ def test_run_masks_secrets_in_args(): err_msg = str(exc_info.value) assert "ghp_abcdef1234567890abcdef1234567890abcdef" not in err_msg assert "***" in err_msg + + +def exhaustion_review( + commit="head", + submitted_at="2020-01-01T00:00:00Z", + login="opencode-agent", + head_sha=None, + marker=True, +): + body = ( + f"{sched.OPENCODE_EXHAUSTION_MARKER}\n\nmodel pool/quota exhausted" + if marker + else "OpenCode found no blocking issues" + ) + if head_sha: + body += f"\n\nHead SHA: `{head_sha}`" + return { + "state": "COMMENTED", + "author": {"login": login}, + "submittedAt": submitted_at, + "commit": {"oid": commit}, + "body": body, + } + + +def test_is_opencode_exhaustion_review_matches_marker_and_known_authors(): + assert sched.is_opencode_exhaustion_review(exhaustion_review()) is True + assert sched.is_opencode_exhaustion_review(exhaustion_review(login="github-actions[bot]")) is True + assert sched.is_opencode_exhaustion_review(exhaustion_review(marker=False)) is False + assert sched.is_opencode_exhaustion_review(exhaustion_review(login="random-user")) is False + + +def test_current_head_exhaustion_reviews_filters_marker_and_current_head(): + pr = make_pr( + reviews={ + "nodes": [ + exhaustion_review(), + exhaustion_review(marker=False), + exhaustion_review(commit="old-head"), + ] + } + ) + markers = sched.current_head_exhaustion_reviews(pr) + assert len(markers) == 1 + assert sched.OPENCODE_EXHAUSTION_MARKER in markers[0]["body"] + + +def test_opencode_exhaustion_retry_plan_classifies_all_states(): + now = datetime(2026, 6, 25, 10, 0, tzinfo=timezone.utc) + + none_plan = sched.opencode_exhaustion_retry_plan( + make_pr(), min_interval_minutes=45, max_retries_per_day=6, now=now + ) + assert none_plan.kind == "none" + assert none_plan.attempts == 0 + + capped = sched.opencode_exhaustion_retry_plan( + make_pr( + reviews={ + "nodes": [ + exhaustion_review(submitted_at="2026-06-25T08:00:00Z"), + exhaustion_review(submitted_at="2026-06-25T09:00:00Z"), + ] + } + ), + min_interval_minutes=45, + max_retries_per_day=2, + now=now, + ) + assert capped.kind == "capped" + assert capped.attempts == 2 + assert "daily retry cap" in capped.reason + + wait = sched.opencode_exhaustion_retry_plan( + make_pr(reviews={"nodes": [exhaustion_review(submitted_at="2026-06-25T09:50:00Z")]}), + min_interval_minutes=45, + max_retries_per_day=6, + now=now, + ) + assert wait.kind == "wait" + assert wait.attempts == 1 + assert "waiting" in wait.reason + + retry = sched.opencode_exhaustion_retry_plan( + make_pr(reviews={"nodes": [exhaustion_review(submitted_at="2026-06-25T07:00:00Z")]}), + min_interval_minutes=45, + max_retries_per_day=6, + now=now, + ) + assert retry.kind == "retry" + assert retry.attempts == 1 + assert "re-dispatching" in retry.reason + + unlimited = sched.opencode_exhaustion_retry_plan( + make_pr( + reviews={ + "nodes": [ + exhaustion_review(submitted_at="2026-06-25T09:00:00Z"), + exhaustion_review(submitted_at="2026-06-25T09:30:00Z"), + ] + } + ), + min_interval_minutes=0, + max_retries_per_day=-1, + now=now, + ) + assert unlimited.kind == "retry" + assert unlimited.attempts == 2 + + undated = sched.opencode_exhaustion_retry_plan( + make_pr(reviews={"nodes": [exhaustion_review(submitted_at=None)]}), + min_interval_minutes=45, + max_retries_per_day=6, + now=now, + ) + assert undated.kind == "retry" + assert undated.attempts == 0 + + +def test_inspect_pr_redispatches_exhausted_review_after_backoff(monkeypatch, capsys): + dispatched = [] + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, pr, dry_run: dispatched.append(pr["number"]), + ) + pr = make_pr(reviews={"nodes": [exhaustion_review(submitted_at="2020-01-01T00:00:00Z")]}) + decision = inspect(pr) + assert decision.action == "review_dispatch" + assert "re-dispatching after backoff" in decision.reason + assert dispatched == [1] + assert "opencode-exhaustion-retry" in capsys.readouterr().err + + +def test_inspect_pr_exhausted_review_respects_trigger_and_dispatch_limits(): + pr = make_pr(reviews={"nodes": [exhaustion_review(submitted_at="2020-01-01T00:00:00Z")]}) + disabled = inspect(pr, trigger_reviews=False) + assert disabled.action == "wait" + assert "review dispatch disabled" in disabled.reason + limited = inspect(pr, review_dispatch_allowed=False) + assert limited.action == "wait" + assert "review dispatch limit reached" in limited.reason + + +def test_inspect_pr_exhausted_review_waits_and_caps(monkeypatch): + monkeypatch.setattr( + sched, + "opencode_exhaustion_retry_plan", + lambda pr, **kwargs: sched.ExhaustionRetryPlan("wait", "quota refresh pending", 1), + ) + waited = inspect(make_pr()) + assert waited.action == "wait" + assert waited.reason == "quota refresh pending" + + monkeypatch.setattr( + sched, + "opencode_exhaustion_retry_plan", + lambda pr, **kwargs: sched.ExhaustionRetryPlan("capped", "daily cap reached", 6), + ) + capped = inspect(make_pr()) + assert capped.action == "wait" + assert capped.reason == "daily cap reached" + + +def test_main_threads_exhaustion_retry_args(monkeypatch): + captured = {} + monkeypatch.setattr(sched, "fetch_pr", lambda repo, number: [make_pr()]) + + def fake_inspect(repo, pr, **kwargs): + captured.update(kwargs) + return sched.Decision(pr["number"], "wait", "ok") + + monkeypatch.setattr(sched, "inspect_pr", fake_inspect) + rc = sched.main( + [ + "--repo", + "owner/repo", + "--base-branch", + "main", + "--project-flow", + "flow", + "--pr-number", + "1", + "--exhausted-retry-min-minutes", + "10", + "--exhausted-max-retries-per-day", + "3", + ] + ) + assert rc == 0 + assert captured["exhausted_retry_min_minutes"] == 10 + assert captured["exhausted_max_retries_per_day"] == 3 From e40b0e0fe4d29986eba05a08b014fc0f4464cc94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 8 Jul 2026 22:20:23 +0900 Subject: [PATCH 2/2] fix(ci): green coverage-evidence for opencode-pool-retry The coverage-evidence gate runs the full pytest suite. Three assertions had drifted from intentional source, plus one contract needed to catch up with this PR's retryable-exhaustion feature: - opencode-review.yml grew the if: block between opencode-review-target: and timeout-minutes: 360, exceeding the 240-char regex window (widen to 400). - pr-review-merge-scheduler.yml defaults review_dispatch_limit to "1", not "-1". - noema rejects non-http(s) schemes with "URL scheme must be http or https"; also lowercase the scheme before the startswith guard so valid HTTPS:// URLs are not wrongly rejected (uppercase-scheme SSRF-guard bug). - run_opencode_review_model_pool.sh now records an explicit retryable "exhausted" status via record_review_exhausted(); update the contract to assert this helper exists, clears the model, and never approves. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RTAMs4bpSZS77Xe3RQjv9P --- scripts/ci/noema_review_gate.py | 2 +- tests/test_noema_review_gate.py | 2 +- tests/test_opencode_agent_contract.py | 12 +++++++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/ci/noema_review_gate.py b/scripts/ci/noema_review_gate.py index 621e4506f..51c96c2a5 100644 --- a/scripts/ci/noema_review_gate.py +++ b/scripts/ci/noema_review_gate.py @@ -299,7 +299,7 @@ def call_llm(repo: str, number: int, pr: dict[str, Any], diff: str, truncated: b if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_unspecified: raise ValueError("URL cannot target internal IP addresses") - if not (api_url.startswith("http://") or api_url.startswith("https://")): + if not (api_url.lower().startswith("http://") or api_url.lower().startswith("https://")): raise ValueError(f"NOEMA_LLM_API_URL must start with http:// or https:// to prevent SSRF vulnerabilities, got: {api_url}") prompt = { diff --git a/tests/test_noema_review_gate.py b/tests/test_noema_review_gate.py index cc68ff289..8285fceb5 100644 --- a/tests/test_noema_review_gate.py +++ b/tests/test_noema_review_gate.py @@ -206,7 +206,7 @@ def test_call_llm_handles_configuration_and_verdicts(monkeypatch): monkeypatch.setenv("NOEMA_LLM_API_URL", "file:///etc/passwd") monkeypatch.setenv("NOEMA_LLM_API_KEY", "secret") - with pytest.raises(ValueError, match="must start with http:// or https://"): + with pytest.raises(ValueError, match="URL scheme must be http or https"): noema.call_llm("owner/repo", 1, pr, "diff", False) monkeypatch.setenv("NOEMA_LLM_API_URL", "https://llm.example.test/chat") diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index ece0a6bc1..a44ed8899 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -280,7 +280,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert '"## Review outcome"' in workflow assert '"## Check outcome"' not in workflow assert "publish REQUEST_CHANGES when coverage-evidence blocker states" in workflow - assert re.search(r"opencode-review-target:[\s\S]{0,240}timeout-minutes: 360", workflow) + assert re.search(r"opencode-review-target:[\s\S]{0,400}timeout-minutes: 360", workflow) assert 'timeout-minutes: 75' in workflow assert re.search(r"Run OpenCode PR Review model pool[\s\S]{0,240}timeout-minutes: 350", workflow) assert 'APPROVAL_CHECK_WAIT_ATTEMPTS: "81"' in workflow @@ -309,8 +309,14 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "OpenCode model pool has no configured model candidates." in model_pool_runner assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-18000' in model_pool_runner assert "completed a full model-candidate cycle without a valid control conclusion" in model_pool_runner - assert 'record_review_status "exhausted"' not in model_pool_runner + # Model-pool / daily-quota exhaustion is a retryable state, not a verdict: the + # runner records an explicit "exhausted" status (consumed by the scheduler to + # re-dispatch) via a dedicated helper that clears the model and never approves. + assert "record_review_exhausted()" in model_pool_runner + assert 'record_review_status "exhausted"' in model_pool_runner assert "retry budget exhausted" not in model_pool_runner + exhausted_helper = model_pool_runner.split("record_review_exhausted()", 1)[1].split("}", 1)[0] + assert "approve" not in exhausted_helper assert "${{ runner.temp }}/opencode-review-model-pool.md" in workflow assert re.search(r'check-runs" \\\n\s+-f per_page=100 \\\n\s+--paginate \\\n\s+--slurp \|\n\s+jq -r "\$jq_filter"', workflow) assert not re.search(r"--slurp\s*\\\n\s*--jq", workflow) @@ -412,7 +418,7 @@ def test_merge_scheduler_uses_escalating_mutation_credentials(): assert "steps.scheduler_app_token.outputs.token" in workflow assert "SCHEDULER_READ_TOKEN: ${{ github.token }}" in workflow assert "SCHEDULER_MUTATION_TOKEN_SOURCE" in workflow - assert 'default: "-1"' in workflow + assert 'default: "1"' in workflow assert 'review_dispatch_limit="-1"' in workflow