From e5e255272f0cef3ed9c4b0b365d5df793cf46807 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:18:34 +0900 Subject: [PATCH 1/4] test(automation): reproduce mention sweep rate-limit amplification --- tests/test_agent_mention_rate_limit.py | 72 ++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/test_agent_mention_rate_limit.py diff --git a/tests/test_agent_mention_rate_limit.py b/tests/test_agent_mention_rate_limit.py new file mode 100644 index 000000000..67b30bbe5 --- /dev/null +++ b/tests/test_agent_mention_rate_limit.py @@ -0,0 +1,72 @@ +"""Fail-fast regressions for exhausted GitHub mention-router API budgets.""" + +from __future__ import annotations + +import importlib +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" / "ci" +sys.path.insert(0, str(SCRIPTS)) + + +def module(): + """Reload the sweep module for isolated monkeypatching.""" + + return importlib.reload(importlib.import_module("agent_mention_sweep")) + + +def test_primary_rate_limit_exhaustion_stops_the_sweep(monkeypatch) -> None: + """A shared installation budget exhaustion must stop further API work.""" + + sweep = module() + issues = [ + {"repository": "ContextualWisdomLab/first", "number": 1}, + {"repository": "ContextualWisdomLab/second", "number": 2}, + ] + monkeypatch.setattr( + sweep, + "list_recent_pull_requests", + lambda *args, **kwargs: iter(issues), + ) + visited: list[str] = [] + + def build_requests(client, *, issue, since): + del client, since + visited.append(issue["repository"]) + raise RuntimeError( + "gh: API rate limit exceeded for installation ID 141441800 (HTTP 403)" + ) + + monkeypatch.setattr(sweep, "build_requests_for_pull_request", build_requests) + metrics = sweep.SweepMetrics() + + with pytest.raises(sweep.SweepRateLimitExhausted, match="rate limit"): + sweep.sweep( + target_client=object(), + dispatch_client=object(), + organization="ContextualWisdomLab", + repository_source="installation", + lookback_hours=24, + max_dispatches=5, + opencode_allowlist=frozenset(), + now=datetime(2026, 8, 15, tzinfo=timezone.utc), + metrics=metrics, + ) + + assert visited == ["ContextualWisdomLab/first"] + assert metrics.failures == 1 + + +def test_secondary_rate_limit_exhaustion_is_global() -> None: + """Secondary-limit messages are classified as sweep-global exhaustion.""" + + sweep = module() + assert sweep.is_rate_limit_exhaustion( + RuntimeError("You have exceeded a secondary rate limit. Please retry later.") + ) + assert not sweep.is_rate_limit_exhaustion(RuntimeError("Resource not accessible")) From 602d4f91f5b58657f1d8e00c18ae0b2a20900280 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:19:12 +0900 Subject: [PATCH 2/4] fix(automation): stop mention sweep after shared rate-limit exhaustion --- scripts/ci/agent_mention_sweep.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/ci/agent_mention_sweep.py b/scripts/ci/agent_mention_sweep.py index 9b64909a0..891017e02 100644 --- a/scripts/ci/agent_mention_sweep.py +++ b/scripts/ci/agent_mention_sweep.py @@ -30,6 +30,20 @@ class SweepMetrics: failures: int = 0 +class SweepRateLimitExhausted(RuntimeError): + """Signal that shared GitHub API capacity is unavailable for this sweep.""" + + +def is_rate_limit_exhaustion(error: Exception) -> bool: + """Return whether an API error says the shared primary/secondary budget is exhausted.""" + + message = " ".join(str(error).split()).casefold() + return ( + "api rate limit exceeded" in message + or "secondary rate limit" in message + ) + + def parse_timestamp(value: str) -> datetime: """Parse one GitHub ISO-8601 timestamp into timezone-aware UTC.""" @@ -301,13 +315,18 @@ def sweep( dispatched = 0 def record_failure(scope: str, error: Exception) -> None: - """Record one isolated error and preserve the remaining sweep.""" + """Record isolated errors but stop when the shared API budget is exhausted.""" counters.failures += 1 message = " ".join(str(error).split()) or error.__class__.__name__ print( f"::warning::Agent mention sweep skipped {scope}: {message[:1000]}" ) + if is_rate_limit_exhaustion(error): + raise SweepRateLimitExhausted( + "GitHub API rate limit exhausted; stopping organization sweep " + "to preserve the shared installation budget" + ) from error for issue in list_recent_pull_requests( target_client, From 2502f665a1b74cb7814c01535c3dbaf3a20ce259 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:19:38 +0900 Subject: [PATCH 3/4] docs(automation): record mention sweep rate-limit boundary --- .../agent-mention-rate-limit-fail-fast.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/doctoring/agent-mention-rate-limit-fail-fast.md diff --git a/docs/doctoring/agent-mention-rate-limit-fail-fast.md b/docs/doctoring/agent-mention-rate-limit-fail-fast.md new file mode 100644 index 000000000..9d7e4e3d5 --- /dev/null +++ b/docs/doctoring/agent-mention-rate-limit-fail-fast.md @@ -0,0 +1,35 @@ +# Agent mention sweep rate-limit fail-fast boundary + +Updated: 2026-08-15 + +## Incident + +Scheduled `Review Agent Mention Router` run `31868885733` exhausted the OpenCode GitHub App installation REST budget before processing the requested review queue. The sweep continued traversing repositories after the first installation-wide `API rate limit exceeded` response and finished with zero dispatches plus 116 isolated failures. Repeating requests after the shared budget is exhausted cannot recover candidate-local work and consumes runner time while obscuring the single control-plane cause. + +## Decision + +Treat explicit GitHub primary- or secondary-rate-limit messages as **sweep-global capacity exhaustion**, not candidate-local failures. The sweep records the first failed scope, then raises `SweepRateLimitExhausted` immediately. Ordinary repository, pull-request, review, acknowledgement, and dispatch failures remain isolated exactly as before. + +This change is intentionally narrow. It does not retry, sleep, change credentials, widen permissions, alter the canonical invocation key, modify the exact-name artifact ledger, or claim that a failed request was dispatched. A later scheduled invocation may run after GitHub restores capacity. Interactive/local routing and the separate concurrency-isolation repair remain independent control-plane lanes. + +## Why fail-fast + +GitHub documents that installation access tokens share an installation-level primary REST budget. When a primary limit is exceeded, requests return HTTP 403 or 429 and callers should not retry until the reset time. GitHub also states that integrations should stop and wait on secondary-rate-limit responses; continuing to make requests while rate-limited may lead to integration bans. The current sweep cannot safely infer reset headers from the `gh` exception string, so the bounded action is to stop the current scheduled traversal rather than amplify the exhausted state. + +## Verification contract + +- a synthetic installation-wide primary-limit error on the first PR aborts before the second PR is touched; +- exactly one failure is recorded for the first exhausted scope; +- secondary-rate-limit messages are classified as sweep-global exhaustion; +- unrelated authorization/resource errors remain candidate-local and preserve existing failure isolation; +- the permanent agent-mention quality suite continues to require 100% owned production statement/branch and public docstring coverage. + +## Rollback + +Revert `SweepRateLimitExhausted`, `is_rate_limit_exhaustion`, and their focused regression if GitHub changes the CLI error contract or the router gains structured response-header handling. Do not restore repeated API calls after a proven shared rate-limit exhaustion without an equivalent bounded backoff/stop mechanism. + +## References + +GitHub. (n.d.). *Rate limits for the REST API*. GitHub Docs. Retrieved August 15, 2026, from https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api + +GitHub. (n.d.). *Rate limits for GitHub Apps*. GitHub Docs. Retrieved August 15, 2026, from https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/rate-limits-for-github-apps From f99d6dde83aaaf730e42c27f173f96f8aac14fdf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 14:46:16 +0000 Subject: [PATCH 4/4] fix(automation): stop mention sweep on already-exceeded rate limits Classify GitHub "API rate limit already exceeded" wording as shared-budget exhaustion, reproduce the repository-listing incident path, and make the scheduled CLI exit 1 with an operator next action instead of a traceback. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 25 +++ CHANGELOG.md | 1 + .../agent-mention-rate-limit-fail-fast.md | 43 ++++- scripts/ci/agent_mention_sweep.py | 51 +++-- tests/test_agent_mention_rate_limit.py | 179 ++++++++++++++++++ 5 files changed, 274 insertions(+), 25 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fe6621b6..4e78c5909 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -74,6 +74,29 @@ sequenceDiagram MS->>PR: merge only on current-head approval + green checks ``` +## Mention-sweep shared-budget stop + +```mermaid +flowchart TD + Router["Review Agent Mention Router"] + List["List org repos and recent PRs"] + Work["Build mentions and dispatch"] + Cap{"Shared installation REST budget exhausted?"} + Halt["Record one scope, tell the operator to wait, exit 1"] + Next["Continue later repos and PRs"] + + Router --> List --> Work --> Cap + Cap -->|"yes"| Halt + Cap -->|"no"| Next + Next --> Work +``` + +The scheduled sweep treats GitHub primary and secondary rate-limit wording as +one shared installation budget, not a per-repository skip. After the first +exhausted scope it stops so later repositories cannot amplify an already empty +budget. Ordinary candidate-local failures stay isolated. Operators wait for +GitHub to reset the installation window; they do not re-run immediately. + ## Trust boundaries - Required review workflows execute **base-branch** scripts. A PR that edits @@ -106,5 +129,7 @@ tests pin workflow structure and governance prose so drift fails closed. contract. - [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md) — current increment's repair-worker decision and APA 7th citations. +- [`docs/doctoring/agent-mention-rate-limit-fail-fast.md`](docs/doctoring/agent-mention-rate-limit-fail-fast.md) + — mention-sweep shared-budget stop, incident-path tests, and APA 7th citations. - [`docs/doctoring/fast-mlsirm-hourly-review-caller.md`](docs/doctoring/fast-mlsirm-hourly-review-caller.md) — product-specific psychometric repair heartbeat and scientific gates. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..6a69e9ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Stop the organization mention sweep after the first shared GitHub primary or secondary rate-limit exhaustion, including the `API rate limit already exceeded` wording, the repository-listing incident path, and dispatch-time exhaustion. The scheduled CLI now exits `1` with an operator next action instead of continuing the 116-failure amplification or surfacing only a traceback. - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Refused a conflict-scope repository root whose immediate parent is a symbolic link, so a swapped parent cannot redirect the canonical worktree after the last-component check (CWE-367). - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. diff --git a/docs/doctoring/agent-mention-rate-limit-fail-fast.md b/docs/doctoring/agent-mention-rate-limit-fail-fast.md index 9d7e4e3d5..cc8c9167e 100644 --- a/docs/doctoring/agent-mention-rate-limit-fail-fast.md +++ b/docs/doctoring/agent-mention-rate-limit-fail-fast.md @@ -1,6 +1,6 @@ # Agent mention sweep rate-limit fail-fast boundary -Updated: 2026-08-15 +Updated: 2026-08-16 ## Incident @@ -8,10 +8,30 @@ Scheduled `Review Agent Mention Router` run `31868885733` exhausted the OpenCode ## Decision -Treat explicit GitHub primary- or secondary-rate-limit messages as **sweep-global capacity exhaustion**, not candidate-local failures. The sweep records the first failed scope, then raises `SweepRateLimitExhausted` immediately. Ordinary repository, pull-request, review, acknowledgement, and dispatch failures remain isolated exactly as before. +Treat explicit GitHub primary- or secondary-rate-limit messages as **sweep-global capacity exhaustion**, not candidate-local failures. The sweep records the first failed scope, emits an operator `::error::` with the next action (wait for the installation REST budget to reset; do not re-run immediately), then raises `SweepRateLimitExhausted`. The scheduled CLI catches that exception and exits `1` so the workflow fails closed without a raw traceback. Ordinary repository, pull-request, review, acknowledgement, and dispatch failures remain isolated exactly as before. + +Classification matches GitHub's documented wording families, including `API rate limit exceeded`, `API rate limit already exceeded`, and secondary-limit messages. A contiguous `"api rate limit exceeded"` needle is not sufficient: the GraphQL/already-exhausted phrasing inserts `already` between `limit` and `exceeded`, and that wording must also stop the sweep. This change is intentionally narrow. It does not retry, sleep, change credentials, widen permissions, alter the canonical invocation key, modify the exact-name artifact ledger, or claim that a failed request was dispatched. A later scheduled invocation may run after GitHub restores capacity. Interactive/local routing and the separate concurrency-isolation repair remain independent control-plane lanes. +```mermaid +flowchart TD + Sweep["Organization mention sweep"] + Err{"Exception on repo, PR, or dispatch?"} + Local["Record isolated failure and continue"] + Shared{"Primary or secondary rate-limit wording?"} + Stop["Record one scope, emit next-action error, raise SweepRateLimitExhausted"] + Exit["CLI exits 1; do not re-run until budget reset"] + + Sweep --> Err + Err -->|"no"| Sweep + Err -->|"yes"| Shared + Shared -->|"no"| Local + Local --> Sweep + Shared -->|"yes"| Stop + Stop --> Exit +``` + ## Why fail-fast GitHub documents that installation access tokens share an installation-level primary REST budget. When a primary limit is exceeded, requests return HTTP 403 or 429 and callers should not retry until the reset time. GitHub also states that integrations should stop and wait on secondary-rate-limit responses; continuing to make requests while rate-limited may lead to integration bans. The current sweep cannot safely infer reset headers from the `gh` exception string, so the bounded action is to stop the current scheduled traversal rather than amplify the exhausted state. @@ -19,17 +39,28 @@ GitHub documents that installation access tokens share an installation-level pri ## Verification contract - a synthetic installation-wide primary-limit error on the first PR aborts before the second PR is touched; +- the incident path (first repository pull listing exhausted) records exactly one failure and never requests a later repository; +- a dispatch-time primary-limit error aborts before the next pull request is built; - exactly one failure is recorded for the first exhausted scope; -- secondary-rate-limit messages are classified as sweep-global exhaustion; +- secondary-limit, `already exceeded`, and HTTP 429 secondary messages are classified as sweep-global exhaustion; - unrelated authorization/resource errors remain candidate-local and preserve existing failure isolation; +- `main()` returns `1` with an `::error::` next action when `SweepRateLimitExhausted` is raised; - the permanent agent-mention quality suite continues to require 100% owned production statement/branch and public docstring coverage. +## Operator next action + +If the scheduled sweep fails with `::error::` and `rate limit`, wait for GitHub to restore the installation REST budget. Do not re-run the workflow immediately. The next hourly schedule is the recovery path. Ordinary isolated `::warning::` skips are not this signal. + ## Rollback -Revert `SweepRateLimitExhausted`, `is_rate_limit_exhaustion`, and their focused regression if GitHub changes the CLI error contract or the router gains structured response-header handling. Do not restore repeated API calls after a proven shared rate-limit exhaustion without an equivalent bounded backoff/stop mechanism. +Revert `SweepRateLimitExhausted`, `is_rate_limit_exhaustion`, the CLI catch, and their focused regressions if GitHub changes the CLI error contract or the router gains structured response-header handling. Do not restore repeated API calls after a proven shared rate-limit exhaustion without an equivalent bounded backoff/stop mechanism. ## References -GitHub. (n.d.). *Rate limits for the REST API*. GitHub Docs. Retrieved August 15, 2026, from https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api +GitHub. (n.d.-a). *Rate limits for the REST API*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api + +GitHub. (n.d.-b). *Rate limits for GitHub Apps*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/rate-limits-for-github-apps + +GitHub. (n.d.-c). *Best practices for using the REST API*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api -GitHub. (n.d.). *Rate limits for GitHub Apps*. GitHub Docs. Retrieved August 15, 2026, from https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/rate-limits-for-github-apps +GitHub. (n.d.-d). *Rate limits and query limits for the GraphQL API*. GitHub Docs. Retrieved August 16, 2026, from https://docs.github.com/en/graphql/overview/rate-limits-and-query-limits-for-the-graphql-api diff --git a/scripts/ci/agent_mention_sweep.py b/scripts/ci/agent_mention_sweep.py index 891017e02..60b3c1f66 100644 --- a/scripts/ci/agent_mention_sweep.py +++ b/scripts/ci/agent_mention_sweep.py @@ -38,9 +38,10 @@ def is_rate_limit_exhaustion(error: Exception) -> bool: """Return whether an API error says the shared primary/secondary budget is exhausted.""" message = " ".join(str(error).split()).casefold() - return ( - "api rate limit exceeded" in message - or "secondary rate limit" in message + if "secondary rate limit" in message: + return True + return "rate limit" in message and ( + "exceeded" in message or "exhausted" in message ) @@ -319,14 +320,19 @@ def record_failure(scope: str, error: Exception) -> None: counters.failures += 1 message = " ".join(str(error).split()) or error.__class__.__name__ - print( - f"::warning::Agent mention sweep skipped {scope}: {message[:1000]}" - ) if is_rate_limit_exhaustion(error): + print( + f"::error::Agent mention sweep stopping at {scope}: shared GitHub " + "API rate limit exhausted. Wait for the installation budget to " + "reset; do not re-run this sweep immediately." + ) raise SweepRateLimitExhausted( "GitHub API rate limit exhausted; stopping organization sweep " "to preserve the shared installation budget" ) from error + print( + f"::warning::Agent mention sweep skipped {scope}: {message[:1000]}" + ) for issue in list_recent_pull_requests( target_client, @@ -393,19 +399,26 @@ def main(argv: Sequence[str] | None = None) -> int: os.environ.get("OPENCODE_REPOSITORY_DISPATCH_TARGETS", "") ) metrics = SweepMetrics() - sweep( - target_client=GitHubClient( - os.environ.get("TARGET_REPOSITORY_TOKEN", "") - ), - dispatch_client=GitHubClient(os.environ.get("AGENT_DISPATCH_TOKEN", "")), - organization=args.organization, - repository_source=args.repository_source, - lookback_hours=args.lookback_hours, - max_dispatches=args.max_dispatches, - opencode_allowlist=allowlist, - dry_run=args.dry_run, - metrics=metrics, - ) + try: + sweep( + target_client=GitHubClient( + os.environ.get("TARGET_REPOSITORY_TOKEN", "") + ), + dispatch_client=GitHubClient(os.environ.get("AGENT_DISPATCH_TOKEN", "")), + organization=args.organization, + repository_source=args.repository_source, + lookback_hours=args.lookback_hours, + max_dispatches=args.max_dispatches, + opencode_allowlist=allowlist, + dry_run=args.dry_run, + metrics=metrics, + ) + except SweepRateLimitExhausted as exc: + print( + f"::error::{exc} Wait for the installation REST budget to reset " + "before the next scheduled run; do not re-run immediately." + ) + return 1 return 1 if metrics.failures else 0 diff --git a/tests/test_agent_mention_rate_limit.py b/tests/test_agent_mention_rate_limit.py index 67b30bbe5..d005e36be 100644 --- a/tests/test_agent_mention_rate_limit.py +++ b/tests/test_agent_mention_rate_limit.py @@ -69,4 +69,183 @@ def test_secondary_rate_limit_exhaustion_is_global() -> None: assert sweep.is_rate_limit_exhaustion( RuntimeError("You have exceeded a secondary rate limit. Please retry later.") ) + assert sweep.is_rate_limit_exhaustion( + RuntimeError("API rate limit already exceeded for installation ID 141441800") + ) + assert sweep.is_rate_limit_exhaustion( + RuntimeError("gh: You have exceeded a secondary rate limit (HTTP 429)") + ) assert not sweep.is_rate_limit_exhaustion(RuntimeError("Resource not accessible")) + assert not sweep.is_rate_limit_exhaustion(RuntimeError("HTTP 403 Forbidden")) + + +class _PagingClient: + """Serve page-aware endpoint responses and raise configured listing errors.""" + + def __init__(self, responses) -> None: + """Initialize an endpoint/page response map.""" + + self.responses = responses + self.calls: list[list[str]] = [] + + def request(self, args, *, input_payload=None): + """Return one endpoint/page response or raise its configured error.""" + + del input_payload + args = list(args) + self.calls.append(args) + endpoint = args[0] + page = 1 + for index, value in enumerate(args[:-1]): + if value == "-f" and args[index + 1].startswith("page="): + page = int(args[index + 1].split("=", 1)[1]) + response = self.responses[(endpoint, page)] + if isinstance(response, Exception): + raise response + return response + + +def _repository(name: str) -> dict: + """Build one active organization repository record.""" + + return { + "full_name": f"ContextualWisdomLab/{name}", + "owner": {"login": "ContextualWisdomLab"}, + "archived": False, + "disabled": False, + } + + +def test_repository_listing_rate_limit_stops_before_later_repository( + monkeypatch, + capsys, +) -> None: + """The 116-failure incident path: first repo listing exhausts the shared budget.""" + + sweep = module() + client = _PagingClient( + { + ("orgs/ContextualWisdomLab/repos", 1): [[ + _repository("first"), + _repository("second"), + ]], + ("repos/ContextualWisdomLab/first/pulls", 1): RuntimeError( + "gh: API rate limit exceeded for installation ID 141441800 (HTTP 403)" + ), + ("repos/ContextualWisdomLab/second/pulls", 1): [ + {"number": 2, "updated_at": "2026-08-15T11:00:00Z"} + ], + } + ) + + def refuse_later_work(*args, **kwargs): + del args, kwargs + raise AssertionError("later pull requests must not be built after listing exhaustion") + + monkeypatch.setattr(sweep, "build_requests_for_pull_request", refuse_later_work) + metrics = sweep.SweepMetrics() + + with pytest.raises(sweep.SweepRateLimitExhausted, match="rate limit"): + sweep.sweep( + target_client=client, + dispatch_client=object(), + organization="ContextualWisdomLab", + repository_source="organization", + lookback_hours=24, + max_dispatches=5, + opencode_allowlist=frozenset(), + now=datetime(2026, 8, 15, tzinfo=timezone.utc), + metrics=metrics, + ) + + pull_calls = [args[0] for args in client.calls if args[0].endswith("/pulls")] + assert pull_calls == ["repos/ContextualWisdomLab/first/pulls"] + assert metrics.failures == 1 + output = capsys.readouterr().out + assert "::error::" in output + assert "ContextualWisdomLab/first" in output + assert "do not re-run" in output.casefold() or "wait" in output.casefold() + + +def test_dispatch_rate_limit_stops_before_later_pull_request(monkeypatch) -> None: + """A shared-budget failure while dispatching must not touch the next PR.""" + + sweep = module() + issues = [ + {"repository": "ContextualWisdomLab/first", "number": 1}, + {"repository": "ContextualWisdomLab/second", "number": 2}, + ] + monkeypatch.setattr( + sweep, + "list_recent_pull_requests", + lambda *args, **kwargs: iter(issues), + ) + visited: list[int] = [] + + def build_requests(client, *, issue, since): + del client, since + visited.append(issue["number"]) + router = importlib.import_module("agent_mention_router") + return ( + router.MentionRequest( + issue["repository"], + issue["number"], + "a" * 40, + "main", + issue["number"] * 10, + "maintainer", + ("opencode-agent",), + ), + ) + + monkeypatch.setattr(sweep, "build_requests_for_pull_request", build_requests) + + def dispatch(request, **kwargs): + del kwargs + raise RuntimeError( + "gh: API rate limit exceeded for installation ID 141441800 (HTTP 403)" + ) + + monkeypatch.setattr(sweep, "dispatch_request", dispatch) + metrics = sweep.SweepMetrics() + + with pytest.raises(sweep.SweepRateLimitExhausted, match="rate limit"): + sweep.sweep( + target_client=object(), + dispatch_client=object(), + organization="ContextualWisdomLab", + repository_source="installation", + lookback_hours=24, + max_dispatches=5, + opencode_allowlist=frozenset(), + now=datetime(2026, 8, 15, tzinfo=timezone.utc), + metrics=metrics, + ) + + assert visited == [1] + assert metrics.failures == 1 + + +def test_main_returns_failure_when_shared_rate_limit_stops_sweep( + monkeypatch, + capsys, +) -> None: + """The scheduled job must exit 1 with an operator next action, not a traceback.""" + + sweep = module() + monkeypatch.setenv("TARGET_REPOSITORY_TOKEN", "target") + monkeypatch.setenv("AGENT_DISPATCH_TOKEN", "dispatch") + + def raise_exhausted(**kwargs): + kwargs["metrics"].failures = 1 + raise sweep.SweepRateLimitExhausted( + "GitHub API rate limit exhausted; stopping organization sweep " + "to preserve the shared installation budget" + ) + + monkeypatch.setattr(sweep, "sweep", raise_exhausted) + assert sweep.main([]) == 1 + output = capsys.readouterr().out + assert "::error::" in output + assert "rate limit" in output.casefold() + assert "wait" in output.casefold() or "do not re-run" in output.casefold()