Architecture diagram — 4-host NixOS fleet with multi-agent worktree guards

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:

hostsystemrole
desktopx86_64-linuxprimary dev · 12 cores · 64 GB RAM
Pedros-MacBook-Proaarch64-darwinmobile · nix-darwin
steam-deckx86_64-linuxportable · SteamOS with nix overlay
macbook-proaarch64-darwinheavy 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 add overhead.

Architecture

Three planes — same convention every architecture writeup on this blog uses (three-plane convention ):

  1. Data plane (mauve) — the hosts themselves. Each evaluates the same flake.nix against its own hostPlatform and pulls the modules that match.
  2. Control plane (sapphire) — the flake. Inputs are pinned in flake.lock so any host on any day produces the same closure.
  3. 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

nix
{
  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:

bash
# 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 flight

This 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.

bash
#!/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 0

That 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.

json
{
  "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 on main is 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 stash in a multi-agent setup. Lateral working-tree mutation. If the urge to stash arises, open another worktree.
  • git checkout -b in the main worktree. Same race class — the main worktree stays on main, 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 -s is 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:

  1. cd ~/NixOS && git pull --ff-only (main worktree, on main, always clean).
  2. git worktree add ../NixOS-NNN-slug -b NNN-slug origin/main — Claude Code session opens here.
  3. Edit, commit, push. lefthook fires alejandra --check, deadnix --fail, gitleaks protect --staged, nix-instantiate --parse on the changed *.nix files (typically <500ms).
  4. Pre-push validates hostname -s + every commit author against meta/host-authors.json.
  5. PR opens, GitHub ruleset enforces the conventional-commits regex + linear history + Copilot review + status checks.
  6. Squash-merge — one signed commit on main, one CHANGELOG line.
  7. cd ~/NixOS && git pull && nh os switch . → host re-evaluates the closure, switches the system.
  8. 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


Patterns extracted from a 4-host fleet running a single flake; reproducible against any heterogeneous NixOS + nix-darwin setup with multiple parallel coding agents.