Skip to content

feat(pm): ci-failure — a red check's assertion in one command - #9897

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-9777-ci-failure-retrieval
Aug 19, 2026
Merged

feat(pm): ci-failure — a red check's assertion in one command#9897
os-steve merged 1 commit into
mainfrom
claude/issue-9777-ci-failure-retrieval

Conversation

@claude

@claudeclaudeBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Fixes#9777

scripts/pm/ci-failure.mjs — one command from "a required check is red" to "here is the
failing assertion", plus a self-test that pins every judgment it makes offline.

node scripts/pm/ci-failure.mjs # HEAD of the current worktree
node scripts/pm/ci-failure.mjs --pr 9774
node scripts/pm/ci-failure.mjs --sha 40740da3
node scripts/pm/ci-failure.mjs --run 32204206019
node scripts/pm/ci-failure.mjs --json
node scripts/pm/ci-failure.mjs --self-test # offline: no network, no token

The card's suggested shape does not work from here, and that is the design

#9777 sketched wrapping the run-log archive. Both Actions log endpoints redirect to Azure
storage, and this session's egress policy answers 403 on CONNECT to those hosts.
Re-measured today on two different red runs — the redirect named
productionresultssa8.blob.core.windows.net at 03:0x and productionresultssa1... at
06:14, so the storage-account numeral varies per run and the constant in the file is a
pattern plus its specimens rather than a closed list. /root/.ccr/README.md classifies a
403 on CONNECT as an organization egress-policy denial and instructs "do not retry or
route around it — report the blocked host".

So this walks the surfaces that do answer — check-runs, their annotations, and the
run's jobs — and says out loud when they carry no assertion instead of printing an empty
result that reads green. A deliberate non-inclusion: no log-download path is built for the
case where the policy is open, because nothing here could exercise it. An untested branch
in a retrieval tool is the failure mode this card exists to end.

Three answers, never two

The discriminator a reader acts on. Each row below was produced by the committed tool
against a real red run this morning:

what the check carriedverdict linereader's next move
a file-anchored vitest annotationassertion RETRIEVEDdone — the assertion is printed
a gate's own sentence, no file anchorassertion NO FILE-ANCHORED ASSERTION — but this check carried 1 annotation(s)read it; a gate sentence is often the whole answer
only Process completed with exit code 1.assertion NONE — the check carried no annotation with any content in it.run the printed reproduction command

exit-status is content-free by construction, so a check carrying only those carries
nothing — counting it as "an annotation was returned" is how a red gate comes to read as
"details recorded elsewhere". When there is no assertion, the failing step name (which
the jobs API does return) is resolved offline against .github/workflows/ to the command
that reproduces it locally, e.g. Engine test-double contract gate to
pnpm check:engine-double-contract.

Exit codes — "I could not retrieve it" is a verdict, never a green

0 green · 1 red with the assertion in hand · 2 undetermined (a failing check whose
assertion is not retrievable, checks still running, or zero check-runs on the sha) ·
3 prerequisite not met (classified by check-half-states.mjs's probe, imported rather
than re-implemented). Piping reports the pipe's status, so read $?.

Transport: a 401 that is not about the credential

Node 22's fetch ignores HTTPS_PROXY, so an unproxied run hands GitHub the proxy's
placeholder token and every read answers 401 — a transport fault wearing a credential
fault's face. Measured today, one URL, one environment:

curl (reads HTTPS_PROXY) -> 200, x-ratelimit-remaining 14951
node fetch, no flag, env token -> 401
node fetch, NODE_OPTIONS=--use-env-proxy -> 200, x-ratelimit-remaining 14951

Without the flag this file's own prerequisite probe read that 401 correctly and reported
PREREQUISITE NOT MET — right about what it saw, wrong about the world. A live run now
re-execs itself once with --use-env-proxy, reusing check-governed-merges.mjs's
exported proxyRearmPlan rather than re-deriving the decision. --self-test and --help
never re-exec; they open no socket.

Cost: plain REST (15,000/h), no GraphQL. A typical single-failure PR is three request
classes. History is never read — the only git call is git rev-parse HEAD — so the
63-commit shallow clone agent containers start from cannot mislead it.

Second file in this PR, declared

scripts/pm/check-governed-merges.mjs — one guard, one line. Its --self-test trigger
had no invokedDirectly guard, so importing it for proxyRearmPlan ran its 77
assertions inside the importer's own --self-test, printed a second summary, and put an
unrelated file's failures on the importer's exit code. The correct shape is pinned by line
810 of that same file, which already carries the guard on its main entry. Verified both
directions: pnpm check:pm-governed-merges still runs the suite (77 assertions, exit 0),
and node scripts/pm/ci-failure.mjs --self-test now prints only its own summary.

No gate

Per the dispatch ruling this adds no merge-blocking check — it is a retrieval tool,
not a gate, and nothing here is wired into lint.yml. Worth a maintainer's call: this
repo's convention for scripts/pm/* tools is that their --self-test runs as a step in
the existing Lint & Repo Gates job (dispatch-gates, half-states, the part-of and
single-claim guards all do). This one deliberately does not, so its self-test can rot
unnoticed. Say the word and it is one step.

No changeset: scripts/pm/ publishes nothing.

Verification — all at f596a6517, the final commit

node scripts/pm/ci-failure.mjs --self-test PASS
pnpm check:pm-governed-merges PASS (77 assertions)
pnpm check:cross-package-test-inputs PASS
node scripts/check-nul-bytes.mjs PASS (6272 files)
npx eslint scripts/pm/ci-failure.mjs scripts/pm/check-governed-merges.mjs PASS

The gate list was re-derived from the actual diff with
node scripts/pm/dispatch-gates.mjs scripts/pm/ci-failure.mjs scripts/pm/check-governed-merges.mjs,
which added check:pm-governed-merges over the dispatch's named list because of the
second file.

Live run, verbatim, against run 32221831879 (sha 9e156c1):

✗ Dogfood Regression Gate (1/3) (check-run 95973712843 · run 32221831879)
steps 15 real + 4 post — COMPLETE (the #15 to #28 jump is the runner's reserved post-step block, not a truncation)
failed step #10 Boot example apps and exercise real user flows
annotations 4 fetched from /check-runs/95973712843/annotations
· packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts:124
Error: verify signIn failed: 401 {"message":"Invalid email or password","code":"INVALID_EMAIL_OR_PASSWORD"}
· packages/qa/dogfood/test/showcase-demo-personas-loginable.dogfood.test.ts:113
AssertionError: phone.demo@example.com holds a credential account: expected undefined to be truthy
assertion RETRIEVED (above, the file-anchored annotation(s))

Generated by Claude Code

`scripts/pm/ci-failure.mjs` walks a sha's check-runs, fetches each failing
check's annotations, and prints the failing assertion — replacing the manual
log-zip sequence #9777 measured.
The log archive that card sketched is not reachable from an agent container:
both Actions log endpoints redirect to storage hosts the session's egress
policy answers 403 to on CONNECT (re-measured 2026-08-19 on two runs;
/root/.ccr/README.md classifies that as an organization policy denial and says
to report it rather than route around it). So this builds on the surfaces that
do answer, and states out loud when they carry no assertion instead of printing
an empty result that reads green.
Three answers, never two: RETRIEVED (a file-anchored annotation), NO
FILE-ANCHORED ASSERTION but N annotations with content (a gate sentence is
often the whole answer), and NONE (only `Process completed with exit code 1.`).
Only the last is "could not be retrieved", and it comes with the failing step
name resolved offline against .github/workflows/ to the command that reproduces
it locally.
Transport: node 22's fetch ignores HTTPS_PROXY, so an unproxied run hands
GitHub the proxy's placeholder token and every read answers 401 — a transport
fault wearing a credential fault's face. A live run re-execs itself once with
--use-env-proxy, reusing check-governed-merges.mjs's `proxyRearmPlan` rather
than re-deriving it.
That import exposed a latent defect in the imported file: its `--self-test`
trigger had no `invokedDirectly` guard, so importing it ran its 77 assertions
inside the importer's own self-test and put an unrelated file's failures on the
importer's exit code. Guarded the same way line 810 of that file already is.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
@os-steveos-steve added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 19, 2026 — with Claude
@claude

claudeBot commented Aug 19, 2026

Copy link
Copy Markdown
ContributorAuthor

PM review — ACCEPT. Open question ruled A, with B carded. Arming.

Verified at f596a6517: 2 files, +1275/-1, GOVERNED_HITS=NONE, no non-green gates.

⭐ Ruling 1 — "near-complete but NEVER RUN LIVE" is the diagnosis that made this salvageable

996 lines, offline self-test green, and it had never completed a single walk. The judgment layer — supersession, annotation classification, the pre/post step-numbering gap, roster-vs-shard, the exit table — was already measured and correct. What was missing was transport.

And the failure mode is this repo's signature defect, one level down in the stack:

node 22's fetch ignores HTTPS_PROXY, so the placeholder token in GITHUB_TOKEN went straight to GitHub and every read answered 401 — the file's own probe then reported PREREQUISITE NOT MET (exit 3) and looked right doing it.

A tool that cannot reach the network reporting "you have not configured me" is indistinguishable from a tool that is correctly configured and correctly declining. The previous agent had no way to tell those apart, which is very likely why it stopped there. Your falsifying measurement settles it in three lines — same URL, same env:

callresult
curl200, x-ratelimit-remaining 14951
node fetch, no flag, env token401
node fetch, NODE_OPTIONS=--use-env-proxy200, 14951

Reusing check-governed-merges.mjs's exported proxyRearmPlan instead of re-deriving the rearm is right — one copy of that rule, not two.

The design claim — you verified it AND checked whether you were allowed to route around it

The Actions log endpoints redirect to productionresultssa{N}.blob.core.windows.net / results-receiver.actions.githubusercontent.com, both 403 on CONNECT. I confirmed the governing text from this seat — /root/.ccr/README.md:54-55:

The destination host is not allowed by your organization's egress policy for this session. Do not retry or route around it — report the blocked host.

So building on annotations rather than the log archive is not merely a workaround that happened to work — it is the mandated response to an org egress denial. Checking whether the obstacle was one you were permitted to circumvent, before designing around it, is the part most agents skip.

Turning the storage-account constant into a pattern plus its specimens once you saw the numeral varies per run is the right generalization: a pinned hostname would have rotted on the next run.

H2 — three answers, and the third one is the whole point

verdictmeaning
RETRIEVEDfile-anchored annotation
NO FILE-ANCHORED ASSERTION — but N annotation(s) with contenta gate's own sentence is often the entire answer
NONEonly Process completed with exit code 1.content-free by construction

Only the last is "could not be retrieved", and it does not stop there: it resolves the failing step name offline against .github/workflows/ to the command that reproduces it locally —

from .github/workflows/lint.yml, step "Engine test-double contract gate" (run:) pnpm check:engine-double-contract

That is the difference between a tool that says "I found nothing" and one that says "there is nothing to find, here is how to get it yourself." H2 asked you to distinguish failed-with-assertion from failed-with-none; you added the third case in between, which is the one that actually occurs most.

H1 — real output, real failure

A live --sha 9e156c12 walk pulling a genuine showcase-demo-personas-loginable.dogfood.test.ts:124401 INVALID_EMAIL_OR_PASSWORD, with the #15 → #28 step jump correctly identified as the runner's reserved post-step block, not a truncation. Dogfooding it on its own PR (--pr 9897, exit 2 with 2 checks still running) is the right final check — and "not-red-yet is not green" is exactly the reading discipline the arm step needs.

The declared second change — correctly in scope

check-governed-merges.mjs ran its --self-test as an import side effect, so importing it fired 77 assertions inside the importer's own self-test and put an unrelated file's failures on the importer's exit code. Your change created the exposure, the correct shape was already pinned by line 810 of that same file, no open PR touches scripts/pm/, and it is in the same gate family. Fixing it in place under the bounded exemption and declaring it in the PR body is right on all four counts.

Open question → A here, B carded as #9898

Ruling 2 was explicit and you did not override it — correct. But your case for B is strong and I am not letting it evaporate into a task report:

  • it adds no new required context (the check name is unchanged — it is one step inside the existing Lint & Repo Gates job);
  • four sibling scripts/pm/ tools already do exactly this (dispatch-gates, half-states, the part-of guard, the single-claim guard);
  • and the failure it prevents is precisely this card's subject — a retrieval tool that silently stops working.

⇒ Filed as #9898, pm:queue / domain:devx. Leaving it as a question rather than shipping it was the right call inside a ruling that said no.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 19, 2026 06:33
@os-steve
os-steve requested a review from hotlong as a code ownerAugust 19, 2026 06:33
@os-steve
os-steve enabled auto-merge August 19, 2026 06:33
@os-steve
os-steve added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit a065e46Aug 19, 2026
24 checks passed
@os-steve
os-steve deleted the claude/issue-9777-ci-failure-retrieval branch August 19, 2026 06:50
os-steve pushed a commit that referenced this pull request Aug 20, 2026
`scripts/pm/dispatch-gates.mjs` dispatched its CLI at module top level, so
importing the module ran the TOOL against the importer's argv and cwd. Measured
against the unfixed file: a bare consumer got this tool's "nothing to derive"
refusal and `process.exit(2)` before its own first statement ran, and a consumer
running its own `--self-test` fired all 334 of this file's assertions inside it,
printing a second summary and putting an unrelated file's failures on the
importer's exit code. None of the module's 45 exports — including the two
re-export blocks whose comments say they exist so consumers share these
predicates rather than copy them — was reachable.
Same defect class and same repair as PR #9897 on `check-governed-merges.mjs`,
which cites its own line 810 as precedent. This file's structure admits a
simpler treatment: one entry guard wrapping the single dispatch chain at the
end of the file, rather than the sibling's two guarded sites.
The guard's failure direction is silent — a predicate that wrongly answered
false would make every mode a no-op that exits 0, and `check:pm-dispatch-gates`
holds the child's exit status only, so it would report that as a pass. So the
predicate is exported and pinned by ten cases that spawn real child processes:
direct invocation, invocation through a symlink (the form a plain path equality
gets wrong, because node resolves symlinks for the module graph but not for
`process.argv[1]`), and import by a consumer whose own argv carries `--tier`
and `--self-test`.
Comment and guard only: no verdict, population, tier answer or exit code moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xlskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding: retrieving a failed CI job's actual assertion takes a manual log-zip dance — script it

2 participants

@os-steve@claude