ClarifyGateway — o gate humano T4
Quando o agente precisa de uma decisão humana antes de prosseguir, ele levanta uma pausa estruturada: uma pergunta (múltipla escolha ou aberta), e bloqueia na resposta. Em termos Alembic, esta é a superfície do gate humano T4 (ADR-0005). O Python bloqueia uma thread num threading.Event; o Node não tem thread bloqueante, então o equivalente fiel é uma promise + um registro de resolvers + um timeout. Um CLONE do clarify_tool.py + clarify_gateway.py do Hermes.
Esta lição destila o código real do CLONE, lido linha a linha (citações no rodapé). Por que importa pra missão: é a única costura por onde o agente pausa e devolve o controle ao humano — o ponto onde um gasto irreversível ou um ship esperam por um "vai" antes de acontecer.
- Explicar por que "bloquear" no Node é uma
Promise+ registro de resolvers, não uma thread. - Seguir o caminho de uma pergunta:
ask→ pendente →resolve(válido) outimeout. - Distinguir o que o schema Zod valida (forma) do que
validateResponsevalida (cruzado). - Justificar a regra "deixe pendente" e o
monotonicIdFactorydeterminístico.
01 · O problema: como "bloquear" sem uma thread
Imagine um caixa de banco que precisa da assinatura do gerente antes de liberar um saque grande. No mundo do Python, o caixa simplesmente congela na janela — a thread dele dorme num threading.Event — até o gerente apertar o botão. Limpo, mas só funciona porque existe uma thread para congelar.
O Node é single-threaded: não há thread para congelar — se você "dormir", o programa inteiro para. Então o equivalente fiel troca o congelamento por uma promessa de papel: o caixa entrega ao chamador um comprovante (Promise) que diz "te aviso quando o gerente decidir", guarda o pedido numa gaveta indexada (Map<id, pending>), e segue atendendo. Quando a decisão chega — por id — a promessa é liquidada. E um despertador (setTimeout) garante que, se ninguém decidir, o comprovante não fica pendurado para sempre.
threading.Event do Python vira o callback resolve da promise guardada; o dicionário module-level _entries vira this.entries; e a deadline de polling vira o timer do setTimeout. Mesmo comportamento, mecânica nativa do runtime.02 · O mecanismo: ask registra, resolve liquida
O coração do gateway são dois métodos e um descarte automático. ask valida a pergunta, cunha um id, arma o despertador e devolve a promise. resolve recebe uma resposta por id e — se ela for válida — liquida a promise. O timeout é o terceiro caminho: se ninguém responder a tempo, ele descarta a entry e resolve com err. Veja os três caminhos de uma só pergunta:
Em código — o ask condensado, com os dois detalhes que o tornam à prova de vazamento:
// packages/hermes/src/clarify/gateway.ts:82-110 — ask (condensado) const parsed = clarifyQuestionSchema.safeParse(question); if (!parsed.success) return err(new Error(`Invalid clarify question: …`)); // falha fechado SINCRONAMENTE const id = this.mintId(); return new Promise((resolvePromise) => { const timer = setTimeout(() => { this.entries.delete(id); // descarta a entry… resolvePromise(err(new Error('clarify timed out'))); // …nunca trava }, timeoutMs); timer.unref?.(); // deixa o processo sair enquanto pendente const settle = (result) => { clearTimeout(timer); this.entries.delete(id); resolvePromise(result); }; this.entries.set(id, { id, question: valid, settle, timer }); });
"returns err for a >MAX_CHOICES question and registers nothing"). (2) timer.unref() deixa o processo Node sair mesmo enquanto um clarify está pendente — um prompt humano travado não mantém o runtime vivo para sempre.A ordem importa: valida ANTES de registrar
Repare na sequência dentro de ask — ela é deliberada. A validação vem primeiro, antes do id ser cunhado e da entry entrar no Map. Por quê? Porque se a pergunta é lixo, queremos rejeitá-la sem deixar rastro — nenhuma entry pendente, nenhum timer armado, nada para limpar depois:
O ciclo de vida de uma entry no Map
Cada pergunta viva é uma entry no entries: Map<id, pending>. O método pending() é só a janela para essa gaveta — [...this.entries.keys()], na ordem de inserção. Veja os estados pelos quais uma entry passa:
03 · Linha do tempo: quem ganha, settle ou timeout?
Os dois finais de uma pergunta — resposta válida ou timeout — são uma corrida: o que chegar primeiro liquida a promise; o outro caminho some (o clearTimeout cancela o despertador, ou o delete remove a entry). Arraste para escolher quando a resposta humana chega e veja o veredito mudar:
setTimeout é o seguro: passada a deadline, ele descarta a entry e resolve a promise com err('clarify timed out'). O chamador sempre recebe uma resposta — um Result de sucesso ou um de erro — nunca um silêncio eterno. É a diferença entre "geralmente funciona" e "fail-closed".04 · O contrato de dados: choice ou open, limitado a 4
Antes de bloquear em qualquer coisa, o gateway precisa saber o que está sendo perguntado. Uma pergunta é uma união discriminada em kind — ou ela traz uma lista de opções (choice), ou é texto livre (open). Pense numa enquete: ou você marca um quadradinho de uma lista, ou você escreve sua resposta no campo aberto. Nunca os dois.
// packages/hermes/src/clarify/types.ts:48-61 export const clarifyQuestionSchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('choice'), prompt: z.string().min(1, 'prompt cannot be empty'), choices: z.array(z.string().min(1, …)) .min(1, 'choice question needs at least one choice') .max(MAX_CHOICES, `choice question allows at most ${MAX_CHOICES} choices`), }), z.object({ kind: z.literal('open'), prompt: z.string().min(1, …) }), ]);
O helper tolerante: coerceChoices
Um helper sutil de robustez vem junto: coerceChoices, um clone do _flatten_choice da fonte. LLMs às vezes emitem choices em forma de dict ([{description:'…'}]) em vez de strings puras; isto as desembrulha por chaves canônicas de label, em ordem de prioridade. Veja a versão Simples e a Técnica:
"Reembolsar") e outros como uma caixinha embrulhada ({description:"Reembolsar"}). coerceChoices abre cada caixinha e tira a etiqueta de dentro — procurando, nesta ordem, por label, description, text, title. Se a caixa não tem nenhuma dessas, ela é jogada fora (vira '' e some), porque uma etiqueta-lixo é pior que opção nenhuma.// packages/hermes/src/clarify/types.ts:104-121 — flattenChoice const CHOICE_LABEL_KEYS = ['label', 'description', 'text', 'title'] as const; // name/value EXCLUÍDAS // string ⇒ trimada; dict ⇒ primeira chave canônica não-vazia; senão ⇒ '' (descartada) const flattenChoice = (raw) => { if (raw == null) return ''; if (typeof raw === 'string') return raw.trim(); for (const key of CHOICE_LABEL_KEYS) { … } // primeira não-vazia ganha return ''; };
name/value são deliberadamente excluídas — carregam valores de enum/identificadores brutos, não labels humanos, e um label-lixo é pior que nenhuma choice (o dict colapsa para '' e é descartado). Note que coerceChoices não limita a 4 — limitar é o trabalho do schema, então uma lista longa demais falha fechado na validação em vez de ser silenciosamente truncada.
coerceChoices roda primeiro. Quantas choices restam, e a pergunta é aceita?
coerceChoices só limpa (desembrulha dicts, descarta vazias); ele não corta para 4. Então as 5 choices limpas chegam intactas ao clarifyQuestionSchema, cujo .max(MAX_CHOICES) falha — e ask retorna err sincronamente, registrando nada. Se você chutou "ele trima para 4", caiu na armadilha clássica: truncar silenciosamente entrada não confiável esconde o bug em vez de falhar fechado. Limitar é trabalho do schema; limpar é trabalho do helper.05 · Validação cruzada: a resposta tem que servir à pergunta
Aqui está uma distinção que separa código robusto de código ingênuo. O Zod consegue checar a forma de uma resposta isolada — "é um objeto com kind:'choice' e um index inteiro ≥ 0?". Mas ele não consegue saber se essa resposta serve à pergunta viva: a pergunta era de choice ou open? O índice cabe na lista real de opções? Essas são invariantes cruzadas — entre resposta e pergunta — e quem as impõe é o validateResponse:
// packages/hermes/src/clarify/gateway.ts:143-168 — validateResponse if (value.kind !== question.kind) return err(new Error(`Response kind '${value.kind}' does not match question kind '${question.kind}'.`)); if (value.kind === 'choice' && question.kind === 'choice') { if (value.index >= question.choices.length) return err(new Error(`Choice index ${value.index} out of range …`)); }
| Resposta recebida | Pergunta viva | Camada que rejeita | Resultado |
|---|---|---|---|
{ kind:'choice', index:-1 } | choice, 3 opções | Camada 1 (Zod .min(0)) | err — forma inválida |
{ kind:'open', text:'sim' } | choice, 3 opções | Camada 2 (kind mismatch) | err — kind não bate |
{ kind:'choice', index:9 } | choice, 2 opções | Camada 2 (índice fora da faixa) | err — out of range |
{ kind:'choice', index:1 } | choice, 3 opções | passa as duas | ✓ settle(ok) |
06 · A regra "deixe pendente" — um modo de falha pensado
O que acontece quando uma resposta chega inválida? A escolha ingênua seria cancelar a pergunta — mas o gateway faz algo mais cuidadoso. Quando resolve recebe uma resposta inválida (kind errado, índice fora da faixa), ele retorna err mas deixa a entry pendente — para que uma resposta corrigida ainda possa chegar e liquidar a mesma promise. Só uma resposta válida liquida (e remove) a entry. Siga o fluxograma — cada losango é uma decisão:
Um resolve com kind incompatível falha mas pending() ainda lista o id; um resolve correto subsequente então o liquida. Em contraste, um id desconhecido ou já-liquidado é um err simples ("Unknown or already-resolved"), e um double-resolve falha porque o primeiro já removeu a entry. Casos verificados: kind-mismatch-leaves-pending (124–135), out-of-range (137–144), double-resolve (161–169).
07 · Determinismo: ids monotônicos, sem Math.random()
Cada pergunta precisa de um id único para ser encontrada na gaveta. A escolha óbvia seria um id aleatório — mas o motor Alembic proíbe Math.random() e Date.now() em código de plano (a plan VM os rejeita), e ids aleatórios quebrariam o replay (rodar duas vezes daria ids diferentes, e o replay não casaria). A solução é a mesma disciplina do Clock injetado do curador: trocar um global não-determinístico por uma costura injetável, com padrão de contador monotônico:
// packages/hermes/src/clarify/gateway.ts:176-182 export const monotonicIdFactory = (prefix = 'clarify'): (() => ClarifyId) => { let n = 0; return () => { n += 1; return `${prefix}-${n}`; }; };
O timeout padrão é DEFAULT_CLARIFY_TIMEOUT_MS = 600_000 (10 minutos), espelhando os 600s da fonte. Os testes o dirigem com fake timers do vitest, avançando além do prazo para provar que a entry é descartada e a promise resolve para err — sem leak, sem trava.
08 · O gate T4 no motor: onde a pausa se encaixa
Por que esse subsistema existe? Porque o Alembic roda autônomo — mas há ações que ele não deve tomar sozinho: um ship, um gasto irreversível. O ADR-0005 fixa que "o gate humano fica no ship / gasto irreversível". O ClarifyGateway é a superfície de dados desse gate — o ponto exato onde o agente para, levanta a pergunta e espera o humano. Veja onde ele se senta entre os tiers:
E o gate nunca lança através da fronteira: cada caminho falível devolve um Result<T, Error> do @alembic/contracts — ok(valor) ou err(erro). Quem chama trata o erro como um valor, não como uma exceção que pode escapar e derrubar o loop. É a disciplina fail-closed do projeto inteiro, aplicada aqui:
Confusões comuns
Promise retornada que quem chamou aguarda; um registro de resolvers (Map<id, pending>) deixa um callback de plataforma liquidá-la por id, e um setTimeout garante que nunca trave. É o equivalente fiel do threading.Event do Python num runtime async.err enquanto a pergunta fica pendente para uma resposta corrigida. Só uma resposta válida (ou o timeout) remove a entry.09 · Trace a chamada, passo a passo (worked → agora você)
Você já viu cada peça isolada. Agora trace uma chamada inteira, devagar — depois um caso é seu. Recuperar a sequência (não só ver o resultado) é o que fixa.
ask({ kind:'choice', prompt:'Fazer deploy?', choices:['Sim','Não'] }). O schema valida (2 choices ≤ 4 ✓), mintId() devolve clarify-1, o timer é armado em 600 000 ms, a entry entra no Map e a Promise é retornada (ainda pendente).pending() agora retorna ['clarify-1'] — a pergunta está viva, esperando.resolve('clarify-1', { kind:'open', text:'talvez' }). validateResponse vê kind 'open' ≠ 'choice' → retorna err. Mas a entry fica. pending() ainda lista ['clarify-1'].resolve('clarify-1', { kind:'choice', index:1 }). Kind bate, 1 < 2 (faixa ok) → entry.settle(ok({kind:'choice',index:1})): clearTimeout, delete('clarify-1'), a promise resolve.await ask(...) retorna ok({ kind:'choice', index:1 }); pending() agora é []. Um terceiro resolve no mesmo id daria err("Unknown or already-resolved") — a entry sumiu.ask de uma pergunta open e ninguém responde. Os fake timers do vitest avançam 601 000 ms. O que o await ask(...) retorna, e o que pending() retorna depois? Responda antes de revelar.
entries.delete(id) e resolvePromise(err(new Error('clarify timed out'))). Então await ask(...) retorna err('clarify timed out') e pending() retorna [] — a entry foi descartada, sem leak e sem trava. Dica: é o caso de teste "resolves to err and drops the entry once timeoutMs elapses" (177–190).10 · Como isso se encaixa
Você dissecou o gateway por dentro. Agora dê um passo atrás: onde ele liga no resto da máquina? O ClarifyGateway é a superfície de dados de uma das portas de saída do pipeline — o gate humano T4. Ele não fica solto: liga na ponta onde o swarm está executando uma unidade e bate numa ação que o motor não deve tomar sozinho (um ship, um gasto irreversível). Nesse ponto a unidade é estacionada (T4-park) em vez de executar; o humano então decide; e só a decisão dele destrava o fluxo. Em comandos reais, esse ciclo é propose → approve / reject.
Onde você está na metodologia: as Lições 01–03 montaram o motor, a engenharia reversa do Hermes e a matriz de fusão; as Lições 14–19 detalham a cintura estreita, o funil, os gates, o Council e o swarm que executam uma run. Esta Lição 10 é a peça que, dentro dessa execução, para o robô e chama o humano: a superfície T4 onde um gasto irreversível ou um ship esperam por um "vai". Para ver as 30 peças no mesmo mapa e como cada uma liga na seguinte, abra o mapa interativo da metodologia.
- Lição 17 · A pipeline de gates — o T4 é o último e mais conservador dos gates dessa pipeline (Scope → Council → Proof → Validator → T4); enquanto Proof e Validator barram código ruim, o T4 barra o que nem código consegue decidir — uma ação irreversível.
- Lição 19 · O swarm — é o swarm que executa as unidades e, ao bater numa unidade T4, a estaciona em
t4-parked.jsonlem vez de executá-la; este gateway é a costura por onde essa pausa devolve o controle. - Lição 26 · Proveniência & segurança — cada
approve/rejecté gravado append-only (approvals.jsonl/rejections.jsonl) com timestamp — a decisão humana fica auditável, parte da mesma disciplina de proveniência. - Lição 04 · O loop fechado — o mesmo padrão "conservador por padrão" e "propor → dispor": lá o Validador dispõe escritas de memória, aqui o humano dispõe ações irreversíveis. Ambos preferem parar a vazar.
11 · Na prática
Chega de diagrama — eis o gate T4 como comandos reais. Quando uma run estaciona unidades T4, elas ficam no ledger t4-parked.jsonl do dir da run, fora do caminho de execução. Três comandos operam sobre elas: propose reabre as unidades estacionadas como uma run de proposta; approve registra o "vai" de uma unidade; reject registra o "não". As duas decisões são gravações append-only e auditáveis.
# 1) Reabre TODAS as unidades T4 estacionadas de uma run como uma run de proposta. # --offline mantém tudo hermético e $0 (adapter offline determinístico). alembic propose <run-id> --offline # saída (uma linha de cabeçalho + uma de contagem): # proposal <novo-run-id> — Proposed execution of parked tasks from <run-id> # done: 1 | failed: 0 | parked: 0
Fonte da forma exata: apps/cli/src/commands.ts (runPropose) — lê t4-parked.jsonl, re-injeta cada unidade como proposed-<taskId> e imprime proposal <id> — <goal> seguido de done: N | failed: N | parked: N. Se não houver unidades estacionadas, falha fechado com run <id> has no parked T4 tasks.
Para decidir uma unidade individual sem reexecutar tudo, use approve ou reject apontando a unidade pelo id. Atenção à flag: o id da unidade vai em --task-id (não --task) — é o único nome que o parser de argumentos aceita.
# 2a) Aprova UMA unidade estacionada pelo id (registra o "vai", append-only). alembic approve <run-id> --task-id <unit-id> # saída: # approved task '<unit-id>' in run <run-id> # grava uma linha em <run-dir>/approvals.jsonl : # {"taskId":"<unit-id>","decision":"approved","at":1719500400000} # 2b) Rejeita UMA unidade estacionada pelo id (registra o "não", append-only). alembic reject <run-id> --task-id <unit-id> # saída: # rejected task '<unit-id>' in run <run-id> # grava uma linha em <run-dir>/rejections.jsonl
Fonte: apps/cli/src/commands.ts (runApprove / runReject) e apps/cli/src/args.ts — o id da unidade é parseado da opção --task-id (campo taskId no schema; 'task-id' em PARSE_OPTIONS). A unidade precisa existir em t4-parked.jsonl, senão o comando falha fechado com task '<id>' is not parked in run <run-id>. Nota: o texto em prosa da CLAUDE.md escreve --task, mas a flag real aceita pelo CLI é --task-id — confira sempre o args.ts.
- Clone e entre no repo, depois garanta o build verde:
git clone <repo> alembic && cd alembic·pnpm -r typecheck && pnpm -r build && pnpm -w test - Rode uma run que produza unidades T4 e liste as runs para achar o id:
alembic run --goal GOAL.md --plan alembic.plan.ts --offline --yes·alembic runs list - Inspecione o ledger: abra
.alembic/runs/<run-id>/t4-parked.jsonl— cada linha tem otaskIdda unidade estacionada e areason. - Decida uma unidade pelo id (use um
taskIddo ledger):alembic approve <run-id> --task-id <taskId>(oureject). - O que procurar na saída: a linha
approved task '<taskId>' in run <run-id>; depois confira que.alembic/runs/<run-id>/approvals.jsonlganhou uma linha com"decision":"approved". - Prove o fail-closed: tente
approvecom um id que não está estacionado — o comando retornatask '<id>' is not parked in run <run-id>e não grava nada.
alembic propose <run-id> (com --offline para rodar hermético).alembic approve <run-id> --task-id <unit-id>. (O id da unidade vai em --task-id, não --task.)alembic reject <run-id> --task-id <unit-id> (grava em rejections.jsonl).alembic approve run-42 --task <unit-id> e o CLI recusou os argumentos antes de qualquer gravação. Bug do CLI ou erro seu? Decida antes de revelar.
--task-id, não --task. O parser (parseArgs com strict:true) só conhece as opções de PARSE_OPTIONS, e lá consta 'task-id' — não há 'task'. Um token desconhecido faz a tokenização falhar e o comando retorna invalid arguments: … sem tocar em approvals.jsonl. Por que isso importa: é a mesma postura fail-closed do gateway — entrada malformada é rejeitada na fronteira, antes de qualquer efeito durável. Troque para --task-id e a aprovação grava.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
ask retorna uma Promise; um registro Map<id, pending> deixa resolve liquidá-la por id; um setTimeout garante que nunca trave. Equivalente fiel do threading.Event..max(MAX_CHOICES = 4)), sincronamente em ask, registrando nada. coerceChoices só limpa, não corta — limitar é trabalho do schema.err mas deixa a entry pendente — uma resposta corrigida ainda liquida a mesma promise. Só a válida (ou o timeout) remove.monotonicIdFactory e não id aleatório?Math.random()/Date.now(); ids aleatórios quebram replay. Contador injetável (clarify-1, clarify-2) torna runs reproduzíveis.Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As quatro opções têm o mesmo tamanho de propósito — sem pista pela forma.
resolve é chamado com uma resposta de texto aberto para uma pergunta de choice. O que acontece?validateResponse rejeita um kind incompatível com err, mas só uma resposta válida liquida a entry — uma inválida a deixa pendente para um re-prompt; pending() ainda lista o id depois. a erra porque o gateway nunca lança através da fronteira — toda falha vira Result (err), não exceção. b inventa uma coação que não existe: kind errado não vira índice 0; isso mascararia o erro. d contradiz a validação cruzada — um open jamais liquida um choice.coerceChoices. ask valida a pergunta primeiro e retorna err sincronamente, registrando nada — falhando fechado em vez de truncar silenciosamente entrada não confiável. a é a armadilha: coerceChoices só limpa (desembrulha dicts, descarta vazias), não corta. c erra a ordem — a rejeição é na pergunta, antes de existir qualquer resposta. d confunde dois caminhos: o timeout descarta entries pendentes; uma pergunta inválida nunca chega a ser registrada.ClarifyGateway cunha ids via um monotonicIdFactory injetado em vez de um id aleatório?Clock injetado do curador: substituir um global não-determinístico por uma costura injetada. a e b são micro-otimizações irrelevantes — velocidade e tamanho de id não são o ponto; o ponto é reprodutibilidade. c é um efeito colateral bom (testes injetam monotonicIdFactory('q') e afirmam sobre q-1, q-2), mas não a razão: sem determinismo, o replay do motor quebraria.ask recebe?entries.delete(id) e resolvePromise(err(new Error('clarify timed out'))) — sem leak, sem trava (teste 177–190). b é exatamente o que timer.unref() + o timeout existem para evitar: a promise sempre resolve, e o processo pode até sair enquanto pendente. c erra a forma de falha — o gateway nunca lança através da fronteira, devolve Result. d inventa um fallback inexistente: respostas inválidas são rejeitadas e nunca viram o valor de retorno.