Artigo de capacidade. Os padrões se reproduzem em qualquer frota Nix heterogênea que rode vários agentes de programação LLM em paralelo.
O cenário
Quatro máquinas. Um repositório. N agentes escrevendo código ao mesmo tempo.
A frota:
| host | system | papel |
|---|---|---|
desktop | x86_64-linux | dev primário · 12 núcleos · 64 GB RAM |
Pedros-MacBook-Pro | aarch64-darwin | móvel · nix-darwin |
steam-deck | x86_64-linux | portátil · SteamOS com overlay nix |
macbook-pro | aarch64-darwin | build pesado · faz as vezes de runner de CI |
Um flake.nix só gera nixosConfigurations.<host>, darwinConfigurations.<host> e homeConfigurations.<host> para cada uma. O que é comum mora em modules/home/; o que é específico de cada máquina, em hosts/<host>/. Build e deploy cabem num comando por host: nh os switch . (nh
é um wrapper de Nix que joga o build por dentro do nom e atualiza o sistema na mesma tacada).
A parte difícil vem do paralelismo. Várias sessões do Claude Code rodam contra esse monorepo de uma vez — uma por feature, às vezes 4 a 6 ao mesmo tempo. E dois agentes mexendo na mesma working tree é receita de corrida clássica: o git add do agente A leva junto arquivos que o agente B ainda estava no meio de escrever, o commit sai torto, e o PR acaba publicando o WIP do agente B com a assinatura do agente A.
A decisão, em uma frase
No contexto de vários agentes Claude Code rodando em paralelo sobre um único repositório, diante de condições de corrida na working tree, optamos por uma worktree por feature com guardas de push baseados em allowlist de hostname, para conseguir mutações isoladas e proveniência auditável, aceitando como custo o overhead inicial de cada
git worktree add.
Como a coisa está montada
Três planos — a mesma convenção que sustenta todo artigo de arquitetura aqui do blog (convenção de três planos ):
- Plano de dados (mauve) — as máquinas em si. Cada uma avalia o mesmo
flake.nixcontra o própriohostPlatforme puxa só os módulos que casam. - Plano de controle (sapphire) — o flake. As entradas estão pregadas no
flake.lock, então qualquer host, em qualquer dia, fecha o mesmo closure. - Plano de conformidade (peach) — os guardas de push, entre a edição local e o
origin. São duas camadas: uma no cliente (lefthook+core.hooksPath) e outra no servidor (GitHub Repository Ruleset).
No diagrama, o plano de conformidade aparece pontilhado — é auditoria append-only, que nunca entra na linha do fluxo de dados (Wybrow & Marriott GD'09 trata de roteamento ortogonal e deixa as derivações de auditoria como sidecars horizontais).
Os cinco pedaços que sustentam quase tudo
1. A topologia do flake
{
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;
};
}A definição formal de flake está na RFC 49
e no guia oficial do nix.dev
. O que importa nessa topologia é o seguinte: toda máquina roda exatamente a mesma lógica de resolução de closure, e a CI avalia as 4 a cada PR com nix flake check.
Os números, tirados de nix log + nix path-info:
- Acerto do cache de build: ~92% ao longo da frota (cache binário: cachix
phsb5321+nix-community). - Eval por host: ~14s a frio, ~3s a quente.
- Quanto cada PR engorda o closure: em geral <200 MB somados a um closure de sistema de 9 GB.
2. Worktree primeiro, sem exceção
O repositório nunca é alterado a partir da main worktree. Cada feature ganha a sua:
# 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É a disciplina do Trunk-Based Development , só que aplicada ao isolamento da working tree, e não ao isolamento de branch: branches de vida curta, mescladas direto, sempre partindo de uma main limpa — com a diferença de que cada branch também ganha um diretório físico próprio, o que torna impossível o trabalho concorrente colidir. A primitiva está documentada direto na referência de worktree do git.
Antipadrão: num cenário multiagente, git stash está fora de cogitação. O stash é uma jogada lateral sobre a working tree, e ela arrebenta o isolamento — o agente A dá stash para “organizar”, o agente B começa um build, e o stash reidrata calado em cima da branch do agente B. A saída certa é sempre abrir mais uma worktree. Vale a mesma coisa para git checkout -b dentro da main worktree: no instante em que uma feature branch passa a dividir diretório físico com o WIP de outro agente, a corrida está de volta.
3. O guarda de hostname no pre-push (a fitness function)
Um único script bash garante uma coisa: que o commit nasceu numa máquina autorizada a empurrá-lo. Se hostname -s não estiver na allowlist do user.email corrente, o push é recusado.
#!/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 0Isso é uma fitness function no sentido de Building Evolutionary Architectures
: uma asserção executável de que certa propriedade do sistema continua valendo. A propriedade, aqui, é “todo commit na main veio de uma máquina registrada para o seu autor”. Se por algum motivo uma sessão do Claude Code no desktop acabar rodando com um user.email que é do perfil do macbook-pro, o push trava e cospe o diff entre o que se esperava e o que de fato apareceu.
Quem amarra o hook ao repositório local é o lefthook, via core.hooksPath — a config em YAML está na documentação do lefthook
; pre-commit e pre-push apontam para scripts em .githooks/.
4. A pegadinha do hostname (o bug que deu origem ao PR #144)
Numa instalação de macOS feita pelo Assistente de Configuração padrão, hostname -s devolve Pedros-MacBook-Pro — não a chave de flake macbook-pro. A primeira versão do hook tomava a chave de flake como nome canônico da máquina, e o resultado foi que todo push do MacBook morria com STRAY: host 'Pedros-MacBook-Pro' not whitelisted.
A correção: deixar meta/host-authors.json carregar as duas formas.
{
"desktop": ["[email protected]"],
"macbook-pro": ["[email protected]"],
"Pedros-MacBook-Pro": ["[email protected]"],
"steam-deck": ["[email protected]"]
}E o próprio flake faz da chave default do Assistente de Configuração um alias da chave de flake (darwinConfigurations."Pedros-MacBook-Pro" = self.darwinConfigurations.macbook-pro;, lá no trecho de cima). Uma fonte da verdade só, atendendo às duas saídas possíveis de hostname -s, sem bifurcar o build.
5. O ruleset no servidor (defesa em profundidade)
Hook local dá para furar (git push --no-verify), então as mesmas invariantes ficam cravadas também no servidor, num GitHub Repository Ruleset
na main:
required_linear_history: true+non_fast_forward: true— só entra squash-merge vindo de PR.required_signatures: true— todo commit namainvai assinado (a chave de web-flow do GitHub resolve os squash-merges sozinha; commit direto na main exigiria uma chave de assinatura SSH por máquina).strict_required_status_checks_policy: true— rebase-on-main é obrigatório antes do merge.- Regex de conventional-commits nos títulos de PR:
^(feat|fix|refactor|chore|docs|revert|test|perf|build|ci)(\(…\))?!?: .{1,72}. - Revisão do Copilot obrigatória.
permissions: {}no nível do workflow (nega tudo), com re-concessão job a job em todo workflow.
Ruleset e hook local impõem as mesmas regras em momentos diferentes — defesa em profundidade, no sentido SLSA . Furou o hook? Ainda bate na parede do ruleset na hora do push.
Por que cada escolha
Escolha 1: um flake para as 4 máquinas heterogêneas, não 4 flakes.
Um flake.lock só significa que toda máquina pega a mesma revisão do nixpkgs no mesmo dia. O trade-off: x86_64-linux e aarch64-darwin de vez em quando precisam de pacotes condicionais (ramos pkgs.stdenv.isDarwin). Mas esses ramos são curtos e evidentes; a alternativa — deixar o flake.lock derivar por máquina — transformaria “por que o build no macbook ficou diferente?” num mistério sem solução.
Escolha 2: lefthook em vez de git hooks nativos + husky.
Hook nativo exige uma cerimônia de instalação a cada clone; o husky arrasta o npm junto. O lefthook é um binário Go só, com config declarativa em YAML e paralelismo de fábrica. O plugin wa do yolo-labz já usava — repetir o padrão na frota inteira é melhor do que abrir uma exceção avulsa.
Escolha 3: worktree como unidade de isolamento de agente, não branch.
Um modelo só de branch precisaria de lock em volta do git checkout para impedir dois agentes de trocarem o HEAD um do outro. Worktree empurra o isolamento para o escalonador do SO — inode diferente, pwd diferente, zero estado mutável compartilhado. O preço é o git worktree add por feature (~2s) e o espaço em disco de uma working tree a mais (~250 MB neste repositório). A ~12 PRs/semana, esse overhead some; as corridas que ele evita, não.
Onde é fácil errar
git stashem cenário multiagente. Mutação lateral da working tree. Bateu a vontade de dar stash, abra outra worktree.git checkout -bna main worktree. Mesma classe de corrida — a main worktree fica namain, limpa, para sempre.- Pular hook (
--no-verify,--no-gpg-sign). Os hooks existem porque as invariantes contam; furá-los é despachar código que você já sabe que está quebrado. - Hostname como chave de flake, sem alias. O
hostname -sé definido pelo Assistente de Configuração do macOS antes mesmo de o flake ser clonado; supor que a chave de flake bate com ele quebra no primeiro dia de qualquer MacBook novo. - Um bloco
permissions:único, no nível do repositório. Negar tudo no nível do workflow e re-conceder por job é o único modelo que sobrevive a uma dependência maliciosa.
Na prática, do ponto de vista de quem opera
Como o ciclo se desenrola na mão de quem opera:
cd ~/NixOS && git pull --ff-only(main worktree, namain, sempre limpa).git worktree add ../NixOS-NNN-slug -b NNN-slug origin/main— a sessão do Claude Code abre aqui.- Edita, commita, dá push. O lefthook dispara
alejandra --check,deadnix --fail,gitleaks protect --staged,nix-instantiate --parsenos*.nixque mudaram (geralmente <500ms). - O pre-push confere
hostname -se cada autor de commit contrameta/host-authors.json. - O PR abre, e o ruleset do GitHub cobra a regex de conventional-commits + histórico linear + revisão do Copilot + status checks.
- Squash-merge — um commit assinado na
main, uma linha de CHANGELOG. cd ~/NixOS && git pull && nh os switch .→ a máquina reavalia o closure e troca o sistema.git worktree remove ../NixOS-NNN-slug.
Por esse ciclo passam ~12 PRs/semana, aterrissando limpos nas 4 máquinas. A CI avalia nixosConfigurations.{desktop,steam-deck} e darwinConfigurations.{macbook-pro,Pedros-MacBook-Pro} em paralelo num único runner Hetzner; o eval de todas as máquinas, a quente, fica perto de 90s.
Referências
- Eelco Dolstra, RFC 49 — Flakes (2020). A definição canônica de flake.
- Paul Hammant, Trunk-Based Development . Branches de vida curta, merge rápido, partindo de uma main limpa.
- Evil Martians, lefthook — gerenciador de git hooks em binário Go.
- GitHub Docs, About rulesets . Substitui a proteção de branch clássica.
- Neal Ford, Rebecca Parsons, Patrick Kua, Building Evolutionary Architectures 2ª ed (2023). A ideia de fitness function.
- Wybrow & Marriott, GD'09 — roteamento ortogonal para clareza de diagrama.
- SLSA — Supply-chain Levels for Software Artifacts; a disciplina de defesa em profundidade.
Padrões tirados de uma frota de 4 máquinas rodando um único flake; reproduzíveis em qualquer arranjo heterogêneo de NixOS + nix-darwin com vários agentes de programação em paralelo.