Dentro de um alembic.plan.ts, h.swarm() transforma uma lista de itens num fan-out de agentes — um LEAD com N workers — sem nunca perder o controle: profundidade máxima 2 (orchestrator → lead → worker), fila com portão de dependência, journal append-only e cache SHA-256 que faz o replay ser grátis. Nesta lição: como o template {{item}} vira subtasks, por que ids são prefixados, e como h.agent, h.mission e h.swarm descem TODOS para o mesmo motor: runSwarm.
O swarm tem exatamente três papéis, ordenados por profundidade: ROLE_DEPTH = { orchestrator: 0, lead: 1, worker: 2 } e MAX_DEPTH = 2 (packages/swarm/src/types.ts:25–32). Um worker é FOLHA — "cannot spawn". O limite é estrutural duas vezes: no schema, subtasks são taskSpecBaseSchema (sem campo subtasks, types.ts:117–124 — aninhamento limitado a um nível POR TIPO); e em runtime, canSpawn(depth) = depth < MAX_DEPTH (orchestrator.ts:107). Recursão infinita de agentes não é um risco monitorado — é um estado irrepresentável.
Pense como… uma obra com mestre de obras (orchestrator), encarregado (lead) e pedreiros (workers): o pedreiro não contrata ninguém — se a tarefa dele precisar de mais gente, isso volta para o encarregado decidir. Onde quebra: numa obra o limite é informal; aqui o pedreiro-que-contrata simplesmente não compila (o tipo da subtask não tem onde declarar subtasks).
O contrato de entrada é swarmOptionsSchema (packages/mission/src/mission-options.ts:102–121): promptTemplate OBRIGADO a conter o placeholder {{item}} (refine com mensagem própria, :108–110), items de 1 a 128 strings, tier default T2, maxConcurrency default 1 e um ramp opcional (initialLimit 5, intervalMs 700 — subida gradual de concorrência). O compilador compileSwarmToLead (packages/mission/src/compiler.ts:245–286) expande cada item com replaceAll('{{item}}', item) e monta UM lead (roleHint: 'lead') cujas subtasks são os prompts expandidos.
// trecho real, não editado const prompt = swarm.promptTemplate.replaceAll('{{item}}', item); if (seenPrompts.has(prompt)) { const firstItem = seenPrompts.get(prompt)!; throw new Error( `Duplicate prompt generated for items "${firstItem}" and "${item}"`, ); }
No VM, h.swarm (packages/vm/src/hooks.ts:248–292) valida as options, compila o lead e prefixa os ids: swarm-N-lead e swarm-N-task-0… (hooks.ts:270–277). Por quê? Porque a run inteira compartilha UM journal keyed por id — "task ids are prefixed with a per-hook counter so multiple calls inside the same plan never collide in the shared store" (hooks.ts:52–54); o orquestrador ainda reprova ids duplicados em qualquer ponto da árvore antes de journalizar (checkUniqueIds, orchestrator.ts:141–150, 170–175).
runSwarm (packages/swarm/src/orchestrator.ts:158–242) é o mesmo caminho para tudo: valida o spec, checa unicidade de ids na árvore INTEIRA, monta a fila (createQueue), faz resume-or-start (replayInto reaplica o estado durável; task running órfã de um processo morto é rebaixada a ready e re-tentada — "at-least-once execution", orchestrator.ts:181–187), declara task-enqueued idempotente para toda a árvore (:198–211), estaciona o inelegível (parkIneligible → ledger T4, :213–214, 250–271) e então drena: enquanto houver ready, roda um lote limitado por maxConcurrency, um lead abre sub-run um nível abaixo (limitado por canSpawn) e uma folha executa o worker (:216–228).
A fila é o portão de dependência: "a task is ready only when every id in its dependsOn has reached done" (packages/swarm/src/queue.ts:17); sem deps nasce ready, com deps nasce blocked (queue.ts:57), e a promoção blocked → ready acontece quando os deps completam (queue.ts:155–163). O "pronto" de um worker é um RENAME atômico: o relatório é escrito em arquivo de progresso e renomeado para <taskId>.complete.md / .failed.md — "the orchestrator watches only for the terminal names, so it never observes a partially-written report" (packages/swarm/src/worker.ts:24–29, 37–38).
h.agent(prompt) monta UMA task de worker e roda runSwarm com spec de 1 task (hooks.ts:120–141); h.mission(options) compila milestones/units em RunSpec, prefixa mission-N-… e roda runSwarm (hooks.ts:222–246); h.swarm(options) compila o lead templated e roda runSwarm. Tudo desce para o mesmo journal, o mesmo park, o mesmo checkpoint — paralelismo não é um segundo sistema, é o MESMO sistema com mais folhas.As três fachadas diferem no que persiste: h.agent e h.swarm são CACHEADOS — a chave é sha256(JSON estável de {prompt, opts}) (computeCacheKey, packages/vm/src/cache.ts:20–21), guardada em <runDir>/workflows/<wf-id>/cache.json (cache.ts:23–24). Para h.swarm, a chave cobre description, subagentType, promptTemplate, items, tier, maxConcurrency, modelId, ramp (hooks.ts:254–262) — mude UM item e é outra chave. Já h.mission NÃO cacheia o resultado: ele persiste o SPEC compilado em specs/mission-N.json no run-dir (hooks.ts:228–239) e conta com o resume do próprio swarm (journal + checkpoint) para não repetir trabalho — tasks terminais não re-executam num replay.
| Fachada | Compila para | Cache de resultado | Retomada |
|---|---|---|---|
| h.agent | 1 task worker (spec de 1) | Sim — chave sha256 (hooks.ts:106–118) | cache hit devolve direto |
| h.swarm | 1 lead + N subtasks | Sim — chave sobre TODAS as options (hooks.ts:254–267) | cache hit devolve o RunResult salvo |
| h.mission | RunSpec completo (units/milestones) | Não — persiste specs/mission-N.json | journal do swarm: terminal não re-roda |
Date.now()/Math.random() no plano: com entradas determinísticas, "mesma chave ⇒ mesmo resultado" é verdade de verdade, e --no-cache (que pula leitura E escrita, hooks.ts:113, 263) vira a única alavanca necessária para forçar re-execução.replaceAll('{{item}}'). O que faz compileSwarmToLead?compiler.ts:252–257 mantém um mapa seenPrompts e lança citando os DOIS items que colidiram — falha ruidosa e diagnóstica, em vez de gastar dois workers no mesmo trabalho ou esconder um item do usuário. Dedupe silencioso mentiria sobre quantas tasks rodaram.h.swarm prefixa os ids (swarm-1-lead, swarm-1-task-0)?hooks.ts:52–54). O estado de cada task é o último registro JSONL daquele id — uma colisão faria uma chamada sobrescrever o estado da outra. E o orquestrador ainda valida unicidade na árvore inteira antes de journalizar (orchestrator.ts:170–175).Um fan-out real dentro de um plan module, e a inspeção do que ele deixa no disco:
# alembic.plan.ts — fan-out templated (dentro de export const run = async (h) => { … }) await h.swarm({ description: 'resumir um pacote por worker', promptTemplate: 'Resuma o pacote {{item}} em 5 bullets.', items: ['contracts', 'swarm', 'vm'], tier: 'T2', maxConcurrency: 3, }); # rodar e depois retomar/replayar (terminais não re-executam) alembic run --goal GOAL.md --plan alembic.plan.ts --yes alembic replay <run-id> alembic run --goal GOAL.md --plan alembic.plan.ts --resume <run-id> --no-cache --yes # o que fica no disco cat <runDir>/events.jsonl | head # journal append-only (task-enqueued, task-state…) cat <runDir>/workflows/swarm-1/cache.json # cache sha256 da chamada cat <runDir>/specs/mission-1.json # spec persistido por h.mission (se houver) # fontes desta lição sed -n '102,121p' packages/mission/src/mission-options.ts sed -n '245,286p' packages/mission/src/compiler.ts sed -n '158,242p' packages/swarm/src/orchestrator.ts
Regra de leitura: o journal responde "o que aconteceu"; o cache responde "o que não precisa acontecer de novo". São arquivos diferentes porque são perguntas diferentes.
subtasks + canSpawn em runtime (types.ts:117–124, orchestrator.ts:107).{{item}} é contrato (refine no schema); prompt duplicado lança com os dois items nomeados (compiler.ts:252–257).hooks.ts:52–54).h.agent/h.mission/h.swarm descem todos para runSwarm — um motor, três fachadas.createHooks e me entrega esse h — e o que impede meu plano de ler o relógio e quebrar o cache?" — Próxima lição: @alembic/vm — o executor de alembic.plan.ts, o determinismo IMPOSTO (com o erro real na tela) e o sandbox de plan modules.