Curso · produto
Alembic — Swarm, harness & gates
Swarm & Harness — o runtime e a pipeline de gates
Esta lição abre a camada que executa. Onde a lição capabilities mostrou os tijolos $0 (embed, ocr, triage, memory, context-pack), aqui você vê o que acontece quando um agente precisa rodar uma missão real: o @alembic/swarm (orquestrador 3-tier orchestrator → lead → worker), o @alembic/harness (núcleo neutro de transporte com HTTP + SSE + MCP), o @alembic/mission (compilador de units[]/proof[] em RunSpec), o @alembic/vm (executor determinístico do alembic.plan.ts) e o @alembic/coda (a pipeline canônica de gates: Scope → Council → Proof → Validator → Publish). Tudo isso é exposto por cinco comandos da CLI: run, runs, tui, tail, serve, cockpit.
A topologia 3-tier: orchestrator → lead → worker
O swarm tem uma única regra estrutural que carrega o resto: profundidade máxima 2. O orquestrador (depth 0) pode spawnar leads (depth 1); um lead pode spawnar workers (depth 2); um worker é folha — não spawna ninguém. Essa cota é codificada explicitamente em packages/swarm/src/types.ts como MAX_DEPTH = 2 e checada em orchestrator.ts pela função canSpawn(depth) === depth < MAX_DEPTH. Tarefas com subtasks rodam como leads (sub-run filho compartilhando o mesmo journal); tarefas folha rodam como workers.
Cada nó é dependency-gated: um worker só vira ready quando todos os ids em dependsOn chegaram a done. Concorrência é limitada por spec.maxConcurrency dentro de cada nível. E há um park duro T4: tarefas tier === 'T4' ou marcadas irreversible são desviadas para um ledger (t4-parked.jsonl) e nunca executam de forma autônoma — a fila vê o status parked e o monitor pode listar para revisão humana.
Filesystem-as-IPC: como worker fala com orchestrator
Workers não conversam por canal in-memory. Eles escrevem um arquivo de relatório in-progress e fazem rename atômico para o nome terminal. O orchestrator faz poll só do nome final:
// packages/swarm/src/worker.ts
export const REPORT_SUFFIX = {
inProgress: '.report.md',
complete: '.complete.md',
failed: '.failed.md',
} as const;
O rename é o commit: o orchestrator nunca observa um relatório pela metade. E, como tudo está em disco, um worker pode morrer mid-flight — o orchestrator detecta um orphan running no próximo replay e o demove de volta para ready, garantindo execução at-least-once. Para o caso background: true (worker detached), o orchestrator nem segura o processo — só re-anexa no relatório quando o filho terminar (sobrevive até à morte do pai).
Missions, units[] e proof[]
Um mission é a forma humana de descrever um run. Em vez de você escrever um RunSpec com tasks + dependsOn + metadata à mão, você descreve a missão em uma alembic.plan.ts usando h.mission(), e o @alembic/mission compila tudo no RunSpec certo:
// alembic.plan.ts (resumido)
const result = await h.mission({
title: 'mission title',
tier: 'T2',
units: [
{
id: 'u1',
title: 'Implement feature X',
description: 'Detailed description for the worker.',
milestoneId: 'm1',
skillName: 'coder',
fulfills: ['FR-1'],
tier: 'T2',
proof: ['pnpm -r typecheck', 'pnpm -w test'],
},
],
milestones: [
{ id: 'm1', title: 'Milestone one' },
],
});
O compilador faz duas coisas por unit: (1) gera uma task do worker com a description como prompt e o skillName roteando para o adapter certo; (2) para cada string em proof[], gera uma task de processo (bash -c <cmd>) com dependsOn: [<workerTaskId>]. O resultado: o worker termina, os proof[] rodam automaticamente, e o Proof Gate (próxima seção) lê units/<id>/proof-results.jsonl para decidir GO/NO_GO. Não-zero é fail-closed.
A pipeline de gates
Toda missão atravessa cinco gates, em ordem fixa. Cada gate retorna Result<T, Error> — um err é fail-closed e bloqueia o próximo gate:
1. Scope Gate
Implementado em packages/forge/src/scope.ts. Resolve o id do run (content-addressed via resolveRunId sobre o RunSpec), copia GOAL.md, alembic.plan.ts e validation-contract.md para <dataDir>/runs/<runId>/, valida-os contra meta.json em caso de resume. Falha aqui é desvio de escopo — o run nem inicia.
2. Council Gate (pre-flight, opt-in)
Definido em packages/mission/src/council-gate.ts. Roda um pequeno board (optimist + pragmatist + contrarian, default usando o adapter offline determinístico) sobre o objetivo da missão. Devolve GO, NO_GO, ou NEEDS_REVIEW. NO_GO aborta antes de qualquer worker ser despachado.
3. Proof Gate
Implementado em packages/coda/src/proof.ts. Após o draining do swarm, lê o journal append-only events.jsonl, filtra as tasks metadata.kind === 'proof', agrega por unitId e escreve units/<unitId>/proof-results.jsonl. Qualquer proof com outcome === 'failed' retorna err(new Error('Proof Gate failed: ...')). Fail-closed por construção.
4. Validator Gate
Em packages/coda/src/validator.ts. Quem constrói não valida: um board independente de validadores delibera sobre o resultado de cada milestone, e um verifier panel classifica em verified / needs-review / rejected. Só approved === true permite emissão. Para T4, há um seam de fusão de alto risco (fan-out a múltiplos modelos com juiz separado).
4b. Coordinated Validator Gate (opt-in --coordinated)
ADITIVO ao gate anterior, em packages/coda/src/coordinated-validator.ts. Aplica o padrão multi-lens: o risk tier da unit escolhe quais sub-reviewers ("lenses" — requirements, completeness, coherence, ...) rodam; cada lens revisa via um ModelAdapter injetado; achados merge com um floor determinístico (a camada LLM pode elevar severidade, nunca abaixar). Verdict pass / needs-review / fail é gravado em units/<id>/coordinated-verdict.json. Nunca muda a decisão do Council Gate e nunca faz o run falhar — é puro registro de segunda opinião.
5. Publish Gate
Em packages/coda/src/publish.ts. Qualquer artefato outward-facing (curso visual-teach, manifest, etc.) passa por aqui. Se approved === false, o artefato é parqueado em t4-parked.jsonl (irreversibility ledger) e aguarda aprovação humana. Se approved === true, despacha pelos publishers injetados — gist sempre, pages quando configurado — e retorna as URLs públicas.
O harness: o transporte neutro
O @alembic/harness é o conductor de topo. Seu núcleo (HarnessCore, em packages/harness/src/core.ts) tem apenas quatro verbos puros: start, poll, fanout, report. Ele dirige o council (runDebate + verifyDecision) e o swarm (partitionByAutonomy + runSwarm) e emite eventos tipados (HarnessEvent) por um EventBus. Crucialmente: o core não conhece transporte. Cima dele rodam três adapters:
- CLI (
cli.ts): verbosdistill,run,status— o caminho que o seu terminal usa. - HTTP + SSE (
http.ts+server.ts): contrato REST tipado mais um server-sent-events comLast-Event-IDpara resume.POST /runsaceita{ phase, goal, plan, contract?, yes? };GET /runs/:runId/eventsentrega o stream. - MCP read-only (
mcp.ts): expõe o core como tools tipadas (harness_status,harness_events,harness_lane) — somente leitura, semstart/fanout. A asimetria é deliberada: o transporte menos confiável (um host MCP arbitrário) ganha a menor autoridade.
Os cinco comandos da CLI
alembic run — executa uma missão
# Forge scope (GOAL.md + alembic.plan.ts), sem cache, com aprovação default
$ alembic run --goal GOAL.md --plan alembic.plan.ts --yes
# Resume um run específico
$ alembic run --goal GOAL.md --plan alembic.plan.ts \
--resume <run-id> --no-cache --yes
# Coordinated validator (opt-in: registra verdict, não muda o run)
$ alembic run --goal GOAL.md --plan alembic.plan.ts --coordinated --yes
# Ou um spec direto (mission.json / tasks.json)
$ alembic run <spec.json>
--no-cache pula leitura/escrita do cache de h.agent() e h.swarm() (a chave do cache é o SHA-256 de { prompt, opts }, em <runDir>/workflows/<wf-id>/cache.json). --resume <run-id> reutiliza <dataDir>/runs/<run-id> e valida goal/plan/contract contra meta.json. --coordinated liga o Coordinated Validator Gate (opt-in, aditivo, nunca bloqueia).
alembic runs list — lista runs salvas
$ alembic runs list
runs under ~/.alembic/runs:
<runId> [done|failed|parked] <createdAt>
...
Lê dataDir/runs/*/meta.json e ordena por createdAt. --json emite o array bruto.
alembic tail <run-id> [-f] — segue o stream de eventos
# Últimas N linhas (default 50)
$ alembic tail <run-id>
# Modo follow: imprime conforme novos eventos chegam
$ alembic tail <run-id> -f
# JSON cru (uma linha por evento)
$ alembic tail <run-id> --json
Lê o events.jsonl de <dataDir>/runs/<run-id>/. Sem --json, formata via renderSwimlane(parsed) do @alembic/tui — uma linha por swimlane (orquestrador, council, taskLane). Em modo -f, segura o processo até SIGINT/SIGTERM.
alembic tui <run-id> — UI ASCII em tempo real
$ alembic tui <run-id>
Roda o runTuiApp({ runDir }) do @alembic/tui. UI ASCII interativa com swimlanes coloridos, contagem de tarefas por status, e o ledger T4. Lê do mesmo events.jsonl que o tail; o disco é a fonte de verdade.
alembic serve — harness HTTP+SSE como daemon
$ alembic serve
Alembic harness server listening on http://localhost:<port>
# POST /runs cria um run em background
$ curl -X POST http://localhost:<port>/runs \
-H 'content-type: application/json' \
-d '{"phase":"run","goal":"GOAL.md","plan":"alembic.plan.ts","yes":true}'
# Inspeção
$ curl http://localhost:<port>/runs/<runId>/status
$ curl http://localhost:<port>/runs/<runId>/events # SSE stream
O createRun seam materializa o run em background via runGoalPlan e devolve um 202 com o snapshot imediato. --offline força o adapter determinístico (default em CI/testes); sem ele, usa o cliproxyapi.
alembic cockpit — dashboard web sobre run-dirs
$ alembic cockpit
Alembic cockpit listening on http://localhost:<port>
HTTP+SSE com uma UI HTML que lê o dataDir e renderiza o estado dos runs (swimlanes, ledger T4, parking) em tempo real. Read-only por design — não inicia runs.
events.jsonl; o checkpoint é uma fotografia comprimida do estado da fila; o report file é commitado por rename atômico. tail, tui, cockpit e o cliente SSE do serve são todos readers do mesmo journal. Você pode matar a CLI no meio, voltar dois dias depois, rodar alembic run --resume <run-id> e o orquestrador replaya o journal, demove orphans de volta para ready e continua de onde parou.
Como funciona por dentro
Replay determinístico (em packages/swarm/src/orchestrator.ts, função replayInto): aplica o último RunCheckpoint sobre uma fila fresca, depois aplica todos os eventos task-state em ordem (a aplicação é idempotente — o último estado por id ganha). Em seguida, recoverOrphans percorre a fila e demove qualquer task que ficou running de volta para ready com updatedAt renovado. É a mesma função usada por runSwarm (execução) e por resumeQueue (inspeção), garantindo que execução e leitura nunca divergem sobre como interpretar um journal.
Park ledger (em packages/swarm/src/park.ts): classifyPark(spec) devolve uma ParkReason (tier-t4, irreversible, legal, security, manual) ou undefined. Tasks classificadas vão para t4-parked.jsonl e ganham status parked na fila. alembic propose <run-id> reabre essas tasks como um run de proposta; alembic approve/reject --task-id <id> grava o veredicto em approvals.jsonl/rejections.jsonl.
Isolation por git worktree (em packages/swarm/src/worktree.ts, função withWorktree): tasks com isolate: true rodam em um worktree dedicado com porta determinística derivada do branch (evita colisão entre workers concorrentes). O orchestrator grava o descritor de worktree no journal (kind: 'worktree') — o audit trail de execução isolada — e garante o teardown via finally. Fail-closed: isolate: true sem worktree nas OrchestratorDeps é err, nunca silencia rodando un-isolated.
Reward shaping (PARL-style, não RL): computeReward(task, report, ctx) em packages/swarm/src/reward.ts devolve um escalar 0–1 com proveniência (components + rationale) e flag requiresApproval. O sinal vai para o journal (kind: 'reward') e não influencia rotas até receber aprovação humana (HITL gating). É um sinal de telemetria, não um optimizer.
HarnessCore: a separação de transporte. Quatro verbos puros — start, poll, fanout, report — vivem em packages/harness/src/core.ts. Eles emitem HarnessEvents (kind: 'council-started' | 'council-decided' | 'swarm-partitioned' | 'task-state' | ...) por um EventBus. Os três adapters (cli.ts, http.ts+server.ts, mcp.ts) são shapes de transporte que nunca importam node:http no shape — só o server.ts binda porta real. Resultado: a mesma orquestração roda em três superfícies sem duplicação.
Quiz
Por que o swarm tem MAX_DEPTH = 2?
Recursão precisa de uma cota dura ou o sistema pode spawnar indefinidamente. A escolha de 2 níveis (lead → worker, abaixo do orchestrator implícito) modela o padrão clássico de fan-out sem permitir leads-de-leads-de-leads (que explodiriam a topologia). A função canSpawn(depth) em orchestrator.ts é a guarda; uma subtask que carrega subtasks ela mesma é estruturalmente proibida pelo schema Zod (taskSpecBaseSchema ≠ taskSpecSchema).
O que distingue o Validator Gate canônico do Coordinated Validator Gate opt-in?
O Validator Gate canônico (validator.ts) é parte da pipeline — fail-closed; só approved === true permite emitir. Ele roda um board de validadores independentes ("quem constrói não valida") e usa o council para decidir.
O Coordinated Validator (coordinated-validator.ts, --coordinated) é aditivo: roda em paralelo, aplica o padrão multi-lens (cada lens revisa um aspecto), grava o verdict em coordinated-verdict.json, mas nunca bloqueia o run. É uma segunda opinião gravada — útil para análise post-hoc, não para decisão.
Se o processo for morto durante o draining, o que acontece quando você rodar alembic run --resume <run-id>?
O runSwarm chama replayInto(queue, store, now), que (1) aplica o último RunCheckpoint, (2) replica todos os eventos task-state em ordem (idempotente — o último por id ganha), e (3) chama recoverOrphans para demover qualquer task que ficou em running de volta para ready (o worker dela morreu mid-flight; precisa re-tentar). O run continua de onde parou — tasks done não rodam de novo, parked não saem do ledger, e o draining recomeça pelos ready. Tudo isso porque o filesystem é a fonte de verdade — o journal append-only mais o report file commitado por rename atômico [a verificar: comportamento exato sob crash parcial do rename em sistemas de arquivos não-POSIX].
Próxima parada: a lição AI Employee mostra como um employee (Iris, Hermes) é só uma composição de soul + skills + memória + connectors + schedule, e como employee run vira uma unit que entra exatamente neste runtime. A lição capabilities cobriu os tijolos $0 (embed, ocr, triage, memory, context-pack) que cada unit consome lá dentro.