The build that runs anywhere: cutting the last node-pin
When a build can only run on one node, you do not have a cluster — you have one machine with extra steps. This is the story of removing the last thing that pinned an AIFactory build to a single node, why it took three small changes instead of one, and exactly where the honest line sits between "shipped" and "proven."
Two Jobs, one store
When AIFACTORY_BUILD_BACKEND=kubejob, the coder loop (run.py) runs as its own
Kubernetes Job instead of an in-pod subprocess. That is the control/execution split
RFC-0016 and RFC-0017 have been building toward: the web-server pod stays small and
long-lived, and the heavy per-task work goes into a short-lived Job the scheduler
can place wherever there is room.
"Wherever there is room" is the whole point — and it is exactly what a mounted
volume takes away. A Kubernetes pod that mounts an RWO local-path PersistentVolumeClaim
inherits that volume's node affinity, because a local-path PV is physically a
directory on one node. Mount one, and the scheduler has no decision left to make.
A build Job touched two such volumes:
/work— the task's git checkout, which the coder writes to./nix— the toolchain cache. Even thoughrun.pyitself is not wrapped innix develop, the coder runs its inline verification gates through it, against a per-taskflake.nixgenerated from the contract's environment manifest. So the build Job needs a populated/nix/storejust as much as a gate Job does.
Workspace pack/unpack already solved the first:
the producer packs the populated /work to object storage and the Job unpacks it
into a writable emptyDir, so there is no workspace PVC to pin to. But the build
stayed pinned anyway — through /nix.
Why the store was a volume in the first place
The fast way to give a build a warm /nix/store is to share one: an RWX-ish warm
cache PVC so repeat builds do not re-realise the same toolchain closure from
cache.nixos.org every time. Warm, fast — and node-pinned, because that cache was an
RWO local-path PVC living on the control-plane node. Every packed build Job that
co-mounted it got dragged straight back to that node, no matter what the scheduler
would have preferred.
That was the last wire.
Make the store a layer, not a mount
A /nix/store does not have to arrive as a volume. It can arrive as an image layer.
The change landed as three small, independently reversible pieces:
-
A dispatcher gate.
AIFACTORY_PACKED_NIX_IN_IMAGE, on the packed path only, drops the Nix-store PVC from the build Job manifest. Off: nothing changes. On: the Job carries no Nix-store volume. -
A
-nixbuild image. The ordinary AIFactory runtime image plus a baked/nix/store(the Dockerfile'sbuild-runtimestage). It is published on demand by its own workflow — deliberately not part of the per-commit deploy, because baking a multi-gigabyte store onto every push would tax CD for an image most commits do not change. -
The image override.
AIFACTORY_BUILD_IMAGEpoints the build Job — and only the build Job — at that-nixtag. The resolution order isAIFACTORY_BUILD_IMAGE>AIFACTORY_IMAGE> built-in default, so the control plane and the gate Jobs keep running their normal images; the override is surgical.
With AIFACTORY_PACK_WORKSPACE=true, AIFACTORY_PACKED_NIX_IN_IMAGE=true, and
AIFACTORY_BUILD_IMAGE on a -nix tag, a packed build Job mounts no workspace PVC and
no Nix-store PVC. It carries no node affinity. The scheduler is free to place it
anywhere.
A note on the default
It is worth being exact, because it is the kind of thing that is easy to overstate.
The shipped code default for AIFACTORY_BUILD_BACKEND is still subprocess — the
in-pod path — kept deliberately as the safe fallback. Job-native execution is the
deployment default: gitops sets kubejob on the reference cluster. A fresh install
with no flags runs in-pod until an operator opts in. "Live" here means the reference
deployment, not a default baked into the binary.
The honest line
Shipped and wired into the reference deployment: the gate, the -nix image, the
override. The chain is verified at every layer that does not need new hardware — the
image is published and resolvable, the manifest the dispatcher emits for a packed build
has no Nix-store volume, and the toolchain resolves from the baked store. The build Job
is node-agnostic by construction.
Not yet proven: an actual landing on a different node. The live cluster is single-node today, so there is no second node for the scheduler to place a build on. "No node affinity in the manifest" is a strong structural guarantee, but it is not the same as watching a packed build run on node B while node A is cordoned. That demonstration waits on one thing — a second node — and on no more code. When the node exists, the proof is a five-minute exercise.
We would rather ship the mechanism, mark the line precisely, and show the landing when the hardware is real. The wire is cut.
