detached 9728ecc

fix(ci): end a cancelled job whose assignment was not acknowledged

A fail-fast or owner cancel that arrived before the first heartbeat was lost
when the assignment was cancelled: the job went back to the queue and ran.
commit
9728ecc6a5786423d9605fcf7e08bc797acf0f9e
author
brice · · 2026-10-12 19:09 UTC
parent
ce785bf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +43 -0
M internal/app/ci_flow_test.go +33 -0
@@ -594,3 +594,36 @@ func TestCINoRunnerNoRunsNoChecks(t *testing.T) {
594594 c.push("main", map[string]string{"a.txt": "a\n"}, "")
595595 c.waitFor("a run once a runner exists", func() bool { return len(c.runs()) == 1 })
596596 }
597+
598+// A job that fail-fast cancelled while its assignment was not acknowledged is
599+// over when the assignment is cancelled: it is not given to a runner again.
600+func TestCICancelledUnacknowledgedAssignmentIsNotAssignedAgain(t *testing.T) {
601+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
602+ cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
603+ })
604+ runner := c.registerRunner("alpha", "ubuntu-latest")
605+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
606+jobs:
607+ build:
608+ runs-on: ubuntu-latest
609+ strategy:
610+ matrix:
611+ n: [1, 2]
612+ steps:
613+ - run: echo ${{ matrix.n }}
614+`}, "")
615+ a, b := runner.await(), runner.await() // both assigned, neither acknowledged yet
616+ assert.NotEqual(t, a.GetJobId(), b.GetJobId())
617+
618+ runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x") // fail-fast asks b to stop
619+
620+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
621+ for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
622+ if j.GetId() == b.GetJobId() {
623+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion())
624+ }
625+ }
626+ again, err := runner.fetch("")
627+ require.NoError(t, err)
628+ assert.Nil(t, again, "a cancelled job is not given again")
629+}
@@ -594,3 +594,36 @@ func TestCINoRunnerNoRunsNoChecks(t *testing.T) {
594 c.push("main", map[string]string{"a.txt": "a\n"}, "")594 c.push("main", map[string]string{"a.txt": "a\n"}, "")
595 c.waitFor("a run once a runner exists", func() bool { return len(c.runs()) == 1 })595 c.waitFor("a run once a runner exists", func() bool { return len(c.runs()) == 1 })
596 }596 }
597+
598+// A job that fail-fast cancelled while its assignment was not acknowledged is
599+// over when the assignment is cancelled: it is not given to a runner again.
600+func TestCICancelledUnacknowledgedAssignmentIsNotAssignedAgain(t *testing.T) {
601+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
602+ cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
603+ })
604+ runner := c.registerRunner("alpha", "ubuntu-latest")
605+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
606+jobs:
607+ build:
608+ runs-on: ubuntu-latest
609+ strategy:
610+ matrix:
611+ n: [1, 2]
612+ steps:
613+ - run: echo ${{ matrix.n }}
614+`}, "")
615+ a, b := runner.await(), runner.await() // both assigned, neither acknowledged yet
616+ assert.NotEqual(t, a.GetJobId(), b.GetJobId())
617+
618+ runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x") // fail-fast asks b to stop
619+
620+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
621+ for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
622+ if j.GetId() == b.GetJobId() {
623+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion())
624+ }
625+ }
626+ again, err := runner.fetch("")
627+ require.NoError(t, err)
628+ assert.Nil(t, again, "a cancelled job is not given again")
629+}
M internal/usecase/ci_watch.go +10 -0
@@ -198,6 +198,16 @@ func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob
198198 }
199199 }
200200 c.mu.Unlock()
201+ if j.CancelReason != domain.CICancelNone {
202+ // Cancelled (by the owner, fail-fast or the time budget) before the
203+ // runner answered: it is over, not queued again for another runner.
204+ conclusion := domain.CICancelled
205+ if j.CancelReason == domain.CICancelTimeout {
206+ conclusion = domain.CITimedOut
207+ }
208+ rs.end(j, conclusion, "")
209+ return
210+ }
201211 if j.AssignCancels >= domain.CIMaxAssignCancels {
202212 rs.end(j, domain.CIFailure, "The job was given to a runner "+itoa(int64(domain.CIMaxAssignCancels))+" times and none of them acknowledged it.")
203213 return
@@ -198,6 +198,16 @@ func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob
198 }198 }
199 }199 }
200 c.mu.Unlock()200 c.mu.Unlock()
201+ if j.CancelReason != domain.CICancelNone {
202+ // Cancelled (by the owner, fail-fast or the time budget) before the
203+ // runner answered: it is over, not queued again for another runner.
204+ conclusion := domain.CICancelled
205+ if j.CancelReason == domain.CICancelTimeout {
206+ conclusion = domain.CITimedOut
207+ }
208+ rs.end(j, conclusion, "")
209+ return
210+ }
201 if j.AssignCancels >= domain.CIMaxAssignCancels {211 if j.AssignCancels >= domain.CIMaxAssignCancels {
202 rs.end(j, domain.CIFailure, "The job was given to a runner "+itoa(int64(domain.CIMaxAssignCancels))+" times and none of them acknowledged it.")212 rs.end(j, domain.CIFailure, "The job was given to a runner "+itoa(int64(domain.CIMaxAssignCancels))+" times and none of them acknowledged it.")
203 return213 return

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close