Uma chamada TTS falha apagou um arquivo do usuário: por dentro da correção P0 do caminho de saída do Hermes


Você pede ao Hermes para transformar o resumo de uma reunião em áudio e acrescenta uma linha ao prompt: salve como meeting-notes.mp3. Acontece que esse nome de arquivo é justamente a gravação de uma chamada de cliente que você fez hoje de manhã. Poucos segundos depois, a ferramenta retorna um erro — o provider de TTS estava indisponível. Você abre o arquivo. Não está corrompido. Não está vazio. Ele sumiu.

Esse foi um P0 de verdade no Hermes: uma chamada de text-to-speech que falhou apagou o arquivo existente para o qual output_path apontava. Foi corrigido em 9 de outubro (PR #135531 — cinco linhas de código mais um arquivo de teste). Veja o mecanismo, a correção e o que isso significa para a forma como você usa a ferramenta.

Por que uma falha podia levar seu arquivo embora

Comece por como a ferramenta text_to_speech faz a própria limpeza.

Ela dá suporte a síntese de textos longos dividindo o texto em chunks, então cada chamada mantém um conjunto chamado generated_artifacts: todo caminho que a chamada tocou é registrado ali. Quando a chamada termina — com sucesso ou com falha — um bloco finally varre o conjunto e chama _remove_quietly() em tudo que não foi de fato entregue. Esse design é totalmente razoável. Se o chunk 3 de 5 falha, os arquivos .chunk001.mp3 pela metade não deveriam ficar no disco.

O problema estava na etapa de registro. Antes da correção, o código era assim:

chunk_path = base_path            # o caminho para o qual output_path apontava
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))   # ← o arquivo do próprio usuário foi registrado como "minha saída temporária"

output_path é um argumento da ferramenta que o modelo pode escolher. Quando ele apontava para um arquivo que já existia, o código antigo registrava esse arquivo como algo que esta chamada tinha produzido. A partir daí:

  • a síntese dá certo → a etapa de entrega faz os.replace() do novo áudio sobre o alvo (a substituição esperada);
  • a síntese falha → a varredura do finally reivindica o caminho e o remove.

O ramo de falha é onde morava o P0: o arquivo do usuário nunca participou da síntese, mas a lógica de limpeza o contava como saída deixada para trás. O bug apareceu durante uma revisão de catálogo do plugin wyoming-voice-bridge.

A correção: escreva ao lado e só mude de endereço em caso de sucesso

O novo _synthesize_chunks verifica se existe um arquivo antes de escrever:

if os.path.lexists(chunk_path):
    # Um arquivo que não criamos: sintetize ao lado dele, para que uma tentativa falha não possa
    # truncá-lo nem removê-lo; em caso de sucesso, a etapa de entrega faz os.replace() do áudio sobre ele (substituição).
    chunk_path = chunk_path.with_name(f".{chunk_path.stem}.{uuid.uuid4().hex}{chunk_path.suffix}")

Quando o alvo já existe, a síntese vai para um arquivo irmão oculto — .meeting-notes.9f3c….mp3. Agora os dois desfechos são limpos:

  • Falha: a varredura remove apenas o irmão. Seu arquivo nunca é aberto, truncado ou desvinculado.
  • Sucesso: a etapa de entrega continua fazendo os.replace() do áudio sobre o alvo, então o contrato de substituição segue igual.

Caminhos novos — incluindo o caminho de cache padrão com timestamp — seguem exatamente o mesmo fluxo de código de antes, e a saída parcial de uma tentativa falha continua sendo limpa.

Vale saber: a ferramenta já tinha um conjunto de travas de caminho, mas elas guardam onde você pode escrever, não o que você pode substituir:

  • um componente de travessia .. é rejeitado (caminhos absolutos são aceitos; caminhos relativos não podem subir);
  • caracteres de controle ou separadores de linha no caminho são rejeitados;
  • uma diretiva MEDIA: dentro do caminho é rejeitada, então uma tag de entrega forjada não consegue seguir adiante;
  • locais protegidos de credenciais/sistema (o is_write_denied / is_write_approval_required do file_safety) são rejeitados.

Omita output_path e a ferramenta escreve em <audio cache>/tts_<timestamp>.<ext> — um nome com timestamp muito improvável de colidir com algo que seja seu. É também por isso que a maioria das pessoas nunca encontrou esse bug: para atingi-lo, é preciso apontar a ferramenta para um arquivo que já existe.

Se já aconteceu com você

Sem rodeios: um arquivo desvinculado não vai para a lixeira e, sem backup, ele se foi. Mas alguns hábitos reduzem o estrago:

  • Checkpoints. O sistema de checkpoint do Hermes consegue reverter mudanças de arquivo dentro de uma sessão; os comandos hermes checkpoints permitem inspecioná-las e restaurá-las. Ele é feito para edições do agente, então só ajuda se o arquivo tocado estiver em um workspace rastreado.
  • Exportações de sessão. O texto que foi sintetizado normalmente ainda existe no transcript, então o áudio pode ser regerado. Uma gravação, não — e é exatamente essa diferença que constitui todo o risco.
  • Mantenha arquivos insubstituíveis fora de diretórios expostos às ferramentas. Documentamos uma classe semelhante de perda acidental de arquivos no relato do incidente de perda de dados de worktree suja.
  • Para a superfície completa de checkpoint, veja a referência de hermes checkpoints.

Como usar output_path com segurança

Uma regra resolve tudo: nunca aponte output_path para um arquivo que você não pode perder.

  • Quer uma versão falada de algum texto? Não passe output_path de jeito nenhum — fique com o caminho de cache padrão.
  • Está arquivando áudio? Use um nome de arquivo novo (summaries/2026-10-10-daily.mp3), não um existente.
  • Substituir de propósito um arquivo de saída fixo agora é seguro por contrato (sucesso substitui, falha preserva), mas não deixe a única cópia de nada ao lado dele.

Quando você pode obtê-la

A correção foi integrada em 2026-10-09, depois de a v0.21.6 ser lançada em 8 de outubro, então ela está apenas em origin/main e ainda não em nenhuma release tagueada. O hermes update vai buscá-la do main, ou você pode esperar a próxima release. O status da release e o trabalho de estabilidade mais amplo estão resumidos na nossa página da release v0.21.6.

O teste de regressão veio junto (tests/tools/test_tts_failure_preserves_existing.py), fixando o contrato em três asserções: um arquivo existente sobrevive a uma falha do provider sem deixar nada parcial para trás, uma falha em um caminho novo não deixa nada para trás, e um sucesso ainda substitui o arquivo. A lição vale para qualquer um que escreva ferramentas: código de limpeza precisa ter certeza do que lhe pertence. Registrar o arquivo de outra pessoa como sua própria saída temporária custa ao usuário a gravação dele.