Você vai entender por que, quando a unidade de trabalho — "fábrica de petições" — entra no swarm, ela não pode correr para sempre: o Alembic não conta passos, ele limita a forma do trabalho. Profundidade (MAX_DEPTH=2), re-litígio (MAX_LOOPS=3), o DAG que esvazia, o timeout do subprocesso e o park T4 — mais a VM que recusa Date.now() e o roteador que degrada sem nunca lançar exceção.
Esta lição destila os princípios nº 11/12/22 do mapa e os ancora em código real: packages/swarm/src/{orchestrator,park,process}.ts, packages/council/src/verifier.ts, packages/vm/src/run-plan.ts, packages/harness/src/funnel.ts. Leitura complementar do founder: learn-harness-engineering (PT-BR) e ai-engineering-from-scratch.
ModelAdapter + Result), e que a saída é validada com Zod nos boundaries.maxIterations — e o que ele usa no lugar.Date.now(), new Date(), Math.random()) e por quê (cache + resume).err e o funil degrada o tier sem lançar.Um agente autônomo que pode chamar a si mesmo e chamar modelos é um foguete sem corte de combustível: sem limites, ele queima dinheiro, gira em círculos, ou faz algo irreversível. O trabalho do harness é instalar os guardrails — as cercas que garantem que o loop sempre para, e para do jeito certo.
O detalhe surpreendente do Alembic: ele não conta passos. Não existe um maxIterations = 50 em lugar nenhum. Em vez disso, o Alembic limita a forma do trabalho. A unidade "fábrica de petições" não é um loop livre que pergunta "o que faço agora?" indefinidamente — ela é um grafo finito de tarefas (um DAG). Um grafo finito sempre esvazia. Isso é terminação por construção, não por relógio.
Pense como… uma esteira de fábrica, não um hamster na roda. A roda do hamster pode girar para sempre — você precisa de um cronômetro para pará-la. A esteira tem um começo e um fim: quando a última peça sai, a esteira para sozinha. O Alembic transforma o agente de hamster em esteira. Onde a analogia quebra: a esteira é uma linha reta; o DAG pode ter ramos paralelos (um líder abre vários operários) — mas continua finito.
A terminação no Alembic é estrutural, não um step-counter (HARNESS-MAP.md §11). São quatro mecanismos somados, cada um em um arquivo diferente: (a) o plano é um DAG finito de units[]/milestones[] — o drain roda while (!queue.isComplete()) e cada lote move ≥1 tarefa a um estado terminal (orchestrator.ts:294); (b) o BudgetGuard bloqueia a próxima chamada paga antes que o teto seja rompido (etl/budget.ts); (c) o park T4 retém trabalho irreversível/legal/de segurança da execução autônoma (swarm/park.ts); (d) cada subprocesso tem um timeout e um teto de buffer (swarm/process.ts).
O gap honesto: para um loop ReAct aberto (o estilo "pensa → age → observa → repete" sem grafo prévio), faltaria um teto maxIterations/maxToolCalls. O Alembic não precisa dele porque seu modelo é um DAG limitado — mas se um dia ele expusesse um agente livre, precisaria adicionar um. Honestidade vale como engenharia: nomeie a fronteira do seu modelo.
Os guardrails desta lição vivem todos na camada Harness — a única das três camadas que o Alembic possui. A moldura "Self-Learning Agents" do founder separa: Model (os pesos, delegado), Harness (o loop/gates/routing, possuído) e Context (memória, composto). O loop e seus limites são território de casa.
Aqui está o caminho da unidade "fábrica de petições" através do loop, com os guardrails como portões. Repare: cada portão é uma forma de parar — ou de continuar com segurança.
drain, que esvazia o grafo até ele acabar.Vamos abrir cada uma das quatro cercas (mais o park, a quinta e mais dura). Pense nelas como motivos diferentes pelos quais o loop não corre para sempre — e repare que nenhum é "contei 50 passos, paro".
O Alembic tem três papéis em uma hierarquia: orquestrador (profundidade 0) → líder (1) → operário (2). Um orquestrador pode abrir líderes; um líder pode abrir operários; um operário é uma folha — ele faz o trabalho e nunca abre ninguém. A regra que garante isso é uma linha: canSpawn = depth < MAX_DEPTH. Com MAX_DEPTH = 2, a árvore não pode crescer infinitamente para baixo.
canSpawn é falso — o operário é folha e a árvore para de crescer. (swarm/types.ts:25,32)// types.ts — a escada e o teto numa constante só export const ROLE_DEPTH = { orchestrator: 0, lead: 1, worker: 2 }; export const MAX_DEPTH = 2; // orchestrator.ts — o limite de recursão, em uma linha: export const canSpawn = (depth: number): boolean => depth < MAX_DEPTH; // e em runtime, fail-closed se um líder estiver fundo demais: if (subtasks.length > 0 && !canSpawn(depth)) { return err(new Error(`task '${task.id}' has subtasks but is at max depth`)); }
O orquestrador drena no nível do líder (ROLE_DEPTH.lead = 1); uma tarefa que carrega subtasks roda como um sub-run filho uma profundidade abaixo (executeOne(task, ctx, depth + 1)). Quando esse depth + 1 chega a 2, canSpawn vira falso: uma tarefa com subtasks nesse nível é um erro fail-closed, nunca uma recursão silenciosa. O efeito: a árvore tem no máximo três níveis, então o número total de nós é limitado pela largura, não pela profundidade. Limitar profundidade é o que impede "um agente que abre um agente que abre um agente…" — a forma de runaway mais cara.
Um operário (profundidade 2) recebe uma tarefa que, por engano, carrega subtasks. O que o Alembic faz?
err fail-closed: "task '…' has subtasks but is at max depth 2 (a leaf cannot spawn)". Ele não ignora as subtasks silenciosamente nem tenta abri-las assim mesmo. A regra é dura: na dúvida, falhe alto. (orchestrator.ts:343)MAX_DEPTH limita o espaço (quão fundo a árvore vai). MAX_LOOPS limita o tempo (quantas vezes a mesma decisão pode ser re-julgada). O verificador (o "checker" do padrão maker-checker) decompõe uma decisão do conselho em claims atômicos e prova cada um com um oráculo determinístico. Se a mesma decisão já foi re-litigada mais de 3 vezes (loopCount > MAX_LOOPS), o verificador para de re-decidir e escala para T4 — entrega ao humano em vez de girar para sempre.
parkedTier = escalateTier(T3) = T4. O loop de decisão não pode girar para sempre. (council/verifier.ts:49,233)export const MAX_LOOPS = 3 as const; export const verifyDecision = (options: VerifyOptions): VerificationReport => { // ... prova cada claim com seu oráculo determinístico ... const loopCount = options.loopCount ?? 0; if (loopCount > MAX_LOOPS) { // escala ao topo da escada (T4) e PARA de re-decidir. const parkedTier = escalateTier(Tier.T3); return { verdict: 'needs-review', proofs, provenCount, parkedTier }; } return { verdict: verdictFromProofs(proofs), proofs, provenCount }; };
DebateResult do maker e o ContextPack), não tem adapter, não tem registry, não pode re-rodar um modelo nem editar um voto. Ele só inspeciona evidência estruturada (votos, scores, spread, quórum) e emite um veredito. É isso que o torna um checker, não um segundo maker. Os oráculos nunca leem a prosa do maker — só os números.TIER_LADDER): escalateTier sobe um degrau; quando o verificador escala de T3, chega a T4 — o park humano, o topo da escada. (contracts/tier.ts:42,68)Esta é a terminação mais sutil e a mais importante. O drain roda enquanto houver tarefas ready (com dependências satisfeitas). A cada volta ele pega um lote, executa, e aplica o resultado. A garantia de terminação está em uma frase: cada lote move pelo menos uma tarefa para um estado terminal (done/failed/parked). Como o grafo é finito, o número de estados terminais é finito — então o loop tem que acabar.
const drain = async (queue, ctx, depth) => { while (!queue.isComplete()) { const batch = queue.readyTasks().slice(0, ctx.maxConcurrency); if (batch.length === 0) break; // só sobra bloqueado-por-falha; pare. const outcomes = await Promise.all(batch.map((t) => executeOne(t, ctx, depth))); for (const outcome of outcomes) await commitOutcome(queue, outcome.value, ctx); } };
drain sempre converge.isComplete() vira true), ou (b) só restam tarefas bloqueadas por uma dependência que falhou — e aí batch.length === 0 dispara o break. Não há terceiro caso. Sem relógio, sem contador: a topologia do grafo garante o fim. Isto é o equivalente de engenharia da "esteira que para quando a última peça sai".running para ready indefinidamente, o loop giraria. O Alembic só faz essa demoção uma vez, na recuperação de órfãos no resume (recoverOrphans) — não dentro do drain. É um detalhe que a leitura do código revela e a intuição esconde.Quando um operário roda um comando externo (hoje operações de git worktree; amanhã builds e testes), esse comando é um processo filho que poderia travar ou inundar a memória. runProcess instala duas cercas duras: um timeout de relógio de parede (padrão 120s) que mata o filho, e um teto de buffer (padrão 10 MiB) de stdout/stderr — um filho desgovernado não pode pendurar nem causar OOM em um run AFK de horas.
export const DEFAULT_PROCESS_TIMEOUT_MS = 120_000; // 120s export const DEFAULT_PROCESS_MAX_BUFFER = 10 * 1024 * 1024; // 10 MiB // argv array (NUNCA string de shell — sem superfície de injeção), // timeout, AbortSignal, e teto de buffer. Honra never-throws: execFile(file, [...args], { cwd, timeout: opts.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS, maxBuffer: opts.maxBuffer ?? DEFAULT_PROCESS_MAX_BUFFER, signal: opts.signal, killSignal: 'SIGTERM', }, (error, stdout, stderr) => { if (error && typeof error.code === 'number') // rodou e saiu ≠0 → é um processo FINALIZADO resolve(ok({ code: error.code, stdout, stderr })); else if (error) // ENOENT/ETIMEDOUT/ABORT/ENOBUFS → não rodou até o fim → err resolve(err(toError(error))); });
typeof error.code: número → o processo rodou e finalizou → ok; string → não rodou até o fim → err. Nunca um throw. (process.ts:76–84)ok (tem um code numérico — ele rodou). Só uma falha de spawn, timeout, abort ou overflow de buffer (códigos-string) é err. O discriminador é typeof error.code: número = finalizou; string = não rodou até o fim. Saída ≠ 0 é um sinal, não um crash.As quatro terminações acima fazem o loop parar. O park T4 faz algo diferente: ele garante que certo trabalho nunca corra sozinho. Qualquer tarefa marcada como T4, irreversível, ou com marcador legal/segurança é roteada para o ledger (t4-parked.jsonl, append-only) e exige conselho + adjudicação humana antes de poder rodar. E o detalhe de ouro: o tier padrão é T4 — então trabalho não-classificado faz park por padrão. Na dúvida, park.
classifyPark decide: marcadores legal/security/irreversible (nessa ordem de precedência), depois a regra T4. Retornar undefined = elegível para autônomo. (swarm/park.ts:38–45)// a precedência: o motivo mais específico vence (entra no ledger). export const classifyPark = (spec: TaskSpec): ParkReason | undefined => { for (const [key, reason] of REASON_KEYS) // legal, security, irreversible if (spec.metadata[key] === true) return reason; 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 }; // tier.ts — na dúvida, park: export const DEFAULT_TIER: Tier = Tier.T4;
| Trava | Limita o quê | Constante / regra | Onde mora |
|---|---|---|---|
| MAX_DEPTH | Profundidade da árvore (espaço) | 2 · canSpawn = depth<2 | swarm/types.ts:32 |
| MAX_LOOPS | Re-litígio da mesma decisão (tempo) | 3 · loopCount>3 → T4 | council/verifier.ts:49 |
| DAG drain | Total de trabalho (topologia) | cada lote esvazia ≥1 → terminal | swarm/orchestrator.ts:294 |
| Subprocesso | Tempo + memória de um filho | 120s · 10 MiB | swarm/process.ts:42 |
| Park T4 | Autonomia (o humano decide) | DEFAULT_TIER = T4 | swarm/park.ts:38 |
Há um guardrail de natureza diferente das cinco travas: ele não para um loop em runtime — ele recusa o plano antes de executá-lo. A VM determinística lê o módulo alembic.plan.ts e o rejeita se ele usar Date.now(), new Date() ou Math.random(). Por quê? Porque um plano que depende da hora ou do acaso não pode ser cacheado nem retomado — rode-o duas vezes e você terá dois resultados diferentes, e o cache + o resume quebram.
Pense como… a regra de uma receita de bolo publicada: "não escreva 'asse até parecer pronto'". Se a receita diz "tire às 14h32 de hoje", ninguém consegue repeti-la amanhã. A VM exige que o plano seja uma receita reproduzível: mesmas entradas → mesmo bolo, sempre. Onde quebra: a receita pode usar um timer (uma duração relativa); o que ela não pode é cravar um instante absoluto.
.ts usa regex (Acorn não parseia TypeScript), .js usa AST Acorn. Ambos recusam os mesmos três construtos. Fail-closed. (vm/run-plan.ts:16–28,53–66)// Para .ts, um sweep de regex (conservador: pode pegar dentro de comentário, // mas um falso-positivo é mais seguro que deixar não-determinismo passar). const forbidden = [ { pattern: /\bDate\.now\s*\(/, name: 'Date.now()' }, { pattern: /\bnew\s+Date\s*\(\s*\)/, name: 'new Date()' }, { pattern: /\bMath\.random\s*\(/, name: 'Math.random()' }, ]; for (const { pattern, name } of forbidden) if (pattern.test(source)) return { ok: false, reason: `Non-deterministic construct detected: ${name}` };
A VM é a "fiscal da reprodutibilidade". Se seu plano tenta saber a hora ou jogar um dado, ela barra na porta e diz exatamente qual construto encontrou. Isso é o que permite o cache (rodar de novo = mesmo resultado, então o resultado anterior vale) e o resume (retomar um run do meio sem refazer o que já passou).
Há duas verificações porque há dois formatos de plano. Para .js, checkDeterminism usa a AST do Acorn — preciso, não pega ocorrências em comentários/strings. Para .ts, Acorn não parseia a sintaxe TypeScript, então o sweep de regex entra como rede de segurança intencionalmente conservadora. A assimetria é deliberada: prefere-se um falso-positivo (barrar um Date.now() dentro de um comentário) a um falso-negativo (deixar passar um real). Em ambos os caminhos, achar um construto proibido faz o runPlan devolver { ok: false } — fail-closed antes de qualquer import do módulo.
Date.now() mudaria a chave a cada vez — o cache nunca acertaria.O último pedaço do loop é a escolha do modelo e o que acontece quando algo dá errado. Aqui o Alembic toma uma decisão contraintuitiva e forte: ele recusa fazer fallback silencioso. Se não há modelo para um tier, o roteador devolve um err tipado e deixa o chamador decidir escalar — ele não troca por um modelo mais barato sem avisar.
Pense como… um farmacêutico honesto. Faltou o remédio da marca que o médico prescreveu? Um farmacêutico desonesto entrega um genérico parecido sem dizer nada (fallback silencioso). O honesto avisa: "não tenho este; quer falar com o médico sobre uma alternativa?" (devolve err, o chamador escala). A segunda postura é mais trabalhosa, mas nunca esconde uma regressão de custo ou capacidade.
err (no_model_for_tier / adapter_not_registered) e o chamador decide escalar via escalateTier. (HARNESS-MAP.md §12)"No-silent-fallback" não significa "estoure na cara do usuário". Significa: quando o orçamento bloqueia uma chamada paga, o funil degrada o tier — mantém o sinal não-refinado — em vez de lançar uma exceção. O loop continua, só com menos qualidade naquele item, e conta quantos itens foram bloqueados. Degradação graciosa, fail-closed, never-throws.
// T2: refina os sinais mais fortes via chamada FRONTIER, com checagem de // orçamento fail-closed antes de CADA chamada paga. Um bloqueio degrada // o tier (o sinal fica não-refinado) em vez de lançar. for (const batch of chunk(shortlist, opts.batchSize)) { const check = opts.budget.check({ modelId: route.pricingModelId, usage: estimateUsage(input) }); if (!check.ok) { blocked += 1; // conta o item degradado logAt(logger, 'warn', 'funnel T2 budget-blocked', { spentUsd: check.spentUsd }); continue; // NÃO roda a chamada paga; segue o loop } const result = await route.adapter.run(input); // cabe no teto → roda meterSpend(opts.budget, route.pricingModelId, result); }
BudgetGuard é pré-flight: ele projeta o custo da próxima chamada e a bloqueia antes de gastar se o teto seria rompido. Nada é desperdiçado. (etl/budget.ts:131–145)BudgetGuard bloqueia a próxima chamada antes de gastar (pré-flight, não pós-fato); (2) T0/local é sempre $0 e nunca bloqueado — o substrato grátis sempre roda, então há sempre um caminho de degradação; (3) o bloqueio vira um contador (t2BudgetBlocked) no relatório, não um crash. O sinal sobrevive não-refinado; o run continua.Vire cada cartão (clique, ou Enter/Espaço) e tente responder antes de ver o verso. Recuperar da memória fixa melhor que reler.
maxIterations. A terminação é estrutural: o DAG esvazia, o orçamento bloqueia, a profundidade limita, o park retém.canSpawn = depth < 2 garante?loopCount > 3 no verificador?parkedTier = escalateTier(T3)). O humano assume; o loop de decisão não gira para sempre.Date.now(), new Date(), Math.random(). Porque quebram cache e resume: um plano não-determinístico não pode ser retomado.DEFAULT_TIER, e o que isso implica?Vamos seguir a unidade "fábrica de petições" por um run que estressa todos os guardrails de uma vez, passo a passo.
classifyPark vê irreversible: true → retorna 'irreversible' → vai para o t4-parked.jsonl. Ela nunca roda sozinha. As sub-unidades de geração de minuta (T2) seguem.canSpawn(2) é falso, nenhum pode abrir mais. O drain roda enquanto há ready.BudgetGuard projeta que a próxima chamada frontier romperia o teto. check.ok é falso → o item é degradado (sinal não-refinado), t2BudgetBlocked += 1, o loop continua. Sem crash.git worktree add. O comando trava. Aos 120s, runProcess mata o filho (ETIMEDOUT) → devolve err. A tarefa vira failed, não pendura o run.loopCount = 4 > 3), o verificador escala: parkedTier = T4. Humano decide.batch.length === 0 → break. O drain retorna. O run termina, com um checkpoint, sem nunca ter "contado passos".irreversible nem tier explícito, em qual tier ela cairia — e o que aconteceria? (Dica: DEFAULT_TIER.) Resposta: T4 → park por padrão. Na dúvida, o humano decide.Mexa nos controles. Este simulador aplica as mesmas regras do código: canSpawn = depth < 2, o teto de orçamento, e loopCount > 3 → T4. Veja o veredito mudar de RODA para PARA em tempo real — e por quê.
Cole (ou escolha um exemplo) um trecho de alembic.plan.ts. Este verificador roda o mesmo sweep de regex da VM (vm/run-plan.ts) e diz se a VM aceitaria ou recusaria o plano — e qual construto a barrou.
A escolha de design mais importante desta lição: o Alembic não é um loop ReAct aberto. Entender a diferença é entender por que ele não precisa de um contador de passos — e onde estaria o gap se ele um dia precisasse.
"Pensa → age → observa → repete" sem um grafo prévio. O agente decide o próximo passo a cada volta. Pode girar para sempre → precisa de um teto maxIterations/maxToolCalls explícito como única defesa. Flexível, mas a terminação é frágil (depende de um número bem escolhido).
Um grafo finito de units[]/milestones[] decidido antes de rodar. O drain esvazia o grafo; cada lote move ≥1 ao terminal. Termina por construção → não precisa de contador. Menos flexível em runtime, mas a terminação é estrutural e provável.
maxIterations/maxToolCalls ceiling on an open-ended agent loop, because Alembic's model is a bounded DAG, not an open ReAct loop." Se o Alembic algum dia expuser um agente livre, ele precisará adicionar esse teto. Hoje, o cap de orçamento + a finitude do DAG substituem. Nomear a fronteira do seu modelo é engenharia honesta.| Situação do filho | error.code | Resultado | Por quê |
|---|---|---|---|
| Rodou e saiu 0 | — | ok({ code: 0 }) | Sucesso normal |
| Rodou e saiu ≠ 0 | número | ok({ code: n }) | Finalizou — saída ≠ 0 é um sinal, não um crash |
| Comando não existe | ENOENT (string) | err | Não rodou |
| Estourou 120s | ETIMEDOUT | err | Não rodou até o fim |
| Estourou 10 MiB | ENOBUFS | err | Morto por proteção de memória |
Os seis pontos que ficam. Avance com → ou os botões.
Três perguntas. Sua pontuação acumula abaixo.
maxIterations no loop principal?drain roda enquanto há ready e cada lote move ≥1 tarefa ao terminal num grafo finito (orchestrator.ts:294). O gap honesto: um loop ReAct aberto precisaria de um teto — o DAG limitado o substitui.loopCount ultrapassa MAX_LOOPS (3)?verifier.ts:233). Ele é read-only — não pode re-rodar modelo nem editar voto, só inspecionar evidência.BudgetGuard bloqueia uma chamada paga no funil T2, o que ocorre?t2BudgetBlocked, e faz continue (funnel.ts:454). T0/local é $0 e nunca bloqueado, então há sempre um caminho de degradação. É fail-closed sem lançar.maxIterations.canSpawn = depth < 2: um operário é folha e nunca abre outro agente.DEFAULT_TIER = T4: o não-classificado e o irreversível ficam para o humano.Date.now()/new Date()/Math.random() — ou não há cache nem resume.err; deixe o chamador escalar explicitamente.ok; só não-rodou é err.