Skip to content

fix(install): ride out a CDN blip instead of losing the run to it - #906

Open
tend-agent wants to merge 1 commit into
mainfrom
fix/claude-install-retry-31298989023
Open

fix(install): ride out a CDN blip instead of losing the run to it#906
tend-agent wants to merge 1 commit into
mainfrom
fix/claude-install-retry-31298989023

Conversation

@tend-agent

@tend-agent tend-agent commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

review-reviewers run 31297986524 lost its numbagg/numbagg leg to a transient 403 from the claude installer CDN. All three install attempts landed inside ~15.7s and all three got the same 403, while the other four matrix legs — installing the same version from the same runner at the same moment — succeeded:

CLAUDE_VERSION: 2.1.220
curl: (22) The requested URL returned error: 403
Install attempt 1 failed; retrying
curl: (22) The requested URL returned error: 403
Install attempt 2 failed; retrying
curl: (22) The requested URL returned error: 403
##[error]failed to install claude 2.1.220 after 3 attempts

Two things make this worse than a lost leg. The blip outlived the retry window: three attempts at a flat 5s backoff spend everything inside ~15s, which is short enough that a CDN hiccup takes the whole run rather than costing it a few seconds. And the failure is silent — Report failure is gated on steps.claude.outcome == 'failure', so a step that fails ahead of the agent files no tend-outage row and appends nothing to the open tracker (#905). The run went red with no record anywhere a maintainer looks. That gating is #857's subject and is not touched here.

The concurrency angle points the same way: review-reviewers installs on five matrix legs at once from one runner's egress address, so a rate limit hits them together — and with an unjittered backoff, every leg retries together too, reproducing the burst that tripped it.

Change

shared/steps/install-claude-binary.sh goes from three attempts at a flat 5s to five backing off exponentially with jitter — 5, 10, 20, 40 seconds plus 0–9s each, so the window spans roughly 75–110s instead of 15s, and sibling legs spread out instead of retrying in lockstep. The happy path is unchanged: one fetch, no sleep.

Tests

Four new tests in generator/tests/test_shared_steps.py, following the existing fake-binaries-on-PATH pattern (fake sudo drops the privilege change, fake curl fails a configurable number of times then emits an installer, fake sleep records the delay instead of paying it). Three of the four fail against the pre-fix script:

Test Against main
rides out a 403 burst (4 failures then success) fails — gives up after 3
backs off exponentially fails — sleeps [5, 10]
reddens when every attempt fails, naming the count fails — says "after 3 attempts"
installs first try without sleeping passes (happy path is unchanged)

Full generator suite: 383 passed. shellcheck -S warning clean on the modified script.

Uncertainty

One observed occurrence — I swept the 20 most recent failed tend-*/review-* runs and this is the only install-time 403 among them, so the blip is rare rather than chronic.

The cost of being wrong about that frequency depends on how the install fails, and it splits into two very different numbers. Against a fast failure like the observed 403, curl returns in well under a second, so the added cost is only the extra backoff: the step goes from ~15s to ~75–110s. Against a CDN that hangs, each attempt burns the full timeout 60, so the step's ceiling goes from 3×60+15 ≈ 195s to 5×60+111 ≈ 411s — about 3.6 minutes longer, not the ~90s an earlier revision of this section claimed (that figure only ever held for the fast-failure shape).

Weighing ~7 minutes for a wedged install rather than ~90s, I'd still keep five attempts: reaching that ceiling takes all five attempts hanging, a much rarer shape than the fast 403 this exists to ride out, and in that case the run is lost either way — the extra minutes cost runner time but change no outcome. Trimming the count or the ceiling re-narrows the window the change exists to widen. Still happy to dial it back if you'd rather bound the wedged case.

@tend-agent tend-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing my own PR, so this is a COMMENT rather than a verdict.

The sibling install step still carries the pre-fix loop. shared/steps/install-proxy-uv.sh has the same idiom this PR replaces — for i in 1 2 3, sleep $((i * 5)), failed to install uv ${UV_VERSION} after 3 attempts — and it shares every property the rationale here rests on. It runs in the same job (claude/action.yaml, the run: bash .../install-proxy-uv.sh step, ~80 lines ahead of the install-claude-binary.sh one), so it's equally ahead of the agent step and equally invisible to Report failure; it fetches over the same runner egress on all five review-reviewers legs at once; and its three attempts span the same ~15s. A 403 burst from astral.sh instead of claude.ai loses the leg identically, with no outage row — the incident in the PR body with one hostname swapped. Fixing one of the two leaves the described exposure roughly halved rather than closed.

I'd rather not fold it into this PR: either script's loop could be reverted without touching the other, which is the repo's test for "separate PRs". Happy to open the follow-up — same five-attempt jittered shape, plus the fixture pattern these tests establish (there's currently no test coverage for install-proxy-uv.sh at all). Say the word, or close it as not worth the churn given how rare the blip is.

The worst-case ceiling grows more than the body claims. "~90s longer to say so" holds for the observed shape, where curl returns 403 in well under a second and the added cost is just the extra backoff. It doesn't hold when the CDN hangs: each attempt can burn the full timeout 60, so the step's ceiling goes from 3×60+15 ≈ 195s to 5×60+111 ≈ 411s. Not an argument against the change — but if the open question is whether 5 attempts / 40s is the right ceiling, ~7 minutes for a wedged install is the number to weigh, not ~90s.

Verification notes
  • Confirmed the tests discriminate: with shared/steps/install-claude-binary.sh reverted to 0f35b6b in place, test_install_claude_binary_rides_out_a_403_burst, ..._backs_off_exponentially, and ..._reddens_when_every_attempt_fails fail (backoff did not double: [5, 10], after 3 attempts), and ..._installs_first_try_without_sleeping passes. Matches the table in the PR body.
  • pytest.approx([5, 10, 20, 40], abs=9) is exactly the jitter width, so the tolerance is tight rather than loose, and the separate floor assertion pins the lower bound — a flat or shortened sequence still fails. No flake risk: sleep is faked.
  • Arithmetic checks out under set -euo pipefail: ** binds tighter than * and %, so 5 * 2 ** (i - 1) + RANDOM % 10 is (5 * 2^(i-1)) + (RANDOM % 10) → floors 5/10/20/40, and RANDOM is always set so -u doesn't bite.
  • The only other unretried network install in the actions is npm install -g "@openai/codex@..." in codex/action.yaml, which npm already retries internally — no gap there.

@tend-agent

Copy link
Copy Markdown
Collaborator Author

Both land. Taking them in reverse order, since only one of them changes anything here.

The ceiling. You're right, and the body was wrong in a way worth fixing rather than arguing: ~90s was the fast-failure number stated as though it were the general one. Checked against the pre-fix script at 0f35b6b — 3 attempts, timeout 60 each, sleeps of 5 and 10 → 195s worst case; now 5 attempts with jittered 5/10/20/40 → maximum sleeps of 14+19+29+49 = 111s → 411s. So ~3.6 min wider, ~7 min absolute, against ~90s wider only when curl fails fast the way the observed 403 did. I've rewritten the Uncertainty section to give both numbers instead of the flattering one.

Having weighed the real number I'd still keep five attempts, and said so in the body: reaching 411s takes all five attempts hanging, a much rarer shape than the fast 403 the change exists to ride out, and in that case the leg is lost either way — the extra minutes cost runner time but change no outcome. Trimming the count or the ceiling re-narrows exactly the window this widens. That's a judgement rather than a proof, so it stays an open question for whoever merges — but now with the right number attached to it.

install-proxy-uv.sh. Confirmed: same for i in 1 2 3, same sleep $((i * 5)), same after 3 attempts message, same job, same egress, same five-legs-at-once exposure. Agree it's a separate PR — either loop reverts without touching the other, which is the repo's test.

One correction to your note: it isn't uncovered. .github/workflows/ci.yaml runs the script directly to install the uv it then smokes mitmdump through, and the comment above that step calls it out as the script's only CI coverage. That's a happy-path exercise, so the gap is narrower than "no coverage" — the retry loop specifically is untested, which is the part a follow-up would change.

I'd rather sequence that follow-up after this lands than open it now: if the five-attempt shape gets dialled back here, the second PR wants whatever this one settles on, and the fixture pattern it would reuse doesn't exist until this merges. Opening both at once means reworking two PRs to resolve one question. So I'll open it once this is merged — say the word if you'd rather skip it as churn, and I won't.

max-sixty pushed a commit that referenced this pull request Aug 11, 2026
…will convert (#914)

## Problem

Filing an issue in this repo is not a note to a maintainer —
`tend-triage` fires on `issues`. Twice in 66 minutes this morning a run
filed an issue describing a fix it had already fully scoped, and triage
then re-derived that fix from the issue body and opened the PR:

| Issue filed | Triage boots | Turns / cost | PR opened |
|---|---|---|---|
| [#909](#909) 07:18:53Z |
[31300864191](https://github.com/max-sixty/tend/actions/runs/31300864191)
07:18:56Z | 64 / $4.08 |
[#910](#910) `fix/issue-909`
07:25:17Z |
| [#911](#911) 07:39:37Z |
[31301666194](https://github.com/max-sixty/tend/actions/runs/31301666194)
07:39:40Z | 58 / $4.20 |
[#912](#912) `fix/issue-911`
07:49:37Z |

Three seconds from issue to triage boot in both cases. Neither issue was
under-specified: #909 named `codex/action.yaml:120-124` and proposed
extracting to `shared/steps/install-codex-cli.sh` sourcing
`lib/retry.sh`; #911 named `proxy/setup-sandbox.sh:319` and proposed the
same treatment. Both PRs implemented exactly the proposal. Triage's own
session log confirms the conversion — run 31300864191's artifact is
`claude-session-logs-n909`, and it ran `gh pr create --head
fix/issue-909`, classifying the reporter as "the bot's own review of
#908".

So the issue bought nothing and cost $8.28 of agent time plus two
threads. The filing run could have opened both PRs itself.

<details><summary>Wider context — the cascade this sat inside</summary>

A single transient CDN 403 lost one matrix leg of `review-reviewers` run
[31297986524](https://github.com/max-sixty/tend/actions/runs/31297986524)
at 06:03Z (`curl: (22) ... 403`, three attempts inside 15.7 s, the other
four legs installing the same version from the same runner at the same
moment succeeded). Between 06:29Z and 07:56Z that produced #906, #907,
#908, #909, #910, #911 and #912 — five stacked PRs and two issues, none
merged, across ~14 agent runs and roughly $30 at list prices.

The engineering is sound and correctly atomic; this PR does not argue
with any of it. Only the two issue hops are pure overhead, and they are
the part with a mechanical fix.

</details>

## Solution

A short section in `running-in-ci` — the skill every workflow loads —
stating that an issue in this repo is picked up by triage, so a fix you
can already scope should be opened as a PR in the same run. The escape
hatches stay: too large or ambiguous to fix, needs a maintainer
decision, or verification is out of reach from CI.

This generalizes a rule that already exists but only in two skills:
`review-runs` ("Issue (fallback): Only for problems too large or
ambiguous to fix directly") and `review-reviewers` carry it, so the runs
that have it don't do this. The runs that did — a `tend-review` session
and a `tend-mention` session — load neither.

## Gate assessment

- **Evidence level**: High — 2 occurrences this window, and the
self-amplifying-output family has been recorded in prior windows
(2026-08-04: review→fix ping-pong over five rounds; two bot PRs seven
minutes apart on one root cause).
- **Structural**: yes for the costly half. The bot's choice to file
rather than fix is stochastic, but once an issue exists the triage
conversion has no decision point — it fires on the `issues` event every
time.
- **Change type**: targeted fix (one short section), normal evidence
bar.
- **Both gates**: pass.

Found by `/tend-ci-runner:review-runs` on run
[31302531474](https://github.com/max-sixty/tend/actions/runs/31302531474).

---------

Co-authored-by: tend-agent <270458913+tend-agent@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant