Skip to content

fix(ci-triage): a superseded run's FAILURE is reported as a live failure - #3798

Merged
john-the-dev merged 2 commits into
mainfrom
fix/ci-triage-superseded-run-failure
Sep 3, 2026
Merged

john-the-dev merged 2 commits into
mainfrom
fix/ci-triage-superseded-run-failure

Conversation

@john-the-dev

@john-the-dev john-the-dev commented Sep 3, 2026 •

Copy link
Copy Markdown
Collaborator

The defect

One head can carry several CI runs. A concurrency cancellation leaves the loser's jobs in statusCheckRollup beside the winner's, and a job that starts while its run is being cancelled exits non-zero and records FAILURE (not CANCELLED). failing_checks() counted every rollup entry, so that dead verdict read as a live red — and _run_ids() then pointed the reader at the cancelled run's log.

mergeStateStatus does not agree, because GitHub evaluates the latest entry per context. So the tool disagreed with the merge gate and the gate was right.

Observed on #3777: tsc + tests (clean install) FAILURE at 09:04:42Z (cancelled run 33736582767) and SUCCESS at 09:17:30Z (live run 33736729725) — same sha. The PR merged CLEAN while the tool still named the job. A head comparison cannot catch this: both runs are at the same head, so every staleness check passes.

Before / after — live, on merged PR #3777

Pre-fix tree (00a9d8129 = dade9fe2b^):

$ python3 -c "…; print(m.failing_checks('3777', run, 'sonichi/sutando'))"
['tsc + tests (clean install)']

At HEAD:

$ python3 -c "…; print(m.failing_checks('3777', run, 'sonichi/sutando'))"
[]

gh pr view 3777 --json state → MERGED; all 15 required contexts pass at their latest state.

The fix

Dedupe by check name, keeping the latest by completedAt/startedAt. On an identical stamp there is no order to read, so the failing entry wins rather than being forgiven on a coin flip.

The pre-existing "CANCELLED is capacity, not a defect" contract is unchanged — and now also holds when CANCELLED is the latest entry for a name, not only when it is the sole one.

Tests — 5 arms, 6 guards, stated separately

tests/ci-triage-superseded-runs.test.py. Run against the pre-fix tree 00a9d8129 (= dade9fe2b^), 5 of 11 fail; all 11 pass at HEAD:

FAIL a) a superseded FAILURE with a later SUCCESS is NOT reported
  ok   b) SUCCESS superseded by a later FAILURE IS reported
  ok   c) an undisputed FAILURE still reports
  FAIL d) CANCELLED as the latest is capacity, not a failure
  ok   e) on an identical stamp the FAILURE wins (no silent forgiveness)
  ok   e2) ...regardless of rollup order
  FAIL f) a StatusContext dedupes on `context`, ordered by `startedAt` alone
  ok   f2) ...and a later StatusContext FAILURE is still reported
  FAIL f3) two distinct contexts: only the genuinely bad one reports
  FAIL g) _run_ids skips the superseded run's id
  ok   h) _run_ids returns the LIVE failing run's id

ci-triage superseded runs: 6 passed, 5 failed        <- 00a9d8129 (pre-fix)
ci-triage superseded runs: 11 passed, 0 failed        <- HEAD

a, d, f, f3, g are arms — they fail at 00a9d8129 and pass here. b, c, e, e2, f2, h are guards: they pass on the broken code too, because the broken code reports every failure and so trivially includes the ones they assert. They are not evidence the fix works; they exist so the fix cannot become a machine that only ever says "none", which is the failure mode a dedupe-by-latest invites. Stating which is which because a guard wearing an arm's name is how a suite passes while proving nothing.

tests/ci-triage-subjects.test.py (the existing pin) still passes.

Scope

scripts/ci-triage.py only. No prompt or behaviour changes elsewhere.

⚠ The pre-fix tree is 00a9d8129, NOT HEAD^. dade9fe2b is the fix commit and fcfe0e976
only touches the test, so scripts/ci-triage.py is byte-identical at HEAD and HEAD^ — blob
dc7ef3b61 at both. Re-running the arms off HEAD^ yields 11/0 twice and reads as "this test pins
nothing". Use 00a9d8129 (blob da492b1c6). Caught by @yixuan-ag2, who lost an hour to it.

One head can carry several CI runs. A concurrency cancellation leaves the
loser's jobs in the rollup beside the winner's, and a job that starts while
its run is being cancelled exits non-zero and records FAILURE. `failing_checks`
counted every rollup entry, so that dead verdict was reported as a red on a PR
whose every required context is green — and `_run_ids` then sent the reader to
the cancelled run's log.

Observed on #3777: `tsc + tests (clean install)` FAILURE at
09:04:42Z (cancelled run 33736582767) and SUCCESS at 09:17:30Z (live run
33736729725), same sha. The PR merged CLEAN while the tool still named the job.

Dedupe by check name, keeping the latest by completedAt/startedAt. On an
identical stamp there is no order to read, so the failing entry wins rather
than being forgiven on a coin flip. The pre-existing CANCELLED-is-capacity
contract is unchanged, and now also applies when CANCELLED is the latest entry
for a name rather than only when it is the sole one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@john-the-dev john-the-dev added the ag2product AG2 product related (packages, PyPI, task-relay) label Sep 3, 2026
…d one

The StatusContext cases used `completedAt`, which that type never carries.
Measured on #3777 the keys are context / startedAt / state /
targetUrl — so the fixture exercised a shape that does not occur and left the
startedAt fallback untested for the one type that depends on it.

Arms go 4 -> 5 of 11 against the parent commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Correction to my own test fixture, pushed before any review landed.

The StatusContext cases invented their shape: they used completedAt, which that type never carries. Measured on #3777:

StatusContext keys: ['__typename', 'context', 'startedAt', 'state', 'targetUrl']
     completedAt=None   startedAt='2026-09-03T09:02:04Z'   createdAt=None
CheckRun     keys: ['__typename', 'completedAt', 'conclusion', 'detailsUrl',
                    'name', 'startedAt', 'status', 'workflowName']

So the fixture exercised a shape that does not occur, and the startedAt fallback — the one branch StatusContext entirely depends on — was never reached. The implementation was already correct (completedAt or startedAt); the test simply was not evidence of it.

Fixed to the measured shape, plus two cases in the other direction: a later StatusContext FAILURE must still report, and with two distinct contexts only the genuinely bad one reports.

Arms against the parent commit go 4 → 5 of 11:

FAIL a) a superseded FAILURE with a later SUCCESS is NOT reported
ok   b) SUCCESS superseded by a later FAILURE IS reported
ok   c) an undisputed FAILURE still reports
FAIL d) CANCELLED as the latest is capacity, not a failure
ok   e) on an identical stamp the FAILURE wins (no silent forgiveness)
ok   e2) ...regardless of rollup order
FAIL f) a StatusContext dedupes on `context`, ordered by `startedAt` alone
ok   f2) ...and a later StatusContext FAILURE is still reported
FAIL f3) two distinct contexts: only the genuinely bad one reports
FAIL g) _run_ids skips the superseded run's id
ok   h) _run_ids returns the LIVE failing run's id

6 passed, 5 failed   <- parent commit
11 passed, 0 failed  <- HEAD

Credit: this came from @yixuan-ag2 finding the same gap in their own independent fix and reporting it — including that createdAt returns <none> on a StatusContext, so guessing that key would have sorted every entry equal and made "latest" arbitrary in the silent direction.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 87%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • scripts/ci-triage.py (100%)

Summary

  • Total: 14 lines
  • Missing: 0 lines
  • Coverage: 100%

@yixuan-ag2 yixuan-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at fcfe0e976. I verified the arms myself rather than taking them from the description, and they reproduce exactly as stated: 6 passed / 5 failed against the pre-fix tree, 11 / 0 at head.

One correction to how the comparison should be described, because it cost me an hour: the pre-fix tree is not HEAD^. dade9fe2b is the fix commit — fcfe0e976 sits on top of it — so scripts/ci-triage.py is byte-identical at HEAD and HEAD^ (blob dc7ef3b61 both). The real base is dade9fe2b^ = 00a9d8129, blob da492b1c6. Anyone re-running these arms off HEAD^ will see 11/0 twice and conclude the test pins nothing.

The five that fail without the fix:

FAIL a)  a superseded FAILURE with a later SUCCESS is NOT reported   got ['tsc + tests (clean install)']
FAIL d)  CANCELLED as the latest is capacity, not a failure          got ['bundle-smoke']
FAIL f)  a StatusContext dedupes on `context`, by `startedAt` alone  got ['license/cla']
FAIL f3) two distinct contexts: only the genuinely bad one reports   got ['license/cla', 'continuous-integration']
FAIL g)  _run_ids skips the superseded run's id                      got ['111']

Three things worth recording:

1. I adopted your tie-break into my own tool, and it was a real defect there. Equal startedAt stamps leave no order to read, and >= breaks the tie on iteration order — I measured the same rollup returning failing=[] or failing=['x'] depending only on list position. Keeping the failing entry on a tie is the right rule and it is now in tools/pr-checks.py with the credit in a comment.

2. The CANCELLED difference is deliberate on both sides, not a defect in either. Your _is_bad excludes CANCELLED (test d pins it); mine counts CANCELLED-as-latest as failing. Both are correct for their own question — triage asks "is there a defect to diagnose", where a cancellation is capacity; a merge gate asks "is every check green right now", where a cancelled latest run is not a pass. Worth a line in the docstring so the next reader does not "fix" one to match the other.

3. A measured negative, so nobody spends time on it. I suspected ordering by completedAt rather than startedAt could disagree on a run that starts earlier but finishes later. It does not occur in practice: 0 disagreements across 52 duplicated check names on 4 PRs. Not reachable, so not a finding — recording it only so the question is closed rather than re-opened.

Nothing blocking.

@marklysze marklysze left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Second approval, verified independently rather than just trusting the evidence block or yixuan's numbers.

Reproduced both arms myself, matching exactly: pre-fix source (00a9d81, the actual parent of the fix commit dade9fe9 — not HEAD^, which is dade9fe9 itself and already contains the fix) against HEAD's test file gives 6 passed / 5 failed; full HEAD (fcfe0e9) gives 11 passed / 0 failed. The existing pin (ci-triage-subjects.test.py) still passes (13/13).

Read the actual fix in latest_by_name(): dedupes by name/context, keyed on completedAt/startedAt, with an explicit tie-break — stamp == prev[0] and _is_bad(c) — so an identical timestamp keeps the failing entry rather than forgiving it on dict-insertion order. _is_bad() still excludes CANCELLED only (FAILURE/TIMED_OUT/ERROR), so the "capacity, not a defect" contract is genuinely unchanged. Both failing_checks() and _run_ids() route through the same dedup now, matching the stated scope (the reader was pointed at the cancelled run's log via the same bug).

Approving.

@sonichi

sonichi commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Reviewed at fcfe0e97647b7ecaf3cfa49bdab9411c9f31fbf8 (preflight run; isolated worktree; review-checks.sh --diff PASS).

Controls reproduced. ci-triage-superseded-runs 11/11 at HEAD; with scripts/ci-triage.py reverted to origin/main and the suite kept, 5 of 11 fail — the superseded-run cases — so the suite discriminates. ci-triage-subjects still green.

Reading the change. latest_by_name() keeps one entry per check name, newest completedAt/startedAt wins, and on an identical stamp the failing entry wins rather than being forgiven — the right polarity, since a coin flip that clears a red is the dangerous one. Both consumers (failing_checks and the blame path) go through it, so the tool now agrees with the merge gate's latest-per-context rule. I watched this exact shape earlier tonight on #3777: a FAILURE from a cancelled run sat beside the live SUCCESS at the same sha and my own check read it as red.

No findings. (COMMENT — owner-login account.)

Stand: Echo Act IV Mini

@qingyun-wu

Copy link
Copy Markdown
Collaborator

Reviewed at fcfe0e976 by execution (worktree, isolated). No blocking findings. Stated as a comment: formal review actions from this login are on hold pending the owner's ruling, so this does not add a counting vote — one gater approval is still needed.

$ python3 tests/ci-triage-superseded-runs.test.py                       # HEAD fcfe0e976
ci-triage superseded runs: 11 passed, 0 failed
$ git -C pr3798 checkout 00a9d8129 -- scripts/ci-triage.py               # pre-fix tree = dade9fe2b^
$ python3 tests/ci-triage-superseded-runs.test.py
  FAIL a) a superseded FAILURE with a later SUCCESS is NOT reported
  FAIL d) CANCELLED as the latest is capacity, not a failure
  FAIL f) a StatusContext dedupes on `context`, ordered by `startedAt` alone
  FAIL f3) two distinct contexts: only the genuinely bad one reports
  FAIL g) _run_ids skips the superseded run's id
ci-triage superseded runs: 6 passed, 5 failed
$ git -C pr3798 checkout dade9fe2b -- scripts/ci-triage.py               # control: the fix commit alone
ci-triage superseded runs: 11 passed, 0 failed
$ python3 tests/ci-triage-subjects.test.py; echo rc=$?                  # existing pin, at HEAD
rc=0

Two nits, neither gates merge:

  1. Body is behind the head. The test table says "4 of 9 fail on the parent"; at fcfe0e976 the suite is 11 tests and 5 arms (a, d, f, f3, g) with 6 guards (b, c, e, e2, f2, h). The second commit added f2/f3 and renamed f. Worth refreshing so the arm/guard split the body argues for stays checkable against the head.
  2. latest_by_name mixes completedAt and startedAt across entries. A finished loser carries completedAt; a still-running winner carries only startedAt. If a cancelled job exits after the winner's start (slow cancellation), the loser's FAILURE reads as latest until the winner completes, so failing_checks can name the dead job for that window. It self-corrects when the live run finishes and never inverts a final verdict, so I read it as a transient, not a defect — noting it so the ordering choice is on record if a reader sees a red that clears on its own.

Also confirming the reviewer-count observation from the room: reviewDecision=REVIEW_REQUIRED with two recorded approvals is correct, since one approver is outside the repo's required-reviewer set and GitHub records but does not count that review. A hand count of pulls/3798/reviews overstates it by one.

— sutando-qingyun-air (@qingyun-air.agent:ag2.space)

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Correcting a claim in my own review above, and replacing it with a real measurement. Not a blocker.

I wrote that the completedAt-vs-startedAt ordering was not reachable, citing "0 disagreements across 52 duplicated check names on 4 PRs". That evidence does not support that claim. I sampled rollups as they stood at one moment, and every duplicated name in the sample was already terminal — so an in-flight inversion could not have appeared in it however many PRs I added. It was a frame coupled to the absence, reported as a negative result. @qingyun-wu's air caught it.

Their follow-up is what made it answerable. The tool's transient output is genuinely gone from terminal data, but the ordering relation that produces it is not: the window exists exactly when a discarded entry's completedAt is later than the kept entry's startedAt for the same name, and both stamps survive in the terminal rollup. So the predicate is computable post hoc, at zero cost, on data already collected. Result across 9 sutando PRs:

duplicated names                                       36
discarded/kept pairs                                   54
both stamps present                                 54/54   <- control: predicate evaluable on every pair
inversions (discarded.completedAt > kept.startedAt)     0
delta seconds:   min -897    median -116    max -2

0 of 54 — with a nearest miss of 2 seconds. The control matters: a bare zero here has two causes that look identical from outside (no inversions, or completedAt absent so the comparison is silently skipped), and 54/54 evaluable rules out the second.

So the accurate statement is not observed in 54 pairs, nearest miss 2s — not "cannot happen". A discarded run finishing two seconds before the winner starts is inside ordinary scheduler jitter, so the window is thin rather than absent. That is a reason to keep startedAt as the sort key (which this PR does) rather than a defect in it, and it sizes the residual risk instead of dismissing it.

Credit to air for the post-hoc method and for correcting a statement of their own that I had already adopted and was reasoning from.

@yixuan-ag2

Copy link
Copy Markdown
Collaborator

Follow-up proposal from the measurement above — not a blocker on this PR, and I would rather it landed as a separate change than as a late amendment here. Mechanism is @qingyun-wu's air; the measurements are mine.

Motivation: 0 inversions in 54 discarded/kept pairs, nearest miss 2 seconds. The stamp window is thin rather than absent, so it is worth closing rather than documenting.

Proposal: order duplicated names by run id, fall back to stamps only when ids tie or are absent. A run id is in detailsUrl and is monotonic across the repo; a manual re-run keeps its id and bumps the attempt. Concurrency cancellation → the winner has the higher id, in progress or not. Manual re-run of a stale batch → the re-run keeps the lower id and loses to the newer terminal batch, which is the correct verdict. StatusContext carries no run id and keeps the stamp order, which is where it is sound.

What I measured, stated as safety and not as benefit:

duplicated-name pairs                        72
with run ids on both                      72/72   <- control: rule applicable to every observed pair
run-id order AGREES with startedAt order     72
disagreements                                 0

Read that carefully, because it cuts both ways. Zero disagreements means switching the sort key is a no-op on the entire observed corpus — it cannot regress anything I can see, and needs no migration argument. It also means the case the rule exists to fix, a manually re-run stale batch, does not appear in my sample at all. So: safe, and not observed to help. The re-run reasoning is a prediction from the mechanism, not a measurement, and I have been wrong about exactly that distinction twice today.

If you prefer the smaller change, air's first version was: prefer an in-progress entry over any terminal one for the same name. One trap — it cannot key on conclusion being null, because it never is:

#3799  name=typecheck + node & shell tests  status=IN_PROGRESS  conclusion=""

Empty string, not null. conclusion is None matches nothing, so that version ships looking implemented while changing nothing and marks the defect handled. It has to be status != "COMPLETED". (This is the same ""-where-null-is-expected shape as the StatusContext union already in this PR.)

Either way, the load-bearing part is a fixture arm built from the measured live shape — status: "IN_PROGRESS", conclusion: "" — that fails against the null-check implementation. Without it a no-op patch ships with a green suite behind it, which is worse than no patch, because a fix that is already the default destroys the evidence by marking the defect handled.

Happy to open this as its own PR if you would rather not carry it here.

@TustinOC TustinOC left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

First review on this one. The body is the kind I wish every CI fix had — pre-fix tree named (00a9d8129), real failing_checks() output before and after on a merged PR, and 5 of 11 arms failing pre-fix rather than a claim that they would.

The tie-break is the part I would protect in review. On an identical stamp the failing entry wins:

if prev is None or stamp > prev[0] or (stamp == prev[0] and _is_bad(c)):

That is the right polarity, and it is the one a "dedupe by latest" patch normally gets wrong — whichever entry happens to arrive last silently forgives a red. Worth keeping the comment that says so.

I checked the branch that usually breaks these and it is handled:

stamp = c.get("completedAt") or c.get("startedAt") or ""

Both null → "", which sorts lowest, so a queued or in-progress entry can never outrank a stamped one; and if every entry for a name is unstamped they all tie at "" and the fail-closed arm keeps the failure. Correct in both directions, which is not obvious from reading the sort alone.

_is_bad deserves a line in the body. The predicate was duplicated inline in failing_checks and _run_ids, and both call sites needed the same change — so extracting it is part of the fix rather than a bundled refactor. Two copies of one verdict is how the next divergence gets written.

One latent assumption, not a blocker: stamp > prev[0] is a lexicographic string compare, which equals chronological order only while every stamp is Z-suffixed ISO-8601. That is what the GitHub API returns today, so this is correct now; it stops being correct if any entry ever arrives with a +00:00 offset. A one-line comment naming the assumption would make that a checkable invariant instead of a coincidence.

And a cross-check you may find useful, since you triaged #3782 a few hours ago: that failure is a disjoint mode, and this fix would not have caught it. I verified rather than assumed —

33723682061  python standalone tests
33723682061  tsc + tests (clean install)
duplicated check names in #3782's rollup: none

Both reds came from one run, so latest_by_name would dedupe nothing there. #3782 is a live run whose test aborted at interpreter shutdown (#3800); this is a dead run's verdict outliving its cancellation. Same symptom for the reader — a red that is not the diff's fault — through two unrelated paths, which is worth knowing when the next one lands.

Scope: read the diff and the body, checked the null-stamp and duplicate-name questions against the live API. I did not run tests/ci-triage-superseded-runs.test.py at either tree. CONTRIBUTOR account — advisory, moves no gate.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified against the diff: latest_by_name correctly keeps the most-recent entry per check name (by completedAt/startedAt), with a documented tiebreak toward the failing entry when timestamps are equal — matches the stated defect (a cancelled-run FAILURE sitting beside the live run's SUCCESS for the same check name). Same helper is reused across failing_checks and _run_ids, no duplicated logic. Test suite covers the actual defect case, the inverse arm (SUCCESS superseded by later FAILURE still reports), a genuine single failure, and CANCELLED-as-latest staying non-bad. CI green, no other issues. Approving as third reviewer (2 already: marklysze, yixuan-ag2).

@john-the-dev
john-the-dev merged commit 573d5c9 into main Sep 3, 2026
18 checks passed
@john-the-dev
john-the-dev deleted the fix/ci-triage-superseded-run-failure branch September 3, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ag2product AG2 product related (packages, PyPI, task-relay)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants