Lição 13 · Fusão v3 · Swarm: paralelismo com profundidade limitada ← índice
Alembic × Hermes — O Curso de Fusão v3 · Execução e planejamento

Swarm: paralelismo com profundidade limitada

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.

1

A árvore: três papéis, teto de recursão

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.

alembic-completo · 0004-camada-l3-swarm · s0

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).

2

h.swarm(): template {{item}} → lead + 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.

packages/mission/src/compiler.ts:250–257 — trabalho duplicado nunca enfileira
// 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).

h.swarm({ items, promptTemplate }) template DEVE ter {{item}} compileSwarmToLead replaceAll('{{item}}') prompt duplicado ⇒ throw cache lookup sha256({description, template, items, tier,…}) hit ⇒ retorna SEM rodar runSwarm(spec) journal + checkpoint → writeCache(resultado) swarm-1-lead swarm-1-task-0 swarm-1-task-1 swarm-1-task-… ids prefixados por chamada: journal é UM por run — sem prefixo, duas chamadas colidiriam (hooks.ts:52–54)
hooks.ts:248–292 — validar → compilar → prefixar → cache → runSwarm → writeCache. Hit de cache devolve o RunResult salvo sem tocar em modelo.
3

O motor embaixo: fila, park, drain, journal

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).

alembic-completo · 0004-camada-l3-swarm · s1

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).

validar specids únicos na árvore createQueuedeps ⇒ blocked replayIntoórfã running→ready task-enqueuedidempotente parkIneligibleT4 → ledger drain≤ maxConcurrency checkpointrun-finished blocked deps done ready running done failed parked parked nunca vira ready por promoção — só decisão humana (queue.ts:149) rename atômico: .complete.md / .failed.md
orchestrator.ts:158–242 (esteira) + queue.ts (máquina de estados). O replay recupera órfãs; o drain só consome ready; o terminal chega por rename.
Um motor, três fachadas. No VM: 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.
4

Composição com mission e cache

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.

FachadaCompila paraCache de resultadoRetomada
h.agent1 task worker (spec de 1)Sim — chave sha256 (hooks.ts:106–118)cache hit devolve direto
h.swarm1 lead + N subtasksSim — chave sobre TODAS as options (hooks.ts:254–267)cache hit devolve o RunResult salvo
h.missionRunSpec completo (units/milestones)Não — persiste specs/mission-N.jsonjournal do swarm: terminal não re-roda
Por que o cache pode ser tão simples Porque a lição 14 proíbe 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.
2
MAX_DEPTH — teto de recursão da árvore
128
máximo de items por h.swarm (schema)
5/700
ramp default: initialLimit 5, intervalMs 700
5

Cheque seu modelo mental

Dois items geram o MESMO prompt após o replaceAll('{{item}}'). O que faz compileSwarmToLead?
Correto: 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.
Por que h.swarm prefixa os ids (swarm-1-lead, swarm-1-task-0)?
Correto: "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 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).
Camada técnica — comandos copy-paste

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.

O que levar desta lição
Pergunta de acompanhamento sugerida: "quem chama 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.