插件生命周期的失败模式
桌面 Agent 生命周期 · 第 2 / 2 篇
建议先读:第 1 篇:桌面 Agent 不是 Web 套壳。
系列第一篇描述了四层:profile、Host、Client、App。这一篇是一份“层与层之间会怎么出错”的现场手册。
真正有用的问题通常不是“重载能不能工作”,而是“哪一层还是旧的,用户在旧状态下会看到什么”。
一个小模型:期望状态与观察状态
插件有一个期望状态:
installed: true | false
enabled: true | false每一层还有一个观察状态:
profile: installed | missing
host: loaded | unloaded | failed
client: mounted | unmounted | stale
app: current | restart-required大多数生命周期问题不是包管理器算错了,而是这些状态彼此不一致,或者界面只表达了其中一个。
七种失败模式
1. 从 profile 删除了,但 Client 还挂着
现象: 插件卡片消失了,侧栏入口或输入框按钮还在。
原因: 依赖和 patch 已删除,但渲染进程还持有旧的模块图。刷新页面可能修复,重启应用一定能。
设计回应: 返回 client-reload-required,而不是 ok。提供一个生产环境可用的 Client reload。不要在 Client 层确认之前宣称功能已经消失。
2. 原地更新了,但 Host 还跑着旧包
现象: 新版本已经安装,工具或路由却还是旧行为。
原因: Host 在启动时解析了包。替换文件不会替换已加载的模块;包如果换了目录,甚至可能无法热重载。
设计回应: 区分 package-updated 和 host-applied。如果包移动了目录,要求 App restart,而不是假装 HMR 能跟上。
3. 工具调用进行中时被停用
现象: 界面显示已停用,但运行中的任务还在使用工具;或者工具在一轮对话中途消失。
原因: Host 有活跃调用,Client 还持有旧工具列表。停用不是一个时间点事件。
设计回应: 明确停用是优雅还是立即。如果优雅,报告 pending 并排空调用;如果立即,显式取消并展示取消结果。
4. 应用运行时安装
现象: 包已经在磁盘上,但直到重启都没有出现。
原因: profile 变了,但启动组合和 Client 模块图已经构建完成。
设计回应: 只在 profile 层变化时,不要说安装已经完成。应该说“已安装,需要重启”,或提供受控重载路径。
5. 重载执行到一半失败
现象: 插件从列表消失了,但还在运行;或者还在运行,却不在列表里。
原因: 一个多步操作(卸载、删除、重写 patch、重载)在中间失败,系统进入没有按钮能描述的状态。
设计回应: 让操作可观察、可恢复。事务提交前保留上一状态。写出到达的层和错误回执。不要让用户猜哪一半成功了。
6. 两个生命周期操作重叠
现象: 连续点击卸载,或在重启待处理时安装,产生未定义结果。
原因: 生命周期操作不一定是幂等的,第二个操作可能看到第一个操作执行到一半的状态。
设计回应: 按插件串行化。返回 busy 或排队结果。尽可能幂等,并在 UI 中显示当前操作。
7. 没有排空会话就重启
现象: 重启修好了插件,但正在运行的一轮对话丢了,或会话日志不完整。
原因: App restart 是进程生命周期事件,不是插件重载。Host 可能在落盘前被杀掉。
设计回应: 走优雅关闭路径。任务运行中时拒绝或要求确认。重启后报告结果并重连页面。
能避免大部分问题的契约
少量规则可以消除很多歧义:
- 一个 profile 只有一个所有者。 桌面外壳持有 profile 锁;CLI 操作要与它协调。
- 每个动作返回层。
saved、host-applied、client-reload-required、app-restart-required、failed。 - 用户可见层确认前,不报告成功。 写 profile 不等于完成卸载。
- 保留回执。 最近的操作、目标、层和错误应该能在页面刷新后看到。
- 把重启说清楚。 重启可以昂贵,但不应该意外。
- 测试状态,而不只是命令。 矩阵比退出码重要。
测试表
| 场景 | Profile | Host | Client | 期望的用户状态 |
|---|---|---|---|---|
| 应用运行中安装 | 已更新 | 旧 | 旧 | 需要重启 |
| 支持 HMR 的安装 | 已更新 | 已重载 | 已重载 | 已生效 |
| 删除时 Client 仍挂载 | 已删除 | 已卸载 | 旧 | 需要 Client reload |
| 删除并重启应用 | 已删除 | 已卸载 | 已卸载 | 重启后生效 |
| 调用进行中停用 | 不变 | 排空中 | 旧 | pending |
| 更新且包移动了目录 | 已更新 | 旧 | 旧 | 需要 App restart |
| 第一个操作未完成时再次操作 | 不变 | busy | 不变 | busy |
这张表故意很朴素。重点是每一行都有名字。
对插件作者意味着什么
插件作者无法靠增加一个 dispose() 函数解决外壳的生命周期问题。但插件可以让契约更容易成立:
- 避免留下比 unload 更长寿的后台资源;
- 让 Host 和 Client 状态保持在独立模块;
- 通过文档化的生命周期注册工具和 UI;
- 不要假设“文件替换了”等于“代码重载了”;
- 文档写清楚插件需要的最低重载级别。
生命周期仍由外壳拥有。插件不应该假装自己拥有它。
结尾
桌面 Agent 不需要一个万能重载机制。它需要一套词汇,说明哪一层变了、哪一层还是旧的、用户接下来能做什么。
第一篇描述层,这一篇描述层之间如何不一致。下一步,是让这种不一致在界面中可见。