From ebc9f46e482fe777e02f640eb537321a38910a99 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:36:14 +0900 Subject: [PATCH 01/15] test(opencode): require stale poll self-retirement --- tests/test_opencode_poll_self_retirement.py | 72 +++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_opencode_poll_self_retirement.py diff --git a/tests/test_opencode_poll_self_retirement.py b/tests/test_opencode_poll_self_retirement.py new file mode 100644 index 0000000000..c948282388 --- /dev/null +++ b/tests/test_opencode_poll_self_retirement.py @@ -0,0 +1,72 @@ +"""Regression contract for self-retiring Required OpenCode verdict polls.""" + +from pathlib import Path + + +WORKFLOW = Path(".github/workflows/opencode-review.yml") + + +def _fail_closed_step() -> str: + """Return the production current-head verdict polling step.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + return workflow.split( + " - name: Fail closed without a current-head OpenCode verdict\n", 1 + )[1].split("\n cancel-superseded-opencode-review-runs:\n", 1)[0] + + +def _poll_loop() -> str: + """Return only the long-running Reviews API polling loop.""" + step = _fail_closed_step() + return step.split(" while :; do\n", 1)[1].split( + " done\n if [ -z \"$verdict\" ]; then\n", 1 + )[0] + + +def test_poll_revalidates_live_pr_before_every_reviews_api_read() -> None: + """An occupied runner must retire itself when its PR head stops being live.""" + loop = _poll_loop() + live_lookup = ( + 'live_poll_pr="$(gh api ' + '"repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"' + ) + reviews_lookup = ( + 'reviews="$(gh api --paginate ' + '"repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews")"' + ) + + assert live_lookup in loop + assert 'live_poll_head="$(printf \'%s\' "$live_poll_pr" | jq -r ' in loop + assert 'live_poll_draft="$(printf \'%s\' "$live_poll_pr" | jq -r ' in loop + assert 'live_poll_state="$(printf \'%s\' "$live_poll_pr" | jq -r ' in loop + assert ( + 'if [ "${live_poll_head,,}" != "${HEAD_SHA,,}" ]; then' in loop + ) + assert "superseded Required OpenCode Review poll" in loop + assert 'if [ "$live_poll_state" = "closed" ]; then' in loop + assert 'if [ "$live_poll_draft" = "true" ]; then' in loop + assert reviews_lookup in loop + assert loop.index(live_lookup) < loop.index(reviews_lookup) + + +def test_poll_live_state_revalidation_fails_closed_on_malformed_evidence() -> None: + """Missing or malformed live-state evidence cannot turn a stale poll green.""" + loop = _poll_loop() + assert ( + 'if [ -z "$live_poll_head" ] || [ -z "$live_poll_draft" ] || ' + '[ -z "$live_poll_state" ]; then' in loop + ) + assert "Could not validate live pull request state while polling" in loop + assert ( + 'if [ "$live_poll_state" != "open" ] && ' + '[ "$live_poll_state" != "closed" ]; then' in loop + ) + + +def test_self_retirement_does_not_replace_semantic_review_with_a_short_timeout() -> None: + """Capacity hygiene must not impose an arbitrary review inference deadline.""" + target_job = WORKFLOW.read_text(encoding="utf-8").split( + " opencode-review-target:\n", 1 + )[1].split("\n cancel-superseded-opencode-review-runs:\n", 1)[0] + assert "timeout-minutes:" not in target_job.split(" steps:\n", 1)[0] + assert "while :; do" in target_job + assert "sleep 30" in target_job From 13aa1b43164c085cb76e7ee93b29c78c15d2ad05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:38:14 +0900 Subject: [PATCH 02/15] fix(opencode): self-retire stale verdict polls --- .github/workflows/opencode-review.yml | 46 +++++++++++++++++++-------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 0864993179..251d31288e 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -420,6 +420,30 @@ jobs: fi verdict="" while :; do + live_poll_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + live_poll_head="$(printf '%s' "$live_poll_pr" | jq -r '.head.sha // empty')" + live_poll_draft="$(printf '%s' "$live_poll_pr" | jq -r 'if (.draft | type) == "boolean" then (.draft | tostring) else empty end')" + live_poll_state="$(printf '%s' "$live_poll_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" + if [ -z "$live_poll_head" ] || [ -z "$live_poll_draft" ] || [ -z "$live_poll_state" ]; then + echo "::error::Could not validate live pull request state while polling for a current-head OpenCode verdict." + exit 1 + fi + if [ "$live_poll_state" != "open" ] && [ "$live_poll_state" != "closed" ]; then + echo "::error::Could not validate live pull request state while polling for a current-head OpenCode verdict." + exit 1 + fi + if [ "${live_poll_head,,}" != "${HEAD_SHA,,}" ]; then + echo "::notice::Pull request head moved while waiting for a current-head OpenCode verdict; retiring superseded Required OpenCode Review poll." + exit 1 + fi + if [ "$live_poll_state" = "closed" ]; then + echo "PR closed while waiting for the current-head OpenCode verdict; the poll is no longer required." + exit 0 + fi + if [ "$live_poll_draft" = "true" ]; then + echo "PR became draft while waiting for the current-head OpenCode verdict; the poll is no longer required until it is marked ready for review." + exit 0 + fi reviews="$(gh api --paginate "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews")" verdict="$(printf '%s\n' "$reviews" | jq -r -s --arg sha "$HEAD_SHA" ' (add // []) @@ -461,19 +485,15 @@ jobs: echo "Current-head OpenCode verdict: ${verdict}." cancel-superseded-opencode-review-runs: - # Scoping the concurrency group above by exact head SHA (so a delayed - # old-head run can no longer cancel the authoritative newer-head run -- - # Devin Review on `#1568`) also means a *legitimate* new commit no - # longer auto-cancels its own PR's now-obsolete previous-head poll: that - # older run's own live-head check only ran once, before it entered its - # unbounded Reviews API wait, and nothing in that wait loop re-validates - # the head. Left alone, it would occupy a runner until GitHub's own - # per-job ceiling. This job retires it directly, mirroring the - # live-head-validated cleanup pattern in strix.yml's own - # `cancel-superseded-pr-runs` job: every cancellation candidate and - # every cancellation itself is re-verified against the live PR head - # immediately beforehand, so a run for this job that is itself somehow - # delayed/stale cannot wrongly cancel a still-authoritative run. + # Exact-head concurrency protects a newer authoritative run from delayed + # old-head events, while the poll above now revalidates live PR identity on + # every wait iteration so an already-running obsolete poll can self-retire + # without consuming a second runner. This sibling job remains a defense in + # depth for queued/requested old-head runs and for legacy runs created from + # older workflow revisions that lack the in-loop self-retirement check. + # Every cancellation candidate and every cancellation itself is re-verified + # against the live PR head immediately beforehand, so a cleanup run that is + # itself delayed/stale cannot cancel a still-authoritative run. if: github.event_name == 'pull_request_target' && github.event.action == 'synchronize' runs-on: ubuntu-24.04 permissions: From f4ac72776215bf4ef1915a100bb6963753d76d00 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:38:40 +0900 Subject: [PATCH 03/15] docs(opencode): record stale-poll capacity repair --- .../opencode-stale-poll-self-retirement.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/doctoring/opencode-stale-poll-self-retirement.md diff --git a/docs/doctoring/opencode-stale-poll-self-retirement.md b/docs/doctoring/opencode-stale-poll-self-retirement.md new file mode 100644 index 0000000000..a4c3243f88 --- /dev/null +++ b/docs/doctoring/opencode-stale-poll-self-retirement.md @@ -0,0 +1,37 @@ +# OpenCode stale-poll self-retirement + +## Incident boundary + +On 2026-09-02, `ContextualWisdomLab/fast-mlsirm` retained an in-progress `Required OpenCode Review` run for PR #1519 on predecessor head `5453d0df84e4e...` while the live PR head had already advanced to `3a3865f40da12211898c97cbd47e7460381736ae`. The predecessor run had entered the required workflow's Reviews API wait and continued occupying a runner. At the same observation, the repository had a fresh current-head OpenCode run queued and the organization-wide Actions fleet was heavily queued. + +The protected central workflow intentionally keys concurrency by repository, PR number, and exact head SHA. That protects a newer authoritative run from a delayed old-head event, but it also means a new commit cannot cancel the previous head through the concurrency group. A separate `cancel-superseded-opencode-review-runs` job exists for that cleanup, yet it needs its own runner. Under saturation, the cleanup job can therefore wait behind the stale poll it is meant to retire. + +## Root cause + +`opencode-review-target` validated the live PR head/state/draft once before entering an unbounded `while` loop. The loop then queried only the Reviews API every 30 seconds. A head movement after the first validation was invisible to the occupied run, so an obsolete head could remain in progress until GitHub's job ceiling even though it could never receive an authoritative current-head verdict. + +This is a control-plane capacity defect, not a reason to shorten semantic-review inference deadlines. A fixed short `timeout-minutes` would trade one failure mode for another and can kill legitimate long-running review work. + +## Repair contract + +The polling loop now re-fetches the live pull request before every Reviews API read. It: + +- fails closed when live head/state/draft evidence is missing or malformed; +- exits non-passing when the live head no longer equals the workflow's immutable `HEAD_SHA`, allowing the stale run to release its runner itself; +- exits successfully when the PR closes or becomes Draft while the same head is waiting, because no verdict is required in those states; +- keeps exact-head formal `APPROVED` / `CHANGES_REQUESTED` review evidence as the only terminal substantive verdict; and +- retains the existing no-short-timeout contract for legitimate semantic reviews. + +The sibling cancellation job remains defense in depth for queued/requested predecessor runs and for legacy workflow revisions that do not contain the in-loop self-retirement check. + +## Regression evidence + +`tests/test_opencode_poll_self_retirement.py` was committed before the production workflow change. The protected-main workflow did not contain the required in-loop live-state lookup, so the new contract is RED on `main@7d707b8abbb8a3fed95d0efe4121ed9b4f76bb2a`. The production repair follows on the same single-writer branch and is constrained to the existing required-workflow entrypoint plus the new regression and this doctoring record. + +Hosted exact-head evidence remains authoritative for merge. Queue, predecessor, cancelled, skipped, or locally reasoned evidence is not promoted to a passing required check or formal review. + +## Rollback and observability + +Rollback is the ordinary revert of the workflow repair if exact-head evidence shows false retirement of an authoritative run. During operation, inspect the live PR head together with the workflow run's immutable head SHA. An old-head run that remains in progress after a poll interval indicates either a legacy workflow revision or a failure before the self-retirement loop; do not classify a queued replacement verdict as success. + +After protected integration, re-observe affected leaf repositories. Acceptance requires predecessor-head OpenCode polls to release runner capacity without waiting for a separate cleanup runner, while unchanged current-head semantic reviews remain able to run beyond arbitrary short deadlines. From f140a711466e03673055242a81aa68afbc0901fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:42:49 +0900 Subject: [PATCH 04/15] test(opencode): execute stale poll retirement branches --- tests/test_opencode_poll_self_retirement.py | 123 +++++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/tests/test_opencode_poll_self_retirement.py b/tests/test_opencode_poll_self_retirement.py index c948282388..cb5dca4b2e 100644 --- a/tests/test_opencode_poll_self_retirement.py +++ b/tests/test_opencode_poll_self_retirement.py @@ -1,6 +1,11 @@ """Regression contract for self-retiring Required OpenCode verdict polls.""" +from __future__ import annotations + +import json +import os from pathlib import Path +import subprocess WORKFLOW = Path(".github/workflows/opencode-review.yml") @@ -22,6 +27,64 @@ def _poll_loop() -> str: )[0] +def _run_poll_loop( + tmp_path: Path, + *, + head_sha: str, + live_pr: dict[str, object], + reviews: list[dict[str, object]] | None = None, +) -> tuple[subprocess.CompletedProcess[str], list[str]]: + """Execute the production poll body against a deterministic fake ``gh``.""" + call_log = tmp_path / "gh-calls.log" + fake_gh = tmp_path / "gh" + fake_gh.write_text( + """#!/bin/sh +set -eu +printf '%s\\n' "$*" >> "$GH_CALL_LOG" +[ "${1:-}" = "api" ] || exit 90 +shift +if [ "${1:-}" = "--paginate" ]; then + printf '%s\\n' "$GH_REVIEWS" +else + printf '%s\\n' "$GH_LIVE_PR" +fi +""", + encoding="utf-8", + ) + fake_gh.chmod(0o755) + + script = "\n".join( + ( + "set -euo pipefail", + 'verdict=""', + "while :; do", + _poll_loop(), + "done", + ) + ) + env = os.environ.copy() + env.update( + { + "PATH": f"{tmp_path}{os.pathsep}{env.get('PATH', '')}", + "TARGET_REPOSITORY": "ContextualWisdomLab/example", + "PR_NUMBER": "42", + "HEAD_SHA": head_sha, + "GH_CALL_LOG": str(call_log), + "GH_LIVE_PR": json.dumps(live_pr), + "GH_REVIEWS": json.dumps(reviews or []), + } + ) + result = subprocess.run( + ["bash", "-c", script], + check=False, + capture_output=True, + env=env, + text=True, + ) + calls = call_log.read_text(encoding="utf-8").splitlines() + return result, calls + + def test_poll_revalidates_live_pr_before_every_reviews_api_read() -> None: """An occupied runner must retire itself when its PR head stops being live.""" loop = _poll_loop() @@ -38,9 +101,7 @@ def test_poll_revalidates_live_pr_before_every_reviews_api_read() -> None: assert 'live_poll_head="$(printf \'%s\' "$live_poll_pr" | jq -r ' in loop assert 'live_poll_draft="$(printf \'%s\' "$live_poll_pr" | jq -r ' in loop assert 'live_poll_state="$(printf \'%s\' "$live_poll_pr" | jq -r ' in loop - assert ( - 'if [ "${live_poll_head,,}" != "${HEAD_SHA,,}" ]; then' in loop - ) + assert 'if [ "${live_poll_head,,}" != "${HEAD_SHA,,}" ]; then' in loop assert "superseded Required OpenCode Review poll" in loop assert 'if [ "$live_poll_state" = "closed" ]; then' in loop assert 'if [ "$live_poll_draft" = "true" ]; then' in loop @@ -62,6 +123,62 @@ def test_poll_live_state_revalidation_fails_closed_on_malformed_evidence() -> No ) +def test_poll_executes_superseded_head_retirement_before_reviews_read( + tmp_path: Path, +) -> None: + """A moved head exits non-passing before the Reviews API is consulted.""" + head_sha = "a" * 40 + result, calls = _run_poll_loop( + tmp_path, + head_sha=head_sha, + live_pr={"head": {"sha": "b" * 40}, "draft": False, "state": "open"}, + ) + + assert result.returncode == 1 + assert "retiring superseded Required OpenCode Review poll" in result.stdout + assert calls == ["api repos/ContextualWisdomLab/example/pulls/42"] + + +def test_poll_executes_closed_pr_retirement_without_reviews_read(tmp_path: Path) -> None: + """A closed current-head PR releases the occupied runner successfully.""" + head_sha = "c" * 40 + result, calls = _run_poll_loop( + tmp_path, + head_sha=head_sha, + live_pr={"head": {"sha": head_sha}, "draft": False, "state": "closed"}, + ) + + assert result.returncode == 0 + assert "PR closed while waiting" in result.stdout + assert calls == ["api repos/ContextualWisdomLab/example/pulls/42"] + + +def test_poll_executes_live_state_read_before_current_head_review_read( + tmp_path: Path, +) -> None: + """A live head reads PR state first and then accepts only its current review.""" + head_sha = "d" * 40 + result, calls = _run_poll_loop( + tmp_path, + head_sha=head_sha, + live_pr={"head": {"sha": head_sha}, "draft": False, "state": "open"}, + reviews=[ + { + "user": {"login": "opencode-agent[bot]"}, + "commit_id": head_sha, + "state": "APPROVED", + "body": "Source-backed current-head semantic review.", + } + ], + ) + + assert result.returncode == 0, result.stderr + assert calls == [ + "api repos/ContextualWisdomLab/example/pulls/42", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + ] + + def test_self_retirement_does_not_replace_semantic_review_with_a_short_timeout() -> None: """Capacity hygiene must not impose an arbitrary review inference deadline.""" target_job = WORKFLOW.read_text(encoding="utf-8").split( From aeaa5702e118e2715d2a7e4ddee80bdccc2899e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:45:56 +0900 Subject: [PATCH 05/15] test(opencode): reproduce transient live-state poll failure --- tests/test_opencode_poll_self_retirement.py | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/test_opencode_poll_self_retirement.py b/tests/test_opencode_poll_self_retirement.py index cb5dca4b2e..6c3174d221 100644 --- a/tests/test_opencode_poll_self_retirement.py +++ b/tests/test_opencode_poll_self_retirement.py @@ -33,9 +33,11 @@ def _run_poll_loop( head_sha: str, live_pr: dict[str, object], reviews: list[dict[str, object]] | None = None, + fail_live_pr_once: bool = False, ) -> tuple[subprocess.CompletedProcess[str], list[str]]: """Execute the production poll body against a deterministic fake ``gh``.""" call_log = tmp_path / "gh-calls.log" + fail_marker = tmp_path / "failed-live-pr-once" fake_gh = tmp_path / "gh" fake_gh.write_text( """#!/bin/sh @@ -46,12 +48,19 @@ def _run_poll_loop( if [ "${1:-}" = "--paginate" ]; then printf '%s\\n' "$GH_REVIEWS" else + if [ "${GH_FAIL_LIVE_PR_ONCE:-false}" = "true" ] && [ ! -e "$GH_FAIL_MARKER" ]; then + : > "$GH_FAIL_MARKER" + exit 1 + fi printf '%s\\n' "$GH_LIVE_PR" fi """, encoding="utf-8", ) fake_gh.chmod(0o755) + fake_sleep = tmp_path / "sleep" + fake_sleep.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fake_sleep.chmod(0o755) script = "\n".join( ( @@ -70,6 +79,8 @@ def _run_poll_loop( "PR_NUMBER": "42", "HEAD_SHA": head_sha, "GH_CALL_LOG": str(call_log), + "GH_FAIL_LIVE_PR_ONCE": "true" if fail_live_pr_once else "false", + "GH_FAIL_MARKER": str(fail_marker), "GH_LIVE_PR": json.dumps(live_pr), "GH_REVIEWS": json.dumps(reviews or []), } @@ -179,6 +190,35 @@ def test_poll_executes_live_state_read_before_current_head_review_read( ] +def test_poll_retries_transient_live_state_failure_before_reviews_read( + tmp_path: Path, +) -> None: + """A transient live-state read failure retries without ending current authority.""" + head_sha = "e" * 40 + result, calls = _run_poll_loop( + tmp_path, + head_sha=head_sha, + live_pr={"head": {"sha": head_sha}, "draft": False, "state": "open"}, + reviews=[ + { + "user": {"login": "opencode-agent[bot]"}, + "commit_id": head_sha, + "state": "APPROVED", + "body": "Source-backed current-head semantic review.", + } + ], + fail_live_pr_once=True, + ) + + assert result.returncode == 0, result.stderr + assert "Live pull request read failed while polling" in result.stdout + assert calls == [ + "api repos/ContextualWisdomLab/example/pulls/42", + "api repos/ContextualWisdomLab/example/pulls/42", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + ] + + def test_self_retirement_does_not_replace_semantic_review_with_a_short_timeout() -> None: """Capacity hygiene must not impose an arbitrary review inference deadline.""" target_job = WORKFLOW.read_text(encoding="utf-8").split( From 61ee229d0d5b1cc11bdfe2f8298632f4fd7ef724 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:47:07 +0900 Subject: [PATCH 06/15] docs(opencode): disambiguate incident observation date --- docs/doctoring/opencode-stale-poll-self-retirement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/opencode-stale-poll-self-retirement.md b/docs/doctoring/opencode-stale-poll-self-retirement.md index a4c3243f88..0711e36a91 100644 --- a/docs/doctoring/opencode-stale-poll-self-retirement.md +++ b/docs/doctoring/opencode-stale-poll-self-retirement.md @@ -2,7 +2,7 @@ ## Incident boundary -On 2026-09-02, `ContextualWisdomLab/fast-mlsirm` retained an in-progress `Required OpenCode Review` run for PR #1519 on predecessor head `5453d0df84e4e...` while the live PR head had already advanced to `3a3865f40da12211898c97cbd47e7460381736ae`. The predecessor run had entered the required workflow's Reviews API wait and continued occupying a runner. At the same observation, the repository had a fresh current-head OpenCode run queued and the organization-wide Actions fleet was heavily queued. +On 2026-09-01 UTC (2026-09-02 Asia/Seoul), `ContextualWisdomLab/fast-mlsirm` retained an in-progress `Required OpenCode Review` run for PR #1519 on predecessor head `5453d0df84e4e...` while the live PR head had already advanced to `3a3865f40da12211898c97cbd47e7460381736ae`. The predecessor run had entered the required workflow's Reviews API wait and continued occupying a runner. At the same observation, the repository had a fresh current-head OpenCode run queued and the organization-wide Actions fleet was heavily queued. The protected central workflow intentionally keys concurrency by repository, PR number, and exact head SHA. That protects a newer authoritative run from a delayed old-head event, but it also means a new commit cannot cancel the previous head through the concurrency group. A separate `cancel-superseded-opencode-review-runs` job exists for that cleanup, yet it needs its own runner. Under saturation, the cleanup job can therefore wait behind the stale poll it is meant to retire. From b4482ac1e13c130c36df7c98c360857b326800e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:47:42 +0900 Subject: [PATCH 07/15] test(opencode): bound live-state transport retries --- tests/test_opencode_poll_self_retirement.py | 47 +++++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/tests/test_opencode_poll_self_retirement.py b/tests/test_opencode_poll_self_retirement.py index 6c3174d221..f1ffc888e3 100644 --- a/tests/test_opencode_poll_self_retirement.py +++ b/tests/test_opencode_poll_self_retirement.py @@ -33,11 +33,11 @@ def _run_poll_loop( head_sha: str, live_pr: dict[str, object], reviews: list[dict[str, object]] | None = None, - fail_live_pr_once: bool = False, + fail_live_pr_attempts: int = 0, ) -> tuple[subprocess.CompletedProcess[str], list[str]]: """Execute the production poll body against a deterministic fake ``gh``.""" call_log = tmp_path / "gh-calls.log" - fail_marker = tmp_path / "failed-live-pr-once" + fail_counter = tmp_path / "live-pr-failures" fake_gh = tmp_path / "gh" fake_gh.write_text( """#!/bin/sh @@ -48,8 +48,13 @@ def _run_poll_loop( if [ "${1:-}" = "--paginate" ]; then printf '%s\\n' "$GH_REVIEWS" else - if [ "${GH_FAIL_LIVE_PR_ONCE:-false}" = "true" ] && [ ! -e "$GH_FAIL_MARKER" ]; then - : > "$GH_FAIL_MARKER" + count=0 + if [ -e "$GH_FAIL_COUNTER" ]; then + count="$(cat "$GH_FAIL_COUNTER")" + fi + count=$((count + 1)) + printf '%s\\n' "$count" > "$GH_FAIL_COUNTER" + if [ "$count" -le "${GH_FAIL_LIVE_PR_ATTEMPTS:-0}" ]; then exit 1 fi printf '%s\\n' "$GH_LIVE_PR" @@ -61,11 +66,19 @@ def _run_poll_loop( fake_sleep = tmp_path / "sleep" fake_sleep.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") fake_sleep.chmod(0o755) + fake_timeout = tmp_path / "timeout" + fake_timeout.write_text( + "#!/bin/sh\nset -eu\nshift\nexec \"$@\"\n", + encoding="utf-8", + ) + fake_timeout.chmod(0o755) script = "\n".join( ( "set -euo pipefail", 'verdict=""', + 'live_poll_failures=0', + 'max_live_poll_failures=3', "while :; do", _poll_loop(), "done", @@ -79,8 +92,8 @@ def _run_poll_loop( "PR_NUMBER": "42", "HEAD_SHA": head_sha, "GH_CALL_LOG": str(call_log), - "GH_FAIL_LIVE_PR_ONCE": "true" if fail_live_pr_once else "false", - "GH_FAIL_MARKER": str(fail_marker), + "GH_FAIL_LIVE_PR_ATTEMPTS": str(fail_live_pr_attempts), + "GH_FAIL_COUNTER": str(fail_counter), "GH_LIVE_PR": json.dumps(live_pr), "GH_REVIEWS": json.dumps(reviews or []), } @@ -100,7 +113,7 @@ def test_poll_revalidates_live_pr_before_every_reviews_api_read() -> None: """An occupied runner must retire itself when its PR head stops being live.""" loop = _poll_loop() live_lookup = ( - 'live_poll_pr="$(gh api ' + 'live_poll_pr="$(timeout 30s gh api ' '"repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"' ) reviews_lookup = ( @@ -207,7 +220,7 @@ def test_poll_retries_transient_live_state_failure_before_reviews_read( "body": "Source-backed current-head semantic review.", } ], - fail_live_pr_once=True, + fail_live_pr_attempts=1, ) assert result.returncode == 0, result.stderr @@ -219,6 +232,24 @@ def test_poll_retries_transient_live_state_failure_before_reviews_read( ] +def test_poll_fails_closed_after_bounded_live_state_transport_failures( + tmp_path: Path, +) -> None: + """Repeated transport failures release the runner without fabricating evidence.""" + head_sha = "f" * 40 + result, calls = _run_poll_loop( + tmp_path, + head_sha=head_sha, + live_pr={"head": {"sha": head_sha}, "draft": False, "state": "open"}, + fail_live_pr_attempts=3, + ) + + assert result.returncode == 1 + assert "failed 3 consecutive times" in result.stdout + assert calls == ["api repos/ContextualWisdomLab/example/pulls/42"] * 3 + assert all("reviews" not in call for call in calls) + + def test_self_retirement_does_not_replace_semantic_review_with_a_short_timeout() -> None: """Capacity hygiene must not impose an arbitrary review inference deadline.""" target_job = WORKFLOW.read_text(encoding="utf-8").split( From d75f79ab0bf457162ecef39aa5e4bf6afdef08dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:48:25 +0900 Subject: [PATCH 08/15] test(opencode): bound review transport retries --- tests/test_opencode_poll_self_retirement.py | 82 ++++++++++++++++++--- 1 file changed, 73 insertions(+), 9 deletions(-) diff --git a/tests/test_opencode_poll_self_retirement.py b/tests/test_opencode_poll_self_retirement.py index f1ffc888e3..9d353e8e21 100644 --- a/tests/test_opencode_poll_self_retirement.py +++ b/tests/test_opencode_poll_self_retirement.py @@ -34,10 +34,12 @@ def _run_poll_loop( live_pr: dict[str, object], reviews: list[dict[str, object]] | None = None, fail_live_pr_attempts: int = 0, + fail_review_attempts: int = 0, ) -> tuple[subprocess.CompletedProcess[str], list[str]]: """Execute the production poll body against a deterministic fake ``gh``.""" call_log = tmp_path / "gh-calls.log" - fail_counter = tmp_path / "live-pr-failures" + live_fail_counter = tmp_path / "live-pr-failures" + review_fail_counter = tmp_path / "review-failures" fake_gh = tmp_path / "gh" fake_gh.write_text( """#!/bin/sh @@ -46,14 +48,23 @@ def _run_poll_loop( [ "${1:-}" = "api" ] || exit 90 shift if [ "${1:-}" = "--paginate" ]; then + count=0 + if [ -e "$GH_REVIEW_FAIL_COUNTER" ]; then + count="$(cat "$GH_REVIEW_FAIL_COUNTER")" + fi + count=$((count + 1)) + printf '%s\\n' "$count" > "$GH_REVIEW_FAIL_COUNTER" + if [ "$count" -le "${GH_FAIL_REVIEW_ATTEMPTS:-0}" ]; then + exit 1 + fi printf '%s\\n' "$GH_REVIEWS" else count=0 - if [ -e "$GH_FAIL_COUNTER" ]; then - count="$(cat "$GH_FAIL_COUNTER")" + if [ -e "$GH_LIVE_FAIL_COUNTER" ]; then + count="$(cat "$GH_LIVE_FAIL_COUNTER")" fi count=$((count + 1)) - printf '%s\\n' "$count" > "$GH_FAIL_COUNTER" + printf '%s\\n' "$count" > "$GH_LIVE_FAIL_COUNTER" if [ "$count" -le "${GH_FAIL_LIVE_PR_ATTEMPTS:-0}" ]; then exit 1 fi @@ -78,7 +89,8 @@ def _run_poll_loop( "set -euo pipefail", 'verdict=""', 'live_poll_failures=0', - 'max_live_poll_failures=3', + 'review_poll_failures=0', + 'max_poll_transport_failures=3', "while :; do", _poll_loop(), "done", @@ -93,7 +105,9 @@ def _run_poll_loop( "HEAD_SHA": head_sha, "GH_CALL_LOG": str(call_log), "GH_FAIL_LIVE_PR_ATTEMPTS": str(fail_live_pr_attempts), - "GH_FAIL_COUNTER": str(fail_counter), + "GH_FAIL_REVIEW_ATTEMPTS": str(fail_review_attempts), + "GH_LIVE_FAIL_COUNTER": str(live_fail_counter), + "GH_REVIEW_FAIL_COUNTER": str(review_fail_counter), "GH_LIVE_PR": json.dumps(live_pr), "GH_REVIEWS": json.dumps(reviews or []), } @@ -117,7 +131,7 @@ def test_poll_revalidates_live_pr_before_every_reviews_api_read() -> None: '"repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"' ) reviews_lookup = ( - 'reviews="$(gh api --paginate ' + 'reviews="$(timeout 30s gh api --paginate ' '"repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews")"' ) @@ -235,7 +249,7 @@ def test_poll_retries_transient_live_state_failure_before_reviews_read( def test_poll_fails_closed_after_bounded_live_state_transport_failures( tmp_path: Path, ) -> None: - """Repeated transport failures release the runner without fabricating evidence.""" + """Repeated live-state failures release the runner without fabricated evidence.""" head_sha = "f" * 40 result, calls = _run_poll_loop( tmp_path, @@ -245,11 +259,61 @@ def test_poll_fails_closed_after_bounded_live_state_transport_failures( ) assert result.returncode == 1 - assert "failed 3 consecutive times" in result.stdout + assert "Live pull request read failed 3 consecutive times" in result.stdout assert calls == ["api repos/ContextualWisdomLab/example/pulls/42"] * 3 assert all("reviews" not in call for call in calls) +def test_poll_retries_transient_reviews_failure_after_revalidating_head( + tmp_path: Path, +) -> None: + """A Reviews API transport failure retries only after re-reading live PR state.""" + head_sha = "1" * 40 + result, calls = _run_poll_loop( + tmp_path, + head_sha=head_sha, + live_pr={"head": {"sha": head_sha}, "draft": False, "state": "open"}, + reviews=[ + { + "user": {"login": "opencode-agent"}, + "commit_id": head_sha, + "state": "APPROVED", + "body": "Source-backed current-head semantic review.", + } + ], + fail_review_attempts=1, + ) + + assert result.returncode == 0, result.stderr + assert "Reviews API read failed while polling" in result.stdout + assert calls == [ + "api repos/ContextualWisdomLab/example/pulls/42", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + "api repos/ContextualWisdomLab/example/pulls/42", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + ] + + +def test_poll_fails_closed_after_bounded_reviews_transport_failures( + tmp_path: Path, +) -> None: + """Repeated Reviews API failures stop after a finite number of attempts.""" + head_sha = "2" * 40 + result, calls = _run_poll_loop( + tmp_path, + head_sha=head_sha, + live_pr={"head": {"sha": head_sha}, "draft": False, "state": "open"}, + fail_review_attempts=3, + ) + + assert result.returncode == 1 + assert "Reviews API read failed 3 consecutive times" in result.stdout + assert calls == [ + "api repos/ContextualWisdomLab/example/pulls/42", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + ] * 3 + + def test_self_retirement_does_not_replace_semantic_review_with_a_short_timeout() -> None: """Capacity hygiene must not impose an arbitrary review inference deadline.""" target_job = WORKFLOW.read_text(encoding="utf-8").split( From 9ca4dce3b9b26a185ab0f5ff78fe1fcbfaee5546 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:49:29 +0900 Subject: [PATCH 09/15] fix(opencode): bound verdict poll transport retries --- .github/workflows/opencode-review.yml | 29 ++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 251d31288e..5b4ccade23 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -419,8 +419,21 @@ jobs: echo "Event draft snapshot is stale; continuing verdict polling for the live ready PR." fi verdict="" + live_poll_failures=0 + review_poll_failures=0 + max_poll_transport_failures=3 while :; do - live_poll_pr="$(gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")" + if ! live_poll_pr="$(timeout 30s gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then + live_poll_failures=$((live_poll_failures + 1)) + if [ "$live_poll_failures" -ge "$max_poll_transport_failures" ]; then + echo "::error::Live pull request read failed ${live_poll_failures} consecutive times while polling; failing closed and releasing the runner." + exit 1 + fi + echo "::warning::Live pull request read failed while polling (${live_poll_failures}/${max_poll_transport_failures}); retrying after revalidation delay." + sleep 30 + continue + fi + live_poll_failures=0 live_poll_head="$(printf '%s' "$live_poll_pr" | jq -r '.head.sha // empty')" live_poll_draft="$(printf '%s' "$live_poll_pr" | jq -r 'if (.draft | type) == "boolean" then (.draft | tostring) else empty end')" live_poll_state="$(printf '%s' "$live_poll_pr" | jq -r 'if (.state | type) == "string" then .state else empty end')" @@ -444,7 +457,17 @@ jobs: echo "PR became draft while waiting for the current-head OpenCode verdict; the poll is no longer required until it is marked ready for review." exit 0 fi - reviews="$(gh api --paginate "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews")" + if ! reviews="$(timeout 30s gh api --paginate "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews")"; then + review_poll_failures=$((review_poll_failures + 1)) + if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then + echo "::error::Reviews API read failed ${review_poll_failures} consecutive times while polling; failing closed and releasing the runner." + exit 1 + fi + echo "::warning::Reviews API read failed while polling (${review_poll_failures}/${max_poll_transport_failures}); revalidating live PR state before retry." + sleep 30 + continue + fi + review_poll_failures=0 verdict="$(printf '%s\n' "$reviews" | jq -r -s --arg sha "$HEAD_SHA" ' (add // []) | [ @@ -575,4 +598,4 @@ jobs: for active_status in queued in_progress requested waiting pending; do cancel_runs "$active_status" done - echo "Superseded OpenCode review run cleanup completed." \ No newline at end of file + echo "Superseded OpenCode review run cleanup completed." From 41a0bfffc7d85e5c63abad2c1acaf07348a8bb22 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:55:43 +0900 Subject: [PATCH 10/15] test(opencode): bound verdict poll REST request pressure --- tests/test_opencode_poll_rate_budget.py | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/test_opencode_poll_rate_budget.py diff --git a/tests/test_opencode_poll_rate_budget.py b/tests/test_opencode_poll_rate_budget.py new file mode 100644 index 0000000000..66507b9903 --- /dev/null +++ b/tests/test_opencode_poll_rate_budget.py @@ -0,0 +1,45 @@ +"""Rate-budget regression for Required OpenCode review polling.""" + +from pathlib import Path + + +WORKFLOW = Path(".github/workflows/opencode-review.yml") + + +def _poll_loop() -> str: + """Return the long-running current-head verdict polling loop.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + step = workflow.split( + " - name: Fail closed without a current-head OpenCode verdict\n", 1 + )[1].split("\n cancel-superseded-opencode-review-runs:\n", 1)[0] + return step.split(" while :; do\n", 1)[1].split( + " done\n if [ -z \"$verdict\" ]; then\n", 1 + )[0] + + +def test_poll_retains_live_revalidation_but_bounds_rest_request_pressure() -> None: + """Stale-head safety must not consume the repository token budget by design.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + loop = _poll_loop() + + assert " poll_interval_seconds=60\n" in workflow + live_lookup = ( + 'live_poll_pr="$(timeout 30s gh api ' + '"repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"' + ) + reviews_lookup = ( + 'reviews="$(timeout 30s gh api --paginate ' + '"repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100")"' + ) + assert live_lookup in loop + assert reviews_lookup in loop + assert loop.index(live_lookup) < loop.index(reviews_lookup) + assert 'sleep "$poll_interval_seconds"' in loop + assert "sleep 30" not in loop + + +def test_review_poll_uses_maximum_rest_page_size() -> None: + """Review history pagination should minimize requests without dropping evidence.""" + loop = _poll_loop() + assert "/reviews?per_page=100" in loop + assert "gh api --paginate" in loop From b1378cc1e73754186a079a054a10ac9310b5ac8e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 07:59:25 +0900 Subject: [PATCH 11/15] fix(opencode): bound verdict poll REST pressure --- .github/workflows/opencode-review.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 5b4ccade23..e99bddb431 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -355,7 +355,7 @@ jobs: fi separator='&' [[ "$ACTIONS_ID_TOKEN_REQUEST_URL" == *\?* ]] || separator='?' - oidc_token="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDC_AUDIENCE}" | jq -r '.value // empty')" + oidc_token="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDIDC_AUDIENCE}" | jq -r '.value // empty')" if [ -z "$oidc_token" ]; then echo "::error::OpenCode review dispatch could not obtain its OIDC token." exit 1 @@ -422,6 +422,7 @@ jobs: live_poll_failures=0 review_poll_failures=0 max_poll_transport_failures=3 + poll_interval_seconds=60 while :; do if ! live_poll_pr="$(timeout 30s gh api "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}")"; then live_poll_failures=$((live_poll_failures + 1)) @@ -430,7 +431,7 @@ jobs: exit 1 fi echo "::warning::Live pull request read failed while polling (${live_poll_failures}/${max_poll_transport_failures}); retrying after revalidation delay." - sleep 30 + sleep "$poll_interval_seconds" continue fi live_poll_failures=0 @@ -457,14 +458,14 @@ jobs: echo "PR became draft while waiting for the current-head OpenCode verdict; the poll is no longer required until it is marked ready for review." exit 0 fi - if ! reviews="$(timeout 30s gh api --paginate "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews")"; then + if ! reviews="$(timeout 30s gh api --paginate "repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100")"; then review_poll_failures=$((review_poll_failures + 1)) if [ "$review_poll_failures" -ge "$max_poll_transport_failures" ]; then echo "::error::Reviews API read failed ${review_poll_failures} consecutive times while polling; failing closed and releasing the runner." exit 1 fi echo "::warning::Reviews API read failed while polling (${review_poll_failures}/${max_poll_transport_failures}); revalidating live PR state before retry." - sleep 30 + sleep "$poll_interval_seconds" continue fi review_poll_failures=0 @@ -499,7 +500,7 @@ jobs: if [ -n "$verdict" ]; then break fi - sleep 30 + sleep "$poll_interval_seconds" done if [ -z "$verdict" ]; then echo "::error::No APPROVED or CHANGES_REQUESTED from opencode-agent on the current head. This required check is not a review and must not succeed until the authenticated dispatch posts a current-head verdict." From 8683f6e418a26a33d2c2904b45e4db0a27703b4e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:01:00 +0900 Subject: [PATCH 12/15] test(opencode): pin OIDC audience variable --- tests/test_opencode_oidc_audience_contract.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/test_opencode_oidc_audience_contract.py diff --git a/tests/test_opencode_oidc_audience_contract.py b/tests/test_opencode_oidc_audience_contract.py new file mode 100644 index 0000000000..52dd1338df --- /dev/null +++ b/tests/test_opencode_oidc_audience_contract.py @@ -0,0 +1,14 @@ +"""Regression contract for the Required OpenCode OIDC audience binding.""" + +from pathlib import Path + + +WORKFLOW = Path(".github/workflows/opencode-review.yml") + + +def test_opencode_dispatch_uses_declared_oidc_audience_variable() -> None: + """The dispatch token request must use the declared ``OIDC_AUDIENCE`` name.""" + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert "audience=${OIDC_AUDIENCE}" in workflow + assert "OIDIDC_AUDIENCE" not in workflow From 3455a3080abbc5ac975c631a99612a1ce1bfec0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:02:24 +0900 Subject: [PATCH 13/15] fix(opencode): restore OIDC audience binding --- .github/workflows/opencode-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index e99bddb431..4fc6313a2c 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -355,7 +355,7 @@ jobs: fi separator='&' [[ "$ACTIONS_ID_TOKEN_REQUEST_URL" == *\?* ]] || separator='?' - oidc_token="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDIDC_AUDIENCE}" | jq -r '.value // empty')" + oidc_token="$(curl -fsS -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}${separator}audience=${OIDC_AUDIENCE}" | jq -r '.value // empty')" if [ -z "$oidc_token" ]; then echo "::error::OpenCode review dispatch could not obtain its OIDC token." exit 1 From 1354a1ee0fae1c696be8993442e0e5ffc19fedbb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:03:21 +0900 Subject: [PATCH 14/15] docs(opencode): record poll rate-budget repair --- .../opencode-stale-poll-self-retirement.md | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/doctoring/opencode-stale-poll-self-retirement.md b/docs/doctoring/opencode-stale-poll-self-retirement.md index 0711e36a91..4bfdc0be8a 100644 --- a/docs/doctoring/opencode-stale-poll-self-retirement.md +++ b/docs/doctoring/opencode-stale-poll-self-retirement.md @@ -10,28 +10,39 @@ The protected central workflow intentionally keys concurrency by repository, PR `opencode-review-target` validated the live PR head/state/draft once before entering an unbounded `while` loop. The loop then queried only the Reviews API every 30 seconds. A head movement after the first validation was invisible to the occupied run, so an obsolete head could remain in progress until GitHub's job ceiling even though it could never receive an authoritative current-head verdict. +The first self-retirement repair added a live PR read before every Reviews read, but an external review then exposed a second capacity defect: keeping both reads on a 30-second cadence approximately doubled the steady-state REST pressure. Four simultaneous current-head polls would issue about 960 baseline REST calls per hour before Reviews pagination or other automation. That approaches the repository-scoped token budget too closely and turns the reliability repair into a rate-pressure risk. + This is a control-plane capacity defect, not a reason to shorten semantic-review inference deadlines. A fixed short `timeout-minutes` would trade one failure mode for another and can kill legitimate long-running review work. ## Repair contract -The polling loop now re-fetches the live pull request before every Reviews API read. It: +The polling loop re-fetches the live pull request before every Reviews API read and now uses a 60-second poll interval. It: - fails closed when live head/state/draft evidence is missing or malformed; - exits non-passing when the live head no longer equals the workflow's immutable `HEAD_SHA`, allowing the stale run to release its runner itself; - exits successfully when the PR closes or becomes Draft while the same head is waiting, because no verdict is required in those states; -- keeps exact-head formal `APPROVED` / `CHANGES_REQUESTED` review evidence as the only terminal substantive verdict; and -- retains the existing no-short-timeout contract for legitimate semantic reviews. +- bounds each individual live-state and Reviews API request to 30 seconds and permits at most three consecutive transport failures before failing closed and releasing the runner; +- revalidates live PR state before a Reviews retry, so a transport failure cannot let a stale head skip identity validation; +- requests Reviews with `per_page=100` and pagination, minimizing page count without dropping older review evidence; +- uses the same 60-second delay for healthy polling and transient retries rather than busy-retrying GitHub; and +- keeps exact-head formal `APPROVED` / `CHANGES_REQUESTED` review evidence as the only terminal substantive verdict while retaining the no-short-timeout contract for legitimate semantic reviews. + +At four simultaneous polls, the two baseline REST reads per 60-second iteration are approximately 480 calls per hour before Reviews pagination or unrelated automation. This is a bounded pressure reduction, not a claim that pagination can never add calls: repositories with more than 100 reviews still require additional pages. The page-size regression exists to keep that unavoidable pagination as small as the REST endpoint allows. The sibling cancellation job remains defense in depth for queued/requested predecessor runs and for legacy workflow revisions that do not contain the in-loop self-retirement check. ## Regression evidence -`tests/test_opencode_poll_self_retirement.py` was committed before the production workflow change. The protected-main workflow did not contain the required in-loop live-state lookup, so the new contract is RED on `main@7d707b8abbb8a3fed95d0efe4121ed9b4f76bb2a`. The production repair follows on the same single-writer branch and is constrained to the existing required-workflow entrypoint plus the new regression and this doctoring record. +`tests/test_opencode_poll_self_retirement.py` was committed before the production self-retirement change and now executes the extracted production loop under Bash with deterministic fake-`gh` responses for moved-head, closed/draft, exact-head verdict, transient-recovery, and terminal transport-failure paths. `tests/test_opencode_poll_rate_budget.py` is the later RED-to-GREEN contract for 60-second polling and maximum Reviews page size. `tests/test_opencode_oidc_audience_contract.py` independently preserves the dispatch OIDC audience variable after a writer-side typo was caught and repaired during the rate-budget implementation. + +The original protected-main workflow did not contain the required in-loop live-state lookup. Later review-derived regressions additionally prevent the self-retirement repair from regressing into excessive steady-state REST pressure or silently breaking the OIDC dispatch credential path. Hosted exact-head evidence remains authoritative for merge. Queue, predecessor, cancelled, skipped, or locally reasoned evidence is not promoted to a passing required check or formal review. ## Rollback and observability -Rollback is the ordinary revert of the workflow repair if exact-head evidence shows false retirement of an authoritative run. During operation, inspect the live PR head together with the workflow run's immutable head SHA. An old-head run that remains in progress after a poll interval indicates either a legacy workflow revision or a failure before the self-retirement loop; do not classify a queued replacement verdict as success. +Rollback is the ordinary revert of the workflow repair if exact-head evidence shows false retirement of an authoritative run. During operation, inspect the live PR head together with the workflow run's immutable head SHA. An old-head run that remains in progress for materially longer than one 60-second poll interval indicates either a legacy workflow revision or a failure before the self-retirement loop; do not classify a queued replacement verdict as success. + +Monitor both runner occupancy and GitHub API failure/rate-limit evidence. Repeated transport failures should terminate the required check after three bounded attempts rather than leave an immortal poll. A rate-pressure regression should be repaired by changing evidence acquisition/cadence without weakening exact-head review semantics. -After protected integration, re-observe affected leaf repositories. Acceptance requires predecessor-head OpenCode polls to release runner capacity without waiting for a separate cleanup runner, while unchanged current-head semantic reviews remain able to run beyond arbitrary short deadlines. +After protected integration, re-observe affected leaf repositories. Acceptance requires predecessor-head OpenCode polls to release runner capacity without waiting for a separate cleanup runner, while unchanged current-head semantic reviews remain able to run beyond arbitrary short deadlines and current-head polls stay within a defensible REST request budget. From 0d9a016eb2a3912addbae63fd749d684dd7d49ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 08:03:58 +0900 Subject: [PATCH 15/15] test(opencode): align executable poll harness with rate budget --- tests/test_opencode_poll_self_retirement.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/test_opencode_poll_self_retirement.py b/tests/test_opencode_poll_self_retirement.py index 9d353e8e21..cd12a567d5 100644 --- a/tests/test_opencode_poll_self_retirement.py +++ b/tests/test_opencode_poll_self_retirement.py @@ -91,6 +91,7 @@ def _run_poll_loop( 'live_poll_failures=0', 'review_poll_failures=0', 'max_poll_transport_failures=3', + 'poll_interval_seconds=60', "while :; do", _poll_loop(), "done", @@ -132,7 +133,7 @@ def test_poll_revalidates_live_pr_before_every_reviews_api_read() -> None: ) reviews_lookup = ( 'reviews="$(timeout 30s gh api --paginate ' - '"repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews")"' + '"repos/${TARGET_REPOSITORY}/pulls/${PR_NUMBER}/reviews?per_page=100")"' ) assert live_lookup in loop @@ -213,7 +214,7 @@ def test_poll_executes_live_state_read_before_current_head_review_read( assert result.returncode == 0, result.stderr assert calls == [ "api repos/ContextualWisdomLab/example/pulls/42", - "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews?per_page=100", ] @@ -242,7 +243,7 @@ def test_poll_retries_transient_live_state_failure_before_reviews_read( assert calls == [ "api repos/ContextualWisdomLab/example/pulls/42", "api repos/ContextualWisdomLab/example/pulls/42", - "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews?per_page=100", ] @@ -288,9 +289,9 @@ def test_poll_retries_transient_reviews_failure_after_revalidating_head( assert "Reviews API read failed while polling" in result.stdout assert calls == [ "api repos/ContextualWisdomLab/example/pulls/42", - "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews?per_page=100", "api repos/ContextualWisdomLab/example/pulls/42", - "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews?per_page=100", ] @@ -310,7 +311,7 @@ def test_poll_fails_closed_after_bounded_reviews_transport_failures( assert "Reviews API read failed 3 consecutive times" in result.stdout assert calls == [ "api repos/ContextualWisdomLab/example/pulls/42", - "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews", + "api --paginate repos/ContextualWisdomLab/example/pulls/42/reviews?per_page=100", ] * 3 @@ -321,4 +322,5 @@ def test_self_retirement_does_not_replace_semantic_review_with_a_short_timeout() )[1].split("\n cancel-superseded-opencode-review-runs:\n", 1)[0] assert "timeout-minutes:" not in target_job.split(" steps:\n", 1)[0] assert "while :; do" in target_job - assert "sleep 30" in target_job + assert "poll_interval_seconds=60" in target_job + assert 'sleep "$poll_interval_seconds"' in target_job