From ebd58ee6aedc0929656a2facf76c4884f1bf61f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 02:50:33 +0900 Subject: [PATCH 1/3] fix(opencode): check uv lock consistency in coverage evidence --- .github/workflows/opencode-review.yml | 9 +++++++++ scripts/ci/test_strix_quick_gate.sh | 2 ++ tests/test_opencode_agent_contract.py | 6 ++++++ 3 files changed, 17 insertions(+) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 87ad910e..eb4dcbec 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -496,6 +496,14 @@ jobs: PY } + run_python_uv_lock_check() { + local project_dir="$1" + if [ -f "${project_dir}/uv.lock" ]; then + run_and_capture "Python uv lockfile consistency (${project_dir})" \ + bash -c 'cd "$1" && uv lock --check' bash "$project_dir" + fi + } + install_python_project_dependencies() { if [ -f requirements.txt ]; then run_and_capture "Python project dependencies (requirements.txt)" \ @@ -505,6 +513,7 @@ jobs: while IFS= read -r project_dir; do pyproject_file="${project_dir}/pyproject.toml" if [ -f "$pyproject_file" ]; then + run_python_uv_lock_check "$project_dir" if pyproject_has_dev_dependency_group "$pyproject_file"; then run_and_capture "Python project dependencies (${project_dir})" \ uv sync --project "$project_dir" --group dev diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 39872a64..24e62459 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -778,6 +778,8 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$workflow_file" "uv run --with-requirements requirements.txt" "opencode coverage evidence resolves repository Python requirements before pytest" assert_file_contains "$workflow_file" "'requirements.txt' '*/requirements.txt'" "opencode coverage evidence discovers nested requirements-only Python test projects" assert_file_contains "$workflow_file" "Python project dependencies (\${project_dir}/requirements.txt)" "opencode coverage evidence installs nested requirements-only Python project dependencies" + assert_file_contains "$workflow_file" "Python uv lockfile consistency (\${project_dir})" "opencode coverage evidence logs uv lockfile drift before installing uv-managed Python dependencies" + assert_file_contains "$workflow_file" "uv lock --check" "opencode coverage evidence rejects stale uv lockfiles before pytest" assert_file_contains "$workflow_file" "uv sync --project" "opencode coverage evidence installs uv-managed Python project dependencies before pytest" assert_file_contains "$workflow_file" 'cd "$1" && uv run --with-requirements requirements.txt' "opencode coverage evidence resolves requirements inside uv-managed project environments" assert_file_contains "$workflow_file" "--extra dev" "opencode coverage evidence installs pyproject optional dev extras when repositories do not use dependency-groups" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 5ca09edc..f2bc8759 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -249,6 +249,12 @@ def test_opencode_target_coverage_materializes_merge_tree_without_checkout_actio assert "rust_coverage_fail_under_lines()" in measure_step assert "package.metadata.opencode.coverage.minimum_lines" in measure_step assert '--fail-under-lines "$threshold"' in measure_step + assert "run_python_uv_lock_check()" in measure_step + assert "Python uv lockfile consistency (${project_dir})" in measure_step + assert "uv lock --check" in measure_step + assert measure_step.index('run_python_uv_lock_check "$project_dir"') < measure_step.index( + 'uv sync --project "$project_dir" --group dev' + ) def test_opencode_coverage_prefers_declared_pnpm_runner_before_npm(): From a217d6995a1dbff769a27b50d3d8041e41f09dcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 03:11:03 +0900 Subject: [PATCH 2/3] fix(opencode): bound review queue cadence --- .github/workflows/opencode-review.yml | 27 ++++++++++--------- .../workflows/pr-review-merge-scheduler.yml | 8 +++--- scripts/ci/pr_review_merge_scheduler.py | 12 ++++----- scripts/ci/run_opencode_review_model_pool.sh | 6 ++--- scripts/ci/test_strix_quick_gate.sh | 18 ++++++------- tests/test_opencode_agent_contract.py | 15 ++++++----- .../test_required_workflow_queue_contract.py | 2 +- 7 files changed, 46 insertions(+), 42 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index eb4dcbec..d5060866 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -1515,7 +1515,11 @@ jobs: ) ) runs-on: ubuntu-latest - timeout-minutes: 420 + # Coverage and current-head evidence are prepared before the model pool. + # The model pool has a 25-minute retry budget and its publish gate has an + # 8-minute cap, so 45 minutes releases genuinely hung runners without + # truncating the bounded multi-provider review path. + timeout-minutes: 45 permissions: actions: read checks: read @@ -3201,7 +3205,7 @@ jobs: - name: Run OpenCode PR Review model pool id: opencode_review_model_pool if: needs.coverage-evidence.result == 'success' - timeout-minutes: 210 + timeout-minutes: 30 continue-on-error: true env: STRIX_GITHUB_MODELS_TOKEN: ${{ secrets.STRIX_GITHUB_MODELS_TOKEN || github.token }} @@ -3225,17 +3229,16 @@ 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" - # 90 minutes gives DeepSeek V3 enough room for deep tool-using review. - # The retry budget still bounds queue impact: DeepSeek gets a full - # window, then one strong fallback can run before publish fails closed - # with visible provider/check evidence. - OPENCODE_RUN_TIMEOUT_SECONDS: "5400" + # A provider gets ten minutes for a tool-using review before the pool + # advances. This keeps one stalled endpoint from occupying the org + # queue while still allowing multiple high-sensitivity candidates. + OPENCODE_RUN_TIMEOUT_SECONDS: "600" OPENCODE_EXPORT_TIMEOUT_SECONDS: "120" - OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11400" - # Allow one retry catalog pass for transient provider/model-output - # failures; the retry budget still fails closed with visible - # diagnostics before the 210-minute step timeout. - OPENCODE_POOL_MAX_CYCLES: "2" + OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "1500" + # 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. + OPENCODE_POOL_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" diff --git a/.github/workflows/pr-review-merge-scheduler.yml b/.github/workflows/pr-review-merge-scheduler.yml index e7556fcf..77920382 100644 --- a/.github/workflows/pr-review-merge-scheduler.yml +++ b/.github/workflows/pr-review-merge-scheduler.yml @@ -53,7 +53,7 @@ on: stale_opencode_minutes: description: Redispatch OpenCode Review when an in-progress OpenCode check is older than this many minutes required: false - default: "420" + default: "90" type: string project_flow: description: Project flow, usually github-flow or git-flow @@ -122,7 +122,7 @@ on: stale_opencode_minutes: description: Redispatch OpenCode Review when an in-progress OpenCode check is older than this many minutes required: false - default: "420" + default: "90" concurrency: group: >- @@ -195,7 +195,7 @@ jobs: ENABLE_AUTO_MERGE: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || inputs.enable_auto_merge == true }} MERGE_MODE: ${{ inputs.merge_mode || vars.PR_MERGE_MODE || 'direct_or_auto' }} UPDATE_BRANCHES: ${{ github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'pull_request_target' || github.event_name == 'workflow_run' || inputs.update_branches == true }} - STALE_OPENCODE_MINUTES: ${{ inputs.stale_opencode_minutes || vars.STALE_OPENCODE_MINUTES || '420' }} + STALE_OPENCODE_MINUTES: ${{ inputs.stale_opencode_minutes || vars.STALE_OPENCODE_MINUTES || '90' }} steps: - name: Exchange OpenCode app token for scheduler mutations id: scheduler_app_token @@ -440,7 +440,7 @@ jobs: # if MORE than this many repositories become unreachable at once, the whole # credential likely broke and the job fails loudly. ORG_SWEEP_MAX_UNAVAILABLE: ${{ vars.ORG_SWEEP_MAX_UNAVAILABLE || '5' }} - STALE_OPENCODE_MINUTES: ${{ inputs.stale_opencode_minutes || vars.STALE_OPENCODE_MINUTES || '420' }} + STALE_OPENCODE_MINUTES: ${{ inputs.stale_opencode_minutes || vars.STALE_OPENCODE_MINUTES || '90' }} steps: - name: Exchange OpenCode app token for sweep mutations id: sweep_app_token diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 7817a44b..94bda55f 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -109,13 +109,11 @@ """ + PULL_REQUEST_FIELDS_FRAGMENT OPEN_PRS_PAGE_SIZE = 25 -# Must exceed the opencode-review job timeout (120 min) plus typical runner-queue -# wait. QUEUED counts as running and the age clock starts at check creation, so a -# 45-minute threshold marked every queued/long review "stale" and re-dispatched -# it; each re-dispatch went to the back of the runner queue and itself went -# stale, multiplying load until the org queue saturated. 420 only recovers -# genuinely zombie checks that outlive any legitimate run. -DEFAULT_STALE_OPENCODE_MINUTES = 420 +# Must exceed the 45-minute OpenCode job cap plus typical runner-queue wait. +# QUEUED counts as running and the age clock starts at check creation, so this +# remains deliberately larger than the job cap while recovering genuine zombie +# checks in the same operating window instead of leaving them for seven hours. +DEFAULT_STALE_OPENCODE_MINUTES = 90 DEFAULT_UPDATE_BRANCH_HEAD_POLL_ATTEMPTS = 6 DEFAULT_UPDATE_BRANCH_HEAD_POLL_SECONDS = 5.0 OPENCODE_WORKFLOW_NAMES = {"OpenCode Review", "Required OpenCode Review"} diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 96d41ea4..1175a311 100644 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -241,7 +241,7 @@ run_one_model_attempt() { local opencode_export_file="$8" local run_timeout_seconds export_timeout_seconds opencode_status session_id opencode_stderr_file - run_timeout_seconds="${OPENCODE_RUN_TIMEOUT_SECONDS:-5400}" + run_timeout_seconds="${OPENCODE_RUN_TIMEOUT_SECONDS:-600}" export_timeout_seconds="${OPENCODE_EXPORT_TIMEOUT_SECONDS:-120}" opencode_stderr_file="${opencode_json_file}.stderr" @@ -303,8 +303,8 @@ main() { local -a model_candidates attempts="${OPENCODE_MODEL_ATTEMPTS:-3}" - original_run_timeout="${OPENCODE_RUN_TIMEOUT_SECONDS:-5400}" - budget_seconds="${OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-11400}" + original_run_timeout="${OPENCODE_RUN_TIMEOUT_SECONDS:-600}" + budget_seconds="${OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500}" max_cycles="${OPENCODE_POOL_MAX_CYCLES:-0}" if [ "${CENTRAL_REVIEW_PROCESS_FALLBACK_ELIGIBLE:-false}" = "true" ]; then original_run_timeout="${OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_RUN_TIMEOUT_SECONDS:-300}" diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 24e62459..28a9f5cf 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -431,7 +431,7 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_not_contains "$workflow_file" "contents: write" "opencode review workflow does not need repository contents write scope" assert_file_contains "$workflow_file" "pull-requests: write" "opencode review workflow may use github-actions[bot] for same-repository review-thread, update-branch, auto-merge, and merge follow-up" assert_file_contains "$workflow_file" "issues: write" "opencode review workflow can publish or update overview comments through the job token" - assert_file_contains "$workflow_file" "statuses: read" "opencode review workflow can read failed status contexts for approval gating" + assert_file_contains "$workflow_file" "statuses: write" "opencode review workflow can read status contexts and publish the workflow_dispatch status evidence it owns" assert_file_contains "$workflow_file" "Prepare bounded OpenCode review evidence" "opencode review workflow prepares bounded local evidence instead of oversized GitHub prompt data" assert_file_contains "$workflow_file" "emit_file_prefix" "opencode review prompt evidence is byte-capped before GitHub Models requests" assert_file_contains "$workflow_file" "bounded-review-evidence.md" "opencode review prompt reads bounded evidence from the isolated workspace instead of inlining it" @@ -569,14 +569,14 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "not a generic model-exhaustion message" "opencode review tells models to return concrete missing-evidence findings instead of progress-only output" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "tokens_limit_reached" "opencode review detects provider context-window overflow" 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: 420' "opencode review target keeps a bounded runner budget so deep reviews can finish and stalled reviews still release queue capacity" + 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: 210' "opencode model pool gives DeepSeek V3 and one strong fallback a bounded deep-review window while capping stalled model attempts" + 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: 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: "5400"' "opencode primary review gives DeepSeek V3 90 minutes before trying fallback models" - assert_file_contains "$workflow_file" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11400"' "opencode model pool exits before the job timeout so the approval gate can publish a reason" - assert_file_contains "$workflow_file" 'OPENCODE_POOL_MAX_CYCLES: "2"' "opencode model pool allows one retry candidate pass without looping to the job timeout" + 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_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" assert_file_contains "$workflow_file" "always()" "opencode fallback chain uses always() so failed model steps cannot skip every fallback" @@ -701,11 +701,11 @@ assert_opencode_review_uses_codegraph_and_gpt5_fallback() { assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "OpenCode model pool has no configured model candidates." "opencode model pool fails fast when no candidates are configured" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "OPENAI_API_KEY is not configured" "opencode model pool skips native OpenAI candidates when the org secret is absent" assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" "configured max cycle count" "opencode model pool exits before the job timeout after configured cycles" - assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-11400' "opencode model pool keeps a bounded default retry budget unless the workflow explicitly disables it" + assert_file_contains "$REPO_ROOT/scripts/ci/run_opencode_review_model_pool.sh" 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500' "opencode model pool keeps a bounded default retry budget unless the workflow explicitly disables it" 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: "5400"' "opencode catalog fallback has a 90-minute per-model review timeout before step timeout" + assert_file_contains "$workflow_file" 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' "opencode catalog fallback advances after a ten-minute 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" @@ -994,7 +994,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: 210' "opencode model stage has a bounded but deep-review-capable timeout" + 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 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_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index f2bc8759..019eea94 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -599,9 +599,12 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert '"## Check outcome"' not in workflow assert "publish REQUEST_CHANGES when coverage-evidence blocker states" in workflow 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: 420", 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: 210", 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 '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) assert 'APPROVAL_CHECK_WAIT_ATTEMPTS: "12"' in workflow @@ -619,10 +622,10 @@ 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: "5400"' in workflow + assert 'OPENCODE_RUN_TIMEOUT_SECONDS: "600"' in workflow assert 'OPENCODE_EXPORT_TIMEOUT_SECONDS: "120"' in workflow - assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11400"' in workflow - assert 'OPENCODE_POOL_MAX_CYCLES: "2"' in workflow + assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "1500"' in workflow + assert 'OPENCODE_POOL_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( " - name: Run merge scheduler after approval", 1 @@ -670,7 +673,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "OPENAI_API_KEY is not configured" in model_pool_runner assert "configured max cycle count" in model_pool_runner assert "OpenCode model pool has no configured model candidates." in model_pool_runner - assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-11400' in model_pool_runner + assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-1500' in model_pool_runner assert "completed a full model-candidate cycle without a valid control conclusion" in model_pool_runner assert "retry budget/GitHub Actions job timeout" in model_pool_runner assert "OpenCode model pool exhausted before producing a valid control conclusion." in model_pool_runner diff --git a/tests/test_required_workflow_queue_contract.py b/tests/test_required_workflow_queue_contract.py index be59fce7..e0cd6497 100644 --- a/tests/test_required_workflow_queue_contract.py +++ b/tests/test_required_workflow_queue_contract.py @@ -339,7 +339,7 @@ def test_org_queue_sweep_manual_cadence_inputs_reach_the_sweep_job() -> None: ) in workflow assert ( "STALE_OPENCODE_MINUTES: ${{ inputs.stale_opencode_minutes || " - "vars.STALE_OPENCODE_MINUTES || '420' }}" + "vars.STALE_OPENCODE_MINUTES || '90' }}" ) in workflow assert ( "ORG_SWEEP_MAX_PRS: ${{ inputs.max_prs || vars.ORG_SWEEP_MAX_PRS || '1000' }}" From 1d522a65c0efa6894d91db540981ebd119672192 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 13 Jul 2026 03:15:02 +0900 Subject: [PATCH 3/3] fix(opencode): bound model-pool cadence by PR size --- .github/workflows/opencode-review.yml | 20 ++++++-- scripts/ci/run_opencode_review_model_pool.sh | 53 ++++++++++++++++++++ tests/test_opencode_agent_contract.py | 8 +++ tests/test_opencode_model_pool_runner.py | 32 ++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index bf002e74..2fe2d52c 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -3254,10 +3254,9 @@ 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" - # 90 minutes gives DeepSeek V3 enough room for deep tool-using review. - # The retry budget still bounds queue impact: DeepSeek gets a full - # window, then one strong fallback can run before publish fails closed - # with visible provider/check evidence. + # Base ceiling for large or explicitly configured reviews. Dynamic + # cadence below shortens small/medium PRs so a hung provider emits a + # timeout reason and falls through without holding the org queue. OPENCODE_RUN_TIMEOUT_SECONDS: "5400" OPENCODE_EXPORT_TIMEOUT_SECONDS: "120" OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11400" @@ -3265,6 +3264,18 @@ jobs: # failures; the retry budget still fails closed with visible # diagnostics before the 210-minute step timeout. OPENCODE_POOL_MAX_CYCLES: "2" + OPENCODE_DYNAMIC_REVIEW_CADENCE: "true" + OPENCODE_SMALL_CHANGE_FILE_THRESHOLD: "3" + OPENCODE_MEDIUM_CHANGE_FILE_THRESHOLD: "20" + OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS: "900" + OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS: "2100" + OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "1800" + OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS: "3900" + OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "3600" + OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS: "7200" + OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS: "1800" + OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS: "3900" + 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" @@ -3275,6 +3286,7 @@ jobs: OPENCODE_FIRST_ATTEMPT_AGENT: ci-review OPENCODE_AGENT: ci-review-fallback OPENCODE_EVIDENCE_FILE: ${{ runner.temp }}/opencode-review-evidence.md + OPENCODE_CHANGED_FILES_FILE: ${{ runner.temp }}/opencode-changed-files.txt OPENCODE_OUTPUT_FILE: ${{ runner.temp }}/opencode-review-model-pool.md OPENCODE_REVIEW_WORKDIR: ${{ runner.temp }}/opencode-review-project OPENCODE_SOURCE_WORKDIR: ${{ runner.temp }}/opencode-pr-head diff --git a/scripts/ci/run_opencode_review_model_pool.sh b/scripts/ci/run_opencode_review_model_pool.sh index 21d765d5..a7fe79ab 100644 --- a/scripts/ci/run_opencode_review_model_pool.sh +++ b/scripts/ci/run_opencode_review_model_pool.sh @@ -58,6 +58,34 @@ backoff_sleep() { printf '%s\n' "$sleep_for" } +is_non_negative_integer() { + case "${1:-}" in + "" | *[!0-9]*) return 1 ;; + *) return 0 ;; + esac +} + +env_integer_or_default() { + local name="$1" + local default_value="$2" + local value="${!name:-}" + + if is_non_negative_integer "$value"; then + printf '%s\n' "$value" + else + printf '%s\n' "$default_value" + fi +} + +count_changed_files_for_cadence() { + local changed_files_file="${OPENCODE_CHANGED_FILES_FILE:-}" + + if [ -z "$changed_files_file" ] || [ ! -f "$changed_files_file" ]; then + return 1 + fi + awk 'NF { count += 1 } END { printf "%d\n", count + 0 }' "$changed_files_file" +} + should_inline_prompt_evidence_excerpt() { local model_candidate="$1" @@ -349,6 +377,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 max_cycles + local changed_file_count small_file_threshold medium_file_threshold local -a model_candidates attempts="${OPENCODE_MODEL_ATTEMPTS:-3}" @@ -361,6 +390,30 @@ main() { max_cycles="${OPENCODE_CENTRAL_REVIEW_PROCESS_FALLBACK_MAX_CYCLES:-1}" printf 'Central review-process evidence fallback eligible for scope "%s"; limiting OpenCode model pool to %ss per attempt, %ss total budget, and %s cycle(s) so provider delay is logged before the publish fallback evaluates current-head peer evidence.\n' \ "${CENTRAL_REVIEW_PROCESS_FALLBACK_SCOPE_LABEL:-unsupported}" "$original_run_timeout" "$budget_seconds" "$max_cycles" + elif [ "${OPENCODE_DYNAMIC_REVIEW_CADENCE:-false}" = "true" ]; then + small_file_threshold="$(env_integer_or_default OPENCODE_SMALL_CHANGE_FILE_THRESHOLD 3)" + medium_file_threshold="$(env_integer_or_default OPENCODE_MEDIUM_CHANGE_FILE_THRESHOLD 20)" + if changed_file_count="$(count_changed_files_for_cadence)"; then + if [ "$changed_file_count" -le "$small_file_threshold" ]; then + original_run_timeout="$(env_integer_or_default OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS 900)" + budget_seconds="$(env_integer_or_default OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS 2100)" + elif [ "$changed_file_count" -le "$medium_file_threshold" ]; then + original_run_timeout="$(env_integer_or_default OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS 1800)" + budget_seconds="$(env_integer_or_default OPENCODE_MEDIUM_CHANGE_TOTAL_BUDGET_SECONDS 3900)" + else + original_run_timeout="$(env_integer_or_default OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS 3600)" + budget_seconds="$(env_integer_or_default OPENCODE_LARGE_CHANGE_TOTAL_BUDGET_SECONDS 7200)" + fi + max_cycles="$(env_integer_or_default OPENCODE_DYNAMIC_MAX_CYCLES 1)" + printf 'OpenCode dynamic review cadence selected %ss per attempt and %ss total budget for %s changed file(s); max-cycles=%s.\n' \ + "$original_run_timeout" "$budget_seconds" "$changed_file_count" "$max_cycles" + else + original_run_timeout="$(env_integer_or_default OPENCODE_UNKNOWN_CHANGE_RUN_TIMEOUT_SECONDS 1800)" + budget_seconds="$(env_integer_or_default OPENCODE_UNKNOWN_CHANGE_TOTAL_BUDGET_SECONDS 3900)" + max_cycles="$(env_integer_or_default OPENCODE_DYNAMIC_MAX_CYCLES 1)" + printf 'OpenCode dynamic review cadence could not read OPENCODE_CHANGED_FILES_FILE; using %ss per attempt and %ss total budget; max-cycles=%s.\n' \ + "$original_run_timeout" "$budget_seconds" "$max_cycles" + fi fi deadline=0 if [ "$budget_seconds" -gt 0 ]; then diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index c4ea4020..43a9cd76 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -668,6 +668,12 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert 'OPENCODE_EXPORT_TIMEOUT_SECONDS: "120"' in workflow assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "11400"' in workflow assert 'OPENCODE_POOL_MAX_CYCLES: "2"' 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: "900"' in workflow + assert 'OPENCODE_MEDIUM_CHANGE_RUN_TIMEOUT_SECONDS: "1800"' in workflow + assert 'OPENCODE_LARGE_CHANGE_RUN_TIMEOUT_SECONDS: "3600"' 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( " - name: Run merge scheduler after approval", 1 @@ -714,6 +720,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "mini/nano review models are disabled" in model_pool_runner assert "OPENAI_API_KEY is not configured" in model_pool_runner assert "configured max cycle count" in model_pool_runner + assert "OpenCode dynamic review cadence selected %ss per attempt" in model_pool_runner + assert "count_changed_files_for_cadence" in model_pool_runner assert "OpenCode model pool has no configured model candidates." in model_pool_runner assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS:-11400' in model_pool_runner assert "completed a full model-candidate cycle without a valid control conclusion" in model_pool_runner diff --git a/tests/test_opencode_model_pool_runner.py b/tests/test_opencode_model_pool_runner.py index 69b53f57..96572944 100644 --- a/tests/test_opencode_model_pool_runner.py +++ b/tests/test_opencode_model_pool_runner.py @@ -62,6 +62,8 @@ def run_failed_model( json_line: str = "", stderr_line: str = "", evidence_excerpt: str = "", + changed_files: list[str] | None = None, + extra_env: dict[str, str] | None = None, model_candidates: str = "github-models/openai/gpt-5", prompt_capture: Path | None = None, ) -> subprocess.CompletedProcess[str]: @@ -77,6 +79,9 @@ def run_failed_model( shutil.copy2(ROOT / "opencode.jsonc", review_dir / "opencode.jsonc") evidence_file = tmp_path / "evidence.md" evidence_file.write_text("bounded current-head evidence\n", encoding="utf-8") + changed_files_file = tmp_path / "changed-files.txt" + if changed_files is not None: + changed_files_file.write_text("\n".join(changed_files) + "\n", encoding="utf-8") if evidence_excerpt: (review_dir / "bounded-review-evidence-excerpt.md").write_text( evidence_excerpt, encoding="utf-8" @@ -108,6 +113,7 @@ def run_failed_model( "GITHUB_OUTPUT": bash_path(github_output), "GITHUB_WORKSPACE": bash_path(ROOT), "HEAD_SHA": "1" * 40, + "OPENCODE_CHANGED_FILES_FILE": bash_path(changed_files_file), "OPENCODE_EVIDENCE_FILE": bash_path(evidence_file), "OPENCODE_MODEL_ATTEMPTS": "1", "OPENCODE_MODEL_CANDIDATES": model_candidates, @@ -124,6 +130,8 @@ def run_failed_model( "RUN_ID": "29189945378", } ) + if extra_env: + env.update(extra_env) return subprocess.run( [command, bash_path(RUNNER)], cwd=ROOT, @@ -174,6 +182,30 @@ def test_failed_provider_without_reason_logs_explicit_absence(tmp_path: Path) -> ) in result.stdout +def test_dynamic_review_cadence_uses_small_change_timeout(tmp_path: Path) -> None: + """Small PRs fail through hung/unavailable providers quickly with a visible budget reason.""" + result = run_failed_model( + tmp_path, + changed_files=["pyproject.toml", "uv.lock"], + extra_env={ + "OPENCODE_DYNAMIC_REVIEW_CADENCE": "true", + "OPENCODE_RUN_TIMEOUT_SECONDS": "99", + "OPENCODE_TOTAL_RETRY_BUDGET_SECONDS": "99", + "OPENCODE_SMALL_CHANGE_RUN_TIMEOUT_SECONDS": "7", + "OPENCODE_SMALL_CHANGE_TOTAL_BUDGET_SECONDS": "11", + "OPENCODE_DYNAMIC_MAX_CYCLES": "1", + }, + ) + + assert result.returncode == 1 + assert ( + "OpenCode dynamic review cadence selected 7s per attempt and 11s total budget " + "for 2 changed file(s); max-cycles=1." + ) in result.stdout + assert "OpenCode github-models/openai/gpt-5 attempt 1/1 using 7s run timeout" in result.stdout + assert "retry budget remaining." in result.stdout + + def test_github_models_openai_prompt_references_evidence_without_inlining(tmp_path: Path) -> None: """Small-request GitHub Models OpenAI candidates keep evidence as files.""" prompt_capture = tmp_path / "captured-prompt.md"