Salida que se desvanece: cuando un proceso pasa de 1024 archivos abiertos, las llamadas al terminal de Hermes empiezan a volver vacías

Viernes por la tarde: el agente dice que la suite de tests ha terminado. Le pides que pegue la salida y devuelve dos comillas vacías — {"output": "", "exit_code": 0, "error": null}. Le pides que haga read_file de una config de 5 KB y reporta file_size: 0. Haces cat del archivo tú mismo y está perfecto. Lo curioso es que el mismo Python lanzado desde execute_code lee el contenido sin problema. Es como si solo algunos canales se hubieran quedado ciegos.
El modelo no te está dando largas y el disco está bien. Fue un bug que estuvo abierto casi dos meses y se cerró el 21 de octubre de 2026 (issue #94928, PR #142299), y la causa raíz cabe en una línea: en cuanto un proceso sostiene más de 1024 file descriptors, la llamada select() que espera la salida lanza una excepción, y esa excepción se interpretó como “la tubería está cerrada”.
El síntoma: salida vacía, exit code correcto
El reporte original es preciso. En Linux, después de que Hermes Desktop llevara un rato levantado, terminal devolvía stdout vacío para todos los comandos mientras informaba exit code 0. ls y stat volvían vacíos; read_file devolvía file_size: 0 para un archivo de 5092 bytes; el Python de execute_code leía esa misma ruta correctamente.
¿Por qué salida vacía en lugar de un error? Porque el hilo lector se rendía antes de leer nada: daba la tubería por cerrada y descartaba lo que aún no había llegado, mientras el comando se ejecutaba hasta el final y devolvía 0. Desde el lado del llamador parece una llamada exitosa que simplemente no tenía nada que decir.
El reporte incluye los números que lo explican: el proceso principal de Electron sostenía 1438 fds, dos procesos hermes serve sostenían 1429 y 1410, y un hijo git-remote-https sostenía 1384. Todos por encima de la línea.
Causa raíz: el techo de 1024 en select()
El select() de POSIX representa los conjuntos de fds como un bitmap de tamaño fijo cuya longitud es FD_SETSIZE, normalmente 1024 en compilación. Si le pasas un descriptor igual o mayor que 1024, Python lanza:
ValueError: filedescriptor out of range in select()
Hermes lee la salida de comandos en tres sitios — el drenaje en primer plano (_drain_fd_select en tools/environments/base_output.py), el lector de procesos en segundo plano (_reader_loop en tools/process_registry.py) y el PtyBridge que hay detrás del panel de terminal de Desktop. Los tres esperaban en select.select(), y los tres trataban ese ValueError como “el fd ya está cerrado”. Por eso la misma salida perdida aparecía igual en comandos en primer plano, en jobs en segundo plano y en el panel de terminal del escritorio.
No necesitas Hermes para reproducirlo: abre 1100 handles a /dev/null, crea una tubería y llama a select.select() sobre el descriptor nuevo. La excepción llega al instante.
El arreglo: sustituir select(), pero no por poll()
El patch añade tools/environments/fd_wait.py, un pequeño FdWaiter construido sobre selectors.DefaultSelector: epoll en Linux, kqueue en macOS, ninguno de los cuales tiene techo de FD_SETSIZE.
class FdWaiter:
"""Readability wait on one fd. Owns a selector (epoll/kqueue are fds themselves):
create it once per reader lifetime and close() it when the reader is done."""
def __init__(self, fd: int, selector_cls):
self._selector = selector_cls()
try:
self._selector.register(fd, selectors.EVENT_READ)
except BaseException:
self._selector.close()
raise
def wait(self, timeout_s: float) -> bool:
return bool(self._selector.select(max(timeout_s, 0)))
Una decisión contraintuitiva: poll() tampoco tiene límite de 1024, así que ¿por qué no usarlo? Porque el poll() de macOS no soporta fds de dispositivo: reporta un PTY master como POLLNVAL, es decir, “listo ahora mismo”. Un bucle de lectura de PTY montado sobre poll() giraría a toda velocidad siempre que estuviera inactivo, quemando un core. Por eso poll() sobrevive solo como fallback donde no hay otra opción: a diferencia de epoll y kqueue, no necesita un fd propio, así que sigue funcionando bajo EMFILE; en macOS, para un dispositivo de caracteres (PTY), el código lanza en lugar de aceptar una espera que nunca espera.
El patch también cierra dos bordes: un fallo al preparar el selector no debe reportarse nunca como EOF, y cada lector posee un waiter durante toda su vida y lo cierra al terminar, de modo que arreglar la fuga antigua no introduce una nueva.
Cómo saber si te ha tocado
Una tabla de síntomas vale más que hurgar en logs: el comando se ejecutó de verdad (los efectos secundarios están ahí), pero output es una cadena vacía, exit_code es 0 y error es null; read_file reporta 0 bytes para un archivo que no está vacío; execute_code lee ese mismo archivo sin problema. Las tres juntas y puedes dejar de adivinar.
Contar descriptores lo confirma. En Linux:
for p in $(pgrep -f "hermes_cli.main serve"); do
echo "$p: $(ls /proc/$p/fd | wc -l) fds"
done
En macOS:
lsof -p $(pgrep -f "hermes_cli.main serve" | head -1) | wc -l
ulimit -n es solo contexto — macOS suele arrancar en 256 y Linux en 1024 — porque la línea que importa es FD_SETSIZE, no el límite del shell.
En una build antigua el apaño es contundente: reinicia el gateway (y Desktop), baja el contador de fds y la salida vuelve de inmediato. Y no leas nunca “salida vacía con exit 0” como “el comando no imprimió nada”; un resultado vacío que parece plausible es lo más fácil de escribir en una conclusión.
Los servicios de larga vida acumulan descriptores hagas lo que hagas dentro de ellos, así que esto no va de contenedores ni del montaje de contenedor Docker compartido: va de uptime del proceso y número de conexiones. Para terreno operativo cercano, mira la recuperación de procesos huérfanos y la guía del directorio temporal del terminal; los comandos del gateway están en la referencia de hermes gateway.
¿Puedes usarlo hoy?
El patch se mergeó en main el 21 de octubre de 2026 y todavía no está en ninguna release (la última versión publicada, la v0.22.2, es anterior). Saldrá con el siguiente patch. Para verlo funcionar ahora, mantén unos 1100 descriptores abiertos en un proceso y lanza echo: con el código antiguo la salida desaparece, con el nuevo vuelve.
La versión corta
Tres cosas que recordar. Salida vacía con exit 0 significa salida descartada, no salida vacía. La causa es el techo de 1024 descriptores de select(), no la memoria, el disco ni los permisos. El arreglo usa un selector sin techo y evita a propósito el poll() de macOS, que giraría en vacío. Cuando una dolencia solo afecta a instancias de larga vida, cuenta los file descriptors antes de leer los logs.