Una llamada TTS fallida borró un archivo del usuario: dentro del arreglo P0 de la ruta de salida de Hermes

Le pides a Hermes que convierta el resumen de una reunión en audio y añades una línea al prompt: guárdalo como meeting-notes.mp3. Resulta que ese nombre de archivo es justo la grabación de una llamada de cliente que hiciste esta mañana. Unos segundos después la herramienta devuelve un error: el proveedor de TTS no estaba disponible. Abres el archivo. No está corrupto. No está vacío. Ha desaparecido.
Ese fue un P0 real en Hermes: una llamada fallida a text-to-speech borraba el archivo existente al que apuntaba output_path. Se corrigió el 9 de octubre (PR #135531: cinco líneas de código más un archivo de test). Aquí tienes el mecanismo, el arreglo y lo que significa para cómo usas la herramienta.
Por qué un fallo podía llevarse tu archivo por delante
Empecemos por cómo se limpia tras de sí la herramienta text_to_speech.
Admite síntesis de formato largo dividiendo el texto en chunks, así que cada llamada mantiene un conjunto llamado generated_artifacts: en él se registra toda ruta que esa llamada haya tocado. Cuando la llamada termina —con éxito o con fallo—, un bloque finally recorre el conjunto y aplica _remove_quietly() a todo lo que en realidad no se entregó. Ese diseño es del todo razonable. Si falla el chunk 3 de 5, los archivos .chunk001.mp3 a medio terminar no deberían quedarse en el disco.
El problema estaba en el paso de registro. Antes del arreglo, el código tenía este aspecto:
chunk_path = base_path # the path output_path pointed at
if len(chunks) > 1:
chunk_path = base_path.with_name(f"{base_path.stem}.chunk{index:03d}{base_path.suffix}")
generated_artifacts.add(str(chunk_path)) # ← the user's own file was registered as "my temp output"
output_path es un argumento de la herramienta que el modelo puede elegir. Cuando apuntaba a un archivo que ya existía, el código antiguo registraba ese archivo como algo que esta llamada había producido. A partir de ahí:
- la síntesis tiene éxito → el paso de entrega hace
os.replace()del nuevo audio sobre el destino (la sobrescritura esperada); - la síntesis falla → el barrido del
finallyreclama la ruta y la elimina.
La rama de fallo es donde vivía el P0: el archivo del usuario nunca participó en la síntesis, pero la lógica de limpieza lo contaba como salida sobrante. El bug salió a la luz durante una revisión del catalog del plugin wyoming-voice-bridge.
El arreglo: escribir al lado y entrar solo si hay éxito
La nueva función _synthesize_chunks comprueba si ya existe un archivo antes de escribir:
if os.path.lexists(chunk_path):
# A file we didn't create: synthesize beside it so a failed attempt can neither truncate
# nor sweep it; on success the delivery step os.replace()s the audio over it (overwrite).
chunk_path = chunk_path.with_name(f".{chunk_path.stem}.{uuid.uuid4().hex}{chunk_path.suffix}")
Cuando el destino ya existe, la síntesis va a un hermano oculto — .meeting-notes.9f3c….mp3. Ahora los dos resultados son limpios:
- Fallo: el barrido solo elimina el hermano. Tu archivo nunca se abre, ni se trunca, ni se desvincula.
- Éxito: el paso de entrega sigue haciendo
os.replace()del audio sobre el destino, así que el contrato de sobrescritura no cambia.
Las rutas nuevas —incluida la ruta de cache por defecto con marca de tiempo— recorren exactamente el mismo camino de código que antes, y la salida parcial de un intento fallido se sigue limpiando.
Conviene saber que la herramienta ya tenía un conjunto de controles de ruta, pero protegen dónde puedes escribir, no qué puedes sobrescribir:
- un componente de recorrido
..se rechaza (las rutas absolutas están bien; las relativas no pueden subir niveles); - se rechazan los caracteres de control o los separadores de línea en la ruta;
- se rechaza una directiva
MEDIA:dentro de la ruta, así una etiqueta de entrega falsificada no puede viajar aguas abajo; - se rechazan las ubicaciones protegidas de credenciales/sistema (
is_write_denied/is_write_approval_requireddefile_safety).
Si omites output_path, la herramienta escribe en <audio cache>/tts_<timestamp>.<ext>, un nombre con marca de tiempo que es muy poco probable que choque con algo tuyo. Esa es también la razón por la que la mayoría de la gente nunca se topó con este bug: para dar con él hay que apuntar la herramienta a un archivo que ya existe.
Si ya te ha pasado
Sin rodeos: un archivo desvinculado no va a una papelera, y sin una copia de seguridad está perdido. Pero unos cuantos hábitos reducen el radio de daño:
- Checkpoints. El sistema de checkpoints de Hermes puede revertir cambios de archivos dentro de una session; los comandos
hermes checkpointste dejan inspeccionarlos y restaurarlos. Está pensado para ediciones hechas por el agente, así que solo ayuda si el archivo afectado vive en un workspace rastreado. - Exportaciones de session. El texto que se sintetizó normalmente sigue existiendo en la transcripción, así que el audio se puede regenerar. Una grabación no: esa distinción es todo el riesgo.
- Mantén los archivos irremplazables fuera de los directorios expuestos a las herramientas. Documentamos una clase similar de pérdida accidental de archivos en el incidente de pérdida de datos del worktree sucio.
- Para toda la superficie de checkpoints, consulta la referencia de hermes checkpoints.
Cómo usar output_path con seguridad
Una regla lo cubre todo: nunca apuntes output_path a un archivo que no puedas permitirte perder.
- ¿Quieres una versión hablada de un texto? No pases
output_pathen absoluto: quédate con la ruta de cache por defecto. - ¿Archivas audio? Usa un nombre de archivo nuevo (
summaries/2026-10-10-daily.mp3), no uno que ya exista. - Sobrescribir deliberadamente un archivo de salida fijo ahora es seguro por contrato (el éxito sobrescribe, el fallo preserva), pero no guardes junto a él la única copia de nada.
Cuándo puedes conseguirlo
El arreglo se fusionó el 2026-10-09, después de que v0.21.6 se publicara el 8 de octubre, así que está solo en origin/main y todavía no en ninguna release etiquetada. hermes update lo recogerá desde main, o puedes esperar a la próxima release. El estado de la release y el trabajo de estabilidad más amplio están resumidos en nuestra página de la release v0.21.6.
El test de regresión llegó junto con él (tests/tools/test_tts_failure_preserves_existing.py), fijando el contrato en tres aserciones: un archivo existente sobrevive a un fallo del proveedor sin dejar residuos parciales, un fallo sobre una ruta nueva no deja nada atrás, y un éxito sigue sobrescribiendo. La lección se generaliza a cualquiera que escriba herramientas: el código de limpieza tiene que tener claro qué posee. Registrar el archivo de otra persona como tu propia salida temporal le cuesta al usuario su grabación.