Uh oh!
There was an error while loading. Please reload this page.
pm(ci-failure): fetch the job log instead of asserting it unreachable - #10545
Conversation
os-zhuang
commented
Aug 21, 2026
PM review — ⭐ accepted, and you found a better root cause than the card had.⭐ The card said the claim was false. You found that both readings were true, at different times, and that the freezing itself was the defect:
That is the difference between a correction and a fix. A PR that flipped the sentence to "logs ARE reachable" would have been just as wrong the next time egress policy changed, and would have read as authoritative in exactly the same way. ⭐ Making the script ask instead of assert is the only shape that survives the next session. ⭐ You fixed the routing, not just the sentenceThe claim was load-bearing, and you said so with the mechanism: on the no-anchor branch ( ⇒ Fixing the sentence without that would have left a seat reading a corrected paragraph and still being sent to the wrong instrument. The boundaries — this is what I asked for and it is better than asked
⭐ "Expired is 410, not an empty 200" is the single most useful line here — an empty 200 is exactly how this defect class hides, and knowing the API fails loudly means a future reader can trust an empty result rather than having to distrust it. And the cross-check is the right kind: the single-job fetch equals that job's entry inside the run archive to the byte. ⭐ MCP |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10141
scripts/pm/ci-failure.mjsstated that raw CI job logs are not reachable from an agent seat, and built its design and its output on that claim. Re-measured today: they are reachable. The claim was load-bearing, so this PR corrects the routing as well as the sentence.What I measured (not inherited from the card)
All taken 2026-08-21 in this agent container, plain REST via
curlunless noted.GET /repos/{o}/{r}/actions/jobs/96639318058/logs+ follow redirectproductionresultssa16.blob.core.windows.net, then HTTP 200, 1,741,198 bytes / 17,436 linesNODE_USE_ENV_PROXY=1Response.text()drops the BOMcurlkeeps)GET /repos/{o}/{r}/actions/runs/32436724284/logs+ followresults-receiver.actions.githubusercontent.com, then 200, a 2,015,644-byte zip of 184 entries, 15,676,280 bytes unpackedget_job_logs, job 96639318058,return_content: trueoriginal_length: 17436, content windowed totail_lines(default 500)/__agentproxy/statusrecentRelayFailures: []— no CONNECT denial recordedCross-check: the single-job fetch returned exactly the byte count of that job's entry inside the run-level archive (1,741,198).
The prior reading (2026-08-19,
CONNECT 403on both hosts) is kept in the header as history. What was wrong was not the reading — it was freezing a per-session environment fact into a source constant where nothing could falsify it.Boundaries, so the correction is not over-broad in the other direction
410, not an empty200. Bisected on this repo's own runs: 88 days old to 200 (19,574 bytes); 90, 92, 95 and 174 days old to 410 with a JSON body. An old incident is not recoverable this way.Locationis a short-lived Azure SAS URL — both specimens declaredst/seexactly 10m05s apart. Follow the redirect in the same call.Authorizationheader must not survive the hop.curl --location-trustedreturned 401InvalidAuthenticationInfofrom Azure; plaincurl -Land node's fetch both return 200 because both drop it cross-origin. The SAS query string is the credential.What the false claim cost, and what changed because of it
The claim routed a seat to a strictly weaker instrument.
Lint & Repo GatesandTypeScript Type Checkleave annotations with no file anchor, so the report reached itsno-anchor/nonebranch, printed "that stdout exists only in the Actions log blob ... 403 on CONNECT", and offered a local re-run of the failing step as the substitute — a different tree, a build first, minutes under the shared verify lock, instead of one request.verdictOfthen counted the check as unretrievable and the walk exited 2 UNDETERMINED, the code the header instructs callers to branch on.Worked example, measured on check-run 96639318058: its three annotations were
Process completed with exit code 2., a pnpm... exited (2)package pointer, andUnused '@ts-expect-error' directive.carryingpath: .github, start_line: 42— a line number with no file. The job log carries the whole assertion:src/commands/serve-verify-security-parity.contract.test.ts(42,1): error TS2578: Unused '@ts-expect-error' directive.So:
fetchJobLog— one request, spent only where the annotations produced no file-anchored assertion, and skipped for roster jobs and for a check-run that could not be paired with a job. A run whose annotations already answered costs exactly what it did before.classifyLogFetchnames four different facts instead of one sentence:expired(410, retention),denied(a status GitHub returned),absent(404),transport(the request never completed — where a CONNECT refusal lands, carrying/root/.ccr/README.md's instruction to report the blocked host rather than route around it).extractLogAssertionanchors on##[error], not on step names (a raw job log spells a step as##[group]Runplus the first line of the command, so step boundaries are not recoverable and guessing would put a confident wrong window in front of the reader). Four answers:anchored,errors-only(returns the stdout above the first error line — measured to be where acheck:*gate's own sentences land),tail(labelled a WINDOW, never an anchor),empty.classifyLogLinereusesclassifyAnnotation's vocabulary and adds the one shape the annotations API structurally cannot carry: a text-anchoredpath(line,col): error CODE: message.assertionStatusgainssource; a log-derived assertion isretrievedwithsource: 'job-log'. A file-anchored annotation still outranks it (already the answer, and cheaper).verdictOfcounts either source, so the families above can now reach exit 1 RED.LOG_BLOB_HOSTS("hosts this session's egress policy denies") is nowLOG_DOWNLOAD_HOSTS, orientation only, printed solely on thetransport/deniedbranch as the host to report.Verification
Live, end-to-end, on two real red shas (
--use-env-proxyre-exec working as before):80024aa3(run 32436724284),Type Check . workspace:38f68602(run 32436578705),Lint & Repo Gates— oneexit-statusannotation, and the log'serrors-onlybranch surfacing the gate's actual message where the old code printed the egress-denial paragraph:That second row stays UNDETERMINED on purpose: the log anchored no file, so the tool does not claim it retrieved an assertion. Pinned.
Reverse verification. Ablated
classifyLogLine's parenthesised anchor (if (paren)toif (paren && false)), confirmed on disk by string count (removed form 0, injected form 1) rather than by an editor's exit code.--self-testwent from exit 0 to exit 1 with 8 listed failures, includingan assertion that came from the JOB LOG counts toward RED — expected 1, actual 2— the exact regression this PR fixes. Restored from the commit and proved byte-identity withgit hash-objectagainstgit rev-parse HEAD:scripts/pm/ci-failure.mjs(f70ceb93d09bcc67be8e9803fd104f9d70e0a79b, equal),git status --porcelainempty.The first ablation pass exposed a defect in the harness itself — a bare
assertions[0].logLinethrew aTypeErrorand killed the run before any verdict printed, hiding every pin after it. Fixed in its own commit (?.plus the measurement in a comment); the numbers above are from the second pass.Gates, all on the final head
6e854a5a29, each read from the gate's own verdict line (not from a bare$?after a pipe):node scripts/pm/dispatch-gates.mjs(no path arguments) derived the set from the merge base: 1 path, 2 families.pnpm check:cross-package-test-inputs—All 60 self-test cases passed./OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.pnpm check:nul-bytes—check-nul-bytes: OK (scanned 6169 text file(s) ...; no raw ASCII control bytes).Plus a direct control-byte scan of the changed file, with a positive control proving the pattern matches when a control byte is present.pnpm exec eslint scripts/pm/ci-failure.mjs— exit 0.node scripts/pm/ci-failure.mjs --self-test— the commandlint.yml'sPM ci-failure self-testjob runs. Exit 0.Not done here
No changeset: the diff is one repo-internal PM script in no published package, so this PR releases nothing — declared with the
skip-changesetlabel.One governed edit is proposed rather than made (
.claude/**is human-merge only, so it is deliberately not in this diff)..claude/skills/pm-dispatch/references/platform-readings.mdcarries a neighbouring 2026-08-18 reading whose conclusion my measurements corroborate — fetch the full log archive before concluding — but one clause in it is over-broad: it saysget_job_logsreturns only post-step teardown regardless oftail_lines. Measured today on job 96638884991 attail_lines: 40: 34 of the 40 lines were git-credential teardown, and the remaining 6 were the failing gate's own message. So the tail is dominated by teardown and is the wrong place to look, but the quantifier does not hold. Exact proposed wording is in the structured report on #10141; nothing in that file claims raw logs are unreachable, so it does not carry this PR's defect.Everything else is clean: grepping
docs/adr/**,.claude/**,skills/**,AGENTS.mdandCLAUDE.mdfor the blob hosts,403 on CONNECT,connect_rejected, "log archive", "raw log" and "job log" (with a positive control proving the sweep really read those trees) turned up no other instance of the false claim. Outside the governed set the only hits are.github/workflows/merge-queue-triage.ymlandci.yml, which already pull job logs from inside CI and never claimed otherwise.Generated by Claude Code