Todo comando esperava 6,7 segundos antes de começar: como o Hermes tirou três tarefas de inicialização do caminho crítico

Um shell script que faz um loop com uma dúzia de chamadas de hermes sessions list deveria levar alguns segundos; numa máquina levava bem mais, e o interessante é que nada falhava. Cada chamada simplesmente pagava um pedágio antes de fazer qualquer coisa útil. Some o backend da TUI, o hermes serve, o gateway e todo subprocesso que o Hermes dispara, e “essa ferramenta parece pesada” deixa de ser impressão e vira um problema de contabilidade. Em 2026-10-26, três pull requests foram mesclados na main, cada um fechando uma dessas contas. Nenhum deles está numa release ainda — mais sobre isso no final.
O que “custo de inicialização” significa de fato
Todo processo do Hermes — o CLI, o backend por trás da TUI, o hermes serve, o gateway e cada filho que ele deriva — executa as mesmas tarefas preparatórias antes de chegar ao seu pedido. Custo de inicialização é tudo entre o exec e a primeira coisa útil: ler config, importar módulos, descobrir qual versão do código está rodando e, como se viu, apagar diretórios antigos.
Um segundo num comando que você roda duzentas vezes por dia dá três minutos, mas você nunca sente isso como uma espera única. As três tarefas abaixo estavam no caminho crítico por motivos que nada tinham a ver com correção: cada uma era apenas o primeiro chamador.
Conta um: a limpeza do scratch que rodava antes de tudo
O Hermes dá a cada profile um diretório de scratch em ~/.hermes/cache/scratch e aponta TMPDIR/TMP/TEMP para lá — mas só se essas três variáveis já não estiverem definidas.
O problema era onde a varredura acontecia. get_scratch_dir() assume prune=True por padrão, e em quase todo processo seu primeiro chamador é o hook de inicialização: hermes_bootstrap → export_scratch_tmp_env → apply_scratch_tmp_env. Ou seja, “encontrar as entradas ociosas e aplicar rmtree nelas” estava embutido na frente de todo o resto. O autor do PR registrou o hermes --worktree parado por 13 segundos enquanto apagava uma entrada de scratch de 34 GB. Nada estava quebrado; o comando esperava um rmtree.
A correção mantém a política e move o trabalho: a passagem continua rodando uma vez por processo e no máximo uma vez por hora, mas quem faz é um processo filho destacado (python -I, reanexado ao gerenciador de sessão do usuário depois que o comando termina), então o comando retorna na hora. Medido com processos reais, um HOME/HERMES_HOME falsos e duas árvores de git archive, o hermes sessions list foi de 6,74 s para 0,35 s quando uma entrada ociosa continha 600.000 arquivos.
0,35 s não é concessão: uma execução de controle sem essa entrada também leva 0,35 s, e a entrada foi apagada pelo filho destacado 5,8 s depois da inicialização. Com quatro entradas ociosas de 15.000 arquivos cada e um processo que encerra imediatamente, a queda foi de 0,64 s para 0,05 s — e as quatro entradas ainda estavam lá quando o processo saiu.
O que não mudou é a parte que protege você. Um diretório que você colocou ali ou descompactou com mtime antigo continua preservado. Um sleep não reivindicado dentro de uma entrada ociosa ainda recebe TERM antes de o diretório ir embora. O diretório do shell que lançou o Hermes, e os pais dele, são mantidos. Processos em background, gateways e sessions de CLI que o próprio Hermes rastreia nunca são parados. E se o psutil não puder ser importado, nada é apagado — a passagem falha fechada e registra process check failed …, nothing deleted this pass.
A política explica por que o diretório às vezes parece abandonado: uma entrada só sai depois de 24 horas de ociosidade, qualquer escrita em qualquer lugar dentro dela no último dia a mantém viva, e hardlinks não contam como escrita — de propósito, para que um git clone --local que compartilha pack files com a origem não mantenha o clone vivo para sempre. Toda remoção, parada e preservação vai para ~/.hermes/logs/scratch-prune.log. Entradas que você não consegue ler ou gravar — normalmente saída de container escrita como root num bind mount — não são apagadas às cegas; você recebe uma dica e uma sugestão de sudo rm -rf '<path>'. Contexto sobre esse encanamento: o passo a passo do diretório temporário do terminal.
Conta dois: uma consulta completa de versão do git em cada comando
Rodando o Hermes a partir de um checkout do git, todo comando ficou cerca de 1 segundo mais rápido: hermes sessions list de 1,453 s para 0,435 s (média de seis execuções intercaladas em cada sentido), e subprocessos do git por comando de 15 para 2.
Duas causas raiz, o mesmo bug com roupas diferentes. Cada uma rodava uma consulta de versão completa — sete chamadas ao git, uma delas o git tag --merged HEAD, que custa cerca de 0,35 s num repositório grande — no caminho de inicialização.
A primeira é ansiedade no momento do import. import hermes_cli.config enumera cada plugin de provider, e vários montam um User-Agent HermesAgent/<release> no nível do módulo: Fireworks, GMI, xAI, router, Kimi, OpenCode. Para colocar a release nessa string, eles resolviam a versão a partir do git durante o import, e auth_constants, models, model_catalog, auxiliary_client, gemini_native_adapter, web_server_messaging, Slack, Yuanbao e Perplexity faziam o mesmo. Um import de config, um monte de subprocessos do git, antes de o seu comando ter lido um único argumento. Esse custo de camada de import é o tema de o mergulho fundo no cache de config.
A segunda é um jeito surpreendentemente caro de comparar uma string. O aviso de “update pending restart” chama _current_checkout_sha(), que passa por get_code_identity(refresh=True) — limpando o cache e refazendo a consulta inteira, a cada inicialização, mesmo quando não há recibo de atualização algum para comparar.
A correção são três peças pequenas: providers.base.LazyHeaders, uma subclasse de dict que guarda as chaves no import e avalia valores chamáveis na primeira leitura, de modo que o User-Agent com a release dentro só é calculado quando uma requisição é de fato montada, enquanto .copy(), |, update, json.dumps, pickle e isinstance(x, dict) continuam vendo um dict de verdade; version_info.hermes_user_agent(product), calculado no momento de montar a requisição; e version_info.read_checkout_sha(), que obtém o mesmo SHA numa chamada ao git em vez de sete. O hermes --version, o User-Agent e os headers de atribuição seguem idênticos.
Conta três: o commit-graph estava parado há dois meses
O git tag --merged HEAD é usado tanto na inicialização quanto pelo hermes update. Num checkout de instalação real, foi de 0,39 s para 0,05 s.
Três coisas tinham dado errado. Sob partial clone, a escrita do commit-graph pelo próprio git fica deliberadamente desabilitada (upstream #127711). A manutenção de pack do Hermes nunca escrevia um também. E um objects/info/commit-graph.lock deixado por um processo do git morto estava ali havia dois meses, porque a varredura de locks velhos do gitlock não cobria aquele lock específico. Resultado: toda consulta de tag fazia a coisa caro, para sempre, com um arquivo de lock garantindo que assim permanecesse.
A correção atualiza o graph em background depois de cada hermes update e em toda inicialização de hermes -w, usando git commit-graph write --reachable --split --no-changed-paths. A mesma passagem limpa locks de commit-graph e de multi-pack-index e arquivos temporários tmp_graph_* deixados por processos do git mortos, e o shutdown manda SIGTERM para a árvore de processos primeiro, SIGKILL só depois de 5 segundos de carência (comportamento padrão dos outros chamadores inalterado), então os kills deixam menos detritos.
Medido na coisa real: _run_post_update_maintenance caiu de 2,82 s para 2,01 s, o graph dividido foi escrito em até 3 segundos depois da fase retornar, e o commit-graph verify passa. A manutenção em background no hermes -w saiu de não fazer absolutamente nada para escrever o graph em 3,47 s e limpar aquele lock de dois meses. Como isso agora depende do hermes update, o guia de atualização sem sustos cobre o resto.
As três contas lado a lado
| O que você faz | Antes | Depois |
|---|---|---|
hermes sessions list, uma entrada ociosa com 600 mil arquivos |
6,74 s | 0,35 s |
| Quatro entradas ociosas × 15 mil arquivos, processo encerra na hora | 0,64 s | 0,05 s |
Consulta de versão a partir de um checkout (hermes sessions list) |
1,453 s | 0,435 s |
| Subprocessos do git por comando | 15 | 2 |
git tag --merged HEAD num checkout de instalação real |
0,39 s | 0,05 s |
_run_post_update_maintenance (inclui busca real de tags) |
2,82 s | 2,01 s |
Nada desse trabalho estava errado ou era desnecessário. Só estava no lugar errado.
Uma correção menor: um salto de relógio não expulsa mais um gateway em execução
O quarto PR é pequeno, mas produz um mistério. Depois que o relógio do sistema salta, a varredura do scratch podia julgar um gateway em execução como abandonado e tratá-lo como tal; agora não faz mais isso. Se algum serviço de fundo de longa duração já desapareceu logo depois de o notebook acordar ou de o NTP corrigir o relógio, esta é uma explicação plausível — e está corrigida no mesmo lote.
O que isso significa para você hoje
Os quatro PRs foram mesclados em 2026-10-26, o que é posterior à v0.22.5 (lançada em 2026-10-23). Eles não estão em release nenhuma — nem na v0.22.5, nem em qualquer coisa que você possa instalar hoje. Existem apenas na main. Para ter a melhoria agora, acompanhe a main; caso contrário, ela chega com a próxima versão.
Se você vai ficar numa release por um tempo, os números ainda servem de linha de base: se um único hermes sessions list leva seis ou sete segundos, a causa é quase certamente uma entrada ociosa grande em ~/.hermes/cache/scratch, e vale inspecionar antes de concluir que o Hermes ficou lento. O ~/.hermes/logs/scratch-prune.log registra cada remoção, parada e preservação, então “algo comeu meus arquivos temporários?” tem resposta. Num checkout sob ~/.hermes/hermes-agent, um objects/info/commit-graph.lock esquecido é uma lentidão real que nada na v0.22.5 limpa. E se você mesmo exporta TMPDIR, repare que o Hermes só redireciona as três variáveis de temp quando elas estão indefinidas — seus próprios arquivos temporários nunca entram na política do scratch.
Para uma instalação do zero, o guia de instalação cobre tanto o layout de release quanto o de checkout, e a distinção importa aqui: os ganhos de consulta de versão e de commit-graph valem só para instalações por checkout, enquanto o ganho da limpeza de scratch vale para todo mundo.