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

os-steve merged 1 commit into
mainfrom
claude/issue-9777-ci-failure-retrieval

Conversation

@claude

@claude claude Bot 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 carried verdict line reader's next move
a file-anchored vitest annotation assertion RETRIEVED done — the assertion is printed
a gate's own sentence, no file anchor assertion 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-steve os-steve added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 19, 2026 — with Claude
@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

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:

call result
curl 200, x-ratelimit-remaining 14951
node fetch, no flag, env token 401
node fetch, NODE_OPTIONS=--use-env-proxy 200, 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

verdict meaning
RETRIEVED file-anchored annotation
NO FILE-ANCHORED ASSERTION — but N annotation(s) with content a gate's own sentence is often the entire answer
NONE only 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:124 401 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 owner August 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 queue Aug 19, 2026
Merged via the queue into main with commit a065e46 Aug 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
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 23, 2026
…ctstack-ai#9757) (objectstack-ai#10084)

`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 objectstack-ai#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.


Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja

Co-authored-by: Claude <noreply@anthropic.com>
os-project-manager pushed a commit that referenced this pull request Aug 31, 2026
The entry-guard docblock carried "this file carries it at 334" — a frozen
reading of a number that moves on most edits to this file. It had drifted to
1073 by the time anyone looked: a factor of 3.2.

Writing today's number would re-freeze the same defect one value later, so the
figure is now a FLOOR ("more than ten times" #9897's 77), stated as a floor on
purpose and pointing at `--self-test`'s `cases.length` as the live reading.

Measured before choosing the shape: across all 83 commits that have touched
this file, the assertion count went up 72 times, held flat 10 times, and has
never once gone down (14 -> 1047 static call sites; 1073 at runtime after the
file-local loop expansions). A floor is safe precisely because of that.

The neighbouring 77 is left frozen deliberately — it is anchored to PR #9897
and was true there (70 static assertions at that commit), so it is a historical
fact rather than a live count. The sentence now says so explicitly.

No behaviour change: the `invokedDirectly` guard and the self-test are
untouched. `--self-test` still reports 1073 cases pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…ectstack-ai#13962)

The entry-guard docblock carried "this file carries it at 334" — a frozen
reading of a number that moves on most edits to this file. It had drifted to
1073 by the time anyone looked: a factor of 3.2.

Writing today's number would re-freeze the same defect one value later, so the
figure is now a FLOOR ("more than ten times" objectstack-ai#9897's 77), stated as a floor on
purpose and pointing at `--self-test`'s `cases.length` as the live reading.

Measured before choosing the shape: across all 83 commits that have touched
this file, the assertion count went up 72 times, held flat 10 times, and has
never once gone down (14 -> 1047 static call sites; 1073 at runtime after the
file-local loop expansions). A floor is safe precisely because of that.

The neighbouring 77 is left frozen deliberately — it is anchored to PR objectstack-ai#9897
and was true there (70 static assertions at that commit), so it is a historical
fact rather than a live count. The sentence now says so explicitly.

No behaviour change: the `invokedDirectly` guard and the self-test are
untouched. `--self-test` still reports 1073 cases pass.


Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 1, 2026
…reading (objectstack-ai#14002)

The docblock above the `--self-test` entry guard carried two figures frozen at
PR objectstack-ai#9897 and both had rotted:

  * "THIS file's 77 assertions" read as a present-tense claim about this
    file's current suite. It printed 77 at PR objectstack-ai#9897 and prints 255 today — a
    factor of 3.3.
  * "the same reason line 810 carries it" pointed at the sibling
    main-invocation guard, which really was line 810 at PR objectstack-ai#9897. Line 810 is
    now an unrelated `try {` inside the regeneration predicate; the guard has
    moved to line 2336.

Writing today's numbers would re-freeze the same defect one value later, so
the 77 is now anchored to PR objectstack-ai#9897 as the historical fact it is, today's size
is a FLOOR ("more than three times that") stated as a floor on purpose and
pointing at `--self-test`'s `checked` as the live reading, and the line number
is replaced by a reference to the guard itself, which cannot drift.

Measured before choosing the shape, on this file's own history rather than
inherited from the sibling: across all 13 commits that have ever touched it
(introduced 2026-08-18, never renamed), the static `assert(` count went up 10
times, held flat twice, and has never once gone down (22 -> 248 static). The
counting method reproduces the two independent readings on record — 70 static
at a065e46 and 223 at 9c4c431.

The runtime figure is not even constant across environments, which is a second
reason a floor beats a reading: `--self-test` prints 255 in an installed tree
and 253 without node_modules, where the generator-provenance row fails closed
and says so on its `live:` line. Both readings are green and truthful.

No behaviour change: the guard and the self-test are untouched, and the count
is identical before and after this commit (255 at the merge base, 255 here).


Claude-Session: https://claude.ai/code/session_01Msg17tAHJ3jVTYFgHydCm2

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl skip-changeset PR 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