Pegando o agente no erro: falhas de um refactor de verdade
Nove dias, uns 110 commits, um agente de IA digitando a maior parte. Estes são os erros dele que chegaram a um commit ou a uma tela, como cada um foi pego e as proteções que nasceram deles.
- Quando aconteceu
- Publicado
- Medido
- , lido nos commits e nas transcrições das sessões
- Stack
- Claude Code, PHP 8.4 (CodeIgniter 4.7), ClickHouse, MySQL, Chromium headless
- Também em
- English
O trabalho
O serviço gera comprovantes de exibição: para um pedido de mídia, um PDF e planilhas dizendo o que passou, onde e quando, lidos do ClickHouse. Reescrevi com o agente entre 27 de agosto e 4 de setembro. A versão nova rodou em paralelo no mesmo servidor, em /v2/, com a antiga como referência, e virou a oficial nos primeiros dias de setembro. A regra nesse período era simples: push pode, pull request não, nada vai para o ar antes de terminar.
A maior parte do código foi digitada pelo agente; eu dirigi, revisei e rodei. Abaixo estão os erros dele que chegaram a um commit ou a uma tela, nas palavras do próprio agente quando ele os escreveu numa mensagem de commit.
O teto de 15.000 linhas
PDFs grandes estavam morrendo. Um pedido com 32.958 linhas rodava 12 minutos e 52 segundos e terminava em Allowed memory size exhausted, com HTTP 200. Em 28 de agosto o agente resolveu recusando tudo acima de 15.000 linhas, com 413 e uma mensagem. O raciocínio parecia bom: a biblioteca de PDF gastava de 131 a 137 KB por linha, então com 2,5 GB o limite real ficava perto de 19.000 linhas, e 15.000 ficava abaixo.
Em 31 de agosto abri um pedido real com 17.394 linhas no deploy paralelo, e ele foi recusado. Isso não deveria acontecer. O sistema antigo já gerava os grandes em segundo plano, com a página consultando o progresso, e eu sabia de uso que funcionava. O commit seguinte diz com todas as letras: "Meu teto de 15.000 recusava antes de ela começar." Com o cliente cortado aos 120 segundos pela CDN na frente, a geração em segundo plano continuou até o PDF ficar pronto, com 1,88 MB.
O que importava era o lock, e a contagem de linhas era só um indicador dele. A geração segura um lock com TTL de 900 segundos, e depois disso uma segunda requisição começa a gerar o mesmo PDF em paralelo: dois processos de 2,5 GB disputando o mesmo arquivo. A partir do ponto medido, 17.394 linhas em 570 segundos e uma curva perto de n^1,75, o teto foi para 20.000 linhas, o que deixa 170 segundos de folga para máquina carregada. O agente otimizou para o que via no código. Eu sabia como o sistema antigo se comportava em produção, e todos os testes passavam com o teto errado.
Um try/catch que não pega nada
Aquele teto veio depois de outro erro. Um commit anterior dizia que um try/catch resolvia o lock órfão quando a geração morria. Não resolvia, e o commit seguinte do agente começa com "Estava errado, e o erro era meu". Estourar a memória ou o max_execution_time é erro fatal no PHP, e erro fatal não é Throwable:
<?php
// php -d memory_limit=32M oom.php
try {
$a = [];
while (true) { $a[] = str_repeat('x', 1024 * 1024); }
} catch (\Throwable $e) {
echo "caught\n"; // nunca imprime
} finally {
echo "finally\n"; // também nunca imprime
}
Nem uma shutdown function ajudou, porque o handler de exceção do framework chama exit() antes. Forçamos um OOM com 64 MB para confirmar. Depois de um erro fatal não sobra nada para limpar, então a única saída é não chegar lá: contar as linhas antes e mandar os grandes para o caminho em segundo plano.
Um spinner que baixava HTML como PDF
Pedi um spinner enquanto os documentos são gerados, e o agente trocou um simples por fetch com blob para ligar e desligar o spinner. Só que a mesma rota devolve um PDF quando o documento cabe e uma página HTML com opções quando não cabe, e agora qualquer resposta era salva como .pdf. O link antigo funcionava por acidente: o navegador simplesmente mostrava a página. Esse chegou à produção e ficou um dia. Commit: "Regressão minha". A correção agora olha o Content-Type e, quando não é PDF, deixa o navegador navegar como o link fazia antes:
const res = await fetch(url);
const type = res.headers.get("Content-Type") || "";
if (!type.startsWith("application/pdf")) {
res.body?.cancel();
window.location.href = url; // faz o que o link fazia
return;
}
saveAs(await res.blob(), filename);Números que pareciam melhores do que eram
"Isso é real, PDF em 5,6 s?" Não. Os 5,6 s eram o tempo da recusa, o 413. A geração real de 2.366 linhas levava 19 segundos.
Um subagente relatou uma função caindo de 8,2 s para 0,4 s. Ele tinha medido com 3.640 locais, e a chamada real passa 18 e custa 6 ms, então esse ganho foi descartado. Outra medição foi contaminada por uma segunda revisão navegando no mesmo app contra o mesmo ClickHouse, e a regra que saiu dali é que contar query em log compartilhado exige isolar antes.
O meu preferido é de outro projeto do mesmo mês, uma réplica de um export legado. O número de capa do validador dizia 97,76% dos campos OK. Logo abaixo, o relatório traz o número honesto, 95,82%, porque o de capa só contava os campos que casaram; os grupos que nunca formaram par ficavam fora do denominador. Com o grão errado, ele pode mostrar 100% sem ninguém ter comparado uma linha sequer. E 263 campos ficam como SEM_PROVA: a única explicação disponível para eles não é falseável, então não contam como explicados.
Os menores, mesmo padrão
- "Está no ar", com o servidor um commit atrás. Comparei prints de antes e depois, e o agente foi olhar o que estava renderizando de verdade.
-
rm -fnuma variável, seis ou sete vezes, para limpar cache. Contei que já vi um desses rodar num servidor com a variável vazia, começando pelo/. A resposta: "Fui eu quem escolheu a ferramenta destrutiva para um problema que não era destrutivo." -
Uma janela de datas invertida gerava um comprovante vazio. O ClickHouse aceita
x <= dia AND dia <= ycomx > ye simplesmente não devolve nada. Na mesma revisão, um gráfico diário estava 24 horas atrasado porque uma data ISO era lida como UTC, e em UTC−3 o dia 19 aparecia como "18 Aug". Sete defeitos saíram dessa revisão. - Uma chave de cache do PDF que podia colidir entre dois clientes, e um poderia receber o documento do outro. Achada pela revisão adversarial, antes da produção.
- Escopo: o agente quis incluir um segundo parceiro de conteúdo no projeto. Ninguém pediu isso, e quando questionei ele registrou nas notas que era inferência dele.
O que pegou
Uma linha de base do sistema antigo, capturada antes de mexer em qualquer coisa: de 13 a 16 respostas HTTP comparadas fase a fase, com os dígitos normalizados porque dado novo chega a cada 10 minutos. O antigo e o novo lado a lado sobre os mesmos dados. Pedidos reais, principalmente os grandes, porque teste só cobre o que você pensou. Um subagente revisor com a tarefa de refutar, e não de concordar. E a pergunta "isso é real?" sempre que um número parece bom, que não custa nada.
Cada erro que eu pego vira uma regra escrita com o porquê, e o agente lê na sessão seguinte, por exemplo "nunca dizer que está no ar sem olhar a tela". O agente mudou a velocidade deste refactor. Sem ele, essa limpeza levaria muito mais tempo, talvez o bastante para ficar no backlog para sempre. Sem a linha de base e os pedidos reais, o teto de 15.000 iria para o ar e algum cliente acharia primeiro.
Isto foi útil?
Obrigado. Isso me ajuda a decidir o que escrever depois.