语音合成失败一次,你的录音就没了:Hermes 修掉这个 P0


你让 Hermes 把一段会议纪要念成音频存下来,顺手在指令里补了一句“存到 meeting-notes.mp3”。那个文件名,是你上午刚录完的客户通话。几秒后工具报了个错,说 TTS provider 不可用。你点开那个文件——没了。不是内容被覆盖,是文件本身不在了。

这不是你在文件系统里手滑,而是 Hermes 一个被标为 P0 的 bug:一次失败的语音合成,会删除 output_path 指向的已有文件。10 月 9 日它已经被修掉(PR #135531,改动只有 5 行加一个测试文件)。这篇把机制、修复和你能做的防护讲清楚。

为什么一次失败会把你的文件删掉

要理解这件事,先看 text_to_speech 这个工具是怎么收尾的。

它支持长文本分块合成,所以每次调用都会维护一个集合 generated_artifacts:这次合成“碰过”的每一个路径都记在里面。等整个调用结束(无论成功还是失败),finally 里统一做清理——把集合里那些最终没有交付出去的文件 _remove_quietly() 掉。这个设计的初衷完全合理:分块合成失败了,那些 .chunk001.mp3、.chunk002.mp3 的半成品不该留在磁盘上。

问题出在记录的那一刻。修复前的代码是这样的:

chunk_path = base_path            # 也就是 output_path 指向的路径
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))   # ← 用户自己的文件被登记成了"我生成的临时文件"

output_path 是模型可以自己选择的工具参数。当它指向一个已经存在的文件时,旧代码会把这个文件当成自己这次合成出来的产物登记进清理集合。于是:

  • 合成成功 → 投递步骤把音频 os.replace() 覆盖上去,你的文件被新音频替换(这是预期行为);
  • 合成失败 → finally 里的清理逻辑认领了这个路径,把它删掉。

失败的那条分支就是 P0 的来源:用户的东西从来没有参与过这次合成,却被清理逻辑算成了“半成品”。这个 bug 是在对插件目录做评审、检查 wyoming-voice-bridge 时被发现的。

修复:先写到隔壁,成功再换过去

新的 _synthesize_chunks 在写之前加了一次存在性判断:

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}")

也就是说,目标路径已经存在时,合成会写到一个隐藏的兄弟文件 .meeting-notes.9f3c...mp3。接下来两条路径都变干净了:

  • 失败:清理逻辑只会删掉那个兄弟文件。你原来的文件从头到尾没被打开、没被截断、没被 unlink。
  • 成功:投递步骤照旧 os.replace() 把音频换到目标路径上——覆盖语义和以前完全一致。

用一个“新路径”的时候(包括默认的时间戳缓存路径)走的是和以前一样的代码,失败时的半成品也照旧会被清理干净。

顺带一提,这个工具入口本来就有一批路径闸门,只是它们拦的是“不该写的地方”,而不是“不该覆盖的文件”:

  • .. 穿越组件会被拒绝(绝对路径允许,相对路径不能往上跳);
  • 含控制字符/换行分隔符的路径会被拒绝;
  • 路径里带 MEDIA: 指令会被拒绝(否则下游的媒体投递标签会被伪造);
  • 命中凭据/系统保护路径(file_safety 的 is_write_denied / is_write_approval_required)会被拒绝。

不指定 output_path 时,默认落在 <audio cache>/tts_<timestamp>.<ext>:以时间戳命名,几乎不可能撞上你已有的文件。这也解释了为什么大多数人从来没见过这个 bug——撞上它需要“刚好指向一个已有文件”。

如果你已经踩过一次:能救回来吗

先说不好听的:被 unlink 的文件不走回收站,普通情况下救不回来。但如果你平时用上了这几样东西,损失可以很小:

  • 检查点(checkpoints):Hermes 的检查点能在会话里回滚文件改动,hermes checkpoints 系列命令可以查看和恢复。注意它是为“Agent 改坏了文件”设计的,前提是改动发生在被追踪的工作区里。
  • /save 与 session 导出:会话记录里通常留着当时的文本内容,重跑一次合成就能拿回音频本身(语音内容可重建,录音不可重建——这是关键区别)。
  • 把重要文件放在 git 工作区之外:类似的“文件被工具误伤”教训我们已经写过一个 —— 见 工作区脏数据导致文件丢失的那次事故。
  • 需要了解检查点的完整用法,看 hermes checkpoints 命令参考。

怎么用才安全

一句话:不要用 output_path 指向你舍不得丢的文件。

  • 想让它念一段文字给你,直接不给 output_path,用默认缓存路径;
  • 想归档音频,用一个新文件名(summaries/2026-10-10-daily.mp3 这类),而不是复用已有文件;
  • 如果确实要固定覆盖某个输出文件,明白“成功=覆盖、失败=原文保留”这个契约现在已经成立了,但你自己别在它旁边放唯一副本。

什么时候能拿到

这个修复合并于 2026-10-09,晚于 v0.21.6 的发布(10 月 8 日),所以它目前只在 origin/main 上,还没有进入任何正式 release。想立刻用上,可以 hermes update 跟进主线;否则等下一个版本发布后自然生效。

配套的回归测试也一并进了仓库(tests/tools/test_tts_failure_preserves_existing.py),用三行断言钉住了这个契约:provider 失败时已有文件必须存活且不残留半成品,新路径失败时不留垃圾,成功时依然覆盖。这个 bug 值得记住的一点是——工具会“清理自己的垃圾”,但前提是它能分清哪些是垃圾。把别人的文件登记成自己的临时产物,代价就是用户的录音。如果你在做插件或工具,这 5 行改动值得抄一遍。