Why
A runner executes one job at a time and nothing else. ci/agent/agent.go leases a
job, blocks in sess.run until it finishes, tidies the caches, and only then asks
for the next one. There is no --jobs, no worker pool, and --once exists because
that is the only way to get a second job onto a machine: start a second process.
That leaves most of a runner’s hardware idle. A build that is one step waiting on a
network fetch, or a repository whose tests are single-threaded, occupies a whole
machine for its duration. The common answer to “we need more CI capacity” becomes
“another runner”, which is a second host, a second key, a second allowlist entry,
and a second --dir that learns the repositories from cold. On a 16-core machine
running mostly single-core builds that is a poor trade.
The server side already supports concurrency and needs no change for this:
- a lease is taken per SSH session and claimed by a conditional update
(
ci/runs/runs.go,Store.Lease), so two sessions racing the same run means one loses and retries elsewhere; runner_sessionsis keyed by session, so one account can hold several;max_jobs_per_repoalready bounds how many jobs one repository may run at once, and the lease serves the least busy repository first.
The single-job assumption is the runner process, not the protocol or the queue.
Scope
Add vole runner --jobs N (default 1): N slots in one process, each with its own
SSH session and at most one in-flight job. Slots are the existing loop; nothing in
the protocol changes.
Rejected alternative: multiplexing N leases on one channel. Replies are unkeyed and
matched by order (runnerSession.call), and a lease with wait blocks the
server’s decode loop for up to 60s — an idle slot’s long-poll would starve a busy
slot’s heartbeat and expire its lease. It would need request ids, per-request
goroutines, an outbound write mutex, and a presence schema change, to save
connections that SSH can already multiplex at the transport layer.
Shared state: what must land with the flag
These are the parts that are only safe while one job exists per runner. The first
two are latent bugs today for two separate processes sharing one --dir; --jobs
just makes them reachable without doing anything unusual.
-
Namespace lifetime.
ci/cacheTrimmeasures namespaces andforceRemoveAlls whole ones, and it runs after every job (ci/agent/agent.go).forceRemoveAllchmods the 0555 directories the Go module cache extracts and deletes them. A job holding that namespace has no defence: the docs’ own reason for whole-namespace eviction — “a Go module cache missing one file is a checksum error deep in a build rather than a download” — is exactly what a concurrent eviction produces.Fix: a job takes
LOCK_SHon its namespace for the duration;TrimandClearattemptLOCK_EX|LOCK_NBand skip a namespace they cannot have. The tool caches are multi-process safe against themselves, so sharing is the right default; what they are not safe against is a third party unlinking the tree. Same primitive asci/agent/gitcache.golockCache/tryLockCache, including the rule that the lock file is never removed. -
Eviction belongs on a ticker, not after every job. Once
Trimcan skip, running it from N slots at once is mostly noise on the cache root. Move it to one background goroutine. -
--no-cacheneeds the lock, not a scope change. It already removes only the job’s own namespace after each job, so the only broad operation isClear(root)at startup. What it lacks is the lock: the removal after a job can land on a namespace another job has mounted, andClearat startup can empty a namespace a second process sharing--diris building in. -
Image builds. Two slots may build the same
image-filedigest at once (ci/agent/build.go), so the build happens twice and the tag is raced. Same bytes either way, so this is waste rather than corruption: in-process singleflight oncontainer.ImageTag, ideally with a lock file so it also holds across processes sharing--dir. -
One thing per process, done before the slots.
s.streamis per-session state today (ci/agent/agent.go) and becomes local.cleanupJobTrees,maintainTrustedCaches,cleanupStaleContainers,cleanupStaleNetworks,pingandcheckRepoFilterrun once, before the slots start, rather than N times. -
Failure and shutdown. A refused job (malformed payload,
--reposthat matches nothing) means the runner is misconfigured: cancel all slots rather than stopping the one that happened to see it. SIGTERM should let in-flight jobs finish reporting instead of killing N jobs mid-step.
Already safe: do not rewrite these
- The saved checkout uses
flockper repository (ci/agent/gitcache.go), andprepareWorktreeholds it for the whole clone+fetch; the release and site pushes hold it again for the write.flockis per open file description, so it excludes goroutines in one process as well as other processes. - Container and network names carry the run id and attempt, and the leftover sweep
keeps anything whose
vole.runner.pidis alive, so slots cannot reap each other and a crashed--jobs Nrunner leaves N labelled sets for the next start. - Lease ownership is the account, not the session (
lease_owner), so two slots of one account are indistinguishable toStore.Held/Heartbeat. That is benign: the attempt number, incremented on every lease, is what actually refuses a stale record (Store.RecordAttemptrequiresattempts = ?). Worth a comment so nobody later “fixes” ownership to be session-scoped without knowing why it does not have to be.
Residual risk that locks do not fix
- A workflow can clean
/cachefor its siblings. The mount is a plain read-write directory and the docs leave “which tool uses/cache” to the repository, sogo clean -modcache,npm cache clean --force, orrm -rf /cache/*in one job can break a sibling. Only same repository + same trust + same image share a namespace, and least-busy-repository leasing usually puts slots on different repositories, so the victim is the same repository’s own concurrent builds. See the decision below. - Disk overshoot.
--cache-sizeis a total, and a skippable trim means N busy namespaces may exceed it until they finish. Bounded and self-correcting; the operator should be told. - Per-container limits are per container.
--container-memory,--container-cpusand--container-pidsmultiply by N, andmax_jobs_per_repobecomes the knob that stops one repository claiming all N slots. Belongs with #211. - Presence and connection count. The runners page becomes N rows per machine, and
N idle slots park N connections against
ssh_max_connections(default 64, shared with git traffic). Decide whether a row carries a machine name so the page can say “3 of 8 busy”, or whether fan-out is acceptable.
Decision to make before the code
Whether /cache is the runner’s cache (shared between concurrent jobs of the
same repository, evicted by us, siblings may collide) or the job’s cache (per-slot
isolation, no eviction races, no reuse). This issue assumes the first: it is the
smallest change and it keeps the reuse the cache exists for. If that is the answer,
docs/runner.md needs “concurrent builds of the same repository may share /cache”
in the same paragraph that promises isolation by trust today.
Acceptance
vole runner --jobs 4leases and runs up to four jobs at once, one per session, and the runners page shows four sessions and what each is building.--jobs 1behaves exactly as today, including--once.--jobs 2 --onceleases at most two jobs and exits when both slots are idle.- No namespace is removed, cleared or evicted while a job has it mounted, at any
--jobsvalue. This also holds for two separatevole runnerprocesses sharing one--dir. - Two slots building the same
image-filedigest build it once. - A refused job stops the runner and reports why, rather than leaving the other slots running and the process half-alive.
docs/runner.md,docs/cli.md,docs/deploy/runner.mdandpackaging/runner.envexplain the flag, the resource arithmetic, and the/cachesharing rule.
Tests
- Two goroutines on one
--dirwriting distinct namespaces while the trimmer runs: no namespace disappears under a holder. --no-cachewith two jobs at once: neither removes the other’s namespace.- N sessions on one account lease N distinct runs (
server/ssh/lease_wait_test.goalready has the two-client shape), and a stale sixth record is refused by attempt number. - A burst on one repository:
max_jobs_per_repocaps it while other repositories keep flowing.
Order of work
- Namespace locks, trim ticker,
--no-cachescoping. Worth landing alone: it fixes the shared---dircase today. --jobs, thes.streamrefactor, the startup/fatal-error restructure.- Image-build singleflight, then docs and
packaging/runner.env.
Related: #211 covers bounding what one job may consume; this issue multiplies that budget by the slot count.