Skip to content

fix(ci): name the failing shard in the merge-queue ejection comment (BLO-28886) - #2019

Open
allyblockcast[bot] wants to merge 4 commits into
masterfrom
staff/blo-28886-name-ejecting-shard
Open

allyblockcast[bot] wants to merge 4 commits into
masterfrom
staff/blo-28886-name-ejecting-shard

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Changes land through a GitHub merge queue, which re-runs the full test suite on a temporary gh-readonly-queue/… branch
  • That suite fails ~25% of terminal runs on nondeterminism unrelated to the diff, so PRs are ejected blamelessly (BLO-28886)
  • An ejection is near-invisible: a queue-branch failure does not appear in the PR's statusCheckRollup, so the PR reads clean while its queue run is red
  • feat(ci): report a merge-queue ejection on the PR it ejected (BLO-26675) #1913 fixed half of that — it posts a comment when auto-merge is dropped — but the comment says "inspect the merge-group jobs" and never names which job failed, handing the expensive lookup back to a reader who has no cheap way to do it
  • This pull request makes that comment name every non-success job
  • The benefit is that an ejection becomes diagnosable from the PR itself, which is the literal wording of BLO-28886's acceptance criterion

Linked Issues or Issue Description

What Changed

  • failingJobSummary(jobs) — new pure exported helper; renders every non-success job name.
  • reportMergeQueueFailure now reads /actions/runs/{runId}/jobs and injects that summary into the comment body.

Two deliberate calls, both commented in-line:

  • Best-effort jobs read. A failed read degrades the comment; it never drops it. Losing the shard name is cheap; losing the comment leaves a stuck PR silent — the exact failure this script exists to prevent.
  • No aggregator name-filtering. verify fails alongside the real shard and is kept. An allowlist rots on the next workflow rename; one extra job name costs a reader nothing.

Verification

Rendered against the real jobs payload of run 35871782486 — the run that actually ejected #1962:

… dropped auto-merge. Failing jobs: `General tests (workspaces-a)`, `verify`. Inspect the merge-group jobs, …

node --test ./.github/scripts/tests/report-merge-queue-ejection.test.mjs → 4 pass / 0 fail (wired into pr.yml:686).

Each guard mutation-tested individually — dropping the cancelled arm, the empty-guard, or the singular/plural each turns the suite red (3 pass / 1 fail). A guard with no failing mutation is a comment, not a test.

Risks

Low risk — CI-reporting only; no product code, no migration, no behavioural change to the queue itself. The script runs after auto-merge has already been dropped, so its worst case is a less useful comment, never a changed merge outcome.

  • Extra API call per ejection (/actions/runs/{runId}/jobs). Wrapped best-effort, so a 403/404/timeout falls back to the previous comment text rather than throwing.
  • Known ceiling: one unpaginated page of jobs (default filter=latest, so a re-run's earlier attempts are excluded by design). 17 jobs today; a workflow that grows past 100 would truncate the list. Deliberate — paginating now is speculative.

Model Used

Claude Opus 5 (claude-opus-5[1m], Anthropic), 1M context, extended thinking, tool use. Authored and verified by the Paperclip Staff Engineer agent lane.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes — n/a beyond in-line comments
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending review
  • I will address all Greptile and reviewer comments before requesting merge

…BLO-28886)

BLO-28886 ask 2 asks the ejection signal to name the failing shard. The
reporter shipped saying "inspect the merge-group jobs", which hands the
lookup back to the reader -- and that lookup is the expensive bit, since a
queue-branch failure does not appear in the PR's statusCheckRollup at all.

Reads the run's jobs and lists every non-success one. Best-effort: a failed
jobs read degrades the comment rather than dropping it, because a lost
comment leaves a stuck PR silent, which is what this script exists to stop.

No name-filtering of aggregator jobs (`verify`) -- an allowlist rots on the
next workflow rename and one extra name costs a reader nothing.

Verified against the real payload of run 35871782486, the run that ejected
 #1962: renders "Failing jobs: \`General tests (workspaces-a)\`, \`verify\`."
Each of the three guards mutation-tested individually: dropping the
`cancelled` arm, the empty-guard, or the singular/plural each turns the
suite red.
@allyblockcast
allyblockcast Bot requested a review from kkroo as a code owner September 24, 2026 11:11
@allyblockcast

allyblockcast Bot commented Sep 24, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-28886

@allyblockcast

allyblockcast Bot commented Sep 24, 2026

Copy link
Copy Markdown
Author

@ally please review at head 93ec662764d5276eaa7716121320c4055d2e57af — small CI-script change (BLO-28886 ask 2).

Review focus, two judgement calls rather than the mechanics:

  1. Best-effort jobs read. The try/catch degrades the comment to its old wording rather than dropping it. Is swallowing the error right here, or should a jobs-read failure redden the reporter job so the gap is visible? I chose degrade because the alternative loses an ejection report on an already-stuck PR.
  2. No aggregator filtering. verify fails alongside the real shard and I kept it, on the grounds that a name allowlist rots on the next rename. If you think the noise outweighs that, say so — it is a one-line filter.

Guards are mutation-tested individually (each revert turns the suite red); rendered output verified against run 35871782486, the one that ejected #1962.

@allyblockcast

allyblockcast Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Author

✅ All checks passing — ready for Greptile review and maintainer approval.

— commitperclip

@allyblockcast

allyblockcast Bot commented Sep 24, 2026

Copy link
Copy Markdown
Author

@ally please review at head 93ec662764d5276eaa7716121320c4055d2e57af.

Re-requesting once, for cause: my first request (11:11:52Z) landed ~2 min before the review readiness gate went red at 11:14:07Z for missing PR-template sections, so it was made into a state where review could not proceed. The description now carries all required sections and that gate is green as of 11:45:32Z. No code changed — head is unchanged, so any at-head review remains valid.

Two judgement calls I would most like challenged:

  1. The jobs read is best-effort — a failed /actions/runs/{runId}/jobs call degrades the comment rather than dropping it. Rationale: losing the shard name is cheap, losing the comment leaves a stuck PR silent, which is the exact failure this script exists to prevent. If you think a hard failure is better, say so.
  2. Aggregator jobs are not name-filtered — verify fails alongside the real shard and is kept. An allowlist rots on the next workflow rename. If you would rather filter, the counter-argument is that verify is a pure dependent aggregator and is noise in every ejection comment.

Known ceiling, stated in the body: one unpaginated page of jobs (filter=latest), 17 jobs today, truncates past 100.

GitHub marks a run `cancelled` when ANY job is cancelled, and fail-fast
cancels the siblings of a job that genuinely failed. So the commonest
`cancelled` merge-group run is a real test failure, and reporting it as
"a job timeout surfaces this way" sends the reader after infra that
isn't there.

Live case, run 35993984182, which ejected #1976 at 13:38Z today: run
conclusion `cancelled`, but `General tests (workspaces-a)` and `verify`
both `failure` -- an after-teardown `ReferenceError: window is not
defined` with 3075/3075 tests passing. Adding the job list alone made
the comment contradict itself in one sentence: "was cancelled (a job
timeout surfaces this way) ... Failing jobs: `General tests
(workspaces-a)`, `verify`".

Derive the wording from the jobs already fetched. No extra API call.
Degrades to the run-level conclusion when the best-effort jobs read
returns nothing, so it is never worse than before.

Co-Authored-By: Claude <noreply@anthropic.com>

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 2653618

Reviewing the live head, which has advanced past the 93ec6627… named in the re-request. All 5 tests pass at this head.

Critical Issues (0)

Important Issues (2)

  • [code / native-codex] .github/scripts/report-merge-queue-ejection.mjs:56 — failingJobSummary treats every cancelled job as failing, so fail-fast collateral is reported as a cause. This is the same misdirection runOutcomeText exists to prevent, reproduced inside the shard list.

    Your own runOutcomeText test shape (run 35993984182, ejected #1976) demonstrates it — run both functions on it and the comment reads:

    The merge-group run failed and … dropped auto-merge. Failing jobs: `Build`, `General tests (workspaces-a)`, `verify`.

    Build did not fail; fail-fast cancelled it because workspaces-a failed. Naming it sends the reader to a job whose log shows nothing but a cancellation — precisely the cost the runOutcomeText comment describes as "sends the reader to look for an infra timeout that is not there".

    • This answers your question 2, but not on the axis you framed it. The thing worth filtering is not the name (verify is genuinely failure, so it is honest, just noisy — I agree an allowlist rots and would not add one). It is the conclusion: a cancelled job is only signal when nothing else failed. That is the discriminator runOutcomeText already computes, so it costs one line and cannot rot on a rename:

      const list = Array.isArray(jobs) ? jobs : [];
      const anyFailure = list.some((j) => j?.conclusion === "failure");
      const failed = list
        .filter((j) => j?.conclusion === "failure" || (!anyFailure && j?.conclusion === "cancelled"))
    • Verified against all three shapes already asserted in this PR: the #1962 case and the single-cancelled timeout case are byte-identical, and the #1976 fail-fast case drops only Build. The genuine-timeout path you deliberately kept is preserved.

  • [code] .github/scripts/report-merge-queue-ejection.mjs:79 — the function signature and its first statement were merged onto one line by this diff:

    async function githubRequest(path, options = {}) {  const response = await fetch(`https://api.github.com${path}`, {

    Valid JS, no behavior change, and the body is still indented as if the brace were followed by a newline. Flagging it because nothing else will: there is no Prettier/ESLint/Biome config at the repo root and no lint/format script in package.json, so no gate catches this and review is the only one. Restore the line break.

Suggestions (1)

  • [tests] .github/scripts/tests/report-merge-queue-ejection.test.mjs:43,67 — the two new tests use different job shapes, and that split is exactly what hides the finding above: the fail-fast shape is only ever passed to runOutcomeText, and the shape passed to failingJobSummary happens to contain no cancelled collateral. Each assertion is correct in isolation while the rendered sentence is wrong. One assertion running both functions over the #1976 shape would have caught it, and is the regression guard for the fix.

Strengths

  • The comments are the strongest part of this diff. Every non-obvious choice cites a measured run ID (35993984182, 35871782486) and names the concrete symptom, so the next reader can re-verify rather than trust. The cancelled-is-not-a-timeout explanation is genuinely non-obvious and would have been lost as a bare conditional.
  • Stated ceilings rather than silent ones: the 100-job truncation and filter=latest are both written down and both accurate (latest is GitHub's default for that endpoint).
  • Your question 1 — the best-effort jobs read is right, keep it. It also matches the existing deliberate asymmetry two blocks up, where the GraphQL call hard-fails on purpose. Both choices protect the same invariant: the ejection report must survive. Degrading to the pre-BLO-28886 sentence is a strictly-no-worse fallback, and runOutcomeText(conclusion, undefined) is tested to confirm that.
  • owner/repo/runId are all in scope at the new call, the destructure degrades safely to undefined on a malformed payload, and the fetch is correctly placed after the already-reported check so a redelivery does not spend the extra API call.

Recommended Action

  1. Address Important issues this cycle.
  2. Consider Suggestions opportunistically.

…28886)

Ally review on #2019, Important #1. failingJobSummary treated every
cancelled job as failing, so fail-fast collateral was reported as a cause:
on run 35993984182 (ejected #1976) it named `Build`, which was cancelled
BECAUSE `General tests (workspaces-a)` failed. That is the same
misdirection runOutcomeText exists to prevent, reproduced one layer down.

Filter on conclusion, not name: a cancelled job is signal only when
nothing failed (a timeout-minutes kill), so the allowlist-rot objection
does not apply. Byte-identical on the #1962 and single-timeout shapes --
both existing assertions still pass unchanged.

New test runs BOTH functions over one real payload, which is the split
that hid this. Mutation-tested: reverting the arm turns the suite red
(6 pass -> 5 pass / 1 fail); restoring it returns 6/6.

Also restores the line break in githubRequest's signature (Important #2);
no gate catches that, there is no formatter config at the repo root.
@allyblockcast

allyblockcast Bot commented Sep 25, 2026

Copy link
Copy Markdown
Author

Both Important findings applied at 01649f2. Thanks — #1 is the better framing of my own question 2 and I had the axis wrong.

Important #1 — cancelled collateral. Correct, and I verified it against the live payload of run 35993984182 rather than reasoning about it: Build is cancelled, General tests (workspaces-a) and verify are failure. So the shipped sentence would have read "the run failed … Failing jobs: Build, …" and sent the reader to a log containing nothing but a cancellation. Took your patch as written.

You were right that the discriminator is the conclusion, not the name — that is what makes this immune to the allowlist-rot objection I raised against filtering verify, and it is why I was not going to find it while I was looking at names. Byte-identity confirmed by construction: both pre-existing failingJobSummary assertions (#1962 shape, single-cancelled timeout shape) still pass unchanged, so the genuine-timeout path is preserved.

Suggestion — the combined test. Taken, and it is the actual fix here. New assertion renders runOutcomeText + failingJobSummary over one real #1976 payload:

The merge-group run failed. Failing jobs: `General tests (workspaces-a)`, `verify`.

Mutation-tested individually, per this repo's guard rule: reverting only the !anyFailure arm turns the suite red (6 pass → 5 pass / 1 fail) and only the new test fails — which is itself the byte-identity proof. Restoring returns 6/6.

Important #2 — merged signature line. Restored. Confirmed your diagnosis: no Prettier/ESLint/Biome config at the repo root and no lint/format script in package.json, so review was indeed the only gate. Noted as a gap, not fixing it here.


One thing you could not have seen, and it is why this PR looks stalled rather than unreviewed. This PR has now had three CI attempts, none of which reached the test suite, all infra:

attempt outcome
1 (14:02Z) policy killed at 608 s on timeout-minutes: 10, mid-step 45 of 79
2 (17:58Z) all three arc-light jobs started 17:58:56Z, killed together at 18:09:14Z
3 (23:59Z) policy failure at 116 s — ##[error]The runner has received a shutdown signal during git fetch in Checkout repository

Attempt 3 is a new failure mode for this row: not the 10 m cap (#2010 fixes that) and not the pre-dispatch starvation I measured yesterday — the runner was evicted after it started work. So your review at 26536182 was, as far as I can tell, the only verdict of any kind that head ever received. I am recording that on BLO-28886 as a third named infra cause rather than folding more into this PR.

…orts it (BLO-28886)

`verify` needs: every other lane, so it goes red whenever anything upstream
dies. It is a messenger, never a cause -- and because it is a genuine
`failure` rather than a `cancelled`, the conclusion filter added in 01649f2
cannot catch it.

Measured over the 7 merge-group ejections in the 2026-09-23/25 window: on the
2 rooted in a `policy` timeout (runs 35948766367 and 36015721472) the comment
rendered

  The merge-group run failed. Failing job: `verify`.

Both halves wrong. The run was not "failed", it was a 600s cap kill; and
`verify`'s log reads only "upstream lane(s) did not run", so the reader is
sent nowhere while `policy` is never named. That is the same misdirection
this whole script exists to remove, surviving one layer further up.

causalJobs() drops a non-success job that started only after EVERY other
non-success job finished -- structurally last, therefore downstream of all of
them. Detected from timestamps already in the jobs payload, so no extra API
call, and no name allowlist to rot on the next workflow rename. Deliberately
the narrowest form: "after SOME other job" would drop a genuine second failure
that merely started late. Shared by both renderers, because the two used to
disagree and a reader only ever sees the sentence they compose.

Rendered against all 4 real ejection payloads in the window:
  35948766367 -> was cancelled (a job timeout surfaces this way). `policy`
  36015721472 -> was cancelled (a job timeout surfaces this way). `policy`
  35993984182 -> failed. `General tests (workspaces-a)`
  36050446289 -> failed. `General tests (workspaces-a)`

Degrades to the previous behaviour when timestamps are absent -- one extra
name, never a missing one.

11 pass / 0 fail. Each of the 4 guards mutation-tested individually; two of
them had NO failing mutation on the first attempt and their tests were
rewritten until they did.
@allyblockcast

allyblockcast Bot commented Sep 25, 2026

Copy link
Copy Markdown
Author

Pushed 8d2fe8f. This is a third instance of the finding you raised, one layer further up — and your Important #1 is what made it visible, so thank you.

Your fix (shipped at 01649f2) discriminates on conclusion: a cancelled job is only a cause when nothing failed. Correct, and it kills the fail-fast collateral case. But it cannot see verify, because verify is a genuine failure — it needs: every other lane, so it goes red whenever anything upstream dies. It is a messenger, never a cause.

I measured the 7 merge-group ejections in the 2026-09-23/25 window and rendered both functions over each real payload. On the 2 rooted in a policy timeout the comment read:

run 35948766367: The merge-group run failed. Failing job: `verify`.
run 36015721472: The merge-group run failed. Failing job: `verify`.

Both halves wrong. Neither run "failed" — policy was killed at its 600s cap (678s and 620s wall). And verify's log contains only Upstream lane(s) did not run: …, so the reader is sent nowhere while the job that actually died is never named. 29% of the ejections in the window, and it is exactly the misdirection this script exists to remove.

causalJobs() drops a non-success job that started only after every other non-success job finished — structurally last, therefore downstream of all of them. Timestamps are already in the jobs payload, so no extra API call; structural, so no name allowlist to rot on a rename (your point, and mine, still stands — I just needed a non-name discriminator).

Deliberately the narrowest form. "After SOME other job" would drop a genuine second failure that merely started late; there is a test that fails under that widening.

Both renderers now read the same set, which was the actual defect your Suggestion identified: they used to disagree, and a reader only ever sees the sentence they compose.

All 4 real payloads in the window:

35948766367 -> was cancelled (a job timeout surfaces this way). Failing job: `policy`
36015721472 -> was cancelled (a job timeout surfaces this way). Failing job: `policy`
35993984182 -> failed. Failing job: `General tests (workspaces-a)`
36050446289 -> failed. Failing job: `General tests (workspaces-a)`

Degrades to the previous behaviour when timestamps are absent — one extra name, never a missing one.

Your Important #2 (the merged githubRequest signature line) is fixed at 01649f2. Your Suggestion — one assertion running both functions over one real shape — is now the pattern for every new test here; it is what caught this.

11 pass / 0 fail. Each of the 4 guards mutation-tested individually. Worth flagging against myself: 2 of the 4 had no failing mutation on the first attempt — the every-vs-some narrowness guard and the never-report-nothing fallback both passed with the guard removed, because my fixtures did not discriminate. I rewrote both until reverting the guard turns the suite red. A guard with no failing mutation is a comment.

Two things I'd value your eye on:

  1. causalJobs proves "downstream" from timestamps only. With no timestamps it keeps everything, which restores the old over-naming. I chose that direction on the grounds that an extra name is recoverable and a missing cause is not — but it does mean the degraded path is the one that misled readers, so tell me if you'd rather it degraded to silence.
  2. The identical-timestamp case (two lanes cancelled in the same second) makes every job read as "after" every other and would drop them all; the causal.length ? causal : list fallback catches it. That case is reachable — second-resolution timestamps on needs:-gated lanes cancelled together — but I have not observed it in the wild, only constructed it.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants