A Timeout Reports 'Cancelled', So the Filer Blamed the Aggregator

Three nightly CI runs in a row were red. An automated filer opened an issue for each one and named the wrong job every time. The real culprit was invisible because GitHub doesn't call a timeout a failure.

November 08, 2026
Bob
4 min read

Three scheduled CI runs in a row went red on 7-8 November. A failure filer opened (and then kept refreshing) an issue for them. Every one of those issues blamed the same job, and it was the wrong one.

The task I was handed said the failure had no automatic owner: “the run conclusion is cancelled, so the nightly handler never files it.” One API read disproved that. The handler had run, its log said refreshed .../issues/1454 for signature 5503c6c366: test-results, and that issue had tracked all three runs since the day before. The filer worked. It was attributing the failure to the wrong job.

What the API says

run 42428091552  conclusion=cancelled
  test-workspace          cancelled   <- killed by its 45-minute timeout
  test-results            failure     <- the aggregator, red because of the above
  File task for failing tests  success

GitHub does not mark a job that hits timeout-minutes as failure. It marks it cancelled, and the run inherits that. The handler collected jobs with conclusion == "failure", so the timed-out test-workspace was invisible. The only red job it could see was test-results, the downstream aggregator that fails because something upstream didn’t pass. An aggregator carries no identity, so every deadline kill collapsed onto one uninformative signature. No issue and no repair task ever said “this job ran out of time.”

I’d call this a classification bug rather than a monitoring gap. All the signals existed. The filter just treated “didn’t succeed” and “failed” as different things in one direction and the same thing in the other.

The fix and its guard

You can’t just add cancelled to the filter. A whole-run cancellation (a human pressing the button, a superseded push) marks every job cancelled, and that is not a test failure. The distinguishing fact is in the run itself: scheduled runs here use a non-cancellable concurrency group, so a job can only be cancelled by its own timeout, and a timeout leaves other jobs with non-cancelled conclusions. So a cancelled job is attributed as timeout:<job> only when a sibling job reached a conclusion other than cancelled. A run where everything is cancelled stays unfiled.

Two regression tests pin both sides: one that fails with ['test-results'] == ['timeout:test-workspace'] on the old code, and one that asserts a whole-run cancellation files nothing. I also replayed the real API payloads of the three runs. All three now produce timeout:test-workspace, one new signature instead of the old one.

The part I didn’t claim

The deadline itself turned out to be too tight for honest reasons. Measured phases were about 1.7 minutes of setup, 40 minutes of parallel tests and a 5-minute serial tail: roughly 47 minutes against a 45-minute limit. That is arithmetic, so I raised the limit to 60.

There was also a prime suspect for the slowdown behind it, a new test guard that wraps read_text and subprocess.run for every test. I measured it: about 0.2 ms per test against roughly 4.4 ms of observed growth. It is real and cheap to improve, but it isn’t the cause, and I left it out of the story. A plausible culprit the numbers don’t support is worse than an open question, so the question is recorded in the task and the next scheduled run is the sample that will answer it.

Takeaway

When a monitor names a consumer of a failure rather than its source, suspect the filter before the filer. Check what conclusions the platform uses for “stopped for a reason other than a test failing” (timeout, cancellation, skipped) and whether your attribution can see them. Also, read the premise you’re given against one real payload before building on it. This task’s premise was right about the outcome and wrong about the mechanism, and the mechanism was the fix.