From e4c3b3781410fa0ddcf4c69191bf23659eb7a4a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 10:15:06 +0900 Subject: [PATCH 1/7] fix(opencode): bound model-pool queue time --- .github/workflows/opencode-review.yml | 50 +++++++++++++++++---------- tests/test_opencode_agent_contract.py | 28 +++++++++------ 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index c55ba5f39..6ae090c6e 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -3234,7 +3234,7 @@ jobs: - name: Run OpenCode PR Review model pool id: opencode_review_model_pool if: needs.coverage-evidence.result == 'success' - timeout-minutes: 30 + timeout-minutes: 12 continue-on-error: true env: STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} @@ -3258,13 +3258,14 @@ jobs: # the SAME model 5x let a rate-limited/hung leader consume the whole # step, so the pool never reached a healthy fallback model. OPENCODE_MODEL_ATTEMPTS: "1" - # A provider gets ten minutes for a tool-using review before the pool - # advances by default. Dynamic cadence below shortens small PRs and - # lengthens larger ones only within the 30-minute model step cap, so - # one stalled endpoint cannot occupy the org queue indefinitely. - OPENCODE_RUN_TIMEOUT_SECONDS: "600" + # A provider gets three minutes for a tool-using review before the + # pool advances by default. Dynamic cadence below keeps small and + # medium PRs short so one stalled endpoint cannot occupy the org + # queue indefinitely. + OPENCODE_RUN_TIMEOUT_SECONDS: "180" OPENCODE_EXPORT_TIMEOUT_SECONDS: "120" - OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "1500" + OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "540" + OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "540" # Visit the high-sensitivity candidate catalog once. Per-provider # failures and timeouts are logged before the publish gate evaluates # the current-head result and fails closed when no verdict exists. @@ -3272,19 +3273,19 @@ jobs: OPENCODE_DYNAMIC_REVIEW_CADENCE: "true" OPENCODE_SMALL_CHANGE_FILE_THRESHOLD: "3" OPENCODE_MEDIUM_CHANGE_FILE_THRESHOLD: "20" - OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "300" - OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "900" - OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "600" - OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "1500" - OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "900" - OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "1500" - OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "600" - OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "1500" + OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "90" + OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "180" + OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "120" + OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "360" + OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "180" + OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "540" + OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "120" + OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "360" OPENCODE_DYNAMIC_MAX_CYCLES: "1" CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE: ${{ steps.central_review_process_fallback_scope.outputs.eligible || 'false' }} CENTRAL_REVIEW_PROCESS_FALLBACK_SCOPE_LABEL: ${{ steps.central_review_process_fallback_scope.outputs.scope_label || 'unsupported' }} - OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "300" - OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS: "420" + OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "120" + OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS: "180" OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_MAX_CYCLES: "1" OPENCODE_BACKOFF_INITIAL_SECONDS: "30" OPENCODE_BACKOFF_MAX_SECONDS: "30" @@ -3303,7 +3304,20 @@ jobs: RUN_ATTEMPT: ${{ github.run_attempt }} run: | set -euo pipefail - bash "$GITHUB_WORKSPACE/scripts/ci/run_opencode_review_model_pool.sh" + set +e + timeout --kill-after=30s "${OPENCODE_POOL_STEP_TIMEOUT_SECONDS:-540}s" \ + bash "$GITHUB_WORKSPACE/scripts/ci/run_opencode_review_model_pool.sh" + pool_status=$? + set -e + if [ "$pool_status" -eq 124 ] || [ "$pool_status" -eq 137 ] || [ "$pool_status" -eq 143 ]; then + printf 'OpenCode model pool exceeded the outer %ss step budget; marking the pool exhausted so current-head evidence fallback can publish a bounded reason instead of blocking the org queue.\n' \ + "${OPENCODE_POOL_STEP_TIMEOUT_SECONDS:-540}" + { + printf 'review_model=\n' + printf 'review_status=exhausted\n' + } >>"$GITHUB_OUTPUT" + fi + exit "$pool_status" - name: Exchange OpenCode app token for review writes id: opencode_app_token diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index dc08c881b..a4a36611a 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -619,8 +619,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): ) assert "CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE" in workflow assert "CENTRAL_REVIEW_PROCESS_FALLBACK_SCOPE_LABEL" in workflow - assert 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "300"' in workflow - assert 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS: "420"' in workflow + assert 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS: "120"' in workflow + assert 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_TOTAL_BUDGET_SECONDS: "180"' in workflow assert 'OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_MAX_CYCLES: "1"' in workflow assert "Central review-process evidence fallback eligible" in model_pool_runner assert "provider delay is logged before the publish fallback evaluates current-head peer evidence" in model_pool_runner @@ -644,9 +644,12 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert re.search(r"Prepare bounded OpenCode review evidence[\s\S]{0,120}timeout-minutes: 12", workflow) assert re.search(r"opencode-review-target:[\s\S]*?timeout-minutes: 45", workflow) assert 'timeout-minutes: 12' in workflow - assert re.search(r"Run OpenCode PR Review model pool[\s\S]{0,240}timeout-minutes: 30", workflow) - assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' in workflow - assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "1500"' in workflow + assert re.search(r"Run OpenCode PR Review model pool[\s\S]{0,240}timeout-minutes: 12", workflow) + assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "180"' in workflow + assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "540"' in workflow + assert 'OPENCODE_POOL_STEP_TIMEOUT_SECONDS: "540"' in workflow + assert 'timeout --kill-after=30s "${OPENCODE_POOL_STEP_TIMEOUT_SECONDS:-540}s"' in workflow + assert "OpenCode model pool exceeded the outer" in workflow assert 'OPENCODE_POOL_MAX_CYCLES: "1"' in workflow assert re.search(r"Run OpenCode PR Review model pool[\s\S]{0,280}continue-on-error: true", workflow) assert re.search(r"Publish OpenCode review outcome[\s\S]{0,900}timeout-minutes: 8", workflow) @@ -665,15 +668,20 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): 'github-models/deepseek/deepseek-r1"' ) in workflow assert 'OPENCODE_MODEL_ATTEMPTS: "1"' in workflow - assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' in workflow + assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "180"' in workflow assert 'OPENCODE_EXPORT_TIMEOUT_SECONDS: "120"' in workflow - assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "1500"' in workflow + assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "540"' in workflow assert 'OPENCODE_POOL_MAX_CYCLES: "1"' in workflow assert 'OPENCODE_DYNAMIC_REVIEW_CADENCE: "true"' in workflow assert 'OPENCODE_CHANGED_FILES_FILE: ${{ runner.temp }}/opencode-changed-files.txt' in workflow - assert 'OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "300"' in workflow - assert 'OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "600"' in workflow - assert 'OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "900"' in workflow + assert 'OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "90"' in workflow + assert 'OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "180"' in workflow + assert 'OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "120"' in workflow + assert 'OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "360"' in workflow + assert 'OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "180"' in workflow + assert 'OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "540"' in workflow + assert 'OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "120"' in workflow + assert 'OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "360"' in workflow assert 'OPENCODE_DYNAMIC_MAX_CYCLES: "1"' in workflow assert 'OPENCODE_BACKOFF_MAX_SECONDS: "30"' in workflow publish_step = workflow.split(" - name: Publish OpenCode review outcome", 1)[1].split( From 274a2af2da29c9b5e3cf83cf49f47e7cd8ba0d2d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 10:36:41 +0900 Subject: [PATCH 2/7] fix(opencode): use target credentials for dispatch evidence --- .github/workflows/opencode-review.yml | 35 +++++++++++++++++++++------ tests/test_opencode_agent_contract.py | 15 +++++++++++- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 6ae090c6e..cb817be18 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -3856,7 +3856,9 @@ jobs: env: GH_TOKEN: ${{ steps.opencode_app_token.outputs.token || secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || github.token }} CHECK_LOOKUP_GH_TOKEN: ${{ github.token }} + CODE_SCANNING_GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.opencode_app_token.outputs.token || github.token }} CONFIGURED_REVIEW_WRITE_TOKEN_SOURCE: ${{ steps.opencode_app_token.outputs.available == 'true' && 'opencode-app' || secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || 'github-token' }} + CODE_SCANNING_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.opencode_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} GH_REPOSITORY: ${{ github.event.pull_request.base.repo.full_name || github.event.inputs.target_repository || github.repository }} STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} # Exposed so the "openai" provider in opencode.jsonc resolves during the @@ -3955,6 +3957,7 @@ jobs: fi overview_comment_token="$review_write_token" echo "check lookup token source=${check_lookup_token_source}" + echo "code-scanning lookup token source=${CODE_SCANNING_TOKEN_SOURCE:-configured}" echo "review write token source=${review_write_token_source}" echo "review write fallback token source=${review_write_fallback_token_source:-none}" @@ -6038,12 +6041,24 @@ jobs: collect_open_code_scanning_alerts() { local output_file="$1" - local pr_json head_ref - pr_json="$(timeout "$(check_lookup_api_timeout_seconds)s" \ - gh pr view "$PR_NUMBER" --repo "$GH_REPOSITORY" --json headRefName 2>/dev/null)" || return 1 + local pr_json head_ref scan_token lookup_error_file + scan_token="${CODE_SCANNING_GH_TOKEN:-${GH_TOKEN:-}}" + if [ -z "$scan_token" ]; then + printf '::warning::Open code-scanning alert lookup skipped because no target-repository read token was configured.\n' >&2 + return 1 + fi + lookup_error_file="$(mktemp)" + if ! pr_json="$(GH_TOKEN="$scan_token" timeout "$(check_lookup_api_timeout_seconds)s" \ + gh pr view "$PR_NUMBER" --repo "$GH_REPOSITORY" --json headRefName 2>"$lookup_error_file")"; then + sed 's/^/gh: /' "$lookup_error_file" >&2 || true + rm -f "$lookup_error_file" + return 1 + fi + rm -f "$lookup_error_file" head_ref="$(printf '%s\n' "$pr_json" | jq -r '.headRefName // empty')" [ -n "$head_ref" ] || return 1 - timeout "$(check_lookup_api_timeout_seconds)s" \ + lookup_error_file="$(mktemp)" + if ! GH_TOKEN="$scan_token" timeout "$(check_lookup_api_timeout_seconds)s" \ gh api -X GET "repos/${GH_REPOSITORY}/code-scanning/alerts" \ -f "ref=refs/heads/${head_ref}" \ -f state=open \ @@ -6062,7 +6077,12 @@ jobs: ] | .[] | "- " + .tool + "/" + .rule + ": " + .severity + " alert #" + (.number | tostring) + (if .url != "" then " (" + .url + ")" else "" end) - ' >"$output_file" + ' >"$output_file" 2>"$lookup_error_file"; then + sed 's/^/gh: /' "$lookup_error_file" >&2 || true + rm -f "$lookup_error_file" + return 1 + fi + rm -f "$lookup_error_file" } approve_current_head_after_model_unavailable() { @@ -6629,11 +6649,12 @@ jobs: && steps.opencode_review_model_pool.outputs.review_status != '' continue-on-error: true env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || steps.opencode_app_token.outputs.token || github.token }} GH_REPOSITORY: ${{ github.event.inputs.target_repository }} PR_HEAD_SHA: ${{ github.event.inputs.pr_head_sha }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} OPENCODE_MODEL_POOL_OUTCOME: ${{ steps.opencode_review_model_pool.outputs.review_status }} + OPENCODE_STATUS_TOKEN_SOURCE: ${{ secrets.PR_REVIEW_MERGE_TOKEN != '' && 'PR_REVIEW_MERGE_TOKEN' || secrets.OPENCODE_APPROVE_TOKEN != '' && 'OPENCODE_APPROVE_TOKEN' || steps.opencode_app_token.outputs.available == 'true' && 'opencode-app' || 'github-token' }} run: | set -euo pipefail if [ -z "${PR_HEAD_SHA:-}" ]; then @@ -6649,7 +6670,7 @@ jobs: description="OpenCode workflow_dispatch evidence did not produce approval evidence." fi - printf 'Publishing OpenCode workflow_dispatch status context opencode-review for %s at %s with state=%s.\n' "$GH_REPOSITORY" "$PR_HEAD_SHA" "$state" + printf 'Publishing OpenCode workflow_dispatch status context opencode-review for %s at %s with state=%s using %s token.\n' "$GH_REPOSITORY" "$PR_HEAD_SHA" "$state" "${OPENCODE_STATUS_TOKEN_SOURCE:-configured}" gh api -X POST "repos/${GH_REPOSITORY}/statuses/${PR_HEAD_SHA}" \ -f state="$state" \ -f context="opencode-review" \ diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index a4a36611a..49ec053da 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -634,6 +634,14 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert 'if [ "${CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE:-false}" != "true" ]' not in current_head_fallback assert 'if [ "${GH_REPOSITORY:-}" != "ContextualWisdomLab/.github" ]' not in current_head_fallback assert "collect_open_code_scanning_alerts" in workflow + assert ( + "CODE_SCANNING_GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || " + "secrets.OPENCODE_APPROVE_TOKEN || steps.opencode_app_token.outputs.token || " + "github.token }}" + ) in workflow + assert "CODE_SCANNING_TOKEN_SOURCE" in workflow + assert 'GH_TOKEN="$scan_token" timeout "$(check_lookup_api_timeout_seconds)s"' in workflow + assert "Open code-scanning alert lookup skipped because no target-repository read token" in workflow assert "production source 또는 package manifest 변경이 없습니다" not in workflow assert "needs.coverage-evidence.result != 'cancelled'" in workflow assert "request_changes_for_coverage_evidence_failure" in workflow @@ -884,11 +892,16 @@ def test_opencode_runs_merge_scheduler_after_review_without_repo_local_dispatch( assert "python3 scripts/ci/pr_review_merge_scheduler.py" in workflow assert "gh workflow run pr-review-merge-scheduler.yml" not in workflow assert "github.event_name == 'pull_request_target'" in workflow + status_step = workflow.split(" - name: Publish workflow_dispatch OpenCode status", 1)[1].split( + " - name: Run merge scheduler after approval", 1 + )[0] assert ( "GH_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || " "secrets.OPENCODE_APPROVE_TOKEN || steps.opencode_app_token.outputs.token || " "github.token }}" - ) in workflow + ) in status_step + assert "OPENCODE_STATUS_TOKEN_SOURCE" in status_step + assert "using %s token" in status_step assert "SCHEDULER_ACTIONS_TOKEN: ${{ github.token }}" in workflow assert ( "SCHEDULER_READ_TOKEN: ${{ (github.event_name == 'pull_request_target' || " From 0c4abb576442b44205b7a0c017c793bea7927194 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 10:43:51 +0900 Subject: [PATCH 3/7] fix(merge): defer fallback review retry loops --- scripts/ci/pr_review_merge_scheduler.py | 20 +++++++++++ scripts/ci/test_strix_quick_gate.sh | 10 +++--- tests/test_pr_review_merge_scheduler.py | 44 +++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 01380f800..29f43190c 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -1079,6 +1079,17 @@ def is_deterministic_fallback_approval(review: dict[str, Any]) -> bool: return any(marker in body for marker in DETERMINISTIC_APPROVAL_MARKERS) +def has_current_head_deterministic_fallback_approval(pr: dict[str, Any]) -> bool: + """Return whether OpenCode's latest current-head review is fallback-only.""" + for review in reversed((pr.get("reviews") or {}).get("nodes") or []): + if not is_opencode_review(review): + continue + if not review_matches_current_head(review, pr): + continue + return is_deterministic_fallback_approval(review) + return False + + 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() @@ -2046,6 +2057,15 @@ def request_branch_update(freshness_reason: str, *, suffix: str = "") -> Decisio if opencode_state == "running": return decide("wait", "OpenCode review is already in progress") + if ( + os.environ.get("GITHUB_EVENT_NAME") == "workflow_run" + and has_current_head_deterministic_fallback_approval(pr) + ): + return decide( + "wait", + "current-head deterministic fallback is not merge evidence; defer real-model retry to the next scheduler heartbeat", + ) + if behind_by and trigger_reviews: if not update_branches: return decide("wait", "current head has no OpenCode approval; branch update disabled before review dispatch") diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 2e01e874e..a22a41550 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -571,11 +571,11 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "skipping remaining attempts for this model" "opencode review skips same-model retries after context-window overflow" assert_file_contains "$workflow_file" 'timeout-minutes: 45' "opencode review target releases stalled review runners within the bounded queue budget" assert_file_contains "$workflow_file" 'timeout-minutes: 12' "opencode evidence preparation fails closed before it ties up the review queue" - assert_file_contains "$workflow_file" 'timeout-minutes: 30' "opencode model pool gives multiple candidates a bounded review window while capping stalled model attempts" + assert_file_contains "$workflow_file" 'timeout-minutes: 12' "opencode model pool gives multiple candidates a bounded review window while capping stalled model attempts" assert_file_contains "$workflow_file" 'timeout-minutes: 8' "opencode approval publication is bounded so a stalled review releases queue capacity promptly" assert_file_contains "$workflow_file" 'continue-on-error: true' "opencode approval gate still runs after model-pool failure to publish a reason" - assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' "opencode primary review advances after a ten-minute stalled provider attempt" - assert_file_contains "$workflow_file" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "1500"' "opencode model pool exits before the step timeout so the approval gate can publish a reason" + assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "180"' "opencode primary review advances after a bounded stalled provider attempt" + assert_file_contains "$workflow_file" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "540"' "opencode model pool exits before the step timeout so the approval gate can publish a reason" assert_file_contains "$workflow_file" 'OPENCODE_POOL_MAX_CYCLES: "1"' "opencode model pool visits the candidate catalog once without reoccupying the queue" assert_file_contains "$workflow_file" "needs.coverage-evidence.result == 'success'" "opencode model pool only runs after coverage evidence passed" assert_file_contains "$workflow_file" "id: opencode_review_model_pool" "opencode DeepSeek V3 fallback still runs after a primary model timeout or step failure when coverage evidence passed" @@ -705,7 +705,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_not_contains "$workflow_file" "no model produced a valid review control block" "opencode model-failure path no longer documents a final exhausted state" assert_file_contains "$workflow_file" 'OPENCODE_MODEL_ATTEMPTS: "1"' "opencode primary and fallback paths avoid multi-attempt stalls on one model" assert_file_contains "$workflow_file" 'OPENCODE_MODEL_ATTEMPTS: "1"' "opencode catalog fallback tries each model once before moving on" - assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' "opencode catalog fallback advances after a ten-minute stalled provider attempt" + assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "180"' "opencode catalog fallback advances after a bounded stalled provider attempt" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "OpenCode %s attempt %s/%s failed" "opencode catalog fallback records per-model retry failures" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "exponential backoff" "opencode model retry paths use exponential backoff instead of fixed sleeps" assert_file_contains "$workflow_file" "github-models/deepseek/deepseek-v3-0324 openai/gpt-5 github-models/openai/gpt-5" "opencode review tries DeepSeek V3 before OpenAI fallbacks" @@ -992,7 +992,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "collect_failed_check_evidence.sh" "opencode review workflow collects failed check logs and annotations" assert_file_contains "$workflow_file" 'HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.inputs.pr_head_sha }}' "opencode evidence step passes HEAD_SHA to failed-check evidence collection" assert_file_contains "$workflow_file" "FAILED_CHECK_EVIDENCE_ATTEMPTS" "opencode review workflow bounds waiting for peer check failures before model review" - assert_file_contains "$workflow_file" 'timeout-minutes: 30' "opencode model stage has a bounded multi-provider timeout" + assert_file_contains "$workflow_file" 'timeout-minutes: 12' "opencode model stage has a bounded multi-provider timeout" assert_file_contains "$workflow_file" 'timeout-minutes: 12' "opencode evidence preparation has a bounded peer-check wait timeout" assert_file_contains "$workflow_file" 'FAILED_CHECK_EVIDENCE_ATTEMPTS: "6"' "opencode review workflow keeps pre-model peer-check waiting bounded for required workflow DX" assert_file_contains "$workflow_file" 'FAILED_CHECK_EVIDENCE_SLEEP_SECONDS: "5"' "opencode review workflow retries peer-check evidence without stalling the model stage for Strix-scale durations" diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 3d80474d9..8737e55b7 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -1114,6 +1114,50 @@ def test_review_state_and_failed_checks(): assert sched.failed_status_checks(manual_strix_supersedes_pr_target_failure) == ["lint"] +def test_workflow_run_followup_defers_deterministic_fallback_retry(monkeypatch): + head = "a" * 40 + fallback_review = { + **opencode_review("APPROVED", head), + "body": ( + "OpenCode model providers were unavailable, so deterministic current-head evidence " + f"was used.\n\n- Head SHA: `{head}`" + ), + } + pr = make_pr( + headRefOid=head, + reviews={"nodes": [fallback_review]}, + statusCheckRollup={ + "contexts": { + "nodes": [ + strix_check(), + opencode_check(status="COMPLETED"), + ] + } + }, + ) + dispatched = [] + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, current_pr, dry_run: dispatched.append( + (repo, workflow, current_pr["headRefOid"], dry_run) + ), + ) + + monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_run") + followup = inspect(pr) + + assert followup.action == "wait" + assert "next scheduler heartbeat" in followup.reason + assert dispatched == [] + + monkeypatch.setenv("GITHUB_EVENT_NAME", "schedule") + heartbeat = inspect(pr) + + assert heartbeat.action == "review_dispatch" + assert dispatched == [("owner/repo", "OpenCode Review", head, True)] + + def test_current_head_approval_cleans_previous_head_change_gate_before_merge(): pr = make_pr( reviews={ From 6b2bc6b15677941ab3be9aff547ea871164ab575 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 10:53:18 +0900 Subject: [PATCH 4/7] test(merge): cover fallback review filtering --- tests/test_pr_review_merge_scheduler.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 8737e55b7..9b75aa93e 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -1158,6 +1158,29 @@ def test_workflow_run_followup_defers_deterministic_fallback_retry(monkeypatch): assert dispatched == [("owner/repo", "OpenCode Review", head, True)] +def test_deterministic_fallback_detection_ignores_unrelated_reviews(): + head = "a" * 40 + pr = make_pr( + headRefOid=head, + reviews={ + "nodes": [ + opencode_review("APPROVED", head), + opencode_review("APPROVED", "b" * 40), + { + "state": "APPROVED", + "author": {"login": "human-reviewer"}, + "commit": {"oid": head}, + }, + ] + }, + ) + + assert not sched.has_current_head_deterministic_fallback_approval(pr) + assert not sched.has_current_head_deterministic_fallback_approval( + make_pr(reviews={"nodes": []}) + ) + + def test_current_head_approval_cleans_previous_head_change_gate_before_merge(): pr = make_pr( reviews={ From 9e9c93a2095cf6af5d18e98170faca752c9a0298 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 10:54:36 +0900 Subject: [PATCH 5/7] test(merge): cover deterministic fallback review scan --- tests/test_pr_review_merge_scheduler.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 9b75aa93e..e237d485d 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -989,7 +989,19 @@ def test_review_state_and_failed_checks(): assert not sched.is_deterministic_fallback_approval( opencode_review("CHANGES_REQUESTED", exact_head) ) + assert sched.has_current_head_deterministic_fallback_approval(deterministic_fallback) assert not sched.has_current_head_approval(deterministic_fallback) + fallback_scan_without_current_opencode = make_pr( + reviews={ + "nodes": [ + opencode_review("APPROVED", "old"), + opencode_review("APPROVED", "head", login="human"), + ] + } + ) + assert not sched.has_current_head_deterministic_fallback_approval( + fallback_scan_without_current_opencode + ) stale_review = make_pr( reviews={ "nodes": [ From cd4fa5635df84cbeaf89656ce41c78b7d28c7729 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 11:58:48 +0900 Subject: [PATCH 6/7] fix(opencode): accept body head SHA approvals --- scripts/ci/pr_review_merge_scheduler.py | 8 ++- tests/test_pr_review_merge_scheduler.py | 73 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 366a0660c..1162c3790 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -972,10 +972,14 @@ def review_matches_current_head(review: dict[str, Any], pr: dict[str, Any]) -> b """Return whether a review is valid evidence for the current head commit.""" head = pr.get("headRefOid") commit = (review.get("commit") or {}).get("oid") - if not head or commit != head: + if not head: return False body_head = review_body_head_sha(review) - return body_head is None or body_head.lower() == head.lower() + if commit == head: + return body_head is None or body_head.lower() == head.lower() + if not commit and body_head is not None: + return body_head.lower() == head.lower() + return False def review_body_head_sha(review: dict[str, Any]) -> str | None: diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index e2bb14658..d69940d32 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -968,6 +968,42 @@ def test_review_state_and_failed_checks(): }, ) assert sched.has_current_head_approval(body_sha_match) + body_sha_only_match = make_pr( + headRefOid=exact_head, + reviews={ + "nodes": [ + { + **opencode_review("APPROVED", ""), + "body": f"## Gate evidence\n\n- Head SHA: `{exact_head}`", + } + ] + }, + ) + assert sched.has_current_head_approval(body_sha_only_match) + body_sha_only_mismatch = make_pr( + headRefOid=exact_head, + reviews={ + "nodes": [ + { + **opencode_review("APPROVED", ""), + "body": f"## Gate evidence\n\n- Head SHA: `{stale_body_head}`", + } + ] + }, + ) + assert not sched.has_current_head_approval(body_sha_only_mismatch) + body_sha_does_not_override_commit = make_pr( + headRefOid=exact_head, + reviews={ + "nodes": [ + { + **opencode_review("APPROVED", stale_body_head), + "body": f"## Gate evidence\n\n- Head SHA: `{exact_head}`", + } + ] + }, + ) + assert not sched.has_current_head_approval(body_sha_does_not_override_commit) deterministic_fallback = make_pr( headRefOid=exact_head, reviews={ @@ -1170,6 +1206,43 @@ def test_workflow_run_followup_defers_deterministic_fallback_retry(monkeypatch): assert dispatched == [("owner/repo", "OpenCode Review", head, True)] +def test_body_head_sha_approval_prevents_same_run_opencode_rerun(monkeypatch): + head = "a" * 40 + pr = make_pr( + headRefOid=head, + reviews={ + "nodes": [ + { + **opencode_review("APPROVED", ""), + "body": f"## Gate evidence\n\n- Head SHA: `{head}`", + } + ] + }, + statusCheckRollup={ + "contexts": { + "nodes": [ + strix_check(), + opencode_check(status="COMPLETED"), + ] + } + }, + ) + dispatched = [] + monkeypatch.setattr( + sched, + "dispatch_opencode_review", + lambda repo, workflow, current_pr, dry_run: dispatched.append( + (repo, workflow, current_pr["headRefOid"], dry_run) + ), + ) + + decision = inspect(pr) + + assert decision.action == "auto_merge" + assert "current head is approved" in decision.reason + assert dispatched == [] + + def test_deterministic_fallback_detection_ignores_unrelated_reviews(): head = "a" * 40 pr = make_pr( From 4800ff50426dfd55964aac3876336efce7baf285 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 12:04:27 +0900 Subject: [PATCH 7/7] test(opencode): cover missing review head guard --- tests/test_pr_review_merge_scheduler.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index d69940d32..4ce73c9c0 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -941,6 +941,9 @@ def test_review_state_and_failed_checks(): assert sched.current_head_review_state(pr, "APPROVED") assert sched.has_current_head_approval(pr) assert not sched.has_current_head_changes_requested(pr) + assert not sched.has_current_head_approval( + make_pr(headRefOid="", reviews={"nodes": [opencode_review("APPROVED", "head")]}) + ) exact_head = "a" * 40 stale_body_head = "b" * 40 body_sha_mismatch = make_pr(