fix(scheduler): key runner teardown on actual assignment, not dispatch intent#105
Merged
Merged
Conversation
…h intent ephemerd registers one JIT runner per queued job and kept the runner's lifecycle keyed to that job: when the job's completed webhook arrived, the handler destroyed job.dispatched. But GitHub's scheduler treats registered JIT runners with matching labels as FUNGIBLE - a runner picks up ANY queued job it matches. Whenever a multi-job workflow queued several same-label jobs at once, runner R dispatched "for" job A was routinely assigned job B; job A's completion (on another runner) then destroyed R, killing job B mid-flight. The orphaned job failed at ~10m01s (GitHub's runner-communication-lost timeout) with no uploaded logs. This was misdiagnosed as "dead runners" and killed dozens of CI jobs. The fix: - Capture runner_name from workflow_job events (in_progress/completed) into providers.JobEvent.RunnerName; GitHub reports it, and the new providers.RunnerNameReporter optional interface marks providers that do. - Keep a runner-name ledger (Scheduler.runners) alongside the running map. On in_progress, bind the event's runner to the event's job and log at INFO when the binding differs from the dispatch intent - the observability line for this race. - On completed, destroy the runner NAMED IN THE EVENT (when ephemerd owns it), located via the ledger under its dispatch-intent entry. A completed event with no runner_name (job cancelled before assignment, or a provider without runner names) falls back to the dispatch-intent runner, but only when that runner was never observed bound to a different job. - Orphan sweep: a dispatched runner never seen in an in_progress event within a grace window ([runner.orphan_sweep], default on, 10m) is destroyed and deregistered. This replaces the implicit cleanup the old job-keyed destroy provided. It only acts in webhook mode and only on runners from assignment-reporting providers, so polling mode and the Forgejo/Gitea/GitLab/Woodpecker paths keep their exact previous behavior. macOS VM and native macOS runners are JIT-registered with shared labels too, so they share the fungibility problem; teardown resolves the owner entry by runner name first and then uses that entry's own mechanic (VM stop / native stop / dispatch destroy / runtime destroy), fixing all four provisioning paths uniformly. Also fixes the jobs-active gauge leak in polling mode (natural-exit cleanup never decremented it) and removes a duplicated claim_retry block in config.example.toml.
This was referenced Jul 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
ephemerd registers one just-in-time runner per queued job and keys the runner's lifecycle to that job: the completed-webhook handler destroys
job.dispatched. But GitHub's scheduler treats registered JIT runners as fungible — a runner picks up ANY queued job matching its labels. Every multi-job workflow run queues several same-label jobs at once, so runner R dispatched "for" job A routinely gets assigned job B. When job A later completes (on a different runner), the handler destroys R — killing job B mid-flight. The orphaned job fails at exactly ~10m01s (GitHub's runner-communication-lost timeout) with no uploaded logs (BlobNotFound). This has been misdiagnosed as "dead runners" for two days and killed dozens of CI jobs.Production evidence (
ephemerd.log, 2026-07-10)Job 86444761266's completion destroyed
kind_cope, which was mid-flight on job 86444761252; that job died 10m01s after itsin_progress.The fix
runner_namefrom workflow_job events intoproviders.JobEvent.RunnerName(GitHub populates it onin_progress/completed). New optional interfaceproviders.RunnerNameReportermarks providers that report it (GitHub only, today).Scheduler.runners) alongside the job-keyedrunningmap. Onin_progress, the event's runner is bound to the event's job; when the binding differs from the dispatch intent an INFO line fires — this log line is the ongoing observability for the race (the old logs above have no runner names onin_progress; now they do, including in the raw webhook-event log line).completeddestroys the runner NAMED IN THE EVENT (when ephemerd owns it), found via the ledger under whichever dispatch-intent entry holds it. Runner bookkeeping stays filed under its intent key — no re-keying, so the existing per-runner Wait-goroutine cleanup, drain, and destroyAll are untouched. If the event carries norunner_name(job cancelled before assignment, or a provider that doesn't report names), it falls back to the old dispatch-intent destroy — but only when that runner was never observed bound to a different job.[runner.orphan_sweep], enabled by default,grace = "10m", same config shape as[runner.claim_retry]from fix(scheduler): claim retry queue with jittered backoff + fresh rate gauges #104): a dispatched runner never seen in anin_progressevent within the grace window is destroyed and explicitly deregistered (JIT runners that never ran a job don't auto-remove). This replaces the implicit cleanup the old job-keyed destroy provided for stolen/cancelled intents. It runs only in webhook mode (polling has noin_progressevents, so "never observed bound" carries no information) and only for runners from assignment-reporting providers — Forgejo/Gitea/GitLab/Woodpecker and polling-mode behavior are exactly unchanged.macOS VM / native runner paths
They share the fungibility problem: macOS VM and native macOS runners are also JIT-registered per job with shared labels (
self-hosted,macos, arch). The fix is uniform — teardown resolves the owning entry by runner name first, then uses that entry's own mechanic (macosVM.Stop()/nativeRunner.Stop()/ dispatch destroy / runtime destroy), so all four provisioning paths are covered by the same resolution logic.Design decisions / deviations
runningonin_progress: re-keying invalidates the job keys captured by the per-runner Wait goroutines and breaks down when a reassignment event arrives before the displaced job's own provisioning finishes. Filing runners under intent and resolving teardown by name keeps every existing consumer ofrunning(dedup, drain, destroyAll, healthz, vm-ssh) working unchanged.runningJobnow records its provider like the other three paths (its cleanup already calledrj.provider.ReleaseJobbehind a nil check that never fired); removed an accidentally duplicatedclaim_retryblock inconfig.example.toml.Composes with #95 (zombie-job skip — untouched, still keyed on provisioning attempts) and #104 (claim retry queue — the
in_progressretry-drop moved intohandleInProgressintact).Tests
New
pkg/scheduler/reassignment_test.go(fake dispatch gRPC server, matching the existing scheduler test style):TestHandleCompleted_ReassignedRunner_ProductionScenario— dispatch R for A and S for B;in_progress(B, R);completed(A, S)must NOT destroy R;completed(B, R)destroys R; bookkeeping/ledger fully drained at the end.TestHandleCompleted_NoReassignment_HappyPath— unchanged common case.TestHandleCompleted_CancelledBeforeAssignment— emptyrunner_namefallback still tears down the intent runner.TestHandleCompleted_EmptyRunnerName_IntentRunnerBoundElsewhere— fallback declines when the intent runner is bound to another job.TestHandleCompleted_ForeignRunner— foreign runner name destroys nothing.TestHandleInProgress_UnknownOrEmptyRunner— no-op paths.TestSweepOrphanRunners_GraceWindow— sweep matrix (orphan destroyed + deregistered; bound / fresh / unobservable exempt).TestSweepOrphanRunners_DisabledOrPolling— sweep inert when disabled or polling.Plus
TestConvertEvent_RunnerName/TestReportsRunnerNames(github provider) andTestOrphanSweepEnabled/TestOrphanSweepGrace(config).Verified locally (CGO_ENABLED=0):
go build ./...,go vet ./...,go test -tags containers_image_openpgp -count=1on scheduler/config/providers/github/cmd (all pass; scheduler suite 60+ tests),golangci-lint runon touched packages — 0 issues.