From c9c83a34a208d294d92d5c467c4eefa747a34e29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 03:59:58 +0900 Subject: [PATCH] fix(scheduler): treat UTF-8 and query-cost GraphQL errors as transient Unicode refs and large org queues must fall back to REST instead of aborting the merge scheduler. Do not rewrite hashed review-agent workflows. --- AGENTS.md | 1 + ARCHITECTURE.md | 7 +++ CHANGELOG.md | 1 + CLAUDE.md | 2 + .../scheduler-utf8-graphql-fallback.md | 29 +++++++++++ scripts/ci/pr_review_merge_scheduler.py | 2 + tests/test_pr_review_merge_scheduler.py | 52 +++++++++++++++++++ 7 files changed, 94 insertions(+) create mode 100644 docs/doctoring/scheduler-utf8-graphql-fallback.md diff --git a/AGENTS.md b/AGENTS.md index 16f0981c0..2140a5d33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,3 +5,4 @@ Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/hourly-nvidia-nim-autofix.md`](docs/doctoring/hourly-nvidia-nim-autofix.md). Conflict-scope roots fail closed when the immediate parent directory is a symbolic link. +GraphQL `invalid UTF-8 string` and query-cost overruns are transient; fall back to REST. Do not treat review/Checks wait as a blocker. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6fe6621b6..59bd1c87d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,6 +25,13 @@ flowchart LR Products -->|"standalone or as module"| Buyer ``` +## Merge-scheduler GraphQL fallback + +`TRANSIENT_GITHUB_API_ERRORS` includes `invalid UTF-8 string` and +`Resource limits for this query exceeded`. Those failures retry, then +fall back to REST so Unicode refs and large queues do not abort the +org scan. Authorization and schema errors stay fail-closed. + ## Hourly NVIDIA NIM repair gate ```mermaid diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9130a5..a2e842a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Treat GraphQL `invalid UTF-8 string` and `Resource limits for this query exceeded` as transient merge-scheduler failures so Unicode branch names and large org queues fall back to REST instead of stalling. - 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/CLAUDE.md b/CLAUDE.md index 7127d3c1c..81e1544c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,8 @@ repeatable compile command. without running the test suite will break CI. - **100% coverage and 100% docstrings on `scripts/ci/`** are hard gates, not aspirations. New helper code needs matching tests and docstrings. +- **GraphQL `invalid UTF-8 string` and query-cost overruns** are transient for the merge + scheduler. Fall back to REST; do not abort the org queue. - **`pull_request_target` trust boundary.** The required review workflows run the *base branch's* trusted scripts. A PR that edits the trusted review workflows can fail its own checks until the base branch catches up; a same-head manual `workflow_dispatch` Strix run may supply review evidence diff --git a/docs/doctoring/scheduler-utf8-graphql-fallback.md b/docs/doctoring/scheduler-utf8-graphql-fallback.md new file mode 100644 index 000000000..71c123c8f --- /dev/null +++ b/docs/doctoring/scheduler-utf8-graphql-fallback.md @@ -0,0 +1,29 @@ +# Scheduler UTF-8 GraphQL fallback + +## Incident and buyer impact + +The merge scheduler aborted while listing pull requests because `gh api +graphql` returned `invalid UTF-8 string`. That marker was not treated as +transient, so the existing REST fallback never ran. Unicode branch names +and comment bodies then blocked the entire org queue. + +## Decision + +Treat `invalid UTF-8 string` and `Resource limits for this query +exceeded` as transport/capacity failures, not schema or authorization +errors. Retry and fall back to REST. Do not treat GraphQL field errors +or `Resource not accessible by integration` as transient. This keeps +operational Unicode content and the full open-PR queue available +instead of masking or dropping them. + +## References + +Yergeau, F. (2003). *UTF-8, a transformation format of ISO 10646* +(RFC 3629). Internet Engineering Task Force. +https://doi.org/10.17487/RFC3629 + +GitHub. (2025). *Using the GitHub GraphQL API*. +https://docs.github.com/en/graphql + +GitHub. (2025). *Rate limits and node limits for the GraphQL API*. +https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api diff --git a/scripts/ci/pr_review_merge_scheduler.py b/scripts/ci/pr_review_merge_scheduler.py index 75e18c860..566e5c645 100644 --- a/scripts/ci/pr_review_merge_scheduler.py +++ b/scripts/ci/pr_review_merge_scheduler.py @@ -630,6 +630,8 @@ def repository_dispatch_wait_reason(repo: str, workflow: str) -> str | None: "stream error", "temporary failure", "timeout", + "invalid UTF-8 string", + "Resource limits for this query exceeded", "unexpected end of JSON input", "unexpected EOF", "received from peer", diff --git a/tests/test_pr_review_merge_scheduler.py b/tests/test_pr_review_merge_scheduler.py index 3e421e903..d6a987ce4 100644 --- a/tests/test_pr_review_merge_scheduler.py +++ b/tests/test_pr_review_merge_scheduler.py @@ -697,6 +697,58 @@ def fail_graphql(*args, **kwargs): assert sched.fetch_pr("owner/repo", 1) == [{"repo": "owner/repo", "number": 1}] +def test_graphql_utf8_errors_fall_back_to_rest(monkeypatch): + """Unicode GraphQL transport failures must not stall the org queue.""" + + def fail_graphql(*args, **kwargs): + raise RuntimeError("Command failed (1): gh api graphql\ngh: invalid UTF-8 string") + + monkeypatch.setattr(sched, "gh_graphql", fail_graphql) + monkeypatch.setattr(sched, "fetch_open_prs_rest", lambda repo, max_prs: [{"repo": repo, "max": max_prs}]) + monkeypatch.setattr(sched, "fetch_pr_rest", lambda repo, number: [{"repo": repo, "number": number}]) + + assert sched.is_transient_github_api_error(RuntimeError("invalid UTF-8 string")) + assert sched.fetch_open_prs("owner/repo", 1) == [{"repo": "owner/repo", "max": 1}] + assert sched.fetch_pr("owner/repo", 1) == [{"repo": "owner/repo", "number": 1}] + + +def test_graphql_resource_limit_falls_back_to_rest(monkeypatch): + """A 58-PR GraphQL list that exceeds GitHub query cost uses REST instead of aborting.""" + + def fail_graphql(*args, **kwargs): + raise RuntimeError( + "GraphQL: Resource limits for this query exceeded. " + "(repository.pullRequests.nodes.0.url), " + "Resource limits for this query exceeded. " + "(repository.pullRequests.nodes.1.number)" + ) + + monkeypatch.setattr(sched, "gh_graphql", fail_graphql) + monkeypatch.setattr( + sched, + "fetch_open_prs_rest", + lambda repo, max_prs: [{"repo": repo, "max": max_prs, "via": "rest"}], + ) + monkeypatch.setattr( + sched, + "fetch_pr_rest", + lambda repo, number: [{"repo": repo, "number": number, "via": "rest"}], + ) + + observed = RuntimeError( + "GraphQL: Resource limits for this query exceeded. " + "(repository.pullRequests.nodes.0.url)" + ) + assert sched.is_transient_github_api_error(observed) + assert not sched.github_resource_inaccessible(observed) + assert sched.fetch_open_prs("ContextualWisdomLab/.github", 58) == [ + {"repo": "ContextualWisdomLab/.github", "max": 58, "via": "rest"} + ] + assert sched.fetch_pr("ContextualWisdomLab/.github", 934) == [ + {"repo": "ContextualWisdomLab/.github", "number": 934, "via": "rest"} + ] + + def test_graphql_read_errors_do_not_fall_back_for_schema_errors(monkeypatch): def fail_graphql(*args, **kwargs): raise RuntimeError("gh: Field 'unknown' doesn't exist on type 'PullRequest'")