Engine — parte 1 (desde RN-016)
PR Gates
RN-016 — O veredito do gate prevalece sobre o enunciado da task
Se a descrição da task diz uma coisa e o veredito do gate diz outra, vale o veredito.
Desde a Fase 8b, "o veredito" pode vir de duas especializações — a regra
não muda: cada uma prevalece sobre a task DENTRO do que avalia
(Automação sobre a cobertura de testes; Performance/Segurança sobre os RNFs
de performance e os achados de código). QaLead.consolidar/1 não arbitra
entre elas e a task: qualquer changes_requested de qualquer uma já reprova
o todo (ver RN-036).
- Onde: prompt de cada especialização em
apps/engine/lib/engine/gates/qa_automacao_agent.exeqa_performance_seguranca_agent.ex - Origem: ADR 0020 §9
Psicólogo e Anamnese
RN-023 — A causa de término é classificação determinística
Vem do motivo registrado, nunca de julgamento do LLM e nunca por
eliminação. Toda falha registra a origem: infra | modelo | código | política.
- Onde:
apps/engine/lib/engine/psychologist/termination_classifier.ex - Origem: ADR 0022 §5 e ADR 0020 §6
PR Gates
RN-036 — QA vira área: o Lead consolida sem mudar o contrato do gate
A especialização de Automação (o QAAgent da Fase 4a) sempre é delegada;
Performance/Segurança só quando a história tem RNF de performance pertinente
(Engine.Gates.QaLead.rnf_de_performance?/1 — heurística de palavra-chave, não
NLP). A decisão de NÃO delegar é sempre registrada — uma delegação
dispensed com justificativa, nunca silêncio.
Consolidação: approved só se TODAS as delegações que rodaram aprovaram; qualquer
changes_requested reprova o todo, com os itens da(s)
especialização(ões) que pediram mudança, cada item prefixado com o
rótulo de quem o levantou ("[QA de Automação] ...") — é assim que a
origem é rastreada SEM mudar itens de string[] para outro
formato. Falha de delegação (qualquer origem — infra, modelo, codigo,
politica) NUNCA vira changes_requested: o Lead bloqueia a task
com a origem real (RN-015).
O qa_verdict que chega à api é o MESMO artefato e passa
pela MESMA rota de sempre (RecordGateVerdictUseCase,
nextGateStatus) — nenhum dos dois mudou. O que a api sabe da
área mora só nos eventos delegation.completed/delegation.failed/
delegation.dispensed, que a especialização e o Lead
registram à parte.
- Onde:
apps/engine/lib/engine/gates/qa_lead.ex(consolidar/1,rnf_de_performance?/1),qa_lead_server.ex(a fiação);apps/api/src/application/use-cases/execution/record-delegation.use-case.ts;apps/api/src/db/schema/agents.ts(tabeladelegations, enumfailure_origin) - Teste:
apps/engine/test/engine/gates/qa_lead_test.exs(a árvore de decisão pura),qa_lead_server_test.exs(a fiação: decisão → delegação → registro → consolidação → a MESMA chamada de sempre) e — a prova de que o contrato não mudou —record-gate-verdict.use-case.spec.ts,pr-gate-state-machine.spec.tserecord-infra-gate-verdict.use-case.spec.tspassam sem mudança nenhuma - Origem: ADR 0038
Do lado do apps/web (Fase 8d): o painel do time agrupa qa/
qa-automacao/qa-performance-seguranca como lead + especializações
recolhíveis, a linha do tempo da PR abre o veredito consolidado nas
suas partes (ProjectApprovalsTab.tsx, PrGateTimeline.tsx), e o
feed narra delegation.* — ver apps/web/src/lib/agents.ts
(AREAS/areaFor).
RN-047 — Circuit breaker do dev agent: N bloqueios consecutivos o param, sem queimar orçamento em laço
Cada dev agent mantém um contador (dev_agent_states.consecutive_blocked)
de quantas tarefas TERMINARAM blocked em sequência — localmente (no
ToolLoop) ou remotamente (o teto de correções do gate esgotado). Ao atingir o
teto por projeto (max_consecutive_blocked, default 3), o agente para em
idle_tripped sem tentar pegar a próxima tarefa. Um desfecho terminal
aprovado zera o contador; uma tarefa bloqueada isolada segue o fluxo normal
(devolvida com diagnóstico, disponível para um humano desbloquear) — o
breaker trata da SEQUÊNCIA, não da tarefa.
A única saída de idle_tripped é um rearme explícito
(POST .../agents/:agentId/rearm, papel developer): ele zera o contador e
o agente volta a tentar pegar tarefa. Não há destravamento automático — o
mesmo princípio do MarkTaskBlockedUseCase/unblock, aplicado à sequência
em vez da tarefa. Rearmar um agente que não está travado é 409, não
sucesso silencioso: o evento dev.rearmed é imutável, e registrá-lo para um
rearme que não aconteceu seria uma mentira no event log.
Um bloqueio que vem de FORA do agente (o QaLeadServer falhando
internamente, por exemplo) também precisa acordá-lo — é por isso que a
emissão de task.gate_resolved mora no MarkTaskBlockedUseCase, o funil por
onde todo bloqueio passa, e não no RecordGateVerdictUseCase, que só vê
parte deles. Sem isso o agente ficaria em awaiting_gate para sempre, com a
tarefa morta e o contador do breaker nunca incrementando.
Reiniciar o engine com um agente em working não conta para o contador:
a tarefa retida é bloqueada com um diagnóstico de reinício, mas esse
bloqueio não é o agente "queimando o teto" — é a infraestrutura caindo. O
contador só sobe quando o próprio ciclo dev↔gate produz um blocked real.
- Onde:
apps/engine/lib/engine/dev/dev_agent_server.ex(finish_task/2,resume_state/2),apps/api/src/application/use-cases/execution/rearm-dev-agent.use-case.ts,apps/api/src/db/schema/iam.ts(projects.max_consecutive_blocked) - Teste:
apps/engine/test/engine/dev/dev_agent_server_test.exs(describecircuit breaker),apps/engine/test/engine/dev/dev_rehydrator_test.exs(describethe four rehydrated states),test/application/use-cases/execution/rearm-dev-agent.use-case.spec.ts - Origem: ADR 0045
RN-050 — Sem PR aberta, nenhum gate abre
O dev agent propõe commit, push e PR e lê o desfecho de cada um. Ele só
abre o gate se os três executaram. Se um ficou pending — a autonomia do
agente em require_approval — ele entra em awaiting_approval, segurando o
worktree, e não abre gate nenhum.
Sem isso o gate abria de qualquer jeito, e o dano era silencioso: o QA varre
o worktree, não a PR; achou os arquivos, aprovou; o SecOps aprovou; a
tarefa fechou como concluída — sem uma linha commitada e sem PR nenhuma.
Só depois, ao aprovar o commit, o usuário veria a ação falhar (com
diagnóstico vazio, porque System.cmd num diretório apagado devolve
{"", 2}).
O que libera o agente é task.pr_settled, emitido pela api quando pr_open
chega a um desfecho terminal: opened: true abre o gate; opened: false
(negada ou falha) devolve a tarefa com diagnóstico, em vez de deixar o
agente esperando para sempre por um gate que ninguém vai abrir.
Uma PR negada não conta para o circuit breaker da RN-047: a decisão foi do usuário, não do agente queimando o teto — o mesmo princípio da recuperação de reinício.
Essa regra também elimina o D5 (worktree reciclado sob aprovação pendente)
como consequência: o worktree só é liberado em gate_resolved, o gate só
abre depois da PR, e a PR só abre depois do commit e do push.
- Onde:
apps/engine/lib/engine/dev/agent_io.ex(propose/3),apps/engine/lib/engine/dev/dev_agent_server.ex(abrir_gate/1,aguardar_aprovacao/2),apps/api/src/application/use-cases/actions/execute-git-action.use-case.ts(settlePrOpen) - Teste:
apps/engine/test/engine/dev/dev_agent_server_test.exs(describepending approval doesn't open a gate) - Origem: ADR 0048
RN-059 — Falha de turno é evento durável com origem, e o agente fala
Quando um turno de LLM falha, o agente grava agent.error no event log
com três campos: origem (vocabulário do ADR 0020), mensagem em português e
o reason bruto. E a mensagem aparece no fio da conversa, não só no log.
Era o contrário, e o desfecho era o pior possível: os quatro agentes
conversacionais gravavam agent.response com conteúdo vazio —
indistinguível de sucesso no log imutável — e mandavam o motivo por
broadcast, que é efêmero. Quem não estivesse com a aba aberta naquele
segundo nunca saberia que houve erro; quem estivesse, via um balão em branco.
Havia um segundo caminho, pior ainda: quando a api narrava a falha no PRÓPRIO frame final (budget, credencial ausente, binding faltando), o turno não caía no ramo de erro e não emitia evento nenhum — silêncio absoluto.
Esse mesmo ramo, uma vez corrigido, custou uma segunda rodada: no PO, no
Arquiteto e no Dev Lead ele devolvia {state, ""} — uma TUPLA onde todos os
outros ramos de run_turn devolvem o state (um mapa). O Map.put/3 de
TurnoAssincrono.tratar_resultado/2 levantava BadMapError dentro do
handle_info, e como os quatro conversacionais são restart: :temporary, o
agente MORRIA e não voltava. A falha deixava de ser silenciosa e virava uma
queda — com os gatilhos mais corriqueiros que existem. A regra vale inteira: o
agente narra a falha e continua de pé. Por isso tratar_resultado/2 tem
uma segunda barreira, no ponto compartilhado pelos quatro: resultado de turno
que não é mapa vira agent.error com origem codigo, nunca um processo morto.
A origem NUNCA é adivinhada: cada padrão em FalhaDeTurno.origem/1 tem um
motivo escrito, e o que não casa com nenhum sai como codigo — a lacuna é
do nosso classificador, e essa é a origem que aponta a ação certa (ADR 0020).
Os eventos já gravados não se apagam — a tela os NOMEIA como resposta vazia anterior a esta regra, em vez de mostrar branco.
- Onde:
apps/engine/lib/engine/agents/falha_de_turno.ex,criativo_server.ex,po_server.ex,arquiteto_server.ex,dev_lead_server.ex,infra_lead_server.ex(emit_falha/2),turno_assincrono.ex(tratar_resultado/2, a segunda barreira),apps/web/src/lib/session-falha.ts - Teste:
apps/engine/test/engine/agents/criativo_server_test.exs(evento durável com origem; nunca grava resposta vazia; erro narrado no frame final também vira evento);po_server_test.exs(o frame final com erro não derruba o agente — GenServer de VERDADE, comProcess.alive?/1);arquiteto_server_test.exsedev_lead_server_test.exs(mesmo caminho, ciclo completo);turno_assincrono_test.exs(a segunda barreira narra em vez de derrubar);apps/web/src/lib/session-falha.test.ts - Origem: execução real da FASE 13b
RN-061 — Uma falha de FERRAMENTA também é um evento, e volta para o modelo
O resultado de uma chamada de ferramenta nunca é descartado. Ele vira
tool.result no event log (ok e, quando falha, erro), o agente diz o
que aconteceu no fio, e o motivo volta para o modelo no papel tool —
para ele corrigir e reemitir no turno seguinte. Um erro de ferramenta é
entrada do laço, não fim de linha.
O agente Criativo era o único que o descartava
(_ = EmitArtifact.run(args, state)) — o PO e o Arquiteto já o realimentavam.
Numa execução real o modelo emitiu titulo/descricao contra um schema que
exige title/description/origin: as quatro regras de negócio da
conversa foram recusadas, nenhum evento foi gravado, e ele continuava
dizendo "registrei as regras" com o painel vazio.
A descrição da ferramenta agora NOMEIA os campos obrigatórios de cada tipo,
em inglês e com um exemplo preenchido — inclusive que business_rule.origin
é uma lista não vazia de seq das mensagens que originaram a regra, não
texto livre. Sem isso o modelo adivinha, e adivinha no idioma da conversa.
É a mesma regra da RN-059 aplicada ao outro caminho de falha: duas políticas para o mesmo problema seriam duas chances de engolir o erro.
- Onde:
apps/engine/lib/engine/agents/criativo_server.ex(dispatch_tool,realimentar),apps/engine/lib/engine/harness/tools/emit_artifact.ex(descricao/0),apps/engine/lib/engine/harness/artifact_schemas.ex(required/1) - Teste:
apps/engine/test/engine/agents/criativo_server_test.exs(a refused tool becomes a tool.result with an error, and the agent speaks) - Origem: execução real da FASE 13b
RN-062 — Mensagem a agente conversacional REIDRATA o processo
Uma mensagem endereçada a Criativo, PO ou Arquiteto sobe o processo se ele não
estiver de pé, antes de entregar. O init de cada servidor já reconstrói o
histórico do event log; faltava quem o chamasse.
Antes, um restart do engine matava a conversa em silêncio: a sessão sobrevivia
como active, o processo do agente não, e a próxima mensagem morria com
GenServer.call ... exited — sem evento, sem erro na tela, sem nada. O usuário
via a própria mensagem aparecer e nenhuma resposta chegar, para sempre.
O comentário de revise/2 dizia que agente morto nesta rota "é um bug". É — e
basta o engine reiniciar para acontecer. É a mesma garantia que a Fase 12b deu
aos dev agents, aplicada aos conversacionais.
- Onde:
apps/engine/lib/engine_web/controllers/agent_command_controller.ex(message/2) - Teste: coberto pela suite de agentes; a prova de execução está em docs/explanation/validacao-real.md
- Origem: execução real da FASE 13b
RN-063 — Encerrar sem produzir é desfecho, não falha
A Anamnese tem uma ferramenta para dizer "não há nada a emitir, e este é o
motivo" (skip_proficiency). A rodada encerra com anamnese.run_skipped e o
motivo no payload — nunca com anamnese.run_failed.
Antes ela não tinha esse verbo. A única ferramenta era emit_proficiency, que
recusa lista vazia (com razão: perfil vazio não é perfil). Numa janela sem
membro elegível a Anamnese descobria isso na PRIMEIRA iteração, escrevia em
prosa "não há membros elegíveis", chamava emit_proficiency com profiles: [],
era recusada — e repetia até o teto de iterações. Cada volta reenvia o
histórico, que cresce a cada volta.
Numa execução real isso custou 145 mil tokens de entrada e 4× o gasto do Criativo e do PO somados, sem produzir nada. E voltava a cada tick do agendador, a cada 15 minutos, para sempre.
O teto de iterações funcionava — não era laço infinito. O desperdício era por rodada, repetido indefinidamente, que é pior: um laço trava e alguém percebe; este sangrava devagar.
Narrar run_failed para uma rodada que fez a coisa certa também é defeito: quem
lê o log aprende a ignorar o evento de falha.
- Onde:
apps/engine/lib/engine/anamnese/tools/skip_proficiency.ex,apps/engine/lib/engine/anamnese/hooks/termination.ex,apps/engine/lib/engine/workers/anamnese_worker.ex(handle_outcome) - Teste:
apps/engine/test/engine/workers/anamnese_worker_test.exs(encerrar sem perfis é DESFECHO: narra run_skipped com o motivo, não falha) - Origem: execução real da FASE 13b
RN-069 — Repetir uma tarefa recria a branch, não falha
WorktreeManager.add_worktree/3 usa git worktree add -B (cria ou
reseta), não -b. Ele já removia o diretório do worktree anterior, mas
deixava a branch para trás — e como o nome dela vem do slug da tarefa, a
segunda tentativa na MESMA tarefa sempre caía em fatal: a branch named 'feature/<slug>' already exists.
O efeito era permanente: desbloquear a tarefa não ajudava, reativar a
execução não ajudava, e o circuit breaker desarmava sem saída. Numa execução
real a única saída era um git worktree prune manual no workspace do
projeto.
Resetar é a escolha certa: o worktree anterior já foi removido, o trabalho
daquela tentativa não vale nada (a tarefa voltou para a fila), e a branch
renasce do ponto atual de work_dir.
- Onde:
apps/engine/lib/engine/dev/worktree_manager.ex - Teste:
apps/engine/test/engine/dev/worktree_manager_test.exs(retrying the SAME task recreates the worktree instead of failing) - Origem: execução real da FASE 13b
RN-073 — Uma aprovação pendente SUSPENDE o laço, não o queima
Quando uma ferramenta de pipeline devolve pending, o ToolLoop para e o
dev agent entra em :awaiting_approval, segurando a tarefa, o worktree e o
histórico do laço. A decisão do usuário emite task.action_settled, que o
acorda: o resultado real ocupa o lugar onde a palavra "pending" estaria, e o
laço retoma de onde parou.
Duas propriedades que o teste trava:
- Nada é gravado durante a espera. O espaço da mensagem da ferramenta fica vazio. Gravar "pending" ali diria ao modelo que foi isso que o comando respondeu — que era exatamente o defeito.
- Uma recusa é uma resposta. O motivo ocupa o lugar do resultado e o
agente aprende que aquele caminho fechou, em vez de esperar para sempre por
algo que ninguém vai aprovar. É o mesmo princípio de
pr_settledcomopened: false(RN-047), um nível abaixo. - O wake tem de CHEGAR.
task.action_settlednasce no agregadotask, não naproposed_actionque o nome da tabela sugere: o drain do engine lê uma lista fechada de agregados (sessionetask). Emitido fora dessa lista, o evento é gravado com sucesso, fica comprocessed_atnulo e nem chega a ser lido — sem job, sem erro, sem log, e o agente espera para sempre. O contrato atravessa duas linguagens, e é por isso que está fixado nos dois lados.
Se o engine reiniciar durante a espera, a regra deixa de valer para essa
tarefa: o laço suspenso só existe em memória, então ela volta para a fila
bloqueada com origem infra, e a decisão tomada depois não tem onde ser
aplicada. Bloquear com diagnóstico é deliberado — a alternativa era a espera
eterna e silenciosa, que é exatamente o que esta regra existe para acabar.
O que isto conserta: pending costumava voltar como o RESULTADO da
ferramenta e o laço seguia em frente. O modelo lia aquilo como a resposta do
comando, não aprendia nada, tentava outra coisa — e cada tentativa queimava
uma iteração até toolloop.limit_reached {iteration: 8, max_iterations: 8},
com a tarefa bloqueada por "limite de iterações atingido" sem uma linha
escrita. As aprovações concedidas chegavam depois de o laço esgotado e não
serviam para nada.
A allowlist de terminal (RN-068) continua valendo, mas deixa de ser a única defesa: é uma lista de comandos previstos, e o modelo inventa comandos.
- Onde:
apps/engine/lib/engine/harness/hooks/action_pipeline.ex,harness/tool_loop.ex,dev/dev_agent_server.ex,workers/dev_agent_wake_worker.ex; emissão emapps/api/src/application/use-cases/actions/{approve,deny}-action.use-case.ts - Teste:
apps/engine/test/engine/dev/dev_agent_awaiting_approval_test.exs(pending action STOPS the agent;approved: resumes the loop with the REAL output;restart during the wait BLOCKS the task) eapps/engine/test/engine/dev/wake_do_outbox_ao_agente_test.exs, que percorre a cadeia inteira — outbox, drain, fila e processo — porque os testes por elo estavam todos verdes com a entrega quebrada; o agregado está fixado no lado da api emapps/api/test/application/use-cases/actions/approve-deny-action.use-case.spec.ts - Origem: ADR 0052, fase A da triagem
RN-074 — A saída de terminal tem teto de bytes
A saída de um comando é cortada em TERMINAL_OUTPUT_MAX_BYTES (default 32 KiB)
antes de virar resultado da ferramenta, e o corte deixa uma marca dizendo os
dois tamanhos e o que fazer:
[saída truncada: 32768 de 1048576 bytes. Refine o comando (head, grep,
-maxdepth) para ver o que falta.]
Três propriedades que o teste fixa:
- O teto é
>, não>=. Saída que cabe exatamente no limite passa intacta — marcá-la faria o modelo refinar um comando que já deu tudo. - O corte não parte caractere multibyte.
binary_part/3corta por byte; cair no meio de uméproduz binário inválido que quebra a serialização JSON antes de o resultado chegar ao modelo. raw_bytescontinua sendo o tamanho REAL produzido, não o truncado. É medição, e mentir nela esconderia justamente o comportamento que motivou o teto. Quem quiser detectar truncagem comparabyte_size(stdout)comraw_bytes.
O que isso conserta: a saída de cada comando fica no histórico do laço e viaja
em todo turno seguinte. Sem teto, um find numa árvore grande basta — a
execução do hello-limpo morreu com {413, "request entity too large"} no
turno 18, sem uma linha escrita. O estouro é de bytes da requisição, não de
janela de contexto: a maior chamada bem-sucedida tinha 28.993 tokens de entrada.
A marca é endereçada ao modelo, não ao humano — sem dizer o que fazer, ele tende a repetir o mesmo comando.
- Onde:
apps/engine/lib/engine/actions/terminal_executor.ex(truncate/2), teto emapps/engine/config/runtime.exs - Teste:
apps/engine/test/engine/actions/terminal_executor_test.exs(describeteto de bytes da saída) - Origem: achado S de achados-execucao-real.md, Fase F do backlog
RN-076 — A credencial de git nunca é escrita em arquivo
O engine trabalha em repositório remoto pedindo o remoto de trabalho à api
(GET /internal/projects/:projectId/git-remote), que devolve a origem limpa
e o token do owner à parte. O token entra na invocação do git pelo ambiente do
processo filho e em nenhum outro lugar:
- não no
origin— é a URL limpa que fica gravada no.git/config; - não em argv —
psmostra a linha de comando de qualquer processo; - não em arquivo — nem helper persistido, nem
~/.git-credentials.
O helper de credencial é passado por -c, vale só para aquele processo, e vem
depois de um credential.helper= vazio: helpers são acumulativos e o primeiro a
responder ganha, então sem zerar antes um helper do host responderia no lugar.
Por que isso é regra e não preferência. Escrever
https://x-access-token:TOKEN@github.com/… no origin — o que quase todo
tutorial ensina — grava a credencial em texto puro dentro da pasta do
projeto, exatamente onde a RN-075 dá ao dev agent leitura
auto-aprovada. Um cat .git/config devolveria o token sem passar por
aprovação nenhuma, e ele viajaria ao provider de LLM no histórico do laço. O
escopo de caminho protege contra o agente ler para FORA do projeto; não tem como
proteger contra um segredo que o próprio produto colocou DENTRO.
A credencial é a do owner do workspace, pelo mesmo resolvedor da
RN-058 — duas regras de "de quem é a credencial" divergiriam.
Provider local não tem token nem consulta a api: é resolvido direto do banco,
e é o caminho que o pnpm dev e a suite inteira exercitam.
- Onde:
apps/engine/lib/engine/actions/git_auth.ex,engine/projects/project_repository.ex(remoto_de_trabalho/1),apps/api/src/application/use-cases/git/get-project-git-remote.use-case.ts - Teste:
apps/engine/test/engine/actions/git_auth_test.exs(o token não aparece em argv nem no helper) eapps/api/test/application/use-cases/git/get-project-git-remote.use-case.spec.ts(a origem devolvida não contém o token nem@) - Origem: ADR 0056, achado N, Fase B do backlog
RN-077 — A origem da falha é sempre uma das quatro
Todo desfecho de falha nomeia a ORIGEM no vocabulário fechado do
ADR 0020 —
infra | modelo | codigo | politica. Não há quinto valor: null e
"indeterminada" deixaram de ser possíveis.
Duas garantias estruturais, e nenhuma depende de alguém lembrar:
AgentIo.block_task/4não tem default para a origem. Ela era "obrigatória em espírito", com"indeterminada"de default — e o desfecho mais caro da execução real saiu exatamente assim, porque o call site não passou nada. Sem default, esquecer vira erro de compilação.FalhaDeTurno.origem/1sempre devolve uma das quatro, e há teste de tabela que falha se alguma entrada — inclusive uma forma nunca vista — produzir outra coisa.
Por que indeterminada saiu. Ela existiu com um argumento razoável: não
chutar seria mais honesto que escolher no escuro. O efeito real foi o oposto —
indeterminada não aponta ação nenhuma, e quem triava a rodada seguinte
recomeçava a investigação do zero. O que ela significava de fato era o
classificador não reconheceu esta forma, que é lacuna do nosso código: codigo
é a origem que aponta a ação certa (acrescentar a cláusula que falta). O
diagnóstico continua indo verbatim, então nada se perde.
As origens não são chute. Cada desfecho do ToolLoop diz quem decidiu parar:
report_blocked e teto de iterações são do modelo (ele decidiu, ou gastou o
que tinha); orçamento e PR não aprovada são política (foi uma decisão, nada
quebrou); restart e falha ao montar contexto são infra; e quando há
last_error, a origem sai do MESMO erro que o diagnóstico narra — era esse par
que se contradizia, com diagnosis dizendo {413, …} e origem dizendo
"indeterminada" na mesma linha.
- Onde:
apps/engine/lib/engine/agents/falha_de_turno.ex,engine/dev/agent_io.ex(block_task/4, sem default),engine/dev/dev_agent_server.ex(origem_da_parada/1) - Teste:
apps/engine/test/engine/agents/falha_de_turno_test.exs(o vocabulário é fechado) eapps/engine/test/engine/dev/dev_agent_server_test.exs, que afirma a origem no evento emitido - Origem: achados P, Q e T de achados-execucao-real.md, Fase G do backlog
RN-079 — O Psicólogo não analisa sessão sem evento analisável
Antes de gastar um turno de modelo, a análise pergunta se há o que analisar. Não
havendo, ela não roda e o desfecho vira psychologist.analysis_skipped.
Analisável exclui duas coisas, por motivos diferentes:
- o rastro dos próprios analistas. O Psicólogo grava o turno dele no log da
sessão que está analisando (
agent.response,tool.call,tool.result, a hipótese). Contar isso faria uma sessão vazia parecer povoada a partir da primeira análise, e cada retentativa a encheria mais — o critério nunca mais reprovaria. Vale igual para a Anamnese; bootstrap.*, que é provisionamento de repositório rodando sozinho: nove passos de máquina não dizem nada sobre a pessoa.
Tudo o mais conta, inclusive proposed_action.* — o usuário aprovando e negando
sem escrever mensagem nenhuma é comportamento, lição que a Anamnese já tinha
aprendido (RN-063).
São duas contagens, e elas não se substituem. A crua dimensiona o trabalho
(quanto log ler, logo qual tier de triagem, leve ou pesado); a analisável decide
se há trabalho. Confundi-las é o defeito: uma sessão só de bootstrap passava por "20
eventos" sem ter nenhum, ganhava a análise, e o modelo — sem nada para citar —
inventava seq inexistentes até a validação de evidência rejeitar e ele
desistir, com o orçamento já gasto.
Pular vale também para reprocessamento manual. Reprocessar não fabrica material: quem clicou recebe o motivo no log em vez de uma hipótese inventada sobre um log que não existe.
O skip vira evento, ao contrário do da Anamnese, que é só log — aquele roda a cada 15 min e viraria ruído, este roda uma vez por fechamento de sessão, e uma análise ausente sem nada narrado é indiagnosticável.
- Onde:
apps/engine/lib/engine/session_events/event.ex(count_analisaveis/1),apps/engine/lib/engine/psychologist/triage.ex(should_run?/1),apps/engine/lib/engine/workers/psychologist_worker.ex - Teste:
apps/engine/test/engine/session_events/event_analisaveis_test.exs(inclui a reprodução da sessão do achado: 14 eventos, nenhum analisável),apps/engine/test/engine/workers/psychologist_worker_test.exs - Origem: achado J, Fase E do backlog
RN-080 — Regra de negócio duplicada é recusada na entrada
business_rule cujo título já existe no projeto não é gravada. A recusa
volta ao modelo pelo mesmo caminho de um payload inválido, e ele segue para a
próxima regra em vez de parar.
Na entrada porque não há outro lugar. Não existe tabela de regras: o
artefato É o evento artifact.business_rule, e evento de domínio não é apagado
nem editado. Deixar entrar significa conviver com a duplicata para sempre.
Escopo de projeto, não de sessão — é entre sessões que a duplicata nasce. Rodar o Criativo de novo abre sessão nova, e uma checagem por sessão não veria a rodada anterior, que é exatamente o caso do achado.
A comparação normaliza caixa, acento e espaço redundante; pontuação fica.
Duplicata semântica continua passando, e isso é declarado, não esquecido:
"Saudação com nome" e "Quem chama pode se identificar" seguem sendo duas regras,
porque separá-las é julgamento e não cabe num if.
Desde a RN-681 a duplicata semântica de TÍTULO é avisada por
embedding depois de gravar — continua passando, nunca é recusada aqui.
- Onde:
apps/engine/lib/engine/harness/artifact_dedupe.ex,apps/engine/lib/engine/harness/tools/emit_artifact.ex,apps/engine/lib/engine/session_events/event.ex(titulos_de_regras/1) - Teste:
apps/engine/test/engine/harness/artifact_dedupe_test.exs,apps/engine/test/engine/harness/emit_artifact_dedupe_test.exs - Origem: achado K, Fase E do backlog
RN-085 — O teto de iterações é por TIPO de agente
Quantas voltas um agente pode dar no laço de ferramenta depende do trabalho que ele faz: 8 para quem conversa, 60 para o dev agent e para os subagentes de QA. Não há mais um número único.
O teto de 8 nasceu de agente conversacional e foi herdado por quem trabalha.
Na validação real da 13b isso apareceu como bloqueio: o dev agent gastou as
oito voltas explorando um repositório recém-provisionado e nunca escreveu um
arquivo; com 25, escreveu três e rodou os testes. O desfecho registrado era
limite de iterações atingido com origem modelo — tecnicamente verdade e
praticamente inútil, porque o modelo nunca chegou a julgar nada.
Subir o default global seria a correção errada, e é isso que a regra protege: o Criativo não precisa de 60 voltas para conversar, e o teto também é a trava contra laço infinito.
Quem pode subir não é "quem trabalha muito", é quem tem trava de gasto por
baixo. O teto de iterações protege contra laço infinito; quem protege o
BOLSO é o token_budget_micros. Dev agents e subagentes de QA rodam com o
task_budget_micros da task, então afrouxar as voltas não afrouxa a conta.
infra-workflows usa ferramenta pesada e mesmo assim fica em 8: ele roda
sem budget, e para ele o teto é a única trava que existe.
Duas bordas com teste próprio:
-
dev-leadé conversacional, apesar do prefixodev-que identifica os dev agents (dev-<modulo>,dev-<modulo>-2). O lead decide e delega, e sem a cláusula explícita nasceria com o teto do trabalho pesado por acidente de nomenclatura. -
Agente desconhecido cai no teto mais baixo. Errar para o lado barato: quem precisa de mais voltas aparece como
limite de iterações atingidoe é corrigido; quem ganha 60 por engano gasta calado. -
Onde:
apps/engine/lib/engine/harness/iteracoes.ex, aplicado emapps/engine/lib/engine/harness/tool_loop.ex(init/1) -
Teste:
apps/engine/test/engine/harness/iteracoes_test.exseapps/engine/test/engine/harness/tool_loop_test.exs(o teto vem do TIPO do agente quando o chamador não passa um,teto explícito do chamador VENCE o do tipo) -
Origem: achado X, validação real da 13b; ADR 0053, FASE 14d
RN-115 — A Anamnese pode ser pausada globalmente; a pausa é do PRODUTO, nunca apaga dado
ANAMNESE_ENABLED (env var do engine, boolean, default false a partir
desta regra) decide se uma rodada NOVA da Anamnese pode acontecer — periódica
(AnamneseSchedulerWorker) ou sob demanda (AnamneseCommandController).
Decisão de PRODUTO do usuário em 2026-08-10 ("hoje ele não está trazendo
dados de muito valor"), não bug — ver docs/explanation/backlog.md. Desativada,
NENHUM dado existente é tocado: hipóteses, perfis de proficiência e patches
de instrução já gravados continuam intactos e visíveis, e o opt-out POR
MEMBRO (RN-025) continua um conceito separado — a pausa é do SISTEMA, não do
perfilamento individual.
AnamneseSchedulerWorker.kickoff/0 (chamado uma vez no boot) NÃO agenda o
job periódico quando desativado, em vez de agendar e deixar perform/1
no-opar a cada tick — mais barato (a fila do Oban não recebe um job a cada
ANAMNESE_INTERVAL_SECONDS só para não fazer nada) e mais claro para quem
inspeciona a fila. Correção em 2026-08-10 (achado real em execução, não
hipótese): a versão original desta regra deixava perform/1 incondicional
de propósito, para a corrente entre rodadas não carregar a decisão de
ligar/desligar consigo — mas isso significava que uma corrente já agendada
ANTES de a flag existir (ou de alguém desativá-la) continuava se
reagendando pra sempre, rodando Anamnese de verdade com a flag dizendo
false. Foi exatamente o que aconteceu num Postgres de dev mais antigo que
o PR original, remediado manualmente cancelando os jobs agendados.
perform/1 agora confere enabled?/0 a cada tick, igual kickoff/0: se
desativado, nem enqueue_projects/0 nem o reagendamento acontecem, e a
corrente morre ali — o que também AUTO-CURA sozinho o cenário de job antigo
que ainda dispara uma vez, sem precisar de intervenção manual.
AnamneseCommandController.run/2 (rota sob demanda, "reanalisar agora" nas
Configurações) responde 503 com corpo {"error": "anamnese_desativada"}
quando desativado — distinto de propósito do 409 vazio que já existia para
"projeto sem sessão" (os dois eram fáceis de confundir num 409 puro, e são
causas bem diferentes). RunAnamneseUseCase, do lado api, converte o 503 do
engine em ServiceUnavailableException com reason: "anamnese_disabled" no
corpo — nunca um 500 genérico nem um 409 reaproveitado. A web
(ProjectSettingsTab.tsx) descobre o estado no primeiro clique de "Rodar
agora" (não há hoje uma leitura prévia do estado global) e, a partir daí,
desabilita o botão e mantém a explicação VISÍVEL na tela — não só um toast
que some (RN-088: nunca falha silenciosa ou confusa).
- Onde:
apps/engine/lib/engine/workers/anamnese_scheduler_worker.ex(enabled?/0,kickoff/0),apps/engine/lib/engine_web/controllers/anamnese_command_controller.ex,apps/engine/config/runtime.exs,apps/api/src/domain/anamnese/anamnese-disabled.error.ts,apps/api/src/infrastructure/http-clients/api-to-engine-client.ts(runAnamnese),apps/api/src/application/use-cases/anamnese/run-anamnese.use-case.ts,apps/web/src/routes/settings/ProficiencySection.tsx - Teste:
apps/engine/test/engine/workers/anamnese_scheduler_worker_test.exs(kickoff/0não agenda desativado, agenda ativado, default desligado;perform/1desativado no meio da corrente não faz fan-out nem reagenda),apps/engine/test/engine_web/controllers/anamnese_command_controller_test.exs(503 distinto de 409, com e sem sessão),apps/api/test/application/use-cases/anamnese/run-anamnese.use-case.spec.ts,apps/web/src/routes/ProjectSettingsTab.test.tsx(ProficiencySection) - Borda: a flag é GLOBAL (todos os projetos/workspaces), não por projeto
— ao contrário do teto de paralelismo (RN-083) ou do modelo herdável por
área (RN-102), que são decisões por escopo. Ligar de volta é
ANAMNESE_ENABLED=truee reiniciar o engine; não há botão na UI para isso (é operacional, não uma preferência de projeto). - Origem: sem ADR — decisão de produto reversível, não mudança estrutural de arquitetura. Ver docs/explanation/backlog.md.
RN-116 — Falha ao CRIAR um handoff não derruba o agente
confirm_readiness (Criativo → PO) e offer_infra_handoff (Arquiteto → Infra;
o offer_dev_handoff, Arquiteto → Dev Lead, saiu na
RN-672, e o handoff ao Dev Lead que a Infra
oferece segue a mesma régua) chamam a api pra criar o handoff DEPOIS de o
turno já ter rodado — no caso do Criativo, depois de o product_brief já
estar gravado no event log. Se essa chamada falhar (api fora, 5xx, etc.), o
handoff não existe, mas isso NUNCA derruba o GenServer do agente: a falha vira
agent.error durável, com origem (FalhaDeTurno.origem/1) e uma mensagem
que diz o que JÁ foi salvo (o product_brief, as regras) e o que não foi (o
handoff) — para o usuário saber que confirmar de novo é seguro, não repete
trabalho.
Era o oposto: as três chamadas usavam {:ok, _handoff} = EngineApiClient.create_handoff(...)
— um match rígido. {:error, _} virava MatchError, e como os três agentes
sobem com restart: :temporary num DynamicSupervisor :one_for_one, o
processo simplesmente SUMIA — sem agent.error, sem resposta no fio, só
silêncio. Do lado de quem observava: a informação (regras, product brief)
parecia ter "passado" (estava gravada), mas nada iniciava do lado do agente
seguinte, porque o handoff nunca chegou a existir. Reabrir a conversa não
resolvia sozinho — só uma NOVA mensagem reativa o processo (rehidratando do
event log), e só uma nova confirmação de prontidão tenta o handoff de novo.
A mensagem NÃO reusa FalhaDeTurno.mensagem/1 (a de RN-059, "não consegui
completar este turno... nada foi gasto"): nos três call sites o trabalho já
rodou (ou nem precisava rodar, no caso de offer_dev_handoff) — dizer "nada
foi gasto" seria falso quando tokens já tinham sido gastos no turno de
consolidação. Reusa só FalhaDeTurno.origem/1, que classifica pelo FORMATO
do motivo (status HTTP, exceção de transporte), não por ser turno de LLM.
O Engine.Harness.Tools.OfferHandoff (a ferramenta que o PO usa via tool
call, dentro do ToolLoop) já tratava {:error, reason} sem crashar — o
defeito era só nestes três handlers server-driven, que chamam
EngineApiClient.create_handoff/5 DIRETO em vez de passar pela ferramenta.
- Onde:
apps/engine/lib/engine/agents/criativo_server.ex(handle_call(:confirm_readiness, ...),emit_falha_handoff/3),apps/engine/lib/engine/agents/arquiteto_server.ex(handle_call(:offer_infra_handoff, ...),emit_falha_handoff/3);apps/engine/lib/engine/infra/infra_lead_server.ex(emit_falha_do_handoff_ao_dev_lead/2, RN-672) - Teste:
apps/engine/test/engine/agents/criativo_server_test.exs("prontidão: falha ao criar o handoff NÃO derruba o processo, e vira agent.error durável");apps/engine/test/engine/agents/arquiteto_server_test.exs(offer_infra_handoff, sucesso e falha);apps/engine/test/engine/infra/infra_lead_server_test.exs(o handoff ao Dev Lead que a Infra oferece, recusado com 500) - Origem: relato de uso real no projeto
exp-001(Criativo → PO); a mesma falha estrutural foi achada por leitura de código nos dois handoffs do Arquiteto, sem reprodução separada para eles
RN-117 — O Psicólogo pode ser pausado globalmente; a pausa é do PRODUTO, nunca apaga dado
PSYCHOLOGIST_ENABLED (env var do engine, boolean, default false) decide
se uma rodada NOVA do Psicólogo pode acontecer — automática (fechamento de
sessão, roteado pelo Engine.Outbox.Drain) ou sob demanda
(PsychologistCommandController.reanalyze/2). Mesma decisão de PRODUTO do
usuário em 2026-08-10 já aplicada à Anamnese (RN-115, "hoje ele não está
trazendo dados de muito valor") — não bug, ver docs/explanation/backlog.md.
Desativado, NENHUM dado existente é tocado: análises e hipóteses já
emitidas continuam intactas e visíveis.
Diferente da Anamnese (cujo gatilho automático é um TICK periódico que a
própria flag decide se reagenda), o gatilho automático do Psicólogo é o
fechamento de sessão — o Engine.Outbox.Drain roteia
session.closed/session.closed_abnormally pra PsychologistWorker só
quando PsychologistWorker.enabled?/0 é true (Drain.handlers_for/1);
desativado, só SessionLifecycleWorker roda, e o job do Psicólogo nem
nasce. PsychologistWorker.perform/1 continua incondicional de propósito —
mas NÃO é mais "o mesmo padrão" do AnamneseSchedulerWorker (ver a correção
de 2026-08-10 na RN-115 acima): lá perform/1 passou a conferir a flag
porque ele PRÓPRIO reagenda a corrente a cada tick, e um job antigo
disparando incondicionalmente reabria a Anamnese com a flag desligada. O
Psicólogo não tem corrente nenhuma que se reagende sozinha — cada job nasce
de UM evento (session.closed), e quem decide é o Drain no momento em que
RECEBE o evento, não o worker no momento em que RODA; um job de Psicólogo
já enfileirado antes de desligar a flag é, no máximo, a última rodada
pendente, nunca uma corrente infinita. Por isso a suite pré-existente de
PsychologistWorker (que chama perform/1 direto) não precisou mudar.
PsychologistCommandController.reanalyze/2 (rota sob demanda,
"Reanalisar" na aba Insights) responde 503 com corpo
{"error": "psicologo_desativado"} quando desativado, sem sequer criar o
job. ReanalyzeSessionUseCase, do lado api, converte o 503 do engine em
ServiceUnavailableException com reason: "psychologist_disabled" no
corpo — nunca um 500 genérico. Isto descobre a pausa quando já existe uma
análise para reprocessar; a tela SEM hipótese nenhuma tem uma leitura
prévia própria, que não existia aqui — ver RN-454.
- Onde:
apps/engine/lib/engine/workers/psychologist_worker.ex(enabled?/0),apps/engine/lib/engine/outbox/drain.ex(handlers_for/1),apps/engine/lib/engine_web/controllers/psychologist_command_controller.ex,apps/engine/config/runtime.exs,apps/api/src/domain/psychologist/psychologist-disabled.error.ts,apps/api/src/infrastructure/http-clients/api-to-engine-client.ts(reanalyzeSession),apps/api/src/application/use-cases/execution/reanalyze-session.use-case.ts,apps/web/src/routes/ProjectInsightsTab.tsx - Teste:
apps/engine/test/engine/outbox/drain_test.exs(session.closedsó enfileira o Psicólogo quando ativado),apps/engine/test/engine_web/controllers/psychologist_command_controller_test.exs(503 sem criar job, 202 com job enfileirado quando ativado),apps/api/test/application/use-cases/execution/reanalyze-session.use-case.spec.ts,apps/web/src/routes/ProjectInsightsTab.test.tsx - Borda: a flag é GLOBAL (todos os projetos/workspaces), como a da
Anamnese. Ligar de volta é
PSYCHOLOGIST_ENABLED=truee reiniciar o engine; não há botão na UI para isso. - Origem: sem ADR — decisão de produto reversível, não mudança estrutural de arquitetura. Ver docs/explanation/backlog.md.
Session
RN-122 — O botão "Parar" cancela o turno DE VERDADE, matando a task que segura a chamada de LLM
Até aqui não havia cancelamento em lugar nenhum. A raiz era estrutural:
agent_command_controller.ex tratava a mensagem do usuário com
GenServer.call(pid, {:user_message, texto}, ...) — SÍNCRONO — e o
turno inteiro (tool loop + chamada SSE ao LLM) rodava DENTRO do handle_call. O
processo do agente ficava bloqueado até o turno acabar e não tratava NENHUMA outra
mensagem nesse meio-tempo — nem um futuro comando de cancelar.
A correção: o handle_call/handle_cast de turno dos quatro agentes
conversacionais (Criativo, PO, Arquiteto, Dev Lead) dispara um
Task.Supervisor.async_nolink/2 (Engine.Agents.TurnoAssincrono, novo,
compartilhado pelos quatro) com o trabalho pesado e devolve {:noreply, state} —
a resposta ao GenServer.call original fica ADIADA até a task
terminar (GenServer.reply/2, disparado quando {ref, resultado} chega ao
handle_info). Como o processo do agente DEIXA de ficar bloqueado, um novo
handle_cast(:cancel, state) chega e é tratado enquanto o turno roda:
Task.shutdown/2 no modo :brutal_kill mata a task, o que derruba a
conexão HTTP (SSE) que ela mantém com a api — é isso que faz o cancelamento
economizar tokens de fato, e não só parar de renderizar no cliente. O
from original recebe {:error, :cancelado}, e um agent.error TERMINAL é
registrado com origem "politica" (a mesma origem de
orçamento/credencial/vínculo — cancelar é decisão do usuário de parar de gastar
tokens, não um quinto valor no vocabulário fechado do ADR 0020) — sem ele,
o GetSessionPendingWorkUseCase veria agent.activated sem
agent.response/agent.error depois, e a sessão ficaria pendurada
no sinal de trabalho pendente.
Duas mensagens concorrentes para o mesmo agente (o usuário manda de novo enquanto um turno
já roda) nunca disparam uma segunda task:
TurnoAssincrono.iniciar/3 responde {:error, :turno_em_andamento}
na hora.
Revisada pela RN-578 (ADR 0163): a resposta ao
GenServer.calloriginal não fica mais adiada até a task terminar — ela sai no ACEITE, assim que a task sobe, e cancelar não responde a ninguém (quem chamou já tem:ok).{:error, :turno_em_andamento}deixou de ser descartado pelo controller: é um 409 nomeado e umagent.errordurável.
- Onde:
apps/engine/lib/engine/agents/turno_assincrono.ex(o mecanismo),apps/engine/lib/engine/agents/{criativo,po,arquiteto,dev_lead}_server.ex(os quatrohandle_call/handle_castde turno),apps/engine/lib/engine_web/controllers/agent_command_controller.ex:373(cancel/2),apps/engine/lib/engine_web/router.ex(POST /internal/sessions/:sessionId/agent/cancel),apps/api/src/application/use-cases/agents/cancel-agent-turn.use-case.ts,apps/api/src/interfaces/http/agents/agents.controller.ts(POST /projects/:projectId/sessions/:sessionId/agents/:agent/cancel),apps/web/src/routes/SessionPage.tsx(handleCancel, botão "Parar" no composer) - Teste:
apps/engine/test/engine/agents/turno_assincrono_test.exs(a task morre DE VERDADE —Process.alive?/1antes e depois — e não só "deixou de importar o resultado"),apps/api/test/application/use-cases/agents/cancel-agent-turn.use-case.spec.ts - Caso de borda: cancelar sem turno em andamento é NO-OP
idempotente — não há task para matar nem
frompendente para responder. O cancelamento NÃO desfaz efeito colateral que já rodou ANTES de a task morrer (por exemplo, uma ferramenta que já tinha sido despachada e registrado o seu evento) — só interrompe o que ainda não aconteceu. - Origem: investigação desta sessão; sem ADR próprio (mudança de padrão de concorrência DENTRO do harness, não de fronteira de camada/banco).
RN-129 — O ToolLoop nunca grava agent.response vazio; falha de transporte vira agent.error durável
A RN-059 fechou o balão vazio para os quatro agentes
conversacionais, mas eles não passam pelo Engine.Harness.ToolLoop — cada um
chama EngineApiClient.llm_turn_stream/6 no próprio módulo. O ToolLoop
(usado por dev agents, QA Automação/Performance-Segurança, Infra-Workflows,
Anamnese e Psicólogo) tinha o MESMO defeito num caminho diferente: emitia
agent.response a cada iteração, mesmo quando o modelo só chamou ferramenta
sem texto, ou terminou o turno sem produzir nada — e a falha de transporte
(provider fora do ar, timeout) virava agent.response com content ausente,
igualmente indistinguível de sucesso.
Achado ao vivo numa sessão de execução real (dev agents): duas bolhas com o texto de compatibilidade da RN-059 ("resposta vazia — evento anterior...") apareceram numa sessão criada minutos antes — não eram eventos antigos, eram o mesmo defeito acontecendo de novo, só que na aba de execução.
Duas correções, no ponto ESTRUTURAL comum a todo consumidor do ToolLoop,
não módulo por módulo:
- Conteúdo vazio nunca vira
agent.response. Iteração que só chamou ferramenta já está narrada portool.call/tool.result; iteração que não produziu nada (nem texto, nem tool call) deixa o desfecho para quem chamou o loop decidir —ctx.last_error/{:ok, ctx}carregam a informação, e quem consome (ex.:DevAgentServer.handle_outcome/4) já grava o evento durável do PRÓPRIO domínio (dev.blocked, comorigem). - Falha de transporte vira
agent.errordurável, comorigem(Engine.Agents.FalhaDeTurno.origem/1— o MESMO helper que os quatro agentes conversacionais usam, sem duplicar classificação) emensagemem português — nunca maisagent.responsesemcontent.
- Onde:
apps/engine/lib/engine/harness/tool_loop.ex(loop/1,emit_falha/2) - Teste:
apps/engine/test/engine/harness/tool_loop_test.exs("iteração só com tool call (sem texto) não grava agent.response vazio", "modelo termina o turno sem texto e sem tool call...", "falha de transporte... grava agent.error durável com origem") - Origem: RN-059 (regra que esta estende) — achado ao vivo numa sessão de execução real com dev agents
PR Gates
RN-140 — Um ciclo de gate que morre no meio retoma sozinho, sem intervenção manual
QaLeadServer/SecOpsAgentServer são restart: :temporary, e as
transições intermediárias do gate (DevAgentServer.correct/3,
Dispatcher.run_secops/2) são chamadas diretas em memória — feitas DEPOIS
de o record_gate_verdict já ter avançado de forma durável o gate_status na
api. Uma queda entre as duas prendia a PR para sempre em
awaiting_qa/awaiting_secops: nada sabia que aquele passo tinha ficado
pendente, e nenhum restart do engine resolvia — o próprio ADR 0057
já declarava a suspensão em {:awaiting, ...} como limite
conhecido, e investigar de novo mostrou que a janela entre um veredito
registrado e uma chamada de despacho é, na prática, mais fácil de acertar.
gate_states (schema engine, chave {project_id, task_id, gate})
registra o ciclo em voo nos MESMOS pontos em que as transições
já aconteciam — "in_progress" antes de qualquer subagente/scanner rodar,
"dispatch_pending" logo depois de o record_gate_verdict devolver
correct/run_secops e antes da chamada em processo.
Engine.Gates.GateRescuer varre linhas paradas há mais de 15 minutos
(configurável, GATE_RESCUE_STALE_AFTER_SECONDS) — generoso de propósito,
porque o ToolLoop de um subagente de QA roda legitimamente até
TOOL_LOOP_MAX_ITERATIONS_GATE (60) iterações, e um limiar curto
resgataria (e duplicaria) um ciclo só lento — e retoma:
"in_progress" reinicia a área inteira (sem retomada cirúrgica do
ctx, que não sobrevive a um restart, a mesma escolha do dev
agent); "dispatch_pending" reenvia exatamente a chamada perdida. Chamado no
boot e por um tick do Oban que se reagenda a cada 5 minutos
(GATE_RESCUE_INTERVAL_SECONDS),
Engine.Workers.GateRescueSchedulerWorker — o mesmo idioma de
ModelSyncSchedulerWorker/AnamneseSchedulerWorker.
Duas guardas contra trabalho duplicado: processo vivo NO MESMO nó
(Registry.lookup) nunca é perturbado, e o limiar de staleness
cobre o que a guarda local não alcança (réplica remota — o Registry
é local ao nó, a mesma ressalva do Engine.Dev.Wake desde o
ADR 0045). O
pior desfecho de uma corrida residual é trabalho duplicado e barato — a api
rejeita um segundo record_gate_verdict para um gate que não é mais dono do
gate_status (nextGateStatus), e DevAgentServer.correct/3 já é
idempotente por guarda de estado desde o ADR 0052 — nunca
dado inconsistente.
- Onde:
apps/engine/lib/engine/gates/gate_state.ex,gate_rescuer.ex, os pontos de escrita emqa_lead_server.ex(run_area/3,apply_gate_result/6) esecops_agent_server.ex(run_secops/3,apply_verdict/6), eapps/engine/lib/engine/workers/gate_rescue_scheduler_worker.ex - Teste:
apps/engine/test/engine/gates/gate_rescuer_test.exs— mata umQaLeadServerreal com o ciclo em voo (DynamicSupervisor.terminate_child/2, a linha durável sobrevivendo ao processo) e prova queGateRescuer.run/0reconecta a área sozinho até um desfecho real; o cenário do enunciado (run_secopsperdido) e o retornocorrectperdido, ambos com processo e despacho REAIS, semFakeGateDispatcher; e as duas guardas (processo local vivo não é perturbado; linha recente não é tocada) - Origem: ADR 0067, que estende o ADR 0057
RN-141 — O conteúdo lido por read_file também tem teto de bytes
A RN-074 travou a saída do terminal contra
{413, "request entity too large"}, mas deixou aberta a mesma porta pelo
read_file: ele lia o arquivo INTEIRO, sem teto, e esse conteúdo entrava no
histórico do laço e viajava em todo turno seguinte. Um PR com arquivo grande
(lockfile, bundle, arquivo gerado) bastava pra travar dev agents E o QA de
Performance/Segurança — que só tem ReadFile/SearchWorkspace (sem
Terminal, de propósito) pra investigar uma PR, então não tinha rota de
escape nenhuma quando o arquivo era grande demais.
O conteúdo é cortado em READ_FILE_MAX_BYTES (default 32 KiB, mesmo valor da
RN-074 por coincidência de contexto, não por acoplamento — as duas variáveis
são independentes) antes de virar resultado da ferramenta, com marca dizendo
o arquivo e os dois tamanhos:
[arquivo package-lock.json truncado: mostrando 32768 de 1048576 bytes. Use
search_workspace para localizar um trecho específico em vez de reler o
arquivo inteiro.]
Mesmas três propriedades da RN-074 (teto é > não >=; corte não parte
caractere multibyte; a marca é endereçada ao modelo, dizendo o que fazer). A
truncagem mora na FERRAMENTA (Engine.Harness.Tools.ReadFile), não em
Engine.Harness.WorkspaceFiles.read_file/2 — essa é a base genérica de
acesso a arquivo, compartilhada por write_file/search_workspace, e
truncar ali cortaria conteúdo de quem não precisa desse teto.
search_workspace não teve o mesmo tratamento: ele devolve só os PATHS que
bateram (matched_content é booleano), nunca o conteúdo do arquivo — o vetor
de estouro que motivou esta RN não se aplica a ele.
- Onde:
apps/engine/lib/engine/harness/tools/read_file.ex(truncate/2), teto emapps/engine/config/runtime.exs(read_file_max_bytes) - Teste:
apps/engine/test/engine/harness/tools/read_file_test.exs(describeteto de bytes do conteúdo) - Origem: achado ao vivo no event log de uma execução real — os 4 dev
agents de um projeto e os QA de Automação/Performance-Segurança bloqueados
com
{413, "request entity too large"}, mesma causa raiz da RN-074, porta diferente
Session
RN-142 — Confirmar prontidão SEM nenhuma regra de negócio é recusado pelo engine, não só escondido na UI
Antes, clicar em "Estou pronto para produzir" — ou chamar a rota direto —
SEMPRE criava o product_brief e oferecia o handoff ao PO, mesmo
numa conversa em que zero regras de negócio tinham sido capturadas.
business_rule_refs(state) já existia em criativo_server.ex, mas
só para PREENCHER o campo "rules" do brief — nunca como condição
de bloqueio. Na UI, SessionPage.tsx desabilitava o botão só durante o
streaming, sem olhar a contagem de regras.
A garantia real tinha de morar no servidor, não na tela: quem
chama a rota direto (ou um cliente futuro que não seja este frontend)
não pode furar o guardrail só porque o botão está desenhado
desabilitado. CriativoServer.handle_call(:confirm_readiness, ...) agora confere
business_rule_refs(state) ANTES de disparar a Task do
Engine.Agents.TurnoAssincrono (RN-122) — vazio, ele
recusa ali mesmo, sem rodar o turno de consolidação, sem
product_brief, sem handoff.
A recusa não vira HTTP 4xx:
agent_command_controller.ex#readiness/2 IGNORA o retorno deste GenServer.call
e sempre responde 202, o mesmo padrão que o message/2 já usa
desde a RN-122 ("esta resposta é só o recebimento" — o desfecho
real mora no log de eventos). Por isso a recusa é narrada como um
agent.error DURÁVEL (RN-059), com origem "politica" (é uma
decisão de produto, não falha de infra/modelo/turno) — e uma mensagem
explicando por quê; o fio da sessão já sabe renderizar esse tipo de
evento (lerFalhaDeTurno, o mesmo balão vermelho de qualquer outra falha de turno).
A UX complementar, em SessionPage.tsx: o botão nasce disabled
(com um title explicando por quê) enquanto events não tem
artifact.business_rule — a MESMA fonte que já alimenta o
painel "Regras de negócio" (ContextAside), sem segunda leitura que pudesse
divergir da primeira.
- Onde:
apps/engine/lib/engine/agents/criativo_server.ex(handle_call(:confirm_readiness, ...),emit_falha_sem_regra/1),apps/web/src/routes/SessionPage.tsx(hasBusinessRule, o botão "Estou pronto para produzir") - Teste:
apps/engine/test/engine/agents/criativo_server_test.exs("readiness: refuses when NO business rule was captured" — sem turno, sem brief, sem handoff; a recusa narrada com origem"politica"),apps/web/src/routes/SessionPage.readiness-exige-regra.test.tsx(botão desabilitado sem regra, habilitado com 1+) - Caso de borda: a sessão já tinha
readiness.confirmedregistrado pela api ANTES de sinalizar o engine (ConfirmReadinessUseCase) — isso não muda: é o registro do CLIQUE do usuário, um fato que aconteceu independente de o engine aceitar ou recusar depois. - Origem: investigação de código confirmando um relato de usuário — sem ADR (guardrail novo num fluxo que já existia, sem mudança de fronteira de camada/banco).
RN-150 — search_workspace tem teto de QUANTIDADE de hits e de BYTES, cada um com sua marca
Achado numa revisão de PR: search_workspace (dev agents e os dois agentes
de QA/gate, qa_tools.ex e qa_performance_seguranca_agent.ex — este
último só tem read_file/search_workspace, sem Terminal, de propósito)
devolvia TODOS os resultados da busca, sem teto nenhum — mesma classe do
achado S (Engine.Actions.TerminalExecutor.truncate/2) e da correção de
read_file (Engine.Harness.Tools.ReadFile.truncate/2): o resultado fica
no histórico do laço e viaja em todo turno seguinte, e uma árvore grande
basta pra estourar {413, "request entity too large"} do provider.
Dois tetos independentes, porque a busca estoura de duas formas diferentes:
- Quantidade de hits — uma árvore com milhares de arquivos batendo o
termo produz milhares de linhas
- caminhomesmo que nenhum arquivo individual seja grande. Truncar só por BYTES no fim ainda pagaria o custo de escanear e ler o conteúdo de cada um desses arquivos antes de montar a string. Por isso o teto de quantidade (SEARCH_WORKSPACE_MAX_HITS, default 500) vive emWorkspaceFiles.search/3, que já PARA de consumir a busca assim que encontra hit suficiente — o pipeline roda sobre umStream, eEnum.take(stream, max_hits + 1)só lê da fonte o que precisa pra produzir osmax_hits + 1primeiros resultados. O "+1" é o que permite dizer que HAVIA mais sem continuar escaneando o resto pra contar o total exato — contar o total pagaria de novo o I/O que o teto existe pra evitar, então a marca diz "mostrando os N primeiros" e nunca inventa um total. - Bytes do texto final — mesmo com hits limitados, caminhos muito
longos podem produzir uma string grande. Teto de bytes
(
SEARCH_WORKSPACE_MAX_BYTES, default 32.768), mesmo padrão determinal_output_max_bytes/read_file_max_bytes— variável PRÓPRIA, não reaproveita as outras duas: mesma classe de estouro, divergir uma não deve exigir tocar as outras.
A marca de truncagem é dirigida ao MODELO, não ao humano: diz o que foi
cortado (hits e/ou bytes) e instrui a refinar o termo da busca — mesmo
espírito das marcas de TerminalExecutor/ReadFile.
- Onde:
apps/engine/lib/engine/harness/workspace_files.ex(search/3,take_capped/2),apps/engine/lib/engine/harness/tools/search_workspace.ex(truncate/3,marca_de_truncagem/5),apps/engine/config/runtime.exs(search_workspace_max_hits,search_workspace_max_bytes) - Teste:
apps/engine/test/engine/harness/workspace_files_test.exs(search/3commax_hitscorta a QUANTIDADE e marca truncagem só quando há mais que o teto),apps/engine/test/engine/harness/search_workspace_test.exs(busca com poucos resultados não é alterada; busca com mais hits que o teto é truncada com aviso claro; texto final maior que o teto de bytes também é cortado) - Origem: achado de revisão de PR — segunda causa real do 413 em
revisões, depois da correção de
read_file
RN-162 — Perguntas estruturadas do Criativo
O Criativo pode, quando faz VÁRIAS perguntas na mesma resposta, emitir a
lista em formato ESTRUTURADO em vez de deixar o usuário responder item por
item em texto livre — ferramenta nova ask_structured_questions
(apps/engine/lib/engine/harness/tools/ask_structured_questions.ex,
:direct), registrada ao lado de emit_artifact. Schema:
{ questions: [{ id, label, type?, options? }] } — id único e não-vazio,
label não-vazio, type ∈ text|textarea|select (default text),
options obrigatório e não-vazio quando type: select. Grava
chat.structured_question.
O frontend (StructuredQuestionCard, SessionPage.tsx) renderiza um
formulário com um campo por pergunta — Input/Textarea/Select do
design system, conforme type. POST .../agents/:agent/structured- question/:questionSetId/answer (AnswerStructuredQuestionUseCase) valida
que toda pergunta tem resposta não-vazia, grava
chat.structured_question_answered (referenciando questionSetId = id do
evento da pergunta) e REUSA SendAgentMessageUseCase — as respostas viram
uma mensagem concatenada ("1. {label}: {resposta}\n2. ..."), como se o
usuário tivesse digitado no fio; não há canal novo de "o agente lê a
resposta estruturada". Um conjunto de perguntas só pode ser respondido
UMA vez: reenvio é recusado com 409, e o formulário nem chega a
reaparecer — o card vira somente leitura assim que existe um
chat.structured_question_answered posterior com o mesmo questionSetId.
- Onde:
apps/engine/lib/engine/harness/tools/ask_structured_questions.ex,apps/engine/lib/engine/agents/criativo_server.ex,apps/api/src/application/use-cases/agents/answer-structured-question.use-case.ts,apps/api/src/interfaces/http/agents/agents.controller.ts,apps/web/src/routes/SessionPage.tsx(StructuredQuestionCard) - Teste:
apps/engine/test/engine/harness/tools/ask_structured_questions_test.exs,apps/engine/test/engine/agents/criativo_server_test.exs,apps/api/test/application/use-cases/agents/answer-structured-question.use-case.spec.ts,apps/web/src/routes/SessionPage.perguntas-estruturadas.test.tsx - Origem: pedido do usuário — sem precedente de input estruturado no chat
RN-163 — O Criativo cumpre a promessa de tentar de novo
Cada turno do Criativo roda um laço bounded de tool use, com teto de 12 idas ao modelo — o mesmo desenho que o PO e o Arquiteto já tinham. Antes o modelo era chamado UMA vez por turno: o resultado da ferramenta era anexado ao histórico em memória e ninguém mais o lia, então a frase "vou corrigir e tentar de novo" — literal no código — só se cumpria se o usuário mandasse outra mensagem. Para quem usava, o Criativo simplesmente parava de responder depois de dizer que ia corrigir.
Quatro consequências, e cada uma é uma regra:
- Erro de ferramenta é entrada, não fim de linha. O motivo volta ao modelo
como mensagem
toole o laço chama o modelo de novo, que reemite corrigido DENTRO do mesmo turno. - Nada se anuncia que o código não vá executar. A frase de retentativa é decidida depois de despachar as ferramentas e sabendo quantas voltas sobraram: com volta disponível, o agente diz que vai corrigir; sem volta, ele não promete.
- A falha de ferramenta virou
agent.errordurável, com origem (RN-059) — eraagent.response, indistinguível no event log de uma resposta normal.origem: infraquando a api recusou oappend_event;origem: modelopara o payload que o modelo escreveu (chave errada,originque não é lista, regra duplicada, tipo system-emitted). O payload carregatooleretentativa. - Teto esgotado não termina em silêncio. Vira
agent.errorcomreason: "limite_de_iteracoes"eorigem: modelo, a mesma leitura dotoolloop.limit_reacheddoToolLoop. O nome do evento NÃO é reusado: este agente não roda dentro doToolLoop, e o evento mentiria sobre quem o produziu.
Duas fronteiras do laço, ambas deliberadas: ask_structured_questions
bem-sucedida encerra o turno (a bola está com o usuário, e as respostas
voltam num turno futuro — RN-162); e um turno que teve falha em
alguma volta fecha com um desfecho CONSOLIDADO no fio, para a última palavra
não ser o erro de uma volta que já foi corrigida depois.
- Onde:
apps/engine/lib/engine/agents/criativo_server.ex(run_turn_capturing/3,continuar/4,emit_falha_de_ferramenta/4,emit_falha_limite/2) - Teste:
apps/engine/test/engine/agents/criativo_server_test.exs(laço que corrige de verdade, teto esgotado narrado, origeminfravsmodelo, pergunta estruturada que encerra o turno) - Origem: uso real no projeto
exp001— "o Criativo não respondeu depois de dizer que iria corrigir e tentar de novo"
RN-164 — O PO LÊ o que já existe, escopado ao projeto
O PO ganhou duas ferramentas de LEITURA — listar_regras_de_negocio e
listar_backlog (:direct, sem parâmetro nenhum) — servidas por duas rotas
internas escopadas ao projeto: GET /internal/projects/:projectId/business-rules
e GET /internal/projects/:projectId/backlog.
A primeira devolve todo artifact.business_rule das sessões do projeto com a
description inteira, quais histórias já citam cada regra e o
uncoveredCount; a segunda devolve a MESMA árvore épico → história → tarefa
da aba Backlog, pelo mesmo ListBacklogUseCase (três leituras por projeto,
nunca N+1). O texto que volta ao modelo põe as regras DESCOBERTAS primeiro e
os épicos ÓRFÃOS antes da árvore — é o que gera trabalho.
O que a regra corrige: até aqui o PO tinha quatro ferramentas e todas de escrita. O contexto dele era montado uma vez, no kickoff, a partir dos 200 últimos eventos da sessão — dali em diante ele não sabia quais regras existiam, quais já tinha coberto, nem o que já havia criado. O escopo é o projeto e não a sessão de propósito: regra capturada numa sessão anterior é exatamente o que a leitura por sessão escondia.
Ler não vira proposed_action (não é efeito externo), mas é CONTIDA no
sentido do ADR 0060: nenhuma das duas rotas aceita parâmetro além do id do
projeto — sem busca, sem paginação, sem filtro —, o custo por chamada é
constante, e o texto entregue ao modelo tem teto de linhas dizendo o total
real quando trunca.
- Onde:
apps/engine/lib/engine/harness/tools/listar_regras_de_negocio.ex,apps/engine/lib/engine/harness/tools/listar_backlog.ex,apps/engine/lib/engine/agents/po_server.ex,apps/engine/lib/engine/sessions/engine_api_client.ex,apps/api/src/application/use-cases/backlog/list-business-rules.use-case.ts,apps/api/src/interfaces/http/internal/internal-projects.controller.ts - Teste:
apps/engine/test/engine/harness/tools/listar_regras_de_negocio_test.exs,apps/engine/test/engine/harness/tools/listar_backlog_test.exs,apps/engine/test/engine/agents/po_server_test.exs,apps/api/test/application/use-cases/backlog/list-business-rules.use-case.spec.ts - Origem: uso real no projeto
exp001— "crie ferramenta para o PO conseguir listar as regras de negócio"
RN-165 — Épico sem história é cobrado, e o PO pergunta quando não sabe
Quando o PO encerra um turno tendo criado um épico e nenhuma história para
ele, isso vira desfecho EXPLÍCITO: evento durável
backlog.epic_without_story (com origem: "modelo", os ids e títulos dos
épicos e a mensagem) mais o broadcast agent.error — o padrão da
RN-059: o log é o que sobrevive, o broadcast é o agente dizendo no
fio. A cobrança é por OCORRÊNCIA: reportada uma vez, a lista de pendências é
esvaziada, e não vira alarme que repete a cada turno.
O que conta é a criação que deu certo: um create_story recusado pela api
(business_rule_id inexistente, por exemplo) não quita o épico. Tratá-lo como
se quitasse seria trocar um silêncio por outro. Os épicos pendentes vivem no
state do PoServer e NÃO são reidratados: a cobrança é sobre a obrigação
assumida no turno, e reconstruí-la do event log reabriria épico antigo que o
usuário já resolveu de outro jeito.
Junto vieram as duas peças que faltavam para o PO ter uma saída além de parar:
ask_structured_questions — a MESMA ferramenta do Criativo
(RN-162), só passada a advertisar no po_server — e a instrução de
kickoff dizendo, com todas as letras, que épico sem história trava a execução
e que faltando informação se PERGUNTA, nunca se para nem se inventa. A
instrução anterior não dizia uma palavra sobre o que fazer diante de uma
lacuna, e diante de uma lacuna sem instrução um modelo escolhe entre inventar
e parar.
- Onde:
apps/engine/lib/engine/agents/po_server.ex(anotar_obrigacao/4,encerrar_turno/1,obrigacoes/0),apps/engine/lib/engine/harness/tools/create_epic.ex(id_no_resultado/1),apps/web/src/lib/activity.ts - Teste:
apps/engine/test/engine/agents/po_server_test.exs(describe "RN-165"),apps/web/src/lib/activity.test.ts - Origem: uso real no projeto
exp001— backlog sem história, logo sem tarefa, logo execução travada sem erro visível
RN-166 — O teto de iterações do PO deixa rastro
Esgotado o teto de iterações do laço de ferramentas do PoServer (12), o
turno emite toolloop.limit_reached com {iteration, max_iterations} —
o MESMO tipo e o mesmo payload que o Engine.Harness.ToolLoop já emitia
desde a Fase 3. Antes, a cláusula devolvia o state e pronto: de fora, um laço
esgotado era indistinguível de um turno que simplesmente acabou.
Reusar o identificador em vez de criar um po.* é deliberado: é o mesmo fato
(o laço bateu no teto), e quem lê o event log não deve precisar aprender um
segundo nome por causa de o agente conversacional ter laço próprio em vez de
usar o ToolLoop.
- Onde:
apps/engine/lib/engine/agents/po_server.ex(run_turn/2, cláusularemaining <= 0) - Teste:
apps/engine/test/engine/agents/po_server_test.exs("teto de iterações emite toolloop.limit_reached") - Origem: investigação do travamento do
exp001— o laço terminava em silêncio
RN-171 — A pergunta de lista tem saída por texto livre, por default
Pergunta type: "select" de ask_structured_questions (RN-162) aceita
resposta fora da lista. O campo é allowOther, e o default é true:
quem não declara nada oferece a saída. Fechar a lista exige
allowOther: false explícito, e só faz sentido quando ela é genuinamente
fechada ("Sim"/"Não").
O default aberto não é preferência de estilo. Uma lista fechada por
ESQUECIMENTO do modelo trava a conversa inteira e o usuário não tem como
destravá-la de fora — foi exatamente o que o uso real encontrou: o modelo
ofereceu uma opção do tipo "Escreva você mesmo" e o formulário não tinha onde
escrever, porque o schema do tool não sabia expressar "além destas, o que
você quiser". Uma lista aberta por engano, no pior caso, oferece um campo a
mais. Os dois erros não custam a mesma coisa. Pelo mesmo motivo a descrição
do tool proíbe criar uma opção "Outro" dentro de options: o formulário
já a oferece sozinha, e duas escapatórias na mesma lista confundem.
allowOther só existe em select — em text/textarea o campo já é texto
livre, e o engine normaliza esses dois para false em vez de gravar estado
sem significado no event log. Na tela, escolher "Outra (escrever)" troca o
Select por um Input: o sentinela de interface (__outra__) nunca
viaja para o backend, e o que vai é o TEXTO digitado. O botão de envio
continua exigindo TODAS as perguntas preenchidas — AnswerStructuredQuestion UseCase recusa com 400 listando o que falta, então habilitar com campo vazio
só produziria um erro do servidor —, e estar em "Outra" com o texto ainda em
branco NÃO conta como preenchido.
O card também deixou de ser o único item do fio alinhado a nada: ele passa a
ser centralizado com o mesmo teto de 560px do ApprovalCard na variante
chat (RN-173) e ganha o avatar e a cor do agente, que é o que o
faz ler como FALA de alguém em vez de formulário órfão. Antes ele nascia
encostado à esquerda com teto de 480px, enquanto as bolhas começam 45px
adentro.
- Onde:
apps/engine/lib/engine/harness/tools/ask_structured_questions.ex(schema, validação enormalizar/1),apps/web/src/lib/api-types.ts(StructuredQuestion.allowOther),apps/web/src/routes/SessionPage.tsx(StructuredQuestionCard,OUTRA_RESPOSTA,permiteOutra),apps/web/src/routes/SessionPage.module.css(.structuredQuestionCard,.structuredQuestionCabecalho) - Teste:
apps/engine/test/engine/harness/tools/ask_structured_questions_test.exs(default aberto,falseexplícito,allowOthernão booleano recusado),apps/web/src/routes/SessionPage.perguntas-estruturadas.test.tsx(describe "saída por texto livre no select") - Origem: uso real no
exp001— "sempre dê a opção de input do usuário quando ele seleciona Escreva"
RN-175 — Toda resposta de agente diz com qual modelo foi gerada
agent.response carrega modelName nos três produtores do evento, e não
só nos quatro agentes conversacionais:
| produtor | quem passa por ali | antes |
|---|---|---|
os quatro conversacionais (criativo/po/arquiteto/dev_lead) |
chat | já gravava, desde a RN-146 |
Engine.Harness.ToolLoop |
TODO agente de execução e de gate — dev-*, QA, SecOps, Infra-Workflows, Psicólogo, Anamnese |
nunca gravou |
SendChatMessageUseCase (api) |
chat sem agente ativo, em que quem responde é o modelo | nunca gravou |
Nenhuma chamada nova: RunLlmTurnUseCase já devolve modelName no corpo e
StreamLlmTurnUseCase já o põe no frame final — o valor é o nome do modelo
do binding resolvido (model.name), não o eco do provider. Ele é null
quando o turno falhou antes de resolver o binding, e ausente em evento gravado
antes desta regra.
Na tela, o modelo deixou de ser a palavra solta modelo em mono 10px
--text-muted ao lado do nome do agente — que se lê como se o modelo se
CHAMASSE "modelo", e que reprova o contraste de texto de leitura. Virou um
chip com o ícone de modelo, --text-secondary sobre --surface-2, e o
rótulo de desconhecido passou a ser "modelo não registrado". A tela não
adivinha o modelo pelo binding ATUAL do agente quando o dado falta: atribuir a
uma resposta antiga um modelo que talvez nem existisse quando ela foi gerada
seria inventar procedência, e procedência inventada é pior que ausente — o
mesmo argumento do preço congelado em token_usage (RN-044).
Fora do escopo, declarado: agent.error continua sem o modelo nos quatro
servidores, mesmo quando a api o mandou no frame de erro (budget estourado, por
exemplo). É mudança de outro evento, com outra pergunta a responder.
- Onde:
apps/engine/lib/engine/harness/tool_loop.ex,apps/api/src/application/use-cases/llm/send-chat-message.use-case.ts,apps/web/src/routes/SessionPage.tsx(o chip emagent.response),apps/web/src/routes/SessionPage.module.css(.messageModelo) - Teste:
apps/engine/test/engine/harness/tool_loop_test.exs,apps/api/test/application/use-cases/llm/send-chat-message.use-case.spec.ts,apps/web/src/routes/SessionPage.arquiteto-modelo-icone.test.tsx,apps/web/src/design-contraste.test.ts(o par do chip) - Origem: uso real no
exp001— "PO não mostrou o modelo que estava utilizando; todos os agentes devem apresentar o seu modelo ao lado do nome"
Auditoria fluxo.yml × código — o plano do Dev Lead vira aprovação de verdade (RN-284, ADR 0086)
Não é fase planejada: é a correção de uma divergência que a auditoria de
packages/contracts/fluxo.yml × código encontrou (docs/explanation/auditoria-fluxo-vs-codigo.md,
achado A2) — o fluxo já declarava a saída plano-de-paralelismo do dev-lead
como via: proposed_action, e o código nunca foi ajustado para bater.
RN-284 — O turno do agente conversacional pode SUSPENDER esperando aprovação humana
Revisada pela RN-677 (ADR 0194):
propose_execution_planganhou pipeline de execução — aprovar o plano grava o módulo das tarefas (RN-678) e ATIVA a execução; a ação terminaexecutedoufailed, e é esse desfecho que o Dev Lead lê ao retomar. O item 1 abaixo ("não tem execute-* pipeline") deixou de valer para este tipo.
Revisada pela RN-578 (ADR 0163): o turno de TODO conversacional deixou de segurar o
GenServer.call— ofromé respondido no aceite, e a suspensão aqui descrita mudou só o FECHO do turno (awaiting_approvalem vez deagent.done), não mais o momento da resposta. Auser_messagerecusada durante a suspensão passa a ser 409 no clique.
Primeira vez que um agente conversacional (Criativo, PO, Arquiteto, Dev Lead —
todos rodam turno síncrono via GenServer.call de até 180s, mediado por
Engine.Agents.TurnoAssincrono) suspende esperando uma decisão humana. O
padrão já existia para o dev agent (RN-073, ADR 0052) e os gates de
QA/Infra (ADR 0057), mas os dois são disparados por cast e nunca esperavam
resposta síncrona — é exatamente esse ponto que este mecanismo resolve, e o
teto de paralelismo que fez o Dev Lead existir (RN-083) é a razão
de a primeira aprovação suspensa ser a dele.
O mecanismo, em quatro peças:
Engine.Agents.DevLeadTools.run/2(apps/engine/lib/engine/agents/dev_lead_tools.ex:81-107) chamaEngineApiClient.propose_action/5em vez deappend_event/3, e devolve{:ok, texto}(statusexecuted/auto_approved/approved— os três contam como sucesso, porquepropose_execution_plannão tem execute-* pipeline própria e a aprovação manual fica em"approved"para sempre),{:pending, action_id}ou{:error, texto}.Engine.Agents.DevLeadServer.run_turn/2(apps/engine/lib/engine/agents/dev_lead_server.ex, bloco de despacho detool_calls) usaEnum.reduce_whilepara PARAR no primeiro:pending, sem processar as chamadas seguintes nem recursar — e devolve ostatecom a chave:aguardando_aprovacaosetada (%{action_id:, tool_call_id:, tool_name:, remaining:}, oremainingjá descontando a iteração suspensa contra o teto). A mensagemrole: "tool"NÃO entra emstate.messagesnesse momento — gravar "pending" ali mentiria pro modelo que o comando já respondeu isso (mesmo raciocínio do dev agent,Engine.Harness.Hooks.ActionPipeline).Engine.Agents.TurnoAssincrono.tratar_resultado/2(apps/engine/lib/engine/agents/turno_assincrono.ex) responde aofromdo mesmo jeito e na mesma hora de sempre — é o que rompe o bloqueio síncrono no momento certo —, mas em vez definalizar/1(que emiteagent.doneeagent.status: idle) chamasuspender/1: sóagent.status: awaiting_approval, semagent.done, porque o turno não terminou. A checagem é pelo VALOR da chave (Map.get/2, truthy), nunca pela presença dela — o Dev Lead carregaaguardando_aprovacao: nildesde oinit/1, então a chave em si está sempre presente.Engine.Sessions.LiveBroadcast.agent_status/4(apps/engine/lib/engine/sessions/live_broadcast.ex:38-39) ampliou a guarda de["working", "idle"]para incluir"awaiting_approval"— sem isto oagent.statusdo passo 3 nem seria persistido.
A retomada. Engine.Agents.DevLeadServer assina
Engine.Dev.Wake.subscribe(project_id, "dev-lead") no init/1 — o MESMO
módulo que Engine.Gates.QaLeadServer já reusa para os subagentes de QA,
apesar do nome ser "dev": a entrega de {:action_settled, ...} é por AGENTE,
roteada pelo agentId do payload (apps/engine/lib/engine/workers/dev_agent_wake_worker.ex),
não por tipo de agente. Quando a decisão chega, um handle_info monta a
mensagem role: "tool" com o resultado REAL (texto_do_desfecho/1, mesmo
vocabulário do dev agent e do QaLeadServer), zera aguardando_aprovacao e
retoma com TurnoAssincrono.iniciar(state, nil, fn -> run_turn(state, pendente.remaining) end).
Enquanto suspenso, uma segunda user_message não inicia turno novo — um
guard em handle_call({:user_message, _text}, _from, %{aguardando_aprovacao: %{}}), testado ANTES da cláusula genérica, emite agent.error (origem
politica) explicando que há uma decisão pendente em Aprovações, sem subir
task nenhuma.
Lacuna aceita, declarada — restart durante a espera. Ao contrário do dev
agent (que reidrata laco_pendente via handle_continue no init/1, ADR
0052), o Dev Lead NÃO reidrata aguardando_aprovacao — é só em memória. Se o
engine reiniciar enquanto ele está suspenso, a decisão continua registrada e
visível em Aprovações (é durável na api), mas o Dev Lead não narra o desfecho
automaticamente: o processo que assinou o Wake morreu, e o próximo restart
sobe um Dev Lead novo, sem inscrição para aquela ação. Fechar isto exigiria o
mesmo mecanismo do ADR 0052 — fora do escopo desta mudança, que só faz o
comportamento bater com o que packages/contracts/fluxo.yml já declarava.
- Onde:
apps/engine/lib/engine/agents/dev_lead_tools.ex,dev_lead_server.ex;apps/engine/lib/engine/agents/turno_assincrono.ex(compartilhado pelos quatro conversacionais);apps/engine/lib/engine/sessions/live_broadcast.ex;apps/api/src/domain/actions/decide.ts(propose_execution_plan) - Teste:
apps/engine/test/engine/agents/dev_lead_tools_test.exs,dev_lead_server_test.exs(describe "suspensão em aprovação"),turno_assincrono_test.exs(describe "resultado com :aguardando_aprovacao"),live_broadcast_test.exs,wake_do_outbox_ao_dev_lead_test.exs(a corrente INTEIRA — outbox → drain → worker →Engine.Dev.Wake→ o processo do Dev Lead, mesmo padrão deEngine.Dev.WakeDoOutboxAoAgenteTest),apps/api/test/domain/actions/decide.spec.ts(describe "plano de execução do Dev Lead") - ADR: 0086 (revisa parte do ADR 0053; precedente direto: ADR 0052 e ADR 0057)
UX Designer — o quinto agente conversacional (RN-285..287, ADR 0087)
Não é gatilho de separação disparado (packages/contracts/fluxo.yml sempre declarou "quando
o projeto GERENCIADO tiver interface própria a desenhar" como critério) — é
decisão consciente do dono do produto de antecipar o papel. teste-de- usabilidade (exige usuário humano real) fica fora de alcance; metricas-de- uso segue lacuna, porque depende do papel analytics, que continua
proposto.
RN-285 — O UX Designer é conversacional SOLO, sem área
Engine.Agents.UxDesignerServer (apps/engine/lib/engine/agents/ux_designer_server.ex:40)
espelha Engine.Agents.ArquitetoServer/DevLeadServer: GenServer por sessão,
laço bounded de tool use próprio com teto 14 (mesmo calibre de Arquiteto/Dev
Lead — agente de raciocínio, não conversação leve como Criativo/PO). Ativado
por handoff accepted endereçado a "ux-designer" — mecanismo GENÉRICO já
existente (ActivateAgentUseCase/canActivateAgent na api não ganharam
linha nenhuma: qualquer agente com handoff aceito já é ativável).
O kickoff (build_kickoff/1, ux_designer_server.ex:222) lê a
artifact.product_brief mais recente da sessão — a MESMA "necessidade de
negócio" que o Criativo produz, sem artefato novo. O sistema de design
(design/tokens.css, design/COMPONENTS.md) é DESCRITO na identidade
(Engine.Harness.Agents, entrada "ux-designer"), texto estático, porque os
agentes conversacionais não têm ferramenta de leitura de arquivo do repo — é
a única camada do prompt presente em TODO turno, não só no kickoff.
Sem área, sem subagentes: packages/contracts/fluxo.yml já classificava o papel como
camada_produto, ao lado de Criativo e PO, nenhum dos quais tem área.
RN-286 — propose_prototype grava artefato sem tabela e sem caso de uso dedicado, e oferece DOIS handoffs sobre o MESMO artefato
artifact.prototipo_navegavel segue o desenho sem tabela de
artifact.project_image/artifact.c4_diagram (RN-149) — o event
log é o registro —, mas por um caminho DIFERENTE do que os dois usam.
choose_project_image/create_c4_diagram precisam de caso de uso NA API
(DecidirImagemDoProjetoUseCase/CreateC4DiagramUseCase) porque têm
conteúdo DERIVADO de outro artefato (o Container level vem do module_map
vigente) ou recusa de domínio compartilhada por mais de um consumidor (teto
de recursos da imagem). propose_prototype
(Engine.Agents.UxDesignerTools.run/2,
apps/engine/lib/engine/agents/ux_designer_tools.ex:122) não tem nenhum dos
dois motivos — é conteúdo AUTOCONTIDO que só o próprio UX Designer escreve e
só ele lê de volta —, então a validação de FORMA mora no engine
(Engine.Harness.ArtifactSchemas, tipo "prototipo_navegavel",
artifact_schemas.ex:49/135 — personas e jornadas não-vazias, ao menos uma
tela) e a gravação usa o caminho GENÉRICO que a api já expõe para qualquer
tipo de evento (EngineApiClient.append_event_returning/3), o mesmo
mecanismo do artifact.product_brief do Criativo. Nenhuma rota nova na api.
Depois de gravar, gravar_e_ofertar_handoffs/2 (ux_designer_tools.ex:149)
oferece DOIS handoffs sobre o MESMO artefato — create_handoff para "po" e
para "dev-lead", os dois com o artifactId do protótipo. Nunca um segundo
artefato para "spec-visual" (packages/contracts/fluxo.yml): o PO lê resumo/prototipo
para desenhar o backlog, o Dev Lead lê as MESMAS telas/anotacoes como
referência visual de implementação — duplicar o conteúdo arriscaria as duas
cópias divergirem na revisão seguinte, o mesmo argumento por trás do C4 não
redigitar o module_map.
Falha ao ofertar UM dos dois handoffs não desfaz o artefato já gravado nem o outro handoff (RN-116) — o motivo volta como texto do tool-result, entrada do laço e não fim de linha (RN-163): o modelo lê e pode reportar ao usuário na resposta seguinte.
Um propose_prototype BEM-SUCEDIDO encerra o turno
(ux_designer_server.ex:120, mesma guarda de propose_execution_plan no Dev
Lead) — sem isso o laço voltaria ao modelo, que poderia propor de novo e
produzir dois protótipos com o mesmo total. Só existe UMA ferramenta aqui
(não há um segundo tool call a encadear como no Arquiteto), então "para no
primeiro sucesso" não perde nada.