Capability writeup. Patterns are reproducible against any heterogeneous Nix fleet that runs multiple LLM coding agents in parallel.
Context
Four hosts. One repository. N parallel coding agents.
The fleet:
| host | system | role |
|---|---|---|
desktop | x86_64-linux | primary dev · 12 cores · 64 GB RAM |
Pedros-MacBook-Pro | aarch64-darwin | mobile · nix-darwin |
steam-deck | x86_64-linux | portable · SteamOS with nix overlay |
macbook-pro | aarch64-darwin | heavy build · serves as a CI runner |
A single flake.nix produces nixosConfigurations.<host>, darwinConfigurations.<host>, and homeConfigurations.<host> for each. Shared modules live under modules/home/; per-host overrides live in hosts/<host>/. Build + deploy is one command per host: nh os switch . (nh
is a Nix wrapper that pipes builds through nom and updates the system in one shot).
The complication: multiple Claude Code sessions run against this monorepo concurrently — one per feature, sometimes 4–6 at a time. Two agents editing the same working tree is a textbook race: agent A’s git add lands files agent B was midway through writing, the resulting commit is wrong, and the PR ships agent B’s WIP under agent A’s identity.
Y-statement
In the context of multiple parallel Claude Code agents on one repo, facing race conditions on the working tree, we decided worktree-per-feature with hostname-whitelisted push guards to achieve isolated mutations + auditable provenance, accepting upfront
git worktree addoverhead.
Architecture
Three planes — same convention every architecture writeup on this blog uses (three-plane convention ):
- Data plane (mauve) — the hosts themselves. Each evaluates the same
flake.nixagainst its ownhostPlatformand pulls the modules that match. - Control plane (sapphire) — the flake. Inputs are pinned in
flake.lockso any host on any day produces the same closure. - Compliance plane (peach) — push guards that sit between local edits and
origin. Two layers: client-side (lefthook+core.hooksPath) and server-side (GitHub Repository Ruleset).
The compliance plane is dotted in the diagram — append-only audit, never inline with the data flow (Wybrow & Marriott GD'09 on orthogonal routing puts audit taps as horizontal sidecars).
Five components carry most of the weight
1. Flake topology
{
description = "Pedro fleet";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
darwin = {
url = "github:LnL7/nix-darwin";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs @ { self, nixpkgs, home-manager, darwin, ... }: {
nixosConfigurations.desktop = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ ./hosts/desktop ./modules/nixos ];
specialArgs = { inherit inputs; };
};
nixosConfigurations.steam-deck = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [ ./hosts/steam-deck ./modules/nixos ];
specialArgs = { inherit inputs; };
};
darwinConfigurations.macbook-pro = darwin.lib.darwinSystem {
system = "aarch64-darwin";
modules = [ ./hosts/macbook-pro ./modules/darwin ];
specialArgs = { inherit inputs; };
};
# Pedros-MacBook-Pro is an alias for macbook-pro — see "Hostname gotcha"
darwinConfigurations."Pedros-MacBook-Pro" = self.darwinConfigurations.macbook-pro;
};
}Flakes themselves are documented in RFC 49
and the official nix.dev flake guide
. The point of the topology is that every host evaluates the same closure-resolution logic; CI evaluates all 4 on every PR via nix flake check.
Measurements from nix log + nix path-info:
- Build cache hit rate: ~92% across the fleet (binary cache: cachix
phsb5321+nix-community). - Per-host eval: ~14s cold, ~3s warm.
- Closure size delta per PR: typically <200 MB added to a 9 GB system closure.
2. Worktree-first hard rule
The repository never mutates from the main worktree. Every feature gets its own:
# in ~/NixOS (the main worktree, kept clean)
git worktree add ../NixOS-143-git-guardrails -b 143-git-guardrails origin/main
cd ../NixOS-143-git-guardrails
# all edits live here · agent A and agent B can have different feature worktrees
# at the same time without a single shared file in flightThis is the Trunk-Based Development discipline applied to working-tree isolation rather than branch isolation: short-lived branches, frequently merged, off a clean main — but each branch also has its own physical directory so concurrent work cannot collide. The git docs cover the worktree primitive directly.
Anti-pattern: git stash is forbidden in a multi-agent setup. Stash is a lateral move on the working tree that breaks isolation — agent A stashes to “clean up”, agent B starts a build, the stash silently rehydrates onto agent B’s branch. The right answer is always to open another worktree. The same goes for git checkout -b inside the main worktree: the moment a feature branch lives on the same physical directory as another agent’s WIP, the race is back.
3. Pre-push hostname guard (the fitness function)
A single bash script enforces “this commit was authored on a host that is supposed to push it.” If hostname -s is not in the whitelist for the current user.email, the push is rejected.
#!/usr/bin/env bash
# .githooks/pre-push — invoked via lefthook + core.hooksPath
set -euo pipefail
ALLOWLIST="meta/host-authors.json"
HOST="$(hostname -s)"
EMAIL="$(git config --get user.email)"
# fast exit for non-fleet repos (this hook is fleet-scoped only)
[[ "$(basename "$(git rev-parse --show-toplevel)")" =~ ^NixOS ]] || exit 0
# verify host is registered for this email
hosts_for_email="$(jq -r --arg e "$EMAIL" 'to_entries[] | select(.value | index($e)) | .key' "$ALLOWLIST")"
if ! grep -qx "$HOST" <<< "$hosts_for_email"; then
echo "[pre-push] STRAY: host '$HOST' not whitelisted for '$EMAIL'" >&2
echo "[pre-push] expected one of: $(tr '\n' ',' <<< "$hosts_for_email")" >&2
echo "[pre-push] override (use only if intentional): ALLOW_STRAY=1" >&2
[[ "${ALLOW_STRAY:-0}" == "1" ]] || exit 1
fi
# re-validate every commit author in origin/main..HEAD
while read -r sha author_email; do
hosts="$(jq -r --arg e "$author_email" 'to_entries[] | select(.value | index($e)) | .key' "$ALLOWLIST")"
if [[ -z "$hosts" ]]; then
echo "[pre-push] commit $sha authored by unknown email '$author_email'" >&2
exit 1
fi
done < <(git log --format='%H %ae' origin/main..HEAD)
exit 0That is a fitness function in the Building Evolutionary Architectures
sense: an executable assertion that a property of the system holds. The property is “every commit on main originated from a host registered for its author.” If a Claude Code session on desktop somehow ends up running with a user.email that belongs to the macbook-pro profile, the push aborts and prints the diff between expected and actual hosts.
lefthook ties the hook into the local repo via core.hooksPath — see the lefthook docs
for the YAML config; pre-commit and pre-push map to script paths under .githooks/.
4. Hostname gotcha (the bug that earned PR #144)
hostname -s on a default-Setup-Assistant macOS install returns Pedros-MacBook-Pro, not the macbook-pro flake key. The first version of the hook used the flake key as the canonical host name; every push from the MacBook aborted with STRAY: host 'Pedros-MacBook-Pro' not whitelisted.
Fix: meta/host-authors.json carries both forms.
{
"desktop": ["[email protected]"],
"macbook-pro": ["[email protected]"],
"Pedros-MacBook-Pro": ["[email protected]"],
"steam-deck": ["[email protected]"]
}The flake itself aliases the Setup-Assistant default to the flake key (darwinConfigurations."Pedros-MacBook-Pro" = self.darwinConfigurations.macbook-pro; in the snippet above). One source of truth for both hostname -s outputs, no fork in the build.
5. Server-side ruleset (defense in depth)
Local hooks can be bypassed (git push --no-verify), so the same invariants are pinned on the server via a GitHub Repository Ruleset
on main:
required_linear_history: true+non_fast_forward: true— only squash-merge from PRs.required_signatures: true— every commit onmainis signed (GitHub’s web-flow key handles squash-merges automatically; direct-to-main commits would need an SSH-signing key per host).strict_required_status_checks_policy: true— rebase-on-main mandatory before merge.- Conventional-commits regex on PR titles:
^(feat|fix|refactor|chore|docs|revert|test|perf|build|ci)(\(…\))?!?: .{1,72}. - Copilot review required.
- Workflow-level
permissions: {}(deny-all), per-job re-grant on every workflow.
The ruleset and the local hooks enforce the same rules at different stages — defense in depth, in the SLSA sense. A bypassed hook still hits the ruleset wall on push.
Decision narrative
Decision 1: One flake for 4 heterogeneous hosts, not 4 flakes.
Single flake.lock means every host gets the same nixpkgs revision on the same day. Trade-off: x86_64-linux and aarch64-darwin sometimes need conditional packages (pkgs.stdenv.isDarwin branches). The branches are short and obvious; the alternative — drifting flake.locks per host — would make “why is the build different on the macbook” an unsolvable mystery.
Decision 2: lefthook over native git hooks + husky.
Native hooks need per-clone install ceremony; husky pulls in npm. Lefthook is a single Go binary, declarative YAML config, parallel by default. The yolo-labz wa plugin already used it — matching the pattern across the fleet beats a unique snowflake.
Decision 3: Worktrees over branches as the unit of agent isolation.
A branch-only model needs locking around git checkout to prevent two agents from swapping HEAD on each other. Worktrees let the OS scheduler do the isolation — different inodes, different pwd, no shared mutable state. The cost is git worktree add per feature (~2s) and disk space for an extra working tree (~250 MB for this repo). At ~12 PRs/week the overhead is negligible; the avoided race conditions are not.
Anti-patterns
git stashin a multi-agent setup. Lateral working-tree mutation. If the urge to stash arises, open another worktree.git checkout -bin the main worktree. Same race class — the main worktree stays onmain, clean, forever.- Skipping hooks (
--no-verify,--no-gpg-sign). The hooks exist because the invariants matter; bypassing them is shipping known-broken code. - Hostname-as-flake-key without an alias.
hostname -sis set by macOS Setup Assistant before the flake is even cloned; assuming the flake key matches it will fail on day one of every new MacBook. - One
permissions:block at the repo level. Workflow-level deny-all + per-job re-grant is the only model that survives a malicious dependency.
Operational view
What this looks like as an operator:
cd ~/NixOS && git pull --ff-only(main worktree, onmain, always clean).git worktree add ../NixOS-NNN-slug -b NNN-slug origin/main— Claude Code session opens here.- Edit, commit, push. lefthook fires
alejandra --check,deadnix --fail,gitleaks protect --staged,nix-instantiate --parseon the changed*.nixfiles (typically <500ms). - Pre-push validates
hostname -s+ every commit author againstmeta/host-authors.json. - PR opens, GitHub ruleset enforces the conventional-commits regex + linear history + Copilot review + status checks.
- Squash-merge — one signed commit on
main, one CHANGELOG line. cd ~/NixOS && git pull && nh os switch .→ host re-evaluates the closure, switches the system.git worktree remove ../NixOS-NNN-slug.
Through this loop, ~12 PRs/week land cleanly across 4 hosts. CI evaluates nixosConfigurations.{desktop,steam-deck} and darwinConfigurations.{macbook-pro,Pedros-MacBook-Pro} in parallel on a single Hetzner runner; full eval-all-hosts is around 90s warm.
References
- Eelco Dolstra, RFC 49 — Flakes (2020). The canonical flake definition.
- Paul Hammant, Trunk-Based Development . Short-lived branches, fast merge, off a clean main.
- Evil Martians, lefthook — Go-binary git hook manager.
- GitHub Docs, About rulesets . Replaces classic branch protection.
- Neal Ford, Rebecca Parsons, Patrick Kua, Building Evolutionary Architectures 2nd ed (2023). Fitness-function framing.
- Wybrow & Marriott, GD'09 — orthogonal routing for diagram clarity.
- SLSA — Supply-chain Levels for Software Artifacts; defense-in-depth discipline.
Patterns extracted from a 4-host fleet running a single flake; reproducible against any heterogeneous NixOS + nix-darwin setup with multiple parallel coding agents.