Closes #246. A run that never executed a step recorded exit=1, steps=nil, and the
run page said what it says when a test fails — so a runner that could not build an
image red-flagged a commit whose tests passed, and the only way back was a rerun by
hand.
The rule
The two facts are “the job ran and something failed” and “the job never ran”. The runner knows the difference exactly once and does not have to guess whose fault it was: if no step ran, nothing about the commit was tested. That is the split, and it is mechanical rather than a per-call-site judgement about machine versus workflow, which the runner cannot always make truthfully.
What changed
ci/runs: a record can say where it stopped.Failure=setupmeans the attempt ended before the job’s first step, with the runner’s own one-line reason inFailureMessage, masked the same way the log is. A run whose newest attempt has that origin isStatusError, notStatusFailed.- New columns on
runsandrun_attempts, added the way every other column here is (additiveALTER TABLE, old databases keep working). ci/agent: the finish path takes anattemptOutcomeinstead of six positional arguments, which is what lets a failure before the first step carry the reason and a failure after the last one keep the steps that ran instead of dropping them.forge/landing: anerrorrun is a wait, not a refusal. Landing stays blocked because nothing is verified, but the gap names the runner’s reason and the rerun command instead of blaming the commit.- Web: the run list counts and filters it, the status reads “could not run” with the reason and rerun advice on the run page, and the change list says “CI could not run”.
- Docs:
workflows.md(the status vocabulary and the new paragraph),runner.md(queue behaviour),change-requests.md(the list’s words).
A step that exits non-zero is unchanged: failed, and it reds the check, because that
one is the code’s. The record still carries exit=1, so an old runner and an old
instance keep talking; the origin is what carries the new meaning.
Decisions from the issue’s checklist
- A field on the record, not a guess by the server. The server cannot tell from
steps=nilalone whether a workflow with no steps passed or a build never started. - The gate waits rather than reading “no opinion”. Silently ignoring it would let a commit land unverified; a wait keeps the gate honest and the message tells the reader what to do.
- No automatic requeue.
MaxAttemptsis about flaky tests, not broken runners, and a broken runner requeueing the same job is a hot loop. #247 (in review as #256) stops a runner whose runtime cannot create a container from serving at all, which covers the reported cause at the source; this covers what breaks mid-life. - The page says which one happened — status, reason, and what to do — without opening the log.
Testing
New: a setup-failure record round-trips as error with its reason through Get,
Attempt, and NewestAttempt; inconsistent records are refused (a step with a setup
failure, a reason with no origin, a two-line reason); checkGap over every run
status, including the “no reason recorded” fallback; the change list’s new status and
its ordering behind a real failure; the runs list and run page rendering a
could-not-run row; runStatusLabel; and failureLine’s one-line, bounded,
valid-UTF-8 cut.
vole workflow run verify passes (format, vet, build, licenses, govulncheck,
packages, deployment files, links, race suite).
Not in this change, on purpose
A run whose attempts all vanish before reporting (RequeueExpired spending
MaxAttempts) still ends as failed. That run has no record to say what ran, and
from the queue’s side a job that repeatedly kills its runner is not distinguishable
from a machine problem; #243 is where that gets a signal.