fix(pipeline): retry the hand-off API calls that a GitHub brown-out killed#9772
Conversation
…illed On 2026-07-24 (~16:05-16:26 UTC) GitHub returned HTTP 502 on the workflow-dispatch endpoint and HTTP 504 on GraphQL. Four radar-basic PRs fell out of the pipeline and stayed there: #9742 was already ai-approved but impl-merge's condition check died on an unretried `gh pr view`, and #9744/#9749/#9751 were ai-rejected but their impl-repair dispatch never landed. impl-review's verdict step is the pipeline's only hand-off point - every downstream workflow starts from a call made there - so an unretried blip does not merely fail a step, it leaves a PR carrying a verdict label with nothing listening. - retry every API call in that step and in impl-merge's condition check 3x with linear backoff - dispatch impl-repair *before* adding the attempt label, so a label problem can never suppress the dispatch (losing the label costs one over-strict review; losing the dispatch costs the whole PR) - on dispatch exhaustion, drop `ai-rejected` on the way out: the combination ai-rejected + ai-attempt-N matches no watchdog case, while the label-less state is exactly Case 2, which re-dispatches the repair at the attempt number the remaining label still encodes - create `ai-attempt-N` on demand. `ai-attempt-4` never existed, so the 4th repair's label add always failed and `|| true` hid it (#7268/#7039 reached "Repair Attempt 4/4" carrying only ai-attempt-1..3). Every 4th review therefore re-applied the >= 90 attempt-0 bar, and the attempts-exhausted branch could never be reached - that branch's PR comment used a quoted heredoc and rendered a literal "$SCORE/100"; it now expands, with the markdown backticks escaped so they cannot become command substitution - impl-merge's merge retry re-reads state first: a merge that succeeded server-side but lost its response used to fail four more times with "already merged" and exit 1, skipping GCS promotion, the impl:{lib}:done label, closing the issue and the Postgres sync Also stop auto-updating Dependabot branches. That push is authored by github-actions[bot], which makes GitHub gate the resulting runs behind manual approval; because main advances every few minutes, each branch was re-updated long before anyone could approve. PR #9674 burned 174 runs in 22 h - 162 action_required against 4 green. They merge fine while behind (the main ruleset is not strict; the file's header claimed otherwise and is corrected), and /dependabot's playbook told operators to run that same harmful update-branch by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Approving the gated run once is enough — verified that the required contexts (Run Linting / Run Tests / Run Frontend Tests) do report as SUCCESS on a github-actions[bot]-authored head once the run is approved (#9674, commit 5954507). The head only kept getting re-gated because this workflow kept pushing to it; with that stopped, one approval sticks. `@dependabot recreate` is an alternative, not a requirement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Improves the resilience of the implementation pipeline’s workflow hand-offs during transient GitHub API failures (e.g., 5xx/429), and fixes related pipeline reliability issues uncovered during verification (missing ai-attempt-4 label, merge idempotency, Dependabot branch updates).
Changes:
- Add consistent 3× retry + linear backoff around critical
gh/dispatch calls inimpl-review.yml(verdict hand-off) andimpl-merge.yml(pre-merge condition/state reads). - Make repair dispatch non-blockable by attempt-label writes and ensure
ai-attempt-4is created on demand to keep the cascading threshold logic intact. - Skip Dependabot branches in
auto-update-pr-branches.ymland update the/dependabotoperator playbook accordingly; add a detailed changelog entry.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| CHANGELOG.md | Adds detailed Fixed entries documenting the brown-out impact and the pipeline/workflow corrections. |
| agentic/commands/dependabot.md | Updates the operator playbook to avoid harmful update-branch operations on Dependabot PRs and document recovery (@dependabot recreate). |
| .github/workflows/impl-review.yml | Adds gh retry helper and adjusts dispatch/label ordering to prevent stranded PRs; creates missing attempt labels on demand. |
| .github/workflows/impl-merge.yml | Retries PR condition reads and adds an idempotency guard to avoid failing after a server-side merge succeeds. |
| .github/workflows/auto-update-pr-branches.yml | Skips Dependabot branches to prevent action_required gating loops; corrects ruleset assumptions in header. |
| gh pr edit "$PR_NUM" --remove-label "ai-rejected" 2>/dev/null || true | ||
| echo "::error::impl-repair dispatch failed after 3 attempts — verdict label dropped so watchdog Case 2 picks PR #${PR_NUM} up" | ||
| exit 1 |
| PR_DATA=$(cat /tmp/pr.json) | ||
| break | ||
| fi | ||
| echo "::warning::gh pr view failed (attempt ${attempt}/3): $(cat /tmp/pr.err)" |
| STATE=$(gh pr view "$PR_NUM" --repo "$REPOSITORY" --json state -q .state 2>/dev/null || echo "") | ||
| if [ "$STATE" = "MERGED" ]; then | ||
| echo "::notice::PR #${PR_NUM} is already merged — continuing to post-merge steps" | ||
| exit 0 | ||
| fi |
| ] | length | ||
| ') | ||
| if [[ "$SKIPPED" -gt 0 ]]; then | ||
| echo "::notice::Skipping $SKIPPED Dependabot PR(s) — Dependabot rebases those itself (see comment above)" |
… the state probe - impl-review: the `--remove-label ai-rejected` on the dispatch-failure path IS the hand-off to the watchdog, so it can't be an unretried `|| true` — the same brown-out would swallow it and leave the PR in the stranded state it exists to escape. Retried, and loud when it still fails. - impl-merge: retry the new state probe too. A blip there fell through to another `gh pr merge` on an already-merged PR, re-creating the merged-but-bookkeeping-skipped mode the probe was added to prevent. - impl-merge: flatten multi-line gh stderr before interpolating it into `::warning::`, matching impl-review. - auto-update-pr-branches: the ::notice:: still claimed "Dependabot rebases those itself", contradicting the corrected comment above it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Applied all four review comments:
Verified the merge loop's four paths against a stubbed |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (3)
.github/workflows/impl-review.yml:893
- Same pipefail/SIGPIPE issue as above:
tr ... | head -c 500can cause the step to exit underbash -eo pipefail, defeating the label-read retry loop. Usehead | trinstead.
break
fi
LERR=$(tr '\n' ' ' < /tmp/labels.err | head -c 500)
echo "::warning::gh pr view (labels) failed (attempt ${attempt}/3): ${LERR}"
if [ "$attempt" -lt 3 ]; then
.github/workflows/impl-merge.yml:63
::warning::... $(cat /tmp/pr.err)can emit multi-line output, which breaks the annotation format and makes logs harder to parse. Consider flattening + truncating stderr to one line (same pattern used elsewhere in this PR).
if gh pr view "$PR_NUM" --repo "$GH_REPO" --json headRefName,labels > /tmp/pr.json 2> /tmp/pr.err; then
PR_DATA=$(cat /tmp/pr.json)
break
fi
# Flattened: `::warning::` is line-oriented, and gh's HTTP errors
# are routinely multi-line — the tail would spill into the raw log.
PERR=$(tr '\n' ' ' < /tmp/pr.err | head -c 500)
.github/workflows/auto-update-pr-branches.yml:105
- The notice says "Dependabot rebases those itself", but the preceding comment explicitly notes Dependabot does not rebase merely because a branch is behind. Reword this notice to avoid giving operators the wrong mental model.
if [[ "$SKIPPED" -gt 0 ]]; then
echo "::notice::Skipping $SKIPPED Dependabot PR(s) — updating them gates their CI behind manual approval, and the non-strict ruleset means being behind does not block the merge (see comment above)"
fi
| if "$@" 2> /tmp/gh_retry.err; then | ||
| return 0 | ||
| fi | ||
| err=$(tr '\n' ' ' < /tmp/gh_retry.err | head -c 500) | ||
| echo "::warning::${what} failed (attempt ${attempt}/3): ${err}" |
`tr ... | head -c 500` aborts the assignment under `set -o pipefail` once the input exceeds the 64 KB pipe buffer (verified: 200 KB input + pipefail -> abort; without pipefail -> rc 0). These steps run under plain `bash -e`, so it is not reachable today, but other steps in this repo do set `-eo pipefail` and gh's stderr is attacker-independent but unbounded. `head -c 500 | tr` is byte-identical output with no exposure either way. Applied to all four call sites, plus the pre-existing `$(cat /tmp/pr.err)` in "Extract PR info" which had the same multi-line annotation problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Applied — with a correction to the stated premise. The SIGPIPE mechanism is real but not reachable in these steps as written, for two independent reasons:
So today it is safe on both counts. I applied the flip anyway, because it costs nothing and removes the dependency on both of those staying true — Done at all four call sites, plus the pre-existing |
| fi | ||
|
|
||
| # Update branch before merge attempt | ||
| gh pr update-branch "$PR_NUM" --repo "$REPOSITORY" 2>/dev/null || true | ||
| sleep 2 |
The state probe narrows the lost-response window but doesn't close it: if
the probe is blind for the same reason the merge response was lost, every
retry reports "already merged" until the loop exits 1 — and the post-merge
bookkeeping (GCS promotion, impl:{lib}:done, closing the issue, Postgres
sync) is skipped on a PR that is in fact merged.
gh saying the PR is already merged IS the success signal, so match it on
stderr and continue. Genuine failures still retry and still exit 1, and
the retry warning now carries the actual gh error instead of nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Good catch — applied. You're right that the state probe narrows the window without closing it: if the probe is blind for the same reason the merge response was lost, every retry reports "already merged" until the loop exits 1, and the bookkeeping is skipped on a PR that is merged.
Exercised against a stubbed
That's now three layers on the same failure: state probe → stderr match → loud exit. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
.github/workflows/impl-review.yml:862
- The comment says “Every API call below is retried 3x”, but in this block several gh API calls are still not retried (e.g.,
gh pr close,gh pr comment,gh pr edit --add-label quality-poor). Either wrap the remaining calls withgh_retryor narrow the comment to “hand-off/critical calls” so it matches the actual behavior.
# exactly what the 2026-07-24 GitHub API brown-out did to four
# radar-basic PRs (HTTP 502 on the dispatch endpoint, HTTP 504 on
# GraphQL). Every API call below is retried 3x with linear backoff.
# `::warning::` is line-oriented, so multi-line gh stderr would leak
# everything after the first newline into the raw log — flatten it.
| # until the loop exits 1 and the post-merge bookkeeping is skipped. | ||
| # gh saying the PR is already merged IS the success signal. | ||
| MERR=$(head -c 500 /tmp/merge.err | tr '\n' ' ') | ||
| if grep -qiE 'already merged|pull request is (already )?(merged|closed)' /tmp/merge.err; then |
What happened
On 2026-07-24, ~16:05–16:26 UTC GitHub's API browned out — HTTP 502 on the
workflow-dispatch endpoint, HTTP 504 on GraphQL, HTTP 429 on codeload. Four
radar-basicPRs fell out of the pipeline and stayed out, because the calls that hand work to the next
workflow had no retry:
ai-approvedq90impl-merge→gh pr view→ 504ai-rejectedq87ai-attempt-1also lostai-rejectedq88ai-rejectedq88impl-review's verdict step is the pipeline's only hand-off point — every downstreamworkflow starts from a call made there. So an unretried blip doesn't merely fail a step; it
leaves a PR carrying a verdict label with nothing listening.
All four have since been recovered manually and merged; this PR stops it recurring.
Changes
impl-review.yml— the verdict stepExtract PR inforetry, added 2026-05-06 for this same class of failure)
gate it. Losing the label costs one over-strict review; losing the dispatch costs the whole PR
ai-rejectedon the way out:ai-rejected+ai-attempt-Nmatches no watchdog case (Case 2 excludes any verdictlabel, Case 4 excludes PRs that already have an attempt label), while the label-less state is
exactly Case 2 — which re-dispatches the repair at the attempt number the remaining label
still encodes
successful read with no verdict label now fails loudly instead of ending the step green
Two bugs found while verifying the above
ai-attempt-4never existed as a label. The attempt label encodes the cascadingthreshold (90 → 80 → 70 → 60 → 50), but the repo only has
ai-attempt-1..3, so on the 4threpair the add failed every time and
|| trueswallowed it — feat(pygal): implement scatter-matrix-interactive #7268 and feat(pygal): implement boxen-basic #7039 both reached"Repair Attempt 4/4" carrying only
ai-attempt-1..3. Every 4th review therefore re-appliedthe ≥ 90 attempt-0 bar, and the attempts-exhausted branch (close PR, remove stale
implementation) could never be reached. Labels are now created on demand.
This also means my first draft of this PR was a hard blocker: with the label add fatal, the
4th repair would never have been dispatched at all.
$SCORE/100— quoted heredoc. Nowexpands, with the markdown backticks escaped so they can't become command substitution.
impl-merge.ymlHTTP response used to fail four more times with "already merged" and exit 1 — and because
every post-merge step is gated on
should_run == 'true'(implicitsuccess()), that skippedGCS promotion, the
impl:{lib}:donelabel, closing the issue and the Postgres sync. A mergedPR with none of its bookkeeping: exactly the silent partial completion CLAUDE.md warns about
auto-update-pr-branches.yml— Dependabot PRs could never merge while the pipeline ranupdate-branchon Dependabot PRs. That push is authored bygithub-actions[bot], so GitHub gates the resulting runs behind manual approval(
action_required) — and becausemainadvances every few minutes during impl merges, eachbranch was re-updated long before anyone could approve. PR deps(python): Bump the python-minor group across 1 directory with 7 updates #9674 accumulated 174 runs in
22 h: 162
action_requiredagainst only 4 green (the originaldependabot[bot]push)mainruleset isnot strict (
strict_required_status_checks_policy: false); the file's header claimed theopposite and is corrected
/dependabot's playbook told operators to run that same harmfulupdate-branchby hand; itnow says the opposite and documents the
@dependabot recreateremedyVerification
.github/workflows/has no verification loop (CLAUDE.md), so this was reviewed by a 4-lensadversarial pass with an independent refutation round (regression / shell-under-
bash -e/ retry idempotency / the Dependabotpremise), and every claim was re-checked against the live repo before acting. That pass is what
caught the
ai-attempt-4blocker and the false Dependabot-rebase premise in my first draft.bash -nclean on the extracted stepsgh_retrysemantics exercised underbash -e: succeeds first try / recovers after 2failures / multi-arg passthrough / hard failure aborts the step
permanently failing label add (dispatch must still fire), and dispatch exhaustion
(
ai-rejecteddropped, exit 1)ghadds labels fine; only the locally-installed gh 2.45.0 hitsthe
projectCardsGraphQL deprecation, so hard-failing on--add-labelis safe in CIai-attempt-*label set, and the poisoned Dependabot head commitswere all read from the live repo, not assumed
Follow-up (not in this PR)
github-actions[bot]merge commit as their head. Oncethis merges their head stops moving, so approving each gated run once is enough — verified
that the required contexts do report as SUCCESS on a bot-authored head after approval (deps(python): Bump the python-minor group across 1 directory with 7 updates #9674,
commit
59545076). Doing it before this merges would just get re-gated by the next push tomain. (@dependabot recreateis an equivalent alternative; it also restores a cleandependabot[bot]head but re-resolves the versions.)auto-update-pr-branches.ymlshould exist at all now that the ruleset is non-strictis worth deciding separately — it affects every auto-merge PR, so it isn't folded in here.
🤖 Generated with Claude Code