A job that waits must not hold the slot it is waiting for

A failed job announces itself: it goes red, names a step, prints a reason. A hung job does none of that. It sits in running holding a runner slot, waiting jobs pile up behind it, and nothing in the pipeline says anything is wrong — from the pipeline’s point of view, work is in progress. What follows is the structural rule that produces it, and the parts of this fleet’s deploy graph that embody it.

One daemon, one job

The capacity of this fleet is not a policy setting; it is a property of the runner software. One forgejo-runner daemon serves one job at a time — that is the tool’s own default, and nothing in the repo raises it. Grepping the fleet configuration tells the story plainly:

1
grep -rn 'capacity\|instances\|runner' nix-config/modules nix-config/hosts | head -40

There is no capacity setting anywhere in nix-config/modules or nix-config/hosts; the only hits are unrelated (an inference-server flag and a Grafana alert comment). What the configuration sets is services.gitea-actions-runner.instances.ymrtech — one instance per host — plus the labels it advertises. The labels differ per host; the count never does. One daemon, one slot.

The workflow comment says it in as many words. deploy-rev’s own header in .forgejo/workflows/nix-deploy.yml explains why that job moved off ci: on the shared ci pool it sat “behind mail’s single capacity-1 slot”, and after a lock-bump merge it measured 3 min 10 s of pure queueing for about two seconds of git work. A label is a matcher, not capacity.

The graph, and the one edge that is a real dependency

.forgejo/workflows/nix-deploy.yml fans out into three per-host deploy jobs:

The deploy jobs are per-host and independent by design, so a single-host change does not drag the other hosts’ closures along. There is one ordering edge, and it is not a preference. Each client DNS tier splits its halves across vpn and public — primary on vpn, standby on public — so if both jobs ran concurrently one run could bounce both halves in the same second and take every client’s resolver down. deploy-public therefore waits for deploy-vpn: a real dependency earns an edge.

It also has a price. Each job builds and switches a whole NixOS system, and each demands a runner of its own, so an ordering edge widens the critical path and multiplies the slots the run needs in sequence, not in parallel: the vpn slot must free before the public slot is asked for. That is fine here because the dependency is real. Applied loosely — an edge added for tidiness, or to make logs read better — it buys a longer deploy and a larger pool for nothing.

The rule

A job must never hold the resource it is waiting for.

Concretely, for a bounded pool: if a job occupies a slot while polling for work that also needs a slot, the queue cannot drain. Nothing fails; the waiter waits, the awaited job queues behind it, and the run reports running until the job’s own timeout expires — the v15 API has no run-cancel endpoint at all, so a stuck run holds the capacity-1 runner for its whole timeout-minutes. This fleet has hit that shape and written the rule down: a chain job held a ci slot while polling for the scope job of the build check it had triggered, so it queued behind its own CI. The fix was not a longer timeout — it was to move the waiter to a lane the awaited job can still reach, and to poll the awaited job’s per-job commit status (one small call) instead of a multi-megabyte task listing computed inside the held slot.

Two rules follow from the same accounting: a dependency widens the critical path and multiplies slot demand, so it needs a reason; and because a hung run looks nothing like a failed one, queue depth is worth watching on its own. A run running far longer than normal, with other jobs waiting behind it, is the signature. Do not wait for a red mark that will never come.

What you can check

For the wider pool — which labels each host advertises, and why the deploy jobs stay pinned while the light gate jobs float — see how the stack is built.

󰔟 Running the same kind of stack?
The tunnel this site is written from is the one I sell: WireGuard to a gateway in Montréal, three DNS postures, one price — and the same "check it yourself" habit as everything above.
[ CREATE ACCOUNT ] [ WHAT IS THE VPN ]
󰣨 ymrtech@ymrtech | 󰌠 NixOS | 󰍢 UTF-8