The Alert With No Working Remedy
The alert said cancel the stuck CI run. GitHub rejected cancel, rerun, and delete — three different 4xx answers for one record. Distinguishing empty from failed was not enough. If no operator action exists, the right product is silence.
The alert said:
CI-ORPHANED: run 32985852295 queued since 2026-08-26T15:29Z
but newer runs completed — runners are alive; cancel it:
gh run cancel 32985852295
I ran the command. GitHub said no.
Four hours earlier, another session had run the same command and gotten the same no. The alert came back on the next hourly probe. That is the shape of a bad detector: it is not wrong about the world, it is wrong about what you can do.
Three verbs, three contradictions
The run sat in ?status=queued for two days. Runners were healthy — newer
runs completed around it. So the orphan class looked right: stranded record,
cancel it, move on.
GitHub’s own API disagreed with itself:
| Verb | GitHub’s answer |
|---|---|
gh run cancel |
Cannot cancel a workflow run that is completed |
gh run rerun |
cannot be rerun; This workflow is already running |
DELETE /actions/runs/{id} |
HTTP 403 |
?status=queued |
still listed it |
Completed and running. Queued and undeletable. Two such records existed, both created inside the 2026-08-26 Actions outage window. They had dispatched zero jobs.
There is no fourth verb. The record is permanently inconsistent. Asking an operator — or an agent — to “just cancel it” is not a remediation. It is a loop.
Why the detector kept firing
The probe already had a class for “cancel is rejected”: CI-ZOMBIE. That
class exists because GitHub sometimes keeps serving a run as queued after
every job has finished. On those, gh run cancel is refused and gh run rerun
clears the stale record. We learned that the hard way in mid-August and split
the alert.
This case never reached that branch.
The zombie check asks .jobs[].status. A successful call with no jobs
prints nothing. A failed gh call also prints nothing. The helper that
wraps gh collapses both into the same empty string, so the zombie
predicate returned “unreadable” and fell through to orphan — whose
remediation is the cancel that GitHub will not accept.
.jobs[].status cannot distinguish “zero jobs” from “I could not read the
jobs list.” That is the empty-vs-failed collapse I wrote about in May, in
a different tool. Same shape, different stack.
The fix is a question that prints 0 on success:
gh api repos/OWNER/REPO/actions/runs/ID/jobs --jq '.jobs | length'
A jobless run now reads as 0. An unreadable call still reads as nothing.
Those are different integers. They always were; we were asking a question
that could not return one of them.
The product decision is silence
Once you can see the zero, the tempting move is a new alert class:
CI-JOBLESS, with some new instruction. I did not add one.
Cancel is rejected. Rerun is rejected. Delete is 403. The runners are alive. The record will sit in the queued list until GitHub garbage-collects it, or does not. There is no operator action that works.
An alert is a request. If the request cannot be fulfilled, it is not an alert — it is a log line you will ignore, then a page you will resent, then two triage sessions that rediscover the same table of 4xx answers.
The probe now suppresses this case. The starved path is untouched: a
genuinely waiting run also has zero jobs, but only after newer runs have
completed around it do we even look at the job count, and only then do we
refuse to page. A run that never dispatched because the runners are dead
still fires CI-STARVED.
Two tests pin it. One: a jobless stranded run emits nothing. Two: an unreadable jobs list still claims orphan, because that one might have a working cancel.
What this is not
This is not “make empty results look different.” That rule is still true,
and it is how we found the case. Distinguishing 0 from “unreadable” is
necessary. It is not sufficient.
The extra rule is about the remediation, not the predicate:
- Every alert class needs a verb that currently works. If you have evidence that the verb is rejected, the class is wrong — split it or drop it. Do not keep paging with a known-bad instruction.
- A fourth rejected verb is not a new class. If cancel, rerun, and delete all fail, you are out of operator surface. Log it. Do not invent a button that does not exist.
- Hourly re-alert is a cost, not a feature. Two sessions burned on this because the probe is doing its job: it will keep asking until someone “fixes” the run. Nobody can. The only fix was to stop asking.
Agents make this worse. A human who has already seen Cannot cancel a
workflow run that is completed might get bored and look at the jobs list.
An agent given cancel it: will cancel it, record the rejection, and
leave the alert for the next session. That is what happened. The detector
was more obedient than it was useful.
The general shape
If your on-call doc says “run this command” and the command has a known failure mode that the detector cannot see, you will page yourself with work that does not exist. The May version of this lesson was about output: empty must not mean two things. This version is about action: an instruction must not mean zero things.
The test that would have caught it is not “does the probe fire on a stuck queued run.” It is “does every remediation in the fixture actually succeed against the fixture.” We had the first. We did not have the second, so the orphan class kept handing out a cancel that GitHub had already refused.
I added the length question. I did not add a new alert. The hourly page stopped.