Diagrama de arquitectura — flota NixOS de 4 hosts con guardas de worktree multiagente

Artículo de capacidad. Los patrones se reproducen en cualquier flota Nix heterogénea que corra varios agentes de programación LLM en paralelo.

El escenario

Cuatro máquinas. Un repositorio. N agentes escribiendo código al mismo tiempo.

La flota:

hostsystemrol
desktopx86_64-linuxdev primario · 12 núcleos · 64 GB RAM
Pedros-MacBook-Proaarch64-darwinmóvil · nix-darwin
steam-deckx86_64-linuxportátil · SteamOS con overlay nix
macbook-proaarch64-darwinbuild pesado · hace de runner de CI

Un solo flake.nix genera nixosConfigurations.<host>, darwinConfigurations.<host> y homeConfigurations.<host> para cada una. Lo común vive en modules/home/; lo propio de cada máquina, en hosts/<host>/. Build y deploy caben en un comando por host: nh os switch . (nh es un wrapper de Nix que pasa el build por dentro de nom y actualiza el sistema de un tirón).

La parte difícil llega con el paralelismo. Varias sesiones de Claude Code corren contra este monorepo a la vez — una por feature, a veces 4 a 6 al mismo tiempo. Y dos agentes tocando el mismo working tree es la receta de una carrera de manual: el git add del agente A se lleva archivos que el agente B todavía estaba a medio escribir, el commit sale torcido, y el PR termina publicando el WIP del agente B con la firma del agente A.

La decisión, en una frase

En el contexto de varios agentes Claude Code corriendo en paralelo sobre un único repositorio, frente a condiciones de carrera en el working tree, optamos por un worktree por feature con guardas de push basados en allowlist de hostname, para lograr mutaciones aisladas y procedencia auditable, asumiendo como costo el overhead inicial de cada git worktree add.

Cómo está armado

Tres planos — la misma convención que sostiene cada artículo de arquitectura de este blog (convención de tres planos ):

  1. Plano de datos (mauve) — las máquinas mismas. Cada una evalúa el mismo flake.nix contra su propio hostPlatform y trae solo los módulos que encajan.
  2. Plano de control (sapphire) — el flake. Las entradas quedan clavadas en flake.lock, así que cualquier host, cualquier día, cierra el mismo closure.
  3. Plano de cumplimiento (peach) — los guardas de push, entre la edición local y origin. Son dos capas: una en el cliente (lefthook + core.hooksPath) y otra en el servidor (GitHub Repository Ruleset).

En el diagrama, el plano de cumplimiento sale punteado — es auditoría append-only, que jamás entra en la línea del flujo de datos (Wybrow & Marriott GD'09 trata el enrutamiento ortogonal y deja las derivaciones de auditoría como sidecars horizontales).

Las cinco piezas que cargan casi todo

1. La topología del flake

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;
  };
}

La definición formal de flake está en la RFC 49 y en la guía oficial de nix.dev . Lo que importa de esta topología es esto: cada máquina corre exactamente la misma lógica de resolución de closure, y la CI evalúa las 4 en cada PR con nix flake check.

Los números, sacados de nix log + nix path-info:

  • Acierto de la caché de build: ~92% a lo largo de la flota (caché binaria: cachix phsb5321 + nix-community).
  • Eval por host: ~14s en frío, ~3s en caliente.
  • Cuánto engorda cada PR el closure: por lo general <200 MB sumados a un closure de sistema de 9 GB.

2. Worktree primero, sin excepciones

El repositorio nunca se modifica desde el main worktree. Cada feature tiene el suyo:

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

Es la disciplina del Trunk-Based Development , solo que aplicada al aislamiento del working tree y no al aislamiento de branch: branches de vida corta, fusionadas de inmediato, siempre partiendo de una main limpia — con la diferencia de que cada branch también recibe su propio directorio físico, lo que vuelve imposible que el trabajo concurrente colisione. La primitiva está documentada directo en la referencia de worktree de git.

Antipatrón: en un escenario multiagente, git stash queda descartado. El stash es una jugada lateral sobre el working tree, y revienta el aislamiento — el agente A hace stash para “ordenar”, el agente B arranca un build, y el stash se rehidrata callado encima de la branch del agente B. La salida correcta es siempre abrir otro worktree. Vale lo mismo para git checkout -b dentro del main worktree: en el instante en que una feature branch pasa a compartir directorio físico con el WIP de otro agente, la carrera está de vuelta.

3. El guarda de hostname en el pre-push (la fitness function)

Un único script bash garantiza una sola cosa: que el commit nació en una máquina autorizada a empujarlo. Si hostname -s no está en la allowlist del user.email corriente, el push se rechaza.

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

Eso es una fitness function en el sentido de Building Evolutionary Architectures : una aserción ejecutable de que cierta propiedad del sistema sigue valiendo. Acá la propiedad es “todo commit en main vino de una máquina registrada para su autor”. Si por algún motivo una sesión de Claude Code en desktop termina corriendo con un user.email que es del perfil de macbook-pro, el push se traba y escupe el diff entre lo que se esperaba y lo que de hecho apareció.

Quien ata el hook al repositorio local es lefthook, vía core.hooksPath — la config en YAML está en la documentación de lefthook ; pre-commit y pre-push apuntan a scripts bajo .githooks/.

4. La trampa del hostname (el bug que dio origen al PR #144)

En una instalación de macOS hecha por el Asistente de Configuración por defecto, hostname -s devuelve Pedros-MacBook-Pro — no la clave de flake macbook-pro. La primera versión del hook tomaba la clave de flake como nombre canónico de la máquina, y el resultado fue que cada push desde la MacBook moría con STRAY: host 'Pedros-MacBook-Pro' not whitelisted.

La corrección: dejar que meta/host-authors.json cargue las dos formas.

json
{
  "desktop":              ["[email protected]"],
  "macbook-pro":          ["[email protected]"],
  "Pedros-MacBook-Pro":   ["[email protected]"],
  "steam-deck":           ["[email protected]"]
}

Y el propio flake hace de la clave default del Asistente de Configuración un alias de la clave de flake (darwinConfigurations."Pedros-MacBook-Pro" = self.darwinConfigurations.macbook-pro;, allá arriba en el fragmento). Una sola fuente de la verdad, que atiende las dos salidas posibles de hostname -s, sin bifurcar el build.

5. El ruleset en el servidor (defensa en profundidad)

El hook local se puede saltar (git push --no-verify), así que las mismas invariantes quedan grabadas también en el servidor, en un GitHub Repository Ruleset sobre main:

  • required_linear_history: true + non_fast_forward: true — solo entra squash-merge desde un PR.
  • required_signatures: true — todo commit en main va firmado (la clave de web-flow de GitHub resuelve los squash-merges sola; un commit directo a main exigiría una clave de firma SSH por máquina).
  • strict_required_status_checks_policy: true — rebase-on-main obligatorio antes del merge.
  • Regex de conventional-commits en los títulos de PR: ^(feat|fix|refactor|chore|docs|revert|test|perf|build|ci)(\(…\))?!?: .{1,72}.
  • Revisión de Copilot obligatoria.
  • permissions: {} a nivel del workflow (niega todo), con reconcesión job por job en cada workflow.

Ruleset y hook local imponen las mismas reglas en momentos distintos — defensa en profundidad, en el sentido SLSA . ¿Se saltó el hook? Igual choca contra el muro del ruleset a la hora del push.

Por qué cada elección

Elección 1: un flake para las 4 máquinas heterogéneas, no 4 flakes. Un solo flake.lock significa que toda máquina toma la misma revisión de nixpkgs el mismo día. El trade-off: x86_64-linux y aarch64-darwin de vez en cuando necesitan paquetes condicionales (ramas pkgs.stdenv.isDarwin). Pero esas ramas son cortas y evidentes; la alternativa — dejar que el flake.lock derive por máquina — convertiría “¿por qué el build quedó distinto en la macbook?” en un misterio sin solución.

Elección 2: lefthook en lugar de git hooks nativos + husky. El hook nativo exige una ceremonia de instalación en cada clon; husky arrastra npm consigo. Lefthook es un único binario Go, con config declarativa en YAML y paralelismo de fábrica. El plugin wa de yolo-labz ya lo usaba — repetir el patrón en toda la flota es mejor que abrir una excepción suelta.

Elección 3: el worktree como unidad de aislamiento de agente, no la branch. Un modelo de solo branches necesitaría lock alrededor del git checkout para impedir que dos agentes se intercambien el HEAD. El worktree empuja el aislamiento al planificador del SO — inode distinto, pwd distinto, cero estado mutable compartido. El precio es el git worktree add por feature (~2s) y el espacio en disco de un working tree de más (~250 MB en este repositorio). A ~12 PRs/semana, ese overhead desaparece; las carreras que evita, no.

Dónde es fácil equivocarse

  • git stash en escenario multiagente. Mutación lateral del working tree. Si dan ganas de hacer stash, abrí otro worktree.
  • git checkout -b en el main worktree. La misma clase de carrera — el main worktree se queda en main, limpio, para siempre.
  • Saltarse el hook (--no-verify, --no-gpg-sign). Los hooks existen porque las invariantes cuentan; eludirlos es despachar código que ya sabés que está roto.
  • Hostname como clave de flake, sin alias. El hostname -s lo fija el Asistente de Configuración de macOS antes de que el flake siquiera se clone; suponer que la clave de flake coincide con él se rompe el primer día de cualquier MacBook nueva.
  • Un bloque permissions: único, a nivel del repositorio. Negar todo a nivel del workflow y reconceder por job es el único modelo que sobrevive a una dependencia maliciosa.

En la práctica, desde la silla de quien opera

Cómo se desarrolla el ciclo en manos de quien opera:

  1. cd ~/NixOS && git pull --ff-only (main worktree, en main, siempre limpio).
  2. git worktree add ../NixOS-NNN-slug -b NNN-slug origin/main — la sesión de Claude Code abre acá.
  3. Edita, commitea, hace push. Lefthook dispara alejandra --check, deadnix --fail, gitleaks protect --staged, nix-instantiate --parse sobre los *.nix que cambiaron (por lo general <500ms).
  4. El pre-push coteja hostname -s y cada autor de commit contra meta/host-authors.json.
  5. El PR abre, y el ruleset de GitHub cobra la regex de conventional-commits + historial lineal + revisión de Copilot + status checks.
  6. Squash-merge — un commit firmado en main, una línea de CHANGELOG.
  7. cd ~/NixOS && git pull && nh os switch . → la máquina reevalúa el closure y cambia el sistema.
  8. git worktree remove ../NixOS-NNN-slug.

Por ese ciclo pasan ~12 PRs/semana, que aterrizan limpios en las 4 máquinas. La CI evalúa nixosConfigurations.{desktop,steam-deck} y darwinConfigurations.{macbook-pro,Pedros-MacBook-Pro} en paralelo en un único runner Hetzner; el eval de todas las máquinas, en caliente, ronda los 90s.

Referencias


Patrones sacados de una flota de 4 máquinas corriendo un único flake; reproducibles en cualquier arreglo heterogéneo de NixOS + nix-darwin con varios agentes de programación en paralelo.