profile 改名后旧会话指向幽灵?一条命令补上身份迁移


你把 Telegram 机器人的 profile 从 mybot 改成了 assistant:hermes profile rename mybot assistant 跑完,~/.hermes/profiles/mybot 变成了 ~/.hermes/profiles/assistant,命令别名也搬了,机器人在群里照样回话,看起来一切正常。可过几天你翻会话历史,发现老对话还挂在 mybot 这个名字下面,gateway 的路由表里还留着 agent:mybot:* 这样的键——而 mybot 这个 profile 已经不存在了。另一种更诡异的情况是:你明明删掉过一个 profile,几天后它的目录又出现在磁盘上,里面还躺着一个缓存文件。这两件事正是 PR #112653 修掉的问题,也就是 #111926 和 #112592 两个 issue。

先把三个词说清楚。profile 就是一套独立的 Hermes agent:自己的配置、凭据、记忆和会话,住在 ~/.hermes/profiles/<名字> 下面。路由键(routing key) 是 gateway 用来判断“这条消息属于哪个 profile、哪个会话”的字符串,形如 agent:<profile>:<会话>。multiplexed gateway 指的是一个 gateway 进程同时服务多个 profile,它会在内存里维护一张路由索引表。

改名过去只搬了目录,没搬身份

旧的改名逻辑做的是文件系统层面的事:把 profile 目录改名,把 shell 别名改名。数据库里的那一半完全没动。问题在于,Hermes 的状态库里有很多行是用 profile 名字本身当键 的,改完名之后这些行仍然指向 mybot,而这个 profile 已经不在磁盘上了——它们指向的是一个幽灵。

后果不会立刻炸,而是慢慢显形:老会话的名字对不上、后台心跳里挂着一个不存在的 profile、投递义务(delivery obligations,指“这条消息还没送达、需要重试”的记录)留在旧名字下、Telegram 话题表的归属对不上。多个 profile 跑在一个 gateway 下的场景最容易踩坑,这套结构本身的边界可以看多实例 profile 指南。

为什么身份表要用 profile 名当键

因为对数据库来说,profile 名就是身份。名字换了,等于这个 agent 在状态库里换了个身份,所以迁移不是搬文件,而是“改名 + 重写所有以名字为键的行”。受影响的表在 PR #112653 里列得很具体:

  • sessions.session_key、sessions.profile_name、sessions.origin_json
  • gateway_heartbeats.profile
  • delivery_obligations
  • Telegram 的话题表
  • gateway_routing 路由索引(键本身,以及键里嵌的那段 JSON)

会话这边还有一层边界:哪些东西跟着 profile 走、哪些跟着会话走,在会话边界指南里讲得更细。

现在改名会迁移什么:一次事务,加上一份内存索引

数据库侧由 SessionDB.rekey_profile_state(old, new)(在 hermes_state_gateway.py 里)负责:一个事务把所有以 profile 名为键的行重写成新名字。它会先做冲突检查——如果新名字下已经有会撞车的行,就直接拒绝,而不是写出一份混乱的数据;它也是幂等的,重复跑没有副作用;那些还没被懒创建出来的表会被跳过。

但只改数据库是不够的。一个正在运行的 multiplexer 会周期性地把自己内存里的 _entries 写回数据库,所以纯数据库重写会被它下一次刷写覆盖掉。因此还需要 SessionStore.rekey_profile_routing(old, new)(在 gateway/session.py 里):它把内存路由索引里的键和 origin.profile 一起改成新名字,再持久化。这也是为什么活着的 gateway 拥有这次迁移的主导权——索引在它手里,它说了算。

具体链路是:rename_profile 调用 hermes_cli/profile_identity.py 里的 _migrate_profile_identity;如果检测到有活着的 multiplexer,就通过控制套接字(control socket)的 migrate-profile-identity 动词把迁移交给它,否则 CLI 自己完成这次持久化重写。无论哪种情况,迁移失败都不会让改名本身失败——最差的结果是给你一条警告,profile 已经改好了,但身份行还挂在旧名字上。

安全改名的步骤

hermes profile list              # 先看一眼当前有哪些 profile
hermes profile rename mybot assistant
hermes profile show assistant    # 确认新名字和它的显示信息
hermes profile list              # 列表里应该只剩 assistant

改名时要留意命令的输出:如果有一条警告说 live gateway 没能迁移会话身份,别忽略它,按下一节的方法补一次就好。如果你不确定自己的 gateway 是不是 multiplexer,最稳的做法就是照输出的提示走——没警告说明迁移已经落地,有警告就重启 gateway 再补一刀。命令本身的所有参数可以在 hermes profile 命令参考里查;还没装 Hermes 的话先看安装指南。

迁移没跑成?用 migrate-identity 补一次

改名命令不能重复执行——profiles/mybot 已经不在了。所以补迁移有专门的命令:

hermes profile migrate-identity <old-name> <new-name>

真实的使用流程长这样:

hermes profile rename mybot assistant
# ⚠ Profile was renamed, but the live gateway could not migrate session identity (…).
#   Restart the gateway, then run:
#     hermes profile migrate-identity mybot assistant

hermes profile migrate-identity mybot assistant
# ✓ Session/routing identity migrated: mybot → assistant

第一次输出在提醒你:profile 改好了,但活着的 gateway 没能迁移会话身份。它给了两条路——重启 gateway(它启动时会从数据库重新加载路由索引,迁移就会落地),或者把 gateway 停掉(此时没有进程持有那份索引,命令自己就能做持久化重写)。

几个值得知道的性质:

  • 迁移是“按行”驱动的,也就是由那些仍然写着 mybot 的行决定要改什么。所以 profiles/mybot 不需要存在,只有新名字会被检查。
  • 幂等:已经迁移完的再跑一次照样成功,只是没有东西需要改了。
  • 退出码非零的情况有两种:活着的 gateway 拒绝这次迁移;或者数据库拒绝了这次重写——可能是路由冲突、锁,也可能是两个数据库里一个成功一个失败。这种时候错误信息里会写明是哪个数据库、什么错误。

给默认 profile 改名,其实只是改了个显示名

你可能想让默认 profile 也有个像样的名字。这里有个容易误解的地方:默认 profile 的内部 ID 永远是 default,因为 ~/.hermes 本身就是安装根目录,它没法被真正改名。你执行下面这条命令时,它设置的是一个显示名(display name):

hermes profile rename default Harumesu   # Unicode 没问题:小助手

这个显示名会出现在 hermes profile list 和 hermes profile show、/profile 聊天命令、dashboard 以及桌面应用里(包括 Bot Mode 的成员列表)。但它纯粹是展示层的:-p default、服务名、cron 任务以及所有其他引用,用的都还是那个规范的 default ID。它保存在 ~/.hermes/profile.yaml 的 display_name 一行里,把这一行删掉就还原了。命名 profile 也可以有 display_name(而且能躲过一次真正的改名活下来),只不过对它们来说,rename 改的就是 profile 本身。

删除 profile 后会留下墓碑,谁都不许把它复活

删除是这样的:

hermes profile delete coder

它会停掉 gateway、移除 systemd/launchd 服务、删掉命令别名,然后清空这个 profile 的全部数据。默认会要求你输入 profile 名字确认,加 --yes 可以跳过。默认 profile 删不掉(~/.hermes 就是安装根),想清干净得用 hermes uninstall。

删除之后,那个 home 目录会留下一块墓碑(tombstone)——一个标记,意思是“这个命名 profile 已经不存在了,别再写回来”。之前的问题是:某条线程里可能还握着一个已经死掉的 home 路径,稍晚一点做了一次原子写入,而写入前的 mkdir(parents=True) 就把目录重新建了出来。于是你删掉的 coder 又出现在磁盘上,里面只有个缓存文件。

#112592 把这条路堵死了:utils._atomic_write 以及 atomic_roundtrip_yaml_update / atomic_roundtrip_yaml_save 现在都通过 mkdir_under_hermes_home(也就是那个墓碑守卫)来创建父目录,所以任何原子的 JSON / 文本 / 字节 / YAML 写入遇到被立了墓碑的命名 home 时,都会以 FileNotFoundError 拒绝,并且什么都不留下。调用方里散落的那些写入前 mkdir(parents=True) 也统一走了同一道守卫——模型缓存、models.dev 的 ETag、生命周期标记、密钥缓存、MCP OAuth、记忆存储,以及其他被机械扫过的写入点;有两个带 mode= 的地方把 mkdir 保留在自己的 assert_named_profile_home_live 后面。

这道守卫不是无差别的:除非目标路径能明确地回溯到一个 profiles/<name> 祖先目录,它什么都不做。默认 home、隐藏的 .staging 目录,以及那些只是碰巧包含 profiles 的无关路径,照常写入。

注意事项与小结

  • 改了名就检查一下输出。 迁移失败不会让改名失败,只给你一条警告——看到警告就重启 gateway,再跑 hermes profile migrate-identity <旧名> <新名>。
  • 补迁移是幂等的,重复跑没有代价,所以不确定的时候直接跑一次更省事。
  • 活着的 multiplexer 优先。 它持有内存路由索引,由它来迁移才不会被下一次刷写覆盖;把 gateway 停掉的话,命令自己做持久化重写。
  • 默认 profile 的改名叫不动物理路径,只是换了 UI 上的显示名,内部 ID 一直是 default。
  • 删除之后就让它安息。 墓碑守卫保证没有任何一次延迟写入能把删掉的命名 profile 重新变回磁盘上的一个 home。