O swarm: orchestrator → lead → worker
O swarm L3 é como o Alembic roda muitas unidades de trabalho ao mesmo tempo e sobrevive a crashes. Pense num canteiro de obra: um mestre de obras (o orchestrator) divide o serviço entre alguns chefes de equipe (os leads), e cada chefe coloca pedreiros (os workers) para executar. A graça está nas regras que impedem o caos: profundidade limitada, uma fila que só libera quem pode rodar, o disco como fonte da verdade, isolamento por git-worktree e um freio de mão para trabalho perigoso. Fonte: packages/swarm/.
Esta lição destila esse pacote, lido linha a linha. Cada número e cada nome de função têm fonte (rodapé). Por que importa pra missão: é o pacote que a matriz de fusão (§2.4) cita para tornar o delegate_tool.py do Hermes um IGNORE — o Alembic já delega, nativamente e melhor.
- Explicar a hierarquia
orchestrator → lead → workere por queMAX_DEPTH = 2impede fan-out recursivo. - Ler a fila com portão de dependências: quando uma task vira
readye o cicloblocked → ready → running → done/failed. - Descrever o resume seguro a crash:
replayInto+ recuperação de órfãs = execução pelo-menos-uma-vez. - Distinguir as duas fronteiras fail-closed — isolamento de worktree e o park T4 — do reward heurístico (PARL, não RL).
01 · Três camadas, um nível de aninhamento
A hierarquia de papéis é orchestrator → lead → worker, e é deliberadamente curta. O orchestrator pode spawnar leads; um lead pode spawnar workers; um worker faz o trabalho-folha e nunca spawna. O número que prende tudo isso é MAX_DEPTH = 2: a profundidade de cada papel (ROLE_DEPTH) é orchestrator=0, lead=1, worker=2, e a profundidade máxima é justamente 2.
MAX_DEPTH = 2: a organização tem fundo, então ninguém cria uma cadeia infinita de subequipes que ninguém consegue acompanhar.ROLE_DEPTH = { orchestrator: 0, lead: 1, worker: 2 } e MAX_DEPTH = 2 (types.ts). A regra que decide se um nó pode ter filhos é uma linha: export const canSpawn = (depth) => depth < MAX_DEPTH (orchestrator.ts:107). Um worker está em depth === 2, então canSpawn(2) === false: é folha. O comentário do código chama isso de "the load-bearing recursion bound" — o limite que sustenta a estrutura.Como o aninhamento é limitado por construção e não só por convenção? No tipo. Uma task pode carregar subtasks — e quando carrega, ela é um lead: o orchestrator roda os subtasks como uma sub-run um nível mais fundo. Mas os subtasks são do tipo taskSpecBaseSchema, que não tem campo subtasks. Logo um subtask não pode carregar subtasks — o aninhamento é travado em um nível pelo próprio sistema de tipos, além da checagem em runtime por canSpawn.
O bound de uma linha: canSpawn(depth) = depth < MAX_DEPTH
A regra inteira que sustenta a árvore é uma única função, e o melhor jeito de internalizá-la é como um fluxograma de decisão sobre a profundidade: dado o depth de um nó, ela responde "spawna um filho?" comparando com o teto. Siga as setas — só a profundidade 0 e 1 passam; a 2 (worker) é o fundo.
Uma comparação < é todo o anti-fan-out. Fonte: canSpawn (orchestrator.ts:107), childRole (orchestrator.ts:110).
02 · A fila — com portão de dependência, pronto significa pronto
Rodar concorrente sem regra é receita para desastre: uma task que depende de outra rodaria antes do pré-requisito existir. A fila do swarm resolve isso com um portão de dependências. Só tasks ready rodam, e uma task só vira ready quando todos os ids em seu dependsOn alcançaram sucesso-terminal (done). O grafo de dependências é honrado, não torcido.
servir) enquanto o arroz (cozinhar) não estiver pronto. "Pronto para montar" só acende quando o passo anterior terminou de verdade — não quando ele só "começou".A fila é pura contabilidade em memória sobre o estado das tasks; ela nunca executa nada — só responde "o que pode rodar agora?" e "a run terminou?". Antes de qualquer agendamento, validateGraph (estilo Kahn, redução de indegree) rejeita ciclos e dependências para ids inexistentes. Há três tipos de worker, e a diferença entre eles é o que de fato roda:
| Tipo de worker | O que roda | Sinal de "done" |
|---|---|---|
| model worker | uma chamada de adapter (o run() da lição 14) | o report do adapter |
| command worker | taskSpec.command → um subprocesso real (argv, sem shell) | saída 0 → done; qualquer outra → failed |
| background worker | taskSpec.background → um filho destacado que sobrevive à morte do pai | re-anexa ao seu report no resume (requer command, proíbe isolate) |
O sinal atômico de "done": renomear o report
Como o orchestrator sabe, sem corrida, que um worker terminou? Pelo rename do arquivo de report. O worker escreve o corpo num arquivo em progresso e, ao terminar, o renomeia para o nome terminal (.complete.md ou .failed.md). O rename é o sinal atômico — ou o arquivo final existe inteiro, ou não existe. Não há estado meio-escrito que o orchestrator possa ler errado.
O portão em ação: uma task espera as duas dependências
Veja três tasks. C declara dependsOn: [A, B]. Enquanto A e B não estiverem ambas done, C fica blocked — o orchestrador não a toca. Quando a última delas conclui, a fila re-avalia (a função regate) e promove C a ready:
03 · O ciclo de vida de uma task (a máquina de estados)
Toda task vive numa pequena máquina de estados de seis posições. Entender as setas dela é entender o swarm inteiro: o portão de dependências, o resume e o park são todos transições nesta máquina. As terminais (done, failed, parked) não voltam atrás — salvo a recuperação de órfãs, a única seta que reabre um estado, e que é exatamente o coração do resume (seção 05).
A run é completa (isComplete) quando nenhuma task está blocked, ready ou running. Uma task failed ou parked é terminal e não trava a conclusão — seus dependentes simplesmente nunca viram ready. Fonte: queue.ts.
04 · Monte a árvore de execução na mão (passo a passo → agora você)
Você viu a hierarquia e a fila. Agora trace uma run pequena na mão, devagar — recuperar o procedimento fixa mais que ver o resultado pronto. Considere uma run com quatro tasks: setup, build (dep: setup), test (dep: build) e um deploy marcado tier: T4.
initialState diz: sem deps → ready; com deps → blocked; deve ser parkada → parked. Então setup = ready; build/test = blocked; deploy (T4) = parked.setup está ready, então só ele roda. Ao concluir, ele vira done.regate roda: build depende só de setup, que agora está done → build vira ready. test ainda depende de build → segue blocked.build roda → done → regate promove test a ready → test roda → done.deploy permanece parked a run inteira — nenhuma seta o tira de lá. A run termina "completa" mesmo com ele parado, porque parked é terminal (não bloqueia isComplete).docs com dependsOn: [build] (não depende de test). Em que momento docs fica ready, e ele pode rodar concorrente com test? Responda antes de revelar.
docs vira ready no mesmo instante que build conclui (passo 4), porque sua única dependência é build. E sim: com maxConcurrency > 1, docs e test ambos dependem só de build e ficam ready juntos, então rodam em paralelo. A regra é sempre a mesma: uma task acende quando o conjunto de suas deps — e só ele — está todo done.05 · Resume seguro a crash — o filesystem é a verdade
Aqui está a peça que distingue um brinquedo de um motor de produção. O que acontece se a máquina morre no meio de uma run de 8 horas? O swarm não perde o trabalho. O estado é durável no disco: um journal append-only (events.jsonl) mais um checkpoint.json. Re-rodar a mesma run não recomeça do zero — ele reproduz o journal e continua.
replayInto (orchestrator.ts:706): aplica o último checkpoint, depois reproduz todo evento task-state (idempotente, latest-per-id vence — o checkpoint é pré-seed, não corte), depois recupera órfãs. A mesma função serve à inspeção (resumeQueue) e à execução (runSwarm), então as duas nunca divergem na interpretação do journal.A parte engenhosa é como ele trata um worker que morreu no meio do voo. A função recoverOrphans (orchestrator.ts:747) percorre os estados: toda task cujo último estado durável é running teve seu worker morto sem evento terminal — então é rebaixada de volta a ready e re-tentada. Isso dá execução pelo-menos-uma-vez, e como o enqueue é idempotente, o denominador do monitor cobre toda task mesmo após um crash no meio da declaração.
// packages/swarm/src/orchestrator.ts:747-755 (condensado) — recoverOrphans const recoverOrphans = (queue, now) => { let count = 0; for (const state of queue.states()) { if (state.status !== 'running') continue; queue.applyState({ ...state, status: 'ready', updatedAt: now() }); // órfã → re-tentada count += 1; } return count; };
A anatomia do diário: events.jsonl append-only + checkpoint.json
O que exatamente é gravado no disco? Um journal append-only (events.jsonl): uma linha por evento, cada uma com seq (contador monotônico por run), at e um payload validado por Zod contra o schema do kind. Periodicamente, um checkpoint.json condensa o estado reconstituível num ponto — o pré-seed do replay, nunca um corte. As espécies de evento (swarmEventKindSchema) são um conjunto fechado; veja a linha do tempo de uma run típica:
A run-id é content-addressed — e é por isso que o resume "encontra" o estado
Por que re-submeter a mesma run cai exatamente no diretório de estado já existente em vez de criar uma árvore nova? Porque o id da run é o hash do seu spec imutável: runIdFor(spec) = run-${shortHash(spec)} (ids.ts:30), um SHA-256 de 16 caracteres. Crucialmente, o hash usa canonicalJson (chaves ordenadas recursivamente), então a ordem dos campos no objeto não muda o digest — o mesmo goal + as mesmas tasks sempre resolvem para o mesmo runId, e portanto para o mesmo diretório on-disk.
A unicidade global de id por toda a árvore (checkUniqueIds, orchestrator.ts:141) é validada antes de qualquer coisa ser jornalada, porque a run compartilha um único journal chaveado por id — um id duplicado deixaria o estado de uma task sobrescrever o de outra e poderia envenenar um resume.
06 · Decisão: "este nó spawna ou é folha?" (fluxograma)
Junte a profundidade, os subtasks e o portão num único fluxograma de decisão — exatamente o que o orchestrator executa para cada task. Siga as setas: cada losango é uma pergunta que escolhe o caminho entre "vira um lead (sub-run)" e "roda como worker-folha".
classifyPark vem primeiro: a segurança precede a execução. Antes de decidir como rodar, o orchestrator decide se pode. Trabalho perigoso nunca chega ao ramo de execução — ele desvia para o park no primeiro losango.subtasks roda uma sub-run dependency-gated um nível mais fundo (orchestrator → lead → worker), e seu resultado é dobrado no resultado do lead — done sse nenhum subtask falhou ou ficou blocked. O ramo "folha" é o caso comum.O fold do lead: como os subtasks viram um único veredito
Quando uma task vira lead, seus subtasks rodam como uma sub-run — mas o orchestrator precisa de um resultado para a task-lead. Ele dobra (fold) os estados dos subtasks numa regra estrita: o lead é done se e somente se nenhum subtask ficou failed ou blocked. Um único subtask que falha (ou que nunca acendeu por uma dep aberta) já derruba o lead:
07 · Isolamento e o park T4 — fail-closed, ambos
Duas fronteiras do swarm são fail-closed: quando em dúvida, elas param em vez de seguir em silêncio. São o mecanismo de segurança do motor.
isolate: true sem config de worktree é um erro, nunca uma run sem-isolamento em silêncio. O código é explícito (orchestrator.ts:509-514): se !deps.worktree, retorna err("task '…' requires isolation but no worktree config was provided"). Um command worker que não pode tocar o checkout principal recebe um git worktree via withWorktree (branch + porta determinística, derrubado depois), ou não roda. Se você pediu separação e o ambiente não pode prover, a run para — ela não executa quietamente sua task contra a working tree compartilhada.
Tasks irreversíveis / legais / de segurança / T4 são roteadas para o ledger do park (t4-parked.jsonl, append-only) e nunca auto-executadas. O park é idempotente entre resumes (parkIneligible não re-anexa o que já está no ledger) e estruturalmente protegido: em applyState (queue.ts:150), uma task que deve ser parkada é imóvel — nenhuma linha reproduzida, report perdido ou varredura de órfãs consegue tirá-la de parked. As razões são um conjunto fechado: tier-t4 / irreversible / legal / security / manual. É a expressão em nível de swarm do portão humano: o motor recusa dar o passo perigoso por conta própria, e você o reabre com alembic approve/reject/propose.
A precedência de classifyPark (park.ts:38) é deliberada — o primeiro motivo que casa vence, para o ledger registrar a causa mais específica: marcadores legal/security nos metadados primeiro, depois a flag/metadado irreversible, e por último a regra do tier T4. "When in doubt, park."
A ordem é o que dá ao ledger a causa mais específica. Fonte: REASON_KEYS + classifyPark (park.ts:24–45).
08 · O reward — heurístico, não aprendizado por reforço
O swarm tem um sinal de reward, e o nome engana de propósito. computeReward (reward.ts:74) é um escalar heurístico moldado — estilo PARL — e não é aprendizado por reforço: não há gradiente de política, não há replay buffer, não há atualização online de pesos. É uma conta determinística e explicável sobre três componentes, com um humano no loop.
E o sinal é inerte até aprovado: requiresApproval nasce true, então nada pode influenciar o roteamento futuro antes de um humano assinar (HITL). Só sinais aprovados (ou que nunca precisaram de aprovação) contam — isActionable. O reward molda a priorização com um humano no loop; não há treino.
lead-dispatcher.ts) lança subtasks num ramp estilo Kimi — começa com initialLimit (default 5) tarefas e libera +1 vaga a cada intervalMs (default 700 ms) até maxConcurrency; tarefas que terminam não liberam vagas (o ramp controla o total lançado ao longo do tempo). Ele também emite um stream de consult/update por subtask, validado por Zod, para um observador AFK seguir cada passo.O ramp do lead: arranque controlado, não enxurrada
Por que um lead não dispara todos os subtasks de uma vez? Para não saturar a máquina (e o gateway) no instante zero. O ramp começa com initialLimit (5) tarefas e solta +1 vaga a cada 700 ms até maxConcurrency. Detalhe que confunde: tarefas que terminam não devolvem vagas — o ramp governa o total lançado ao longo do tempo, como abrir um registro aos poucos:
E o lead transmite cada passo: o stream consult/update
Enquanto fan-outa, o lead emite um stream de lifecycle por subtask — uma união discriminada por kind, validada por Zod, para um observador AFK (ou um log de transcript compartilhado) acompanhar sem o dispatcher vazar internals. São quatro espécies; hoje o dispatcher emite o par terminal, e update fica reservado para quem fia progresso intermediário:
09 · Por que delegate_tool.py do Hermes é IGNORE (comparativo)
A matriz de fusão (§2.4) classifica o delegate_tool.py do Hermes como IGNORE — não porque delegar seja ruim, mas porque o Alembic já delega nativamente e com mais garantias. Veja o trade-off lado a lado: o que o swarm L3 oferece que uma tool de delegação simples não tem.
| Dimensão | delegate_tool.py (Hermes) | swarm L3 (Alembic) |
|---|---|---|
| Profundidade | delegação ad-hoc, sem limite estrutural | limitada por MAX_DEPTH = 2 (anti-fan-out) |
| Ordenação | chamada direta, sem grafo de deps | fila com portão de dependsOn (ready = ready) |
| Crash mid-run | trabalho perdido se o processo morre | resume via replayInto + órfãs → ready |
| Isolamento | compartilha o ambiente do pai | git-worktree fail-closed por task |
| Trabalho perigoso | executa o que for pedido | park T4 duro (nunca auto-roda) |
10 · Como isso se encaixa
O swarm não age sozinho — ele é o braço executor no meio da máquina. Antes dele, os gates decidem se e o que rodar; o swarm então roda exatamente as unidades aprovadas, em paralelo e com resume; e o que ele não pode tocar (T4/irreversível) ele devolve ao portão humano. Veja a peça (em destaque) dentro do pipeline real do Alembic:
Clique em "Percorrer o fluxo" para acender cada estágio em ordem: gates → swarm → resultados → portão T4 → aprendizado.
Onde você está na metodologia: o swarm é o estágio de execução — o único que roda trabalho de verdade. Tudo antes dele (Scope, Council, Proof, Validator) é decisão; tudo depois (park T4, reward, aprendizado) é consolidação sob portão humano. Ele é o motor que pega o plano aprovado e o transforma em unidades concluídas, sem perder trabalho num crash e sem dar sozinho o passo perigoso. Veja o mapa interativo inteiro em a metodologia completa →.
Porque conecta: os gates (scope → council → proof → validator) são o upstream do swarm — eles decidem quais unidades chegam à fila. O swarm só executa o que a pipeline aprovou.
Porque conecta: o park T4 do swarm é a expressão, em nível de execução, do mesmo portão humano — quando o motor não pode decidir sozinho, ele para e devolve a você (clarify / approve / reject).
Porque conecta: o Council decide antes (GO/NO_GO) e o Verifier observa depois; o swarm é o meio que roda as unidades entre essas duas decisões, alimentando o Verifier com os resultados.
Porque conecta: no capstone você junta tudo — uma run real exercita gates + swarm + park + aprendizado de ponta a ponta. É aqui que o swarm aparece operando dentro da máquina inteira.
11 · Na prática
O swarm não tem um comando "swarm" próprio — ele é o que alembic run aciona por baixo quando uma run tem várias unidades. Você o dispara com run e o observa com runs list, tui e tail; um replay retoma uma run que caiu. Os comandos abaixo são todos canônicos (CLAUDE.md):
# 1) Disparar uma run a partir de um spec (mission.json / tasks.json): # o swarm vira o executor — orchestrator divide em leads, leads em workers. alembic run mission.json # 1b) …ou a partir de um GOAL.md + plano (h.mission() com units[]): alembic run --goal GOAL.md --plan alembic.plan.ts --yes # 2) Listar as runs e seus estados (encontre a run-id content-addressed): alembic runs list # run-3f9a1c… · finished · 7/8 done · 1 parked (T4) # 3) Acompanhar AFK — um dashboard de terminal por run-id: alembic tui run-3f9a1c # 4) …ou seguir o journal append-only em tempo real (events.jsonl): alembic tail run-3f9a1c -f # task-state build running # task-state build done # task-parked deploy tier-t4 → reabra com approve/reject/propose # 5) Caiu no meio? Retome do disco — content-addressed, sem refazer o feito: alembic replay run-3f9a1c # recoverOrphans: 1 task running órfã → ready (re-tentada)
Note o ciclo que toda a lição descreve, agora no terminal: run aciona o swarm; tail -f mostra cada task-state sendo jornalado; um task-parked tier-t4 aparece e não auto-roda; e se a máquina morrer, replay reproduz o journal e a recoverOrphans rebaixa a task órfã de running de volta a ready — exatamente o resume seguro a crash da seção 05. [uncertain] a forma exata das linhas do tail é ilustrativa do conceito (task-state / task-parked do events.jsonl); o formato textual impresso pode diferir.
cd para o checkout do Alembic e garanta o build verde: pnpm -r typecheck && pnpm -r build && pnpm -w test. Sem build, os pacotes dependentes não enxergam os .d.ts.alembic run --goal GOAL.md --plan alembic.plan.ts --yes (ou alembic run mission.json). O swarm pega as unidades aprovadas e as executa — leads fan-outam workers num ramp controlado.alembic runs list para pegar a run-id, depois alembic tail <run-id> -f. Procure as transições task-state … running → done e, se houver uma unidade T4, a linha task-parked.events.jsonl — uma linha por evento, seq crescente — e checkpoint.json (o pré-seed do replay). É o journal append-only da seção 05.alembic replay <run-id>. Porque a run-id é o hash do spec, o replay cai no mesmo diretório, reproduz o journal e a recoverOrphans re-tenta a task deixada running — você verá a run continuar de onde parou, não recomeçar.parked, ela é o portão humano: registre sua decisão com alembic approve <run-id> --task <unit-id> (ou reject), ou reabra como proposta com alembic propose <run-id>. O motor nunca dá esse passo sozinho — você dá.Confusões comuns
computeReward é um escalar heurístico moldado, explicitamente não RL, e tem portão HITL por requiresApproval. O reward molda a priorização com um humano no loop; não há gradiente, não há treino.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
depth 2 = MAX_DEPTH; canSpawn(2)=false. É folha. Subtasks são tasks-folha → aninhamento de 1 nível, travado no tipo.ready?dependsOn chegam a done. regate promove blocked → ready. Pronto significa pronto.running órfã?recoverOrphans rebaixa running → ready e re-tenta → execução pelo-menos-uma-vez. Enqueue idempotente cobre todo o denominador.isolate:true sem worktree config?err — nunca uma run sem-isolamento em silêncio. Pedir separação que não se pode obter para a run.Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As opções têm tamanho parecido de propósito — sem pista pela forma.
canSpawn(2)=false; subtasks são tasks-folha, então o aninhamento é de um nível, travado no tipo e no runtime. a erra o ponto inteiro: "qualquer profundidade" é justamente o fan-out recursivo que o bound existe para impedir. b inventa uma exceção por tipo de worker que não existe — o limite é de profundidade, não de tipo. d imagina uma aprovação em runtime; não há — é um invariante estrutural, não uma decisão dinâmica.replayInto reproduz o checkpoint mais os eventos task-state; recoverOrphans rebaixa a task deixada running a ready e a re-roda. a confunde "worker morreu" com "task falhou": não houve evento terminal failed, então marcá-la falha seria errado. c escolheria a semântica oposta (no-mais-de-uma-vez) e travaria a conclusão para sempre — isComplete nunca fecharia. d ignora todo o ponto do journal durável: o resume continua de onde parou, não recomeça.isolate: true mas nenhuma config de worktree é fornecida. O que o orchestrator faz?err("requires isolation but no worktree config was provided"). a e b são a falha clássica que o fail-closed existe para impedir: executar sem a separação pedida (silenciosa ou não) pode sujar o checkout compartilhado. c "auto-cria com padrões" parece prestativo, mas inventar config de isolamento que o operador não deu é justamente o tipo de suposição perigosa que o motor recusa.runIdFor(spec)=run-${shortHash(spec)} é content-addressed; canonicalJson ordena as chaves, então até a ordem dos campos é irrelevante: mesmo spec → mesmo id → mesmo diretório (filesystem-como-verdade). b inventa um prompt interativo que quebraria runs AFK. c "o mais recente" forkaria árvores erradas quando há várias runs. d é o oposto do design: um UUID aleatório jamais reencontraria o estado anterior — não haveria resume.