From 9f1694802e33fcc6106ed8f94256081cdb726b1a Mon Sep 17 00:00:00 2001 From: tend-agent <270458913+tend-agent@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:24:57 +0000 Subject: [PATCH 1/3] fix(install): give the codex CLI install the same retry window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codex action's `npm install -g @openai/codex` ran bare — one attempt, no timeout, no backoff — while the two CDN installers around it ride out a blip on the shared window. It is the action's only third-party reach and it sits ahead of `Run Codex`, so a registry blip cost the whole run. Extracted to shared/steps/install-codex-cli.sh, which sources lib/retry.sh the way install-proxy-uv.sh does. 60s stands as the timeout: measured on a GitHub-hosted runner with an empty npm cache, the install is 2.6-3.1s across three runs — `@openai/codex` ships a prebuilt binary, so there is no dependency tree to resolve and no toolchain to run. No third parameter on retry_install. Closes #909 --- codex/action.yaml | 20 ++--- generator/tests/test_shared_steps.py | 122 ++++++++++++++++++++++++++- shared/steps/install-codex-cli.sh | 24 ++++++ shared/steps/lib/retry.sh | 20 ++--- 4 files changed, 164 insertions(+), 22 deletions(-) create mode 100755 shared/steps/install-codex-cli.sh diff --git a/codex/action.yaml b/codex/action.yaml index 33fd53e9..85be7087 100644 --- a/codex/action.yaml +++ b/codex/action.yaml @@ -110,18 +110,18 @@ runs: exit 1 fi - # Install the Codex CLI from npm. `@openai/codex` ships a prebuilt - # per-platform binary, so this is ~3 s with no Rust toolchain. We pin - # an explicit version (`codex_version`, default a `0.131.0-alpha.*`) - # because tend needs `codex plugin add` (PR #21396, first in - # `rust-v0.131.0-alpha.17`) and as of 2026-05-15 npm's `latest` - # (0.130.0) still predates it — only the `alpha` dist-tag carries it. - # Bump the default once a stable npm release ships PR #21396. + # Install the Codex CLI from npm, on the retry window the other pre-agent + # installers share (shared/steps/lib/retry.sh). We pin an explicit version + # (`codex_version`, default a `0.131.0-alpha.*`) because tend needs + # `codex plugin add` (PR #21396, first in `rust-v0.131.0-alpha.17`) and as + # of 2026-05-15 npm's `latest` (0.130.0) still predates it — only the + # `alpha` dist-tag carries it. Bump the default once a stable npm release + # ships PR #21396. - name: Install Codex CLI shell: bash - run: | - npm install -g "@openai/codex@${{ inputs.codex_version }}" - codex --version + run: bash "${{ github.action_path }}/../shared/steps/install-codex-cli.sh" + env: + CODEX_VERSION: ${{ inputs.codex_version }} # Install the tend-ci-runner plugin from the action's own checkout. # `github.action_path` is ...//max-sixty/tend//codex/; diff --git a/generator/tests/test_shared_steps.py b/generator/tests/test_shared_steps.py index 02509365..cbe6fbb6 100644 --- a/generator/tests/test_shared_steps.py +++ b/generator/tests/test_shared_steps.py @@ -1390,8 +1390,8 @@ def _run_install(env: dict[str, str]) -> subprocess.CompletedProcess[str]: ) -def _attempts(env: dict[str, str]) -> int: - return int(Path(env["CURL_ATTEMPTS"]).read_text().strip()) +def _attempts(env: dict[str, str], key: str = "CURL_ATTEMPTS") -> int: + return int(Path(env[key]).read_text().strip()) def _sleeps(env: dict[str, str]) -> list[int]: @@ -1573,3 +1573,121 @@ def test_install_proxy_uv_installs_first_try_without_sleeping( assert _attempts(proxy_uv_env) == 1, _attempts(proxy_uv_env) assert _sleeps(proxy_uv_env) == [], _sleeps(proxy_uv_env) assert "uvx-fake" in result.stdout, result.stdout + + +# --------------------------------------------------------------------------- +# install-codex-cli.sh — the same retry contract, on the npm registry +# --------------------------------------------------------------------------- + +INSTALL_CODEX_CLI = REPO_ROOT / "shared" / "steps" / "install-codex-cli.sh" + +# Same failure schedule as the curl fakes, answering with npm's registry-side +# error instead. On the attempt that succeeds it plants a `codex` on PATH, so +# the script's closing `codex --version` has something to run. +FAKE_NPM = """#!/usr/bin/env bash +n=$(cat "$NPM_ATTEMPTS" 2>/dev/null || echo 0) +n=$((n + 1)) +echo "$n" > "$NPM_ATTEMPTS" +if [ "$n" -le "${NPM_FAILURES:-0}" ]; then + echo "npm error code E503" >&2 + echo "npm error 503 Service Unavailable - GET https://registry.npmjs.org/$3" >&2 + exit 1 +fi +printf '#!/usr/bin/env bash\\necho codex-cli %s\\n' "${3##*@}" > "$FAKE_BIN/codex" +chmod +x "$FAKE_BIN/codex" +""" + + +@pytest.fixture +def codex_cli_env(tmp_path: Path) -> dict[str, str]: + """A fake npm/sleep on PATH plus the env the codex install step is given.""" + bindir = _fake_bin(tmp_path, npm=FAKE_NPM, sleep=FAKE_SLEEP_RECORDING) + + return { + "PATH": f"{bindir}:/usr/bin:/bin", + "CODEX_VERSION": "0.131.0-alpha.22", + "FAKE_BIN": str(bindir), + "NPM_ATTEMPTS": str(tmp_path / "npm-attempts"), + "SLEEPS": str(tmp_path / "sleeps"), + } + + +def _run_codex_cli(env: dict[str, str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(INSTALL_CODEX_CLI)], + env=env, + capture_output=True, + text=True, + ) + + +def test_install_codex_cli_rides_out_a_registry_burst( + codex_cli_env: dict[str, str], +) -> None: + """The npm install is as exposed as the two CDN ones, and gets the window. + + It is the codex action's only third-party reach, and it runs ahead of the + agent step, so a registry blip that exhausts its retries costs the whole + run — the sibling failure mode, with registry.npmjs.org in place of a CDN. + """ + codex_cli_env["NPM_FAILURES"] = "4" + + result = _run_codex_cli(codex_cli_env) + + assert result.returncode == 0, ( + f"gave up on a blip it should have ridden out; stderr:\n{result.stderr}" + ) + assert _attempts(codex_cli_env, "NPM_ATTEMPTS") == 5, _attempts( + codex_cli_env, "NPM_ATTEMPTS" + ) + + +def test_install_codex_cli_backs_off_exponentially( + codex_cli_env: dict[str, str], +) -> None: + """Each wait at least doubles; only the floors are pinned, jitter is free.""" + codex_cli_env["NPM_FAILURES"] = "99" + + _run_codex_cli(codex_cli_env) + + assert _sleeps(codex_cli_env) == pytest.approx([5, 10, 20, 40], abs=9), ( + f"backoff did not double: {_sleeps(codex_cli_env)}" + ) + assert all( + actual >= floor + for actual, floor in zip(_sleeps(codex_cli_env), [5, 10, 20, 40], strict=True) + ), f"slept less than the backoff floor: {_sleeps(codex_cli_env)}" + + +def test_install_codex_cli_reddens_when_every_attempt_fails( + codex_cli_env: dict[str, str], +) -> None: + """A registry that stays down still fails the step, and says how hard it tried.""" + codex_cli_env["NPM_FAILURES"] = "99" + + result = _run_codex_cli(codex_cli_env) + + assert result.returncode != 0, result.stdout + assert "after 5 attempts" in result.stdout, result.stdout + assert _attempts(codex_cli_env, "NPM_ATTEMPTS") == 5, _attempts( + codex_cli_env, "NPM_ATTEMPTS" + ) + + +def test_install_codex_cli_installs_first_try_without_sleeping( + codex_cli_env: dict[str, str], +) -> None: + """The happy path is one install, no delay, and a codex that answers. + + The version assertion is what keeps the pin honest: the step has to install + the `codex_version` it was handed, not npm's `latest`, which still predates + `codex plugin add`. + """ + result = _run_codex_cli(codex_cli_env) + + assert result.returncode == 0, result.stderr + assert _attempts(codex_cli_env, "NPM_ATTEMPTS") == 1, _attempts( + codex_cli_env, "NPM_ATTEMPTS" + ) + assert _sleeps(codex_cli_env) == [], _sleeps(codex_cli_env) + assert "codex-cli 0.131.0-alpha.22" in result.stdout, result.stdout diff --git a/shared/steps/install-codex-cli.sh b/shared/steps/install-codex-cli.sh new file mode 100755 index 00000000..3d8159e0 --- /dev/null +++ b/shared/steps/install-codex-cli.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Install the pinned Codex CLI from npm. +# +# The npm registry is the only third-party reach in the codex action, and this +# step runs ahead of `Run Codex` — so a registry blip that isn't ridden out +# costs the whole run, which is the failure lib/retry.sh's window exists for. +# +# `@openai/codex` ships a prebuilt per-platform binary: two packages, no +# dependency tree to resolve and no Rust toolchain to run. Measured on a +# GitHub-hosted runner with an empty npm cache, 2026-08-09: 2.6-3.1 s across +# three installs, which sits far enough inside retry_install's 60 s timeout +# that npm shares the lib's window rather than needing one of its own. +# +# Inputs (env): CODEX_VERSION. +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=lib/retry.sh +. "${SCRIPT_DIR}/lib/retry.sh" + +retry_install "codex ${CODEX_VERSION}" \ + "npm install -g '@openai/codex@${CODEX_VERSION}'" + +codex --version diff --git a/shared/steps/lib/retry.sh b/shared/steps/lib/retry.sh index eafceee9..5ea2ae20 100644 --- a/shared/steps/lib/retry.sh +++ b/shared/steps/lib/retry.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash -# The retry window the pre-agent installers share: install-claude-binary.sh -# and install-proxy-uv.sh both fetch from a CDN before the agent step exists, -# so a blip that exhausts their retries costs the whole run — the step goes red -# having done none of the work the trigger asked for. The lost run is what -# justifies a window this wide, independent of how the failure is reported -# afterwards. +# The retry window the pre-agent installers share: install-claude-binary.sh, +# install-proxy-uv.sh and install-codex-cli.sh each reach a third party — two +# CDNs and the npm registry — before the agent step exists, so a blip that +# exhausts their retries costs the whole run: the step goes red having done +# none of the work the trigger asked for. The lost run is what justifies a +# window this wide, independent of how the failure is reported afterwards. # # Sourced, not executed. @@ -14,10 +14,10 @@ # rate limit hits them together, and an unjittered backoff has them retry # together too. # -# The inner `set -o pipefail` is required by both callers' `curl | sh` -# shape: without it a curl failure passes empty stdin to the downstream -# shell, which exits 0, masking the failure so the loop breaks after one -# attempt without retrying. +# The inner `set -o pipefail` is required by the `curl | sh` callers: +# without it a curl failure passes empty stdin to the downstream shell, +# which exits 0, masking the failure so the loop breaks after one attempt +# without retrying. retry_install() { local label=$1 cmd=$2 attempts=5 i backoff for i in $(seq 1 "$attempts"); do From 1f9be1f4b1abefc8a88509288c2fd48e960fe398 Mon Sep 17 00:00:00 2001 From: tend-agent <270458913+tend-agent@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:31:06 +0000 Subject: [PATCH 2/3] docs(install): the shared window now holds three installers, not both --- shared/steps/install-claude-binary.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/steps/install-claude-binary.sh b/shared/steps/install-claude-binary.sh index 26bf8f14..448d5394 100755 --- a/shared/steps/install-claude-binary.sh +++ b/shared/steps/install-claude-binary.sh @@ -19,7 +19,7 @@ set -eo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # Retry transient 403s/5xxs from the installer CDN, on the window lib/retry.sh -# holds for both pre-agent installers. The lib is concatenated onto the front +# holds for the pre-agent installers. The lib is concatenated onto the front # of the sandbox script rather than sourced from inside it: nothing grants the # sandbox UID read access to the action's own checkout — setup-sandbox.sh # grants traversal (o+x) on the workspace's ancestors only — whereas stdin is a From 64206222f4ea88292b3c7af3d43d2a49efb354f7 Mon Sep 17 00:00:00 2001 From: tend-agent <270458913+tend-agent@users.noreply.github.com> Date: Sun, 9 Aug 2026 07:42:30 +0000 Subject: [PATCH 3/3] test(install): read the fake npm's package spec off the last argument The fake took it from $3, which is the spec only while the install command is exactly `install -g `; a flag added anywhere ahead of it would have the fake echo `-g` as the installed version instead. Raised in review of this PR as non-blocking, and cheap enough to remove the coupling outright. --- generator/tests/test_shared_steps.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/generator/tests/test_shared_steps.py b/generator/tests/test_shared_steps.py index cbe6fbb6..ca6f1bff 100644 --- a/generator/tests/test_shared_steps.py +++ b/generator/tests/test_shared_steps.py @@ -1584,16 +1584,21 @@ def test_install_proxy_uv_installs_first_try_without_sleeping( # Same failure schedule as the curl fakes, answering with npm's registry-side # error instead. On the attempt that succeeds it plants a `codex` on PATH, so # the script's closing `codex --version` has something to run. +# +# The package spec is read off the last argument rather than a fixed position, +# so a flag added to the install command doesn't silently turn the version the +# fake echoes into `-g`. FAKE_NPM = """#!/usr/bin/env bash +spec=${*: -1} n=$(cat "$NPM_ATTEMPTS" 2>/dev/null || echo 0) n=$((n + 1)) echo "$n" > "$NPM_ATTEMPTS" if [ "$n" -le "${NPM_FAILURES:-0}" ]; then echo "npm error code E503" >&2 - echo "npm error 503 Service Unavailable - GET https://registry.npmjs.org/$3" >&2 + echo "npm error 503 Service Unavailable - GET https://registry.npmjs.org/$spec" >&2 exit 1 fi -printf '#!/usr/bin/env bash\\necho codex-cli %s\\n' "${3##*@}" > "$FAKE_BIN/codex" +printf '#!/usr/bin/env bash\\necho codex-cli %s\\n' "${spec##*@}" > "$FAKE_BIN/codex" chmod +x "$FAKE_BIN/codex" """