Skip to content

ci(scheduled-full-run): the release gate retries a transient 5xx or connection reset on a run listing, inside one budget per gate run - #24748

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24741-release-gate-read-retry
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24741-release-gate-read-retry

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24741
Clause-②: no

What this changes

One file: scripts/ci/scheduled-full-run.mjs (HALF 3, the release gate's live read, and its --self-test). release.yml, lint.yml and scheduled-full-run-card.yml are unchanged, and no verdict changes.

  • readRuns reads each page through readBoard, the citation gate's retry policy, imported. An HTTP 5xx or a named connection failure (ECONNRESET, UND_ERR_SOCKET) is retried: 3 attempts per read, with 500 / 1000 ms backoff. A 4xx, an OK body with no workflow_runs array, and every other error are final on the first attempt. It takes two new optional parameters, fetchImpl (default: the global fetch) and budget.
  • readFullBattery (new export) reads every workflow x event x page of one gate run under ONE boardReadBudget(). That is 4 retries per gate run, never 4 per readRuns call.
  • runReleaseGate (new export) is the gate as { exit, lines, verdict }, with readHistory, fetchImpl and sleep injectable. --release-gate prints the same lines and exits the same codes as before: 0 pass, 1 refused, 3 NOT MEASURED.
  • A spent ladder or budget still refuses. The line keeps its prefix ::error::release gate NOT MEASURED -- and its suffix . Refusing: an unread full battery is not a green one., and the exit stays EXIT_PREREQUISITE_NOT_MET (3). Its text now names the last answer and the attempts, readFailure-style, for example GET https://api.github.com/repos/o/r/actions/workflows/lint.yml/runs?branch=main&event=schedule&status=completed&per_page=100&page=1 -> HTTP 503 after 3 attempts (every attempt one read is allowed, so not one blip).
  • Self-test: a new battery, release gate: the read path retries a transient answer, inside one budget per run, with 16 cases and a pinned floor of 16. The battery-count floor moves from 6 to 7.

The card's Wanted list, item by item

  1. readRuns retries a transient failure, and only a transient failure. Done. Retried: HTTP 500 to 599, ECONNRESET and UND_ERR_SOCKET (the closed list RETRYABLE_NETWORK_CODES). Final at once, each one pinned: 401, 403, 404, 422 and 429; an OK body without workflow_runs; ECONNREFUSED; ENOTFOUND; a body that is not JSON.
  2. Reuse gates: check-issue-citations reads one transient HTTP 500 from the board as PREREQUISITE NOT MET with no retry, so a GitHub blip turns the lint lane red and drops a PR from the merge queue #23361's policy, not a second copy of it. Done, by import. scheduled-full-run.mjs imports BOARD_READ_RETRY, boardReadBudget, readBoard and readFailure from scripts/check-issue-citations.mjs, which is byte-identical to the base. Why import and not lift: see below.
  3. Still refuse when the ladder or budget is spent. Done. The prefix, suffix and exit are the same. The text names the last status (or the connection code) and the attempts.
  4. Pins in the file's self-test, with an injectable fetch and sleep. Done: four pins, and each is RED on the base (see below). No case touches the network or sleeps.
  5. Not a new gate; no change to release.yml, its environment or its dispatch. Held. The diff is one file under scripts/ci/. No workflow file is touched, and no release workflow was run or dispatched.

The four pins, and the proof that each one can fail

pin self-test cases (HEAD) CLI probe on BASE 7e7e605de3 CLI probe on HEAD 5cad717ed4
1. a 502 then a 200 is measured, and the verdict is computed 502 then 200 passes (7 requests, one 500 ms backoff). A red battery behind the same blip refuses as a verdict (exit 1), not as NOT MEASURED. A reset, then 200; a socket closed mid-body, then 200; a 503 on page 2, then 200. RED: exit 3, ... page=1 answered HTTP 502. Refusing: ... GREEN: exit 0, PASS, 7 requests
2. three 5xx answers refuse NOT MEASURED, naming the attempts Three 503s: exit 3, -> HTTP 503 after 3 attempts, backoff 500,1000. Persistent resets name ECONNRESET. A persistent 500/502/503/504/reset/socket close on each of the 6 listings refuses. RED: 1 request to that listing, and the text names no attempts GREEN: exit 3, 3 requests to that listing, after 3 attempts
3. a 404 refuses at once, with no retry 404: exit 3, 1 request, no backoff, -> HTTP 404 after 1 attempt (not retried. Also 401/403/422/429, no workflow_runs, and ECONNREFUSED / ENOTFOUND / non-JSON. RED: exit 3 and 1 request, but the text names no attempt count GREEN: exit 3, 1 request
4. the run budget caps total retries across workflows and events Every listing blips once: 4 retries are spent, the fifth listing's 502 is final (this run's budget of 4 retries is spent), 9 requests. Control: 4 blips are all measured. RED: refused at the first request GREEN: exit 3, 9 requests

The CLI probe runs node scripts/ci/scheduled-full-run.mjs --release-gate --release-commit 7e7e605de3… in each tree. It preloads a stub fetch and uses the real git history, so BASE's own code path is what goes red. On BASE, pin 3 holds its "at once" half (the base never retried anything) and goes red only on the attempt count. Two ablations on HEAD prove the behavioural halves of pins 3 and 4:

  • A1: retryable is widened to every status 400 and up in check-issue-citations.mjs (via scripts/ablation-replace.mjs; anchor 1 -> 0, blob changed). --self-test then exits 1 at pin 3: got exit 3 after 3 request(s), slept 500,1000. Restored: blob == HEAD, and git diff HEAD is empty.
  • A2: readFullBattery creates a fresh boardReadBudget() per readRuns call. --self-test then exits 1 at pin 4: got exit 0 after 12 requests and 6 backoff(s). Restored the same way.

The dispatch's offline end-to-end probe is pin 1's row: one 502 on lint.yml's schedule listing, then a 200. BASE refuses it as NOT MEASURED (exit 3); HEAD measures it and passes (exit 0).

Why import, not lift

Both shapes leave one definition. I decided on the four axes:

  • 实际业务需求 (real need). Both serve the same measured pull: release run 39230668843 for this gate, and the merge-queue ejection behind gates: check-issue-citations reads one transient HTTP 500 from the board as PREREQUISITE NOT MET with no retry, so a GitHub blip turns the lint lane red and drops a PR from the merge queue #23361 for the citation gate. Neither adds a capability. Tie.
  • 项目长远合理性 (long-term soundness). A lifted module would name the policy for what it is, and would keep the citation gate's own imports out of the publish lane's load closure. Import adds check-issue-citations.mjs and four helpers (git-env, glob-match, js-comment-mask, symbol-anchors; about 5,600 lines) to that closure. Measured: every one of them imports only node:*, and none runs anything at load (0 child processes and 0 fs calls during the import). The gate already loads pm/check-half-states.mjs (about 35,000 lines), so the added coupling is small. Lift wins, narrowly.
  • 防 AI 写错 (resistance to AI error). Both leave one definition, and a third consumer finds two importers of the citation gate and imports too. The constraint the publish lane depends on (builtins only, nothing run at load) is written at the import. Tie.
  • 创业阶段不扩散需求 (startup scope). Import costs one import line and zero edits to the citation gate. The lift was measured on this branch: it shifts check-issue-citations' line-anchored local-env census row in scripts/pm/dispatch-gates.self-test.mjs from :204 to :205 (pnpm check:pm-dispatch-gates went 1 of 1913 cases red). That file is outside this card's surface, so the lift needs a fourth file. Import wins.

Decision: import. I tried the lift first (42643e8ca9) and reverted it in 5cad717ed4, so the net diff is one file.

The import loads in each job that loads this file:

  • release.yml full-battery: actions/checkout (fetch-depth: 0) and setup-node 22, with no install step. The closure is node builtins only. Proof: I loaded the module and ran --self-test from a git archive of scripts/ and .github/, with no node_modules anywhere above it. Exit 0.
  • scheduled-full-run-card.yml file: actions/checkout, with no setup-node and no install. actions/github-script import()s the file by absolute path, so the closure and the proof are the same.
  • lint.yml lint-ci-engine (the step that runs --self-test and --check-concurrency): checkout (fetch-depth: 0), setup-node, then pnpm install --frozen-lockfile. It loads there too.

Gates

Run on HEAD 5cad717ed4, the final commit (the union ran after it):

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 32 commands (no paths given: it reads the change set off the merge base). All 32 exit 0, including pnpm check:pm-dispatch-gates (1913 cases). --ran reconciles them: 32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN, exit 0.
  • node scripts/ci/scheduled-full-run.mjs --self-test: exit 0. --check-concurrency: exit 0. node scripts/check-issue-citations.mjs --self-test: exit 0 (215 cases, 10 batteries).
  • pnpm exec eslint --no-inline-config scripts/ci/scheduled-full-run.mjs: exit 0.

On BASE 7e7e605de3, the same three direct commands each exit 0, and the citation gate's self-test reports the same 215 cases.

Acceptance notes

  • origin/main moved past the base (4 commits, to 39e93b9ef9 at the last fetch). None of them touches this file or its import closure, so I did not merge; CI tests the merge ref.
  • The builtins-only constraint on the closure is written in a comment at the import, but nothing enforces it. lint.yml's job installs dependencies, so a non-builtin import added anywhere in the closure would fail first on a release run. This is not a defect today. No carrier: noted, not filed.
  • Changeset: none. The diff publishes nothing: scripts/ belongs to the private root package (private: true), and no published package ships it. Following PR ci(scheduled-full-run): a refresh moves a stale pm:blocked back to pm:queue once every blocker has closed #24305's precedent, skip-changeset is declared when this PR is created.

Generated by Claude Code

…ub-read-retry.mjs

The policy (BOARD_READ_RETRY, RETRYABLE_NETWORK_CODES, retryableNetworkCode,
boardReadBudget, readBoard, readFailure, sleepMs) moves byte-for-byte out of
check-issue-citations.mjs, which now imports it. No behaviour change: the
citation gate's self-test answers 215 cases, 10 batteries, exit 0, as on the
base. The module imports nothing, so a consumer run from a bare checkout
with no install step can load it.

Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp
Co-authored-by: Claude <noreply@anthropic.com>
…onnection reset on a run listing, inside one budget per gate run

readRuns reads each page through the shared retry policy
(scripts/github-read-retry.mjs): a 5xx or a named connection failure is
retried, 3 attempts per read at 500 / 1000 ms; a 4xx, an OK body with no
workflow_runs array and every other error stay final on the first attempt.
readFullBattery hands ONE budget (4 retries) to every workflow x event x
page a gate run reads. A spent ladder or budget still refuses as
`release gate NOT MEASURED`, exit 3, its text now naming the last answer and
the attempts made. The read path is driven offline by runReleaseGate, and a
new self-test battery pins the four cases.

Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp
Co-authored-by: Claude <noreply@anthropic.com>
…tions.mjs instead of lifting it

Lifting the policy into its own module shifts check-issue-citations.mjs's
line-anchored `local-env` census row in scripts/pm/dispatch-gates.self-test.mjs
(204 -> 205), a file outside this card's surface. The citation gate keeps its
policy byte-identical to the base, and scheduled-full-run.mjs imports
BOARD_READ_RETRY, boardReadBudget, readBoard and readFailure from it. The
added load closure (check-issue-citations, git-env, glob-match,
js-comment-mask, symbol-anchors) imports node builtins only and runs nothing
at load, so release.yml's install-free full-battery job still loads it.

Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 20, 2026
…eYml line lost

An incidental edit: the `const releaseYml = readFileSync(...)` line in the
`release gate: release.yml wiring` battery had lost the space after `=`.
It is byte-identical to the base again, and the self-test hunk is now a pure
insertion.

Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat review

ACCEPT · domain:devx seat 1 · os-warren · session_01FM29ZqLTrnPNXxijioZwMp · 2026-10-20T04:41Z · head 45a06c8a5b · card #24741 (claim 6282768911)

  • Form. Draft, base main. The first line is Fixes #24741, assignee os-warren, labels size/m and skip-changeset. scripts/ is the private root package's, so it publishes nothing (PR ci(scheduled-full-run): a refresh moves a stale pm:blocked back to pm:queue once every blocker has closed #24305's precedent). node scripts/pm/check-governed-merges.mjs --pr 24748 → NOT governed. git merge-tree against main is clean.
  • Scope. One file, scripts/ci/scheduled-full-run.mjs. scripts/check-issue-citations.mjs is byte-identical to the base, and no workflow file is touched. release.yml, lint.yml and scheduled-full-run-card.yml are unchanged.
  • The read path, read in the diff.
    • readRuns reads each page through the citation gate's readBoard (imported with BOARD_READ_RETRY, boardReadBudget and readFailure). A 5xx, ECONNRESET or UND_ERR_SOCKET is retried: 3 attempts, 500 / 1000 ms apart.
    • A 4xx is final on the first attempt. So is an OK body without workflow_runs (checked after the read), and so is every other error: readBoard rethrows it and runReleaseGate catches it.
    • readFullBattery hands one boardReadBudget() to every workflow × event × page of a run. runReleaseGate creates exactly one per invocation.
    • The NOT MEASURED line keeps its prefix, its suffix and exit 3. The pass and refuse lines and exits 0 / 1 are unchanged; releaseGate prints what runReleaseGate returns.
  • Pins. A new 16-case battery, and the battery floor moves 6 → 7. All four of the card's pins are there, with the negative space as well: 401 / 403 / 404 / 422 / 429, no workflow_runs, ECONNREFUSED, ENOTFOUND, non-JSON, a persistent 5xx or reset on each of the six listings, and the budget control (four blips still measured).
    • The dev's CLI probe on BASE 7e7e605de3 reads 4 RED, 0 GREEN; on the head, 4 GREEN.
    • Two ablations each turn the self-test red at the pin they target. A1 widens the retryable class to ≥ 400: red at pin 3. A2 gives each listing its own budget: red at pin 4.
  • Reuse: import, not lift. Accepted as reasoned.
    • The publish lane's full-battery job runs off a bare checkout with no install. The measured closure is node:* only, with no load-time side effects, and it is proven from a git archive with no node_modules.
    • This file already loads pm/check-half-states.mjs, so the added coupling is small.
    • A lift moves the line-anchored local-env census row in scripts/pm/dispatch-gates.self-test.mjs. That is a fourth file, outside the claim.
  • Fix round 1. The seat's review found one incidental edit outside the change (const releaseYml =readFileSync(, a dropped space). 45a06c8a5b restores it and changes nothing else (git diff 5cad717ed4 45a06c8a5b is that one line). The net diff is 1 file, +304 / −25.
  • Gates. The dev's dispatch-gates union is 32 derived, 32 run, all exit 0. --ran reconciles, with pnpm check:pm-dispatch-gates 1913 cases. --self-test, --check-concurrency and the citation gate's --self-test (215 cases) each exit 0 on the head and on BASE. Check-runs at 45a06c8a5b: 39 check-runs, 31 success, 8 skipped, 0 failing or pending, Lint · CI, release & engine gates (this file's --self-test step) among the successes; node scripts/pm/check-expected-skips.mjs --pr 24748 → OK (all 8 skips in the roster). No review, no comment.

This seat readies the PR and queues it with auto-merge.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants