Todos los comandos esperaban 6,7 segundos antes de empezar: cómo Hermes sacó tres tareas de arranque del camino crítico

Un script de shell que hace un bucle con una docena de llamadas a hermes sessions list debería tardar un par de segundos; en una máquina tardaba bastante más, y lo interesante era que no fallaba nada. Cada llamada simplemente pagaba un peaje antes de hacer algo útil. Añade el backend de la TUI, hermes serve, el gateway y cada subproceso que lanza Hermes, y eso de “esta herramienta pesa” deja de ser una sensación y se convierte en un problema de contabilidad. El 2026-10-26 se fusionaron en main tres pull requests que cerraban una de esas cuentas cada uno. Ninguno está todavía en una release: más sobre eso al final.
Qué significa de verdad “coste de arranque”
Cada proceso de Hermes —la CLI, el backend que hay detrás de la TUI, hermes serve, el gateway y cada hijo que deriva— ejecuta las mismas tareas preparatorias antes de llegar a tu petición. El coste de arranque es todo lo que hay entre exec y la primera cosa útil: leer configuración, importar módulos, resolver qué versión del código está corriendo y, como resultó, borrar directorios viejos.
Un segundo en un comando que ejecutas doscientas veces al día son tres minutos, pero nunca lo vives como una espera única. Las tres tareas de abajo estaban en el camino crítico por razones que no tienen nada que ver con la corrección: cada una era simplemente la primera en llamar.
Factura uno: la limpieza de scratch que corría antes que todo lo demás
Hermes da a cada profile un directorio scratch en ~/.hermes/cache/scratch y apunta TMPDIR/TMP/TEMP ahí, pero solo si esas tres variables no estaban ya definidas.
El problema era dónde ocurría el barrido. get_scratch_dir() usa prune=True por defecto, y en casi todos los procesos su primer llamador es el hook de arranque: hermes_bootstrap → export_scratch_tmp_env → apply_scratch_tmp_env. O sea que “busca las entradas inactivas y hazles rmtree” estaba incrustado por delante de todo lo demás. El autor del PR registró un hermes --worktree parado 13 segundos mientras borraba una entrada de scratch de 34 GB. No había nada roto: el comando estaba esperando a un rmtree.
El arreglo conserva la política y mueve el trabajo: la pasada se sigue ejecutando una vez por proceso y como mucho una vez por hora, pero la hace un proceso hijo desacoplado (python -I, reconectado al gestor de sesiones del usuario cuando el comando termina), así que el comando vuelve de inmediato. Medido con procesos reales, un HOME/HERMES_HOME falsos y dos árboles de git archive, hermes sessions list pasó de 6,74 s a 0,35 s cuando una entrada inactiva contenía 600.000 archivos.
0,35 s no es una concesión: una ejecución de control sin esa entrada también tarda 0,35 s, y la entrada la borró el hijo desacoplado 5,8 s después del arranque. Con cuatro entradas inactivas de 15.000 archivos cada una y un proceso que sale de inmediato, la caída fue de 0,64 s a 0,05 s, y las cuatro entradas seguían ahí cuando el proceso terminó.
Lo que no cambió es la parte que te protege. Un directorio que dejaste caer o descomprimiste con un mtime viejo se conserva igual. Un sleep sin reclamar dentro de una entrada inactiva sigue recibiendo TERM antes de que el directorio se vaya. El directorio del shell que lanzó Hermes, y sus padres, se conservan. Los procesos en segundo plano, los gateways y las sesiones de CLI que Hermes rastrea él mismo nunca se detienen. Y si psutil no se puede importar, no se borra absolutamente nada: la pasada falla en cerrado y registra process check failed …, nothing deleted this pass.
La política explica por qué el directorio a veces parece abandonado: una entrada se va solo tras 24 horas de inactividad, cualquier escritura en cualquier punto de dentro durante el último día la mantiene, y los hardlinks no cuentan como escrituras —a propósito, para que un git clone --local que comparte pack files con su origen no mantenga vivo el clon para siempre—. Cada borrado, parada y conservación queda en ~/.hermes/logs/scratch-prune.log. Las entradas que no puedes leer ni escribir —normalmente salida de contenedores escrita como root dentro de un bind mount— no se borran a ciegas: recibes una pista y un sudo rm -rf '<path>' sugerido. Contexto sobre esa fontanería: el recorrido por el directorio temporal de la terminal.
Factura dos: una búsqueda completa de versión de git en cada comando
Ejecutando Hermes desde un checkout de git, cada comando se volvió unos 1 segundo más rápido: hermes sessions list de 1,453 s a 0,435 s (media de seis ejecuciones intercaladas en cada sentido), y los subprocesos de git por comando de 15 a 2.
Dos causas raíz, el mismo bug con otra ropa. Cada una lanzaba una búsqueda completa de versión —siete llamadas a git, una de ellas git tag --merged HEAD, que cuesta unos 0,35 s en un repositorio grande— en el camino de arranque.
La primera es la ansiedad en tiempo de importación. import hermes_cli.config enumera todos los plugins de provider, y varios construyen un User-Agent HermesAgent/<release> a nivel de módulo: Fireworks, GMI, xAI, router, Kimi, OpenCode. Para meter la release en esa cadena resolvían la versión desde git durante el import, y auth_constants, models, model_catalog, auxiliary_client, gemini_native_adapter, web_server_messaging, Slack, Yuanbao y Perplexity hacían lo mismo. Un solo import de config, un montón de subprocesos de git, antes de que tu comando haya leído un único argumento. Ese coste de la capa de importación es el tema de la inmersión en la caché de config.
La segunda es una forma sorprendentemente cara de comparar una cadena. El aviso de “actualización pendiente de reinicio” llama a _current_checkout_sha(), que pasa por get_code_identity(refresh=True): limpia la caché y repite la búsqueda entera, en cada arranque, incluso cuando no hay ningún recibo de actualización con el que comparar.
El arreglo son tres piezas pequeñas: providers.base.LazyHeaders, una subclase de dict que guarda las claves en el import y evalúa los valores llamables en la primera lectura, así que el User-Agent con la release dentro se calcula solo cuando se construye una petición de verdad, mientras .copy(), |, update, json.dumps, pickle e isinstance(x, dict) siguen viendo un dict real; version_info.hermes_user_agent(product), calculado al construir la petición; y version_info.read_checkout_sha(), que obtiene el mismo SHA en una llamada a git en vez de siete. hermes --version, el User-Agent y las cabeceras de atribución quedan igual.
Factura tres: el commit-graph llevaba dos meses desactualizado
git tag --merged HEAD se usa tanto en el arranque como en hermes update. En un checkout de instalación real pasó de 0,39 s a 0,05 s.
Tres cosas habían ido mal. Bajo clon parcial, la escritura del commit-graph del propio git está desactivada a propósito (upstream #127711). El mantenimiento de packs de Hermes tampoco escribía ninguno. Y un objects/info/commit-graph.lock que dejó un proceso de git muerto llevaba dos meses ahí, porque el barrido de locks obsoletos de gitlock no cubría ese lock en concreto. Así que cada búsqueda de etiqueta hacía la parte cara, para siempre, con un archivo de lock garantizando que siguiera así.
El arreglo refresca el grafo en segundo plano después de cada hermes update y en cada arranque de hermes -w, usando git commit-graph write --reachable --split --no-changed-paths. La misma pasada limpia los locks de commit-graph y de multi-pack-index y los archivos temporales tmp_graph_* que dejan los procesos de git muertos, y el apagado manda SIGTERM al árbol de procesos primero y SIGKILL solo tras 5 segundos de gracia (el comportamiento por defecto para otros llamadores no cambia), así que los kills dejan menos escombros.
Medido sobre lo real: _run_post_update_maintenance bajó de 2,82 s a 2,01 s, el grafo dividido se escribió en los 3 segundos siguientes a que la fase retornara, y commit-graph verify pasa. El mantenimiento en segundo plano de hermes -w pasó de no hacer absolutamente nada a escribir el grafo en 3,47 s y limpiar ese lock de dos meses. Como esto ahora cuelga de hermes update, la guía de actualización sin sustos cubre el resto.
Las tres facturas, lado a lado
| Qué haces | Antes | Después |
|---|---|---|
hermes sessions list, una entrada inactiva con 600.000 archivos |
6,74 s | 0,35 s |
| Cuatro entradas inactivas × 15.000 archivos, el proceso sale de inmediato | 0,64 s | 0,05 s |
Búsqueda de versión desde un checkout (hermes sessions list) |
1,453 s | 0,435 s |
| Subprocesos de git por comando | 15 | 2 |
git tag --merged HEAD en un checkout de instalación real |
0,39 s | 0,05 s |
_run_post_update_maintenance (incluye un fetch real de etiquetas) |
2,82 s | 2,01 s |
Nada de este trabajo estaba mal ni era innecesario. Solo estaba en el sitio equivocado.
Un arreglo menor: un salto de reloj ya no expulsa un gateway en marcha
El cuarto PR es pequeño, pero produce un misterio. Después de que el reloj del sistema saltara, el barrido de scratch podía juzgar mal un gateway en marcha como abandonado y tratarlo en consecuencia; ya no lo hace. Si algún servicio de fondo de larga vida ha desaparecido justo después de que un portátil despierte o de que NTP corrija el reloj, esto es una explicación plausible, y va arreglado en el mismo lote.
Qué significa esto para ti hoy
Los cuatro PRs se fusionaron el 2026-10-26, que es más tarde que v0.22.5 (publicada el 2026-10-23). No están en ninguna release: ni en v0.22.5 ni en nada que puedas instalar hoy. Existen solo en main. Para tener la mejora ahora, sigue main; si no, llegará con la próxima versión.
Si te vas a quedar un tiempo en una release, los números siguen sirviendo como línea base: si un solo hermes sessions list tarda seis o siete segundos, la causa es casi con seguridad una entrada inactiva grande bajo ~/.hermes/cache/scratch, y vale la pena inspeccionarla antes de concluir que Hermes se ha vuelto lento. ~/.hermes/logs/scratch-prune.log registra cada borrado, parada y conservación, así que “¿se ha comido algo mis archivos temporales?” tiene respuesta. Desde un checkout bajo ~/.hermes/hermes-agent, un objects/info/commit-graph.lock olvidado es una ralentización real que nada en v0.22.5 limpia. Y si exportas TMPDIR tú mismo, ojo: Hermes solo redirige las tres variables temporales cuando no están definidas, así que tus propios archivos temporales nunca entran en la política de scratch.
Para una instalación desde cero, la guía de instalación cubre tanto la release como el formato de checkout, y la distinción importa aquí: las mejoras de búsqueda de versión y de commit-graph solo aplican a instalaciones de checkout, mientras que la mejora de limpieza de scratch aplica a todo el mundo.