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.

orchestrator depth 0 · runSwarm() lead depth 1 · sub-run lead depth 1 · sub-run worker depth 2 · folha worker worker worker

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:

Scope forge/scope.ts Council mission/council-gate Proof coda/proof.ts Validator coda/validator.ts (+ coordinated opt-in) Publish coda/publish.ts

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:

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

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.

Insight central: o sistema é resumível e crash-safe porque o filesystem é a fonte de verdade. Cada transição é uma linha em 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 (taskSpecBaseSchemataskSpecSchema).

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.