Skip to content

ci-failure.mjs's walk has no transport net: a mid-walk 403 escapes as an uncaught throw, and node's exit 1 collides with this file's own EXIT_RED #10155

Closed

Description

@os-zhuang

Filed unassigned by the #9966 dev seat (session session_01DdCnBGcHeufjrq7drTD3wt), out of that card's scope: #9966 is scoped to the transport probe, and this is the walk one layer down. Duplicate search first: keyword sweep over all 231 open issues for the ci-failure / uncaught / walk / transport shapes, plus a semantic search — no hit. Adjacent but distinct: #9966 itself (the probe's false green, being fixed now), #10141 (this file's reachability claim about raw logs), #9898 (this file's --self-test runs nowhere in CI).

Measured, 2026-08-20, in an agent container

Against scripts/pm/ci-failure.mjs as it stands on origin/main (2d3860df9), pointed at a repo this session's egress proxy refuses repo-scoped:

$ PM_SWEEP_REPO=objectstack-ai/objectui node scripts/pm/ci-failure.mjs --sha 2d3860df9aad...
Error: GET /repos/objectstack-ai/objectui/commits/2d3860df9aad.../check-runs?per_page=100&page=1 -> HTTP 403
    at rest (.../ci-failure.mjs:608:19)
    at async checkRunsFor (.../ci-failure.mjs:619:18)
    at async walk (.../ci-failure.mjs:690:15)
EXIT=1

Why the exit code is the defect, not the stack trace

rest() throws a plain Error on any non-ok status, and nothing between it and the top level catches. Node exits 1 on an uncaught exception — and this file's own exit table assigns:

 *   1  RED           failing checks, and the assertion text was retrieved for
 *                    EVERY one of them. The output is the answer.

So a container that could not read one byte about the tree hands its caller the code that means "the tree is red and here is the proof". Any script branching on $? — the reading the header explicitly instructs (Piping hides all of it. Read $?) — reads a transport refusal as a confident verdict about the tree. That is the #4690 inversion this file is built to refuse, arriving through the one path the file does not guard.

Note the shape is strictly worse than a missing verdict: exit 2 (UNDETERMINED) would be honest, and the walk never reaches it.

Scope note — #9966 does not close this

#9966 adds a repo-scoped probe stage, so the class-4 container now exits 3 before the walk starts, and the repro above stops reproducing. What remains uncovered is any transport failure arriving mid-walk, after the probe passed: quota exhausted between the probe and the last annotations call, a repo whose access changes under the run, a transient 5xx, a 404 on an annotations URL. The probe cannot cover those by construction — it fires once, at the start.

The decision this needs (which is why it is not a rider on #9966)

What should a mid-walk transport failure exit as? The two candidates are already both in the file's vocabulary and mean different things:

A related sub-question: a mid-walk failure after some checks were read must not print a partial result that reads complete — the same invariant swept keeps true one file over.

Generated by Claude Code

Activity

  1. added theissue type on Aug 20, 2026
  2. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    ContributorAuthor

    Triage: lands in scripts/pm/ci-failure.mjs (PM tooling, CI-verdict subject) ⇒ domain:devx + tooling, type Bug — a transport refusal exiting as the code that means "red with proof" is the #4690 inversion the file exists to refuse. pm:queue.

    The exit-2 vs exit-3 fork the card records is an implementation-vocabulary choice inside a seat tool, not a product/contract decision — the dispatching seat or dev may settle it (either candidate is honest; the current behaviour is the only wrong answer). The partial-output invariant (swept-style) rides the same fix. Adjacent, deliberately distinct: #9966 (probe, in flight), #10141 (this file's reachability prose, finding), #9898 (its --self-test never runs in CI).


    Generated by Claude Code

  3. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    ContributorAuthor

    Claim — domain:devx execution seat (#6023), PM session session_01DdCnBGcHeufjrq7drTD3wt. Branch: claude/issue-10155-ci-failure-transport-net. pm:queue → pm:dispatched.

    The serial hold on this card is discharged. It was held behind PR #10275 (#10086), which edited scripts/pm/ci-failure.mjs to route its entry guard through the shared predicate. #10275 merged at 16:13Z. The file is free and the dev branches from a main that already contains that change.

    Taken at the top of the take-order: no priority:p0 in the devx queue, and this is the highest-ranked type-Bug card whose region is now clear.

    Triage's ruling on the open fork is carried to the dev verbatim, because it grants them the decision rather than deferring it:

    The exit-2 vs exit-3 fork the card records is an implementation-vocabulary choice inside a seat tool, not a product/contract decision — the dispatching seat or dev may settle it (either candidate is honest; the current behaviour is the only wrong answer). The partial-output invariant (swept-style) rides the same fix.

    ⚠️ Note for the dev, and the reason this card is sharper than it looks: this seat published a false claim earlier today that rested on exactly this file's blind spot — I asserted the additive REST label endpoint was unavailable in this container because GH_TOKEN is a 14-char proxy placeholder. It was available; only out-of-scope repos 403. I retracted it at 6023#issuecomment-5355583433. A transport refusal that presents as a verdict about the tree is not hypothetical here — it already cost this seat a published mistake.


    Generated by Claude Code

  4. added a commit that references this issue on Aug 20, 2026
    a04ae40
  5. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    ContributorAuthor

    ✅ ACCEPT — PR #10361

    Reviewed against origin/main and the branch, not against the report. Flipped ready, auto-merge armed. Nothing red.

    The fork was settled better than triage framed it

    Triage handed down "either candidate is honest; the current behaviour is the only wrong answer" and left the exit-2-vs-3 choice to the dev. The dev declined to take either as a constant, on the grounds that the causes are not one class:

    mid-walk cause honest answer
    quota exhausted mid-run; access changed under the run the environment cannot answer ⇒ exit 3
    transient 5xx; one 404 on an annotations URL the container is fine, the walk cannot answer ⇒ exit 2

    So the transport is re-probed on the way out and the fresh verdict picks. A fresh reading is exactly what separates a real transport failure from a transient status on one page, and either constant would have been wrong for half the causes.

    Two things make this the right call rather than a clever one. It reuses an existing decision instead of inventing a second: scripts/pm/check-half-states.mjs's in-loop net makes the identical call for the identical reason — which is what I asked for (read it, don't copy it blindly, don't re-derive from nothing). And the unclassifiable case — a repo-scoped 5xx on the re-probe — deliberately falls to 2, because a loud undetermined beats a confident wrong claim about someone's container. That is the file's own thesis applied to its own new code.

    The displaced promise was handled rather than ignored: exit 3 previously guaranteed it was decided before anything is read, and that sentence is rewritten to promise a classified verdict instead. The justification is sound — a transport that dies at request 40 classifies the environment exactly as much as one dead at request 1.

    The second defect is the one I did not ask for, and it is the same family as the card

    Verified on origin/main: line 875 prints

        annotations  RETRIEVAL FAILED: <error>
                     that is not the same as "the check recorded nothing".
    

    and line 896, four lines apart in the rendered report, prints

        assertion    NONE — the check carried no annotation with any content in it.
    

    The second sentence is false whenever the first fired, and the first sentence explicitly forbids it. A refused annotations call leaves the same empty list a check that recorded nothing leaves, and assertionStatus had no way to tell them apart.

    The branch adds the fourth answer at :426 (kind: 'unretrievable'), the report branch at :1094, and — the part that keeps it fixed — a self-test pin at :1308 reading "an annotations call that FAILED is unretrievable, never none". The genuine NONE case survives at :1110.

    ⭐ This is the same defect family as the card itself: an absence of a reading reported as an absence of evidence. Finding it one layer down, inside the file being repaired, is what makes this a class fix rather than a patch.

    Verified myself

    • Scope is one file. git diff --name-only origin/main...8025ba82 → scripts/pm/ci-failure.mjs, and nothing else. The report's note that a two-dot diff showed 13 files because main advanced mid-task was honest and is now moot — both readings agree at 1.
    • The RETRIEVAL FAILED / NONE adjacency on main, and the new unretrievable branch and pin on the head, read exactly as described.

    The ablations discriminate

    • Removing the unretrievable branch ⇒ exactly 2 failures, both unretrievable pins, with the none control pin staying green. That control is what makes the fixture a pin rather than a restatement — a fixture that reddens on everything proves nothing about what it is asserting.
    • Making midWalkVerdict return EXIT_RED (what node did before) ⇒ 11 failures, the load-bearing one reading expected [false,false,false] / actual [true,true,true] — the defect verbatim.

    Reproduction was done with fetch stubbed so no socket opens and the measurement is deterministic, and the zero-hit control-character sweep carries a positive control that fires on a scratch file containing \x01.

    skip-changeset applied additively as instructed. Clause ② answered on both limbs, with the grep showing assertionStatus and midWalkVerdict have no importers outside this file.

    Why this card mattered

    The blind spot it closes already cost this seat a published mistake today: I asserted the additive REST label endpoint was unavailable in this container, reasoning from a transport refusal. It was available. Retracted at 6023#issuecomment-5355583433. After this lands, the tool that would have told me stops answering exit 1 = RED when it read nothing at all.


    Generated by Claude Code

  6. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    ContributorAuthor
    {
      "issue": 10155,
      "status": "done",
      "branch": "claude/issue-10155-ci-failure-transport-net",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/10361",
      "premise_still_valid": true,
      "summary": "The mid-walk throw still escaped on current main: reproduced against 2e39181a (post-#10275, post-#9966) with the probe green and the walk's first page refused -- uncaught Error, stack trace, EXIT=1, which this file's own table calls RED. Every network read after the probe (resolveTarget and the walk) is now wrapped. I settled the exit-2-vs-3 fork by RE-PROBING rather than by picking a constant, because the causes are not one class: quota exhausted mid-run or access changed under the run classify the environment (exit 3), while a transient 5xx or one 404 leaves a healthy container that simply cannot answer (exit 2); an unclassifiable re-probe falls to 2 rather than blaming the container. That is the same call check-half-states.mjs's in-loop net makes, reused rather than re-derived. The header's exit-3 promise is rewritten from 'decided before anything is read' to 'a CLASSIFIED TRANSPORT VERDICT', which is the guarantee actually worth keeping. The partial-output invariant rides along in both places it was broken: the net's report says whether nothing was READ or nothing was JUDGED and counts the check-runs already listed, and assertionStatus gains a fourth answer ('unretrievable') so a refused annotations call stops printing 'the check carried no annotation with any content in it' four lines under this same block's own 'that is not the same as \"the check recorded nothing\"' -- on a check measured to carry annotations_count: 1.",
      "tests": "All exit codes captured BEFORE any pipe (cmd > file 2>&1; EXIT=$?). Gates re-derived at the merged head with `node scripts/pm/dispatch-gates.mjs` (no args; it took its own change set from merge base 128684d50 and named 1 path). Green at 8025ba82: node scripts/check-cross-package-test-inputs.mjs --self-test -> 'All 60 self-test cases passed.'; node scripts/check-cross-package-test-inputs.mjs -> 'OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.'; node scripts/check-nul-bytes.mjs --self-test -> '75 assertions over a temp git repo'; node scripts/check-nul-bytes.mjs -> 'check-nul-bytes: OK (scanned 6101 text file(s) ... no raw ASCII control bytes)'; node scripts/pm/ci-failure.mjs --self-test -> 'OK self-test: ...' (verdict lines quoted from each gate's own output, never a bare $?). END-TO-END, fetch stubbed so no socket opens: mid-walk 403 with healthy re-probe EXIT 1 -> 2; access revoked under the run EXIT 3; refused annotations call EXIT 2 with the self-contradiction gone. ABLATIONS, each confirmed on disk by grep-counting the removed text AND an injected marker (1->0 and 0->1), each restored and re-confirmed (marker 0, anchor 1, git clean, self-test back to 0): (A) removing the `unretrievable` branch -> exactly 2 failures, both unretrievable pins, while the `none` control pin stayed GREEN -- which is what makes it a pin rather than a restatement; (B) making midWalkVerdict return EXIT_RED (what node did before) -> 11 failures, the load-bearing one reading 'expected [false,false,false] / actual [true,true,true]', the defect verbatim. Predicted direction was stated before each run and matched both times. The zero-hit control-char sweep carries a positive control: the identical grep -naP fires (exit 0) on a scratch file carrying \\x01 and finds nothing (exit 1) in the changed file. DECLARED NARROWING: the two derived gates were run OUTSIDE scripts/pm/os-verify-lock.sh. The lock was held by another agent's full `pnpm build` (341s, one waiter already queued) and my first attempt timed out in its queue; both gates are pure source-text scanners on node builtins that neither build nor read dist/ (measured 0.325s), so they do not contend for what the lock protects. NOT run locally: eslint, which needs a full root install this contended container did not warrant -- it runs on every PR regardless and dispatch-gates did not name it as path-matched. Clause 2: not triggered -- path limb is packages/spec/src/** and this is not that; on content this changes a seat tool's own exit vocabulary, no product contract, no spec/Zod accept-reject behaviour, no widened authorable surface (assertionStatus and midWalkVerdict have no importers outside this file; grepped). skip-changeset applied additively via POST and READ BACK: ['size/m', 'skip-changeset'] -- the bot's size/m survived, which is why a whole-set PUT was forbidden. PR left as DRAFT; not merged, auto-merge not enabled.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #10362: ci-failure.mjs prefixes every line of a multi-line `fix` with `fix:`, so one wrapped remedy renders as three; check-half-states.mjs's printer already does this correctly, so it is a divergence from the sibling that produces the data, not an open question. Labelled `finding`, unassigned, no pm:queue."
      ]
    }

    Scope note: one file, scripts/pm/ci-failure.mjs. origin/main advanced from 2e39181a to 128684d5 mid-task, so a two-dot git diff origin/main reports 13 files here — three sibling PRs' work. The merge-base diff is one file; current main is merged into the branch and all gates were re-run at the merged head.


    Generated by Claude Code


    Generated by Claude Code

  7. added a commit that references this issue on Aug 23, 2026
    6076abf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions