Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' '<!-- opencode-review-exhausted -->'
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()
Expand Down
2 changes: 1 addition & 1 deletion scripts/ci/noema_review_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
152 changes: 151 additions & 1 deletion scripts/ci/pr_review_merge_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = "<!-- opencode-review-exhausted -->"
# 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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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(
Expand Down
26 changes: 21 additions & 5 deletions scripts/ci/run_opencode_review_model_pool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion tests/test_noema_review_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 9 additions & 3 deletions tests/test_opencode_agent_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading