Cómo cuatro minutos de silencio mataron a mi agente de IA
Un agente terminó su trabajo y el orquestador lo mató antes de guardar. La liveness se infería del stdout, así que una síntesis larga y silenciosa parecía muerte. Un keepalive de 15 líneas lo arregló.
El agente hizo todo bien. Leyó los sensores, sacó datos reales y escribió una
síntesis afilada: tres oportunidades de contenido con ganchos y señales de
mercado en vivo. Luego, tres segundos antes de poder guardar ese trabajo, el
sistema lo mató. La ejecución volvió como stranded.
Los dos primeros instintos estaban mal
El write-back falló con un 409, luego un 401, luego adapter_failed. Mis
dos primeros instintos estaban mal.
- Publicar un comentario para mantener viva la ejecución. La idea: generar actividad para que el orquestador viera una ejecución viva. Teatro. El detector de bloqueos nunca lee el campo “última actividad”. Lo confirmé en el código antes de apostar. El reloj que importa es el heartbeat del proceso, y un comentario no lo toca.
- Culpar a la fuente de datos inestable. Una herramienta había dado 404 antes en la ejecución, así que la culpé. No tenía nada que ver. El 404 era un bloqueo por fingerprint en una petición desde un datacenter, un bug real, pero no este.
La falla era una cadena silenciosa
Leyendo el código del runtime línea por línea, la falla era una cadena, y cada eslabón era invisible por sí solo:
- El modelo pasó minutos generando una síntesis larga sin ninguna tool call, así que el proceso hijo no emitió stdout.
- Sin stdout, el adapter no reenvió nada, así que el heartbeat de la ejecución envejeció.
- Un heartbeat viejo se ve exactamente como una ejecución muerta, así que el orquestador levantó un duplicado para recuperarla.
- El duplicado se quedó con el lock de checkout. Entonces el write-back de la
ejecución real fue rechazado: su actor ya no tenía el lock. Ese es el
409, luego el401, luego el strand.
El orquestador no podía distinguir entre “esta ejecución murió” y “esta ejecución se quedó callada para pensar”. Solo tenía una señal, y leía el silencio como muerte.
Quince líneas y un bug autoinfligido
El patch fueron unas quince líneas: un timer que emite una línea de heartbeat cada vez que el proceso se queda callado más allá de un umbral, se reinicia con cualquier salida real y se limpia cuando el hijo termina. Armé una tarea deliberadamente más pesada para forzar una síntesis larga y silenciosa. Corrió unos diez minutos y cerró limpia. La anterior moría a los cinco. El fix del runtime volvió a upstream como un pull request público contra el adapter.
En el camino, recargué el contenedor con docker restart y dejé huérfana la
task del swarm: cero réplicas, la mesh dejó de enrutar, la API devolvía 404
mientras la app estaba sana por dentro. Lo correcto en swarm es
service update --force. Dos bugs en una sesión, los dos instructivos.
Lección
Todo sistema que infiere “vivo” a partir de la salida va a terminar matando a un worker que se queda callado para hacer su trabajo real. Liveness y progreso son señales distintas. Si solo tienes un canal, mándale heartbeat con un timer independiente del trabajo en sí, o el sistema va a castigar la concentración.