O orquestrador 3-tier, depth-bounded e dependency-gated do Alembic. É a peça que faz o Factory rodar vários agentes em paralelo — de forma isolada, resumível e segura — sem nunca deixar trabalho irreversível rodar sozinho.
orchestrator → lead → worker — e por que MAX_DEPTH = 2 impede recursão infinita.ready (a regra do dependency-gating).classifyPark decide.worktree e background).Store.git worktree nem teoria de filas: explicamos.@alembic/contracts.Imagine que você precisa tocar dez tarefas ao mesmo tempo: algumas dependem de outras, algumas são perigosas demais para fazer sem confirmar, e o computador pode travar no meio. Você quer um gerente que: distribui o trabalho, só libera uma tarefa quando o que ela depende terminou, segura o que é arriscado para um humano olhar, e — se cair a energia — retoma de onde parou sem refazer tudo.
Esse gerente é o Swarm, a camada L3 do Alembic, no pacote @alembic/swarm. Ele não inventa o trabalho; ele coordena quem faz. E faz isso com três garantias duras, escritas no próprio código.
O docblock de index.ts (linhas 7–21) é o melhor resumo de uma frase: "um orquestrador 3-tier depth-bounded (orchestrator → lead → worker) sobre uma fila com dependency-gating, com estado durável filesystem-as-truth (JSONL append-only + índice opcional node:sqlite), um hook de reward estilo PARL gated por HITL, isolamento por git-worktree com portas determinísticas, runs resumíveis crash-safe por diretório de run endereçado por conteúdo (re-rodar reproduz o journal e recupera tarefas órfãs em vez de reiniciar), um monitor de progresso read-only por polling de arquivo (readRunProgress) para observabilidade AFK, e um T4 park rígido para trabalho irreversível/legal/security que nunca pode auto-executar."
Do docblock de orchestrator.ts (linhas 32–47): (1) a profundidade é limitada — um nó em MAX_DEPTH é leaf e não pode spawnar; (2) dependency-gating — só tarefas ready rodam; (3) o T4 park — trabalho irreversível/legal/security/T4 vai para o ledger e nunca auto-executa. Toda transição é journaled pelo Store, e um run é resumível a partir do último checkpoint.
Antes do detalhe, a forma geral. Um run entra como um RunSpec (uma meta + uma lista de tarefas). O orchestrator monta a fila, libera o que está pronto, despacha workers, escreve cada passo no Store, e segura o que precisa de humano. Quando tudo chega a um estado terminal, o run termina — e pode ser retomado a qualquer momento.
@alembic/swarm. O orchestrator é o único que decide; o Store é a única fonte de verdade.A árvore de spawn tem exatamente três níveis. O teto MAX_DEPTH = 2 não é um número mágico: é a razão de a recursão nunca explodir.
Uma tarefa B declara dependsOn: ["A"]. A tarefa A terminou, mas falhou (status failed). A tarefa B fica ready e roda?
B só vira ready quando todo id em dependsOn chegou a done — o sucesso terminal. failed não satisfaz a aresta (em queue.ts:25, SATISFYING = 'done'). Se um pré-requisito falha, o que dependia dele permanece blocked. Falha não se propaga como "pode rodar"; ela trava o que vinha depois.O swarm tem exatamente três papéis, ordenados por profundidade (depth). O orchestrator (depth 0) abre o run e dirige a fila. Ele pode criar leads (depth 1). Um lead faz fan-out: pega uma tarefa grande e a quebra em subtarefas. Cada subtarefa é um worker (depth 2), que faz o trabalho de verdade — e é leaf: não pode criar mais ninguém.
Por que parar em três? Porque um worker que pudesse criar outro worker criaria uma cadeia sem fim. O Alembic corta isso com uma constante: MAX_DEPTH = 2. Quem está na profundidade 2 é folha. Ponto.
types.ts:21 define os papéis: swarmRoleSchema = z.enum(['orchestrator', 'lead', 'worker']). types.ts:25–29 dá a profundidade de cada um em ROLE_DEPTH = { orchestrator: 0, lead: 1, worker: 2 }. E types.ts:32 fixa o teto: export const MAX_DEPTH = 2; — "A worker (depth 2) is a leaf and cannot spawn."
Não há uma "classe Lead". Um lead é simplesmente um TaskSpec que tem o campo subtasks (types.ts:122–124). Quando presente, o orchestrator roda essas subtasks como um sub-run filho, depth-bounded e dependency-gated, e dobra o resultado no resultado do lead — "done iff no subtask failed or stayed blocked". As subtasks são leaf (taskSpecBaseSchema) e não podem ter subtasks, então o aninhamento é estruturalmente limitado a um nível, casando com MAX_DEPTH.
O teto também é checado em runtime por canSpawn (citado em types.ts:120): mesmo que alguém forje uma estrutura mais profunda, o orchestrator se recusa a spawnar além de 2.
| Papel | Depth | Pode spawnar? | O que faz |
|---|---|---|---|
| orchestrator | 0 | sim → leads | Abre o run, monta e dirige a TaskQueue, journaliza tudo. |
| lead | 1 | sim → workers | Fan-out: roda subtasks como sub-run filho e agrega o resultado. |
| worker | 2 | não (leaf) | O trabalho real: chamada ao adapter ou subprocesso. Reporta via arquivo. |
canSpawnMAX_DEPTH = 2, um nó nessa profundidade é folha e não pode spawnar — é o que limita a recursão. Orchestrator(0) e lead(1) podem criar o nível abaixo; o worker apenas executa.A TaskQueue é o cérebro de "o que pode rodar agora?". Ela é pura: só mexe em estados na memória, não executa nada, não toca em disco. Toda a durabilidade é trabalho do Store. Manter a fila pura assim deixa a lógica de liberação 100% testável, sem adapters, sem worktrees, sem filesystem.
A regra de liberação é uma frase: uma tarefa fica ready somente quando todos os ids do seu dependsOn chegaram a done. Sem dependências? Começa ready. Tem dependências não satisfeitas? Começa blocked. Precisa de park? Começa parked.
ready) quando todas as suas conexões anteriores pousaram (done). Enquanto uma escala estiver no ar, você espera (blocked). E quem tem uma pendência de documento fica retido à parte (parked) — não embarca por conta própria.types.ts:43–50 — taskStatusSchema = z.enum(['blocked', 'ready', 'running', 'done', 'failed', 'parked']). blocked = dependências não cumpridas; ready = tudo cumprido, elegível; running = reivindicada por um worker; done/failed = terminais; parked = T4, retida da execução autônoma.
queue.ts:14–22 é explícito: "A task is ready only when every id in its dependsOn has reached done. The queue is pure in-memory bookkeeping over TaskState; durability is the store's job. It never executes anything — it answers 'what may run now?' and 'is the run finished?'". O que satisfaz uma aresta é só o sucesso terminal: queue.ts:25 → const SATISFYING: TaskStatus = 'done';.
queue.ts:47–59 (initialState): se classifyPark(spec) não for undefined → começa parked (com o parkReason); senão, dependsOn.length === 0 ? 'ready' : 'blocked'.
A cancela (gate) só abre quando todo dependsOn chegou a done. A fila não executa — ela só responde "o que pode rodar agora?".
queue.ts:14–22) diz: a fila "never executes anything — it answers 'what may run now?'". Manter a decisão de liberação separada da execução deixa o dependency-gating testável em isolamento. Durabilidade não é dela: é do Store.Algumas tarefas nunca devem rodar sozinhas: qualquer coisa de tier T4, qualquer coisa explicitamente irreversível, ou com marcador legal/security. Para essas, o swarm não executa — ele estaciona (park). A tarefa vai para um ledger append-only (t4-parked.jsonl) e exige conselho + adjudicação humana antes de poder rodar.
Isso é uma fronteira de segurança dura, não um palpite. A filosofia, escrita no código: na dúvida, park.
types.ts:57–63 — parkReasonSchema = z.enum(['tier-t4', 'irreversible', 'legal', 'security', 'manual']). O motivo explica por que o orchestrator reteve a tarefa, para que um humano possa decidir a partir do ledger sozinho.
park.ts:38–45 (classifyPark) retorna o motivo ou undefined (elegível para execução autônoma). A precedência é exata: (1) marcadores de metadata legal/security/irreversible (a primeira chave verdadeira vence); (2) a flag irreversible do spec → 'irreversible'; (3) a regra de tier: spec.tier === Tier.T4 || isParked(spec.tier) → 'tier-t4'. "The first matching reason wins so the ledger records the most specific cause."
park.ts:13–21: tarefas parked são roteadas para t4-parked.jsonl (append-only) pelo Store e exigem "council + human adjudication before they can run". makeParkEntry (park.ts:68–81) re-valida o motivo via Zod — um motivo malformado nunca chega ao ledger — e nunca lança exceção.
A função classifyPark é a cancela. Se ela devolve um motivo, a tarefa nasce parked e espera por um humano. Se devolve undefined, segue autônoma.
irreversible: true (ou metadata { legal: true }). Tier dela é T2.initialState chama classifyPark(spec). As chaves de metadata são checadas primeiro; depois a flag irreversible casa → motivo 'irreversible'.ready — nasce parked, com parkReason: 'irreversible', mesmo sendo "só" T2. A flag vence o tier.ParkEntry em t4-parked.jsonl. O run continua com as outras tarefas; esta fica esperando aprovação humana.{ security: true }. Ela roda sozinha? Responda antes de virar o flashcard abaixo.{ security: true }: roda sozinha?security é checado antes da regra de tier e força o park com motivo 'security'. O tier baixo é irrelevante: qualquer marcador legal/security/irreversible estaciona a tarefa. Na dúvida, park.Quando uma tarefa finalmente roda, quem faz é um worker. Ele tem dois modos: chamar um modelo de IA (adapter.run) ou rodar um subprocesso real — um comando do sistema — quando a tarefa carrega o campo command. Esse segundo modo é a primitiva "build" do ADW: roda algo como pnpm test, e o resultado vira o desfecho (exit 0 = done, qualquer outro = failed).
E como o orchestrator sabe que o worker terminou? Por um truque simples e robusto: o arquivo de report. O worker escreve num arquivo "em progresso" e, no fim, faz um rename atômico para um nome terminal. O rename é o "commit": o orchestrator só observa os nomes finais, então nunca vê um report pela metade.
.complete) quando o prato está pronto. O garçom (orchestrator) olha só a roda — nunca pega um prato meio-feito. E se o cozinheiro desmaiar, o papel pendurado continua lá: o serviço sabe o que ficou pronto.worker.ts:23–32: "A worker writes its findings to an in-progress file (<taskId>.report.md), then atomically renames it to a terminal name — <taskId>.complete.md or <taskId>.failed.md. The orchestrator watches only for the terminal names, so it never observes a partially-written report: the rename is the commit. This is filesystem-as-IPC and survives process death, which a shared in-memory channel would not."
worker.ts:35–39 — REPORT_SUFFIX = { inProgress: '.report.md', complete: '.complete.md', failed: '.failed.md' }. O corpo do report é Markdown com um bloco JSON cercado (worker.ts:56–60): o bloco é a carga legível por máquina; a prosa ao redor torna o arquivo auditável num diff.
types.ts:92–98: quando command (um array de argv, sem shell — sem superfície de injeção) está presente, o worker roda um subprocesso em vez de uma chamada de modelo. "exit 0 → done, anything else → failed". Com isolate, o comando roda com cwd apontando para o checkout do worktree.
Rodar vários agentes em paralelo cria dois riscos: eles podem pisar no mesmo código ao mesmo tempo, e um pode morrer junto com o orchestrator. O swarm tem um mecanismo para cada.
Para o primeiro: worktree. Com a flag isolate: true, a tarefa roda num git worktree dedicado — um checkout próprio, numa branch própria, com uma porta de rede determinística. Dois workers nunca tocam a mesma árvore de trabalho, e o teardown é garantido.
Para o segundo: background. Com background: true, o worker roda num processo destacado (detached). Ele sobrevive se o pai morrer; o orchestrator coordena só pelo arquivo de report, e um resume re-attacha ao report em vez de refazer o trabalho.
worktree.ts:15–27: "Each task runs in its own git worktree on a dedicated branch so concurrent workers never touch the same working tree." A porta é derivada de forma determinística do nome da branch — portForBranch (worktree.ts:38–42) devolve um valor em [PORT_BASE, PORT_BASE + PORT_SPAN) = [4000, 5000). A branch é branchForTask (worktree.ts:45–46) = swarm/<runId>/<taskId>. withWorktree garante o teardown; os comandos git passam pelo defaultGitRunner endurecido (argv, sem shell, com timeout/AbortSignal/cap de buffer). Uma tarefa que pede isolate sem config de worktree no orchestrator falha fechado (types.ts:86–91, orchestrator.ts:72–75).
background.ts:12–21 — "the tac-9 /background pattern": o dispatcher lança o worker e o orchestrator coordena apenas pelo report file (poll + re-attach), nunca pelo handle do processo. Por isso ele sobrevive à morte do pai; num resume, re-attacha em vez de re-rodar. O payload vai por env: BACKGROUND_PAYLOAD_ENV = 'ALEMBIC_BG_PAYLOAD' (background.ts:24). Resolver ok significa "launched", não "finished" (background.ts:35–38). Background exige command e proíbe isolate — um filho destacado não pode compartilhar o ciclo de vida do worktree do pai (types.ts:99–107).
Branch dedicada swarm/<runId>/<taskId> + porta determinística em [4000, 5000). Teardown garantido por withWorktree. Isolamento no espaço.
Processo destacado que sobrevive ao pai; coordenação só pelo report file; resume re-attacha. Exige command, proíbe isolate. Isolamento no tempo.
isolate: true mas o orchestrator não recebeu config de worktree?types.ts:86–91 e orchestrator.ts:72–75 são claros: a config de worktree é "required when any task sets isolate: true; such a task fails closed if this is absent." O Alembic prefere falhar de forma segura a degradar o isolamento sem avisar — coerente com o princípio fail-closed de @alembic/contracts.A única fonte de verdade do swarm é o sistema de arquivos — filesystem-as-truth. Quem cuida disso é o Store. Cada coisa que acontece num run vira uma linha num diário append-only (o journal, events.jsonl). De tempos em tempos, um checkpoint guarda o estado completo. Tarefas estacionadas vão para o park ledger.
É isso que torna um run resumível. Se o processo cair, basta re-rodar: o swarm reproduz o journal a partir do último checkpoint, recupera tarefas que estavam no ar (órfãs) e continua — em vez de começar do zero. E como o diretório do run é endereçado por conteúdo (um hash do RunSpec), o mesmo run sempre aponta para o mesmo lugar.
O docblock de types.ts:4–12 explica a disciplina: "Every value that crosses a durability boundary (JSONL line, checkpoint, worker report file) is defined here as a Zod schema... Parsing on read is mandatory — the filesystem is the source of truth, so a corrupt or hand-edited line must be rejected at the boundary rather than trusted."
types.ts:203–217 — swarmEventKindSchema: run-started, run-resumed, task-enqueued, task-state, task-parked, reward, worktree, checkpoint, run-finished (e eventos de nível de run emitidos pela VM acima do swarm: phase-started, phase-finished, run-log). Cada linha (swarmEventSchema, types.ts:225–231) tem um seq monotônico por run para replay determinístico.
runCheckpointSchema (types.ts:238–245) guarda taskStates + parkedIds num ponto no tempo; "replay starts from the last checkpoint and applies subsequent journal events." O docblock de index.ts:13–16 resume: "crash-safe resumable runs by content-addressed run directory (re-running replays the journal and recovers orphaned in-flight tasks instead of restarting)."
O coração da camada cabe em poucos trechos. Comece pelo docblock de arquitetura — ele é o contrato de design do swarm.
packages/swarm/src/orchestrator.ts:32 — o docblock de arquitetura/** * The 3-tier, depth-bounded orchestrator: orchestrator → lead → worker. * * It drives a TaskQueue to a terminal state while honoring three hard * rules: (1) depth is bounded — a node at MAX_DEPTH is a leaf and may * not spawn; (2) dependency-gating — only `ready` tasks run; (3) the T4 park — * irreversible/legal/security/T4 tasks are routed to the ledger and never * auto-executed. Every transition is journaled through the Store * (filesystem-as-truth), and a run is resumable from its last checkpoint. */packages/swarm/src/types.ts:21–32 — papéis, profundidade e o teto
export const swarmRoleSchema = z.enum(['orchestrator', 'lead', 'worker']); /** Depth of each role in the spawn tree; also the hard recursion ceiling. */ export const ROLE_DEPTH = { orchestrator: 0, lead: 1, worker: 2, }; /** Maximum spawn depth. A worker (depth 2) is a leaf and cannot spawn. */ export const MAX_DEPTH = 2;packages/swarm/src/park.ts:38–45 — classifyPark, a cancela de irreversibilidade
export const classifyPark = (spec: TaskSpec): ParkReason | undefined => { for (const [key, reason] of REASON_KEYS) { if (spec.metadata[key] === true) return reason; // legal / security / irreversible } if (spec.irreversible) return 'irreversible'; if (spec.tier === Tier.T4 || isParked(spec.tier)) return 'tier-t4'; return undefined; // elegível para execução autônoma };packages/swarm/src/queue.ts:14–25 — a regra do dependency-gating
/** * A dependency-gated task queue. * A task is `ready` only when every id in its `dependsOn` has reached `done`. * The queue is pure in-memory bookkeeping ...; durability is the store's job. * It never executes anything — it answers "what may run now?" */ /** Terminal-success status that satisfies a dependency edge. */ const SATISFYING: TaskStatus = 'done';
index.ts (export * de cada módulo). Lá também mora partitionByAutonomy (index.ts:55), um utilitário leve que separa tarefas em autonomous vs parked pela regra isAutonomous antes de montar um run completo.Use as abas para ver o que acontece com cada faixa de tier ao montar a fila. O swarm decide, tarefa por tarefa, entre rodar (autônomo) e park (retido para humano).
Tarefas de baixo risco são autônomas. Sem dependências, nascem ready e o worker é despachado.
classifyPark em ação.Cinco ideias, em sequência. Use as setas do teclado ou os botões.
Dois caminhos: confirme o entendimento no quiz e prove a camada rodando os testes reais.
types.ts:32: "A worker (depth 2) is a leaf and cannot spawn." Orchestrator(0) e lead(1) podem spawnar; o worker, não.irreversible: true — o que acontece?classifyPark (park.ts:42), a flag irreversible é checada antes da regra de tier e força o park. Na dúvida, park.worker.ts:23–32: "the rename is the commit. This is filesystem-as-IPC and survives process death, which a shared in-memory channel would not." O orchestrator só vê nomes terminais — nunca um report pela metade.A forma honesta de verificar a camada é rodar a suíte do pacote e ler os testes que cobrem cada garantia.
# roda só o pacote do swarm pnpm --filter @alembic/swarm test # o que olhar: # - orchestrator.test.ts → as 3 regras duras de ponta a ponta # - queue.test.ts → dependency-gating (a regra do 'ready') # - park.test.ts → classifyPark + precedência de motivos # - worktree.test.ts → isolate + porta determinística # - process.test.ts → subprocesso real (command)
orchestrator.ts:32–47 (o docblock). 2) Leia types.ts:43–63 (estados + motivos de park). 3) Rode um swarm pequeno e inspecione o journal no Store. 4) Marque uma tarefa como irreversible e observe o park. 5) Compare um run com e sem isolate: true.