跳到主内容
创建时间:最后更新:作者:白川

让扩展正确重载、退出和处理失败

在会话启动时获取资源,退出时幂等释放,理解初始化失败和运行失败的不同边界。

让扩展正确重载、退出和处理失败

第一次运行正常,重载几次后同一事件出现多次,常见原因是旧监听器和定时器没有释放。删除模块缓存只能换掉代码,不能自动追回扩展已经创建的外部资源。

这篇做一个计数监听器,练习明确的启动与释放。计数只是观察资源是否重复启动,不代表真实业务进度。

本文示例固定使用 @earendil-works/pi-coding-agent@1.5.0,源码位于仓库 columns/coding-agent/examples/extension-development/。示例是独立包,先在该目录安装依赖;不需要修改专栏站点依赖。下面的命令均从这个示例目录运行。

初始化只注册,启动时获取资源

extensions/lifecycle.ts 的工厂只注册处理器;真正的计时器在 session_start 创建。

ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { startMonitor } from "../src/monitor.js"

export default function (pi: ExtensionAPI) {
  let monitor: ReturnType<typeof startMonitor> | undefined
  const dispose = () => {
    monitor?.dispose()
    monitor = undefined
  }
  pi.on("session_start", () => {
    dispose()
    monitor = startMonitor((listener) => pi.events.on("lesson:tick", listener))
  })
  pi.on("session_shutdown", dispose)
  pi.registerCommand("lesson-ticks", {
    description: "查看当前扩展实例的计数",
    handler: async (_args, ctx) => {
      if (ctx.hasUI) {
        ctx.ui.notify(String(monitor?.count() ?? 0), "info")
      }
    },
  })
}

重复收到启动事件前先释放旧实例,退出时清空引用。pi.events.on 返回取消订阅函数,应与监听器归属于同一个扩展实例。

session_shutdown 既要考虑退出,也要考虑资源重建时的旧实例关闭。不能把它理解为“整个电脑上的智能体以后再也不会运行”。以当前宿主的事件派发为准,清理函数应允许重复调用。

清理本身做成可测试的函数

ts
export function startMonitor(subscribe: (listener: () => void) => () => void) {
  let ticks = 0
  let stopped = false
  const unsubscribe = subscribe(() => {
    ticks += 1
  })
  const timer = setInterval(() => {
    ticks += 1
  }, 1000)
  timer.unref()
  return {
    count: () => ticks,
    dispose() {
      if (stopped) {
        return
      }
      stopped = true
      clearInterval(timer)
      unsubscribe()
    },
  }
}

unref() 只表示这个计时器不会独自维持进程运行,不能代替 clearInterval()。真正释放包括取消计时器、取消订阅,以及生产代码中可能存在的子进程、文件 watcher、连接或临时文件。

这里的取消订阅函数同步且不抛错。如果业务资源关闭会失败,应用 try/finally 或聚合结果,确保一个资源的释放失败不会跳过其他资源;同时记录失败,不能吞掉后宣称全部关闭。

区分三类失败

失败位置处理目标核验方式
文件解析或工厂注册
诊断哪个扩展没加载
查看 loader 的 errors 和注册清单
命令或工具执行
明确本次任务失败
检查工具错误、取消及残留资源
退出或重载清理
释放旧资源且暴露失败
重复启动停止、检查监听次数和进程退出

宿主捕获扩展回调异常,不代表任意后台 Promise 都受保护。裸启动异步任务后应绑定取消、处理 rejection,并在关闭时等待必要收尾。否则异常可能脱离当前会话,甚至成为未处理拒绝。

用重复操作检验生命周期

bash
npx pi --extension ./extensions/lifecycle.ts

执行 /lesson-ticks,再用交互模式的 /reload 重载,核对是否只有新的计数器工作。重载前后的业务状态是否应保留,要另做持久化设计;本例计数器刻意在启动时重新计数。

离线测试应至少验证连续两次 dispose() 只取消一次订阅,释放后事件不再增加计数,重复启动不会保留上一实例。这些测试帮助发现资源归属错误,但不能证明真实终端退出、系统信号或子进程全部正确。

扩展重载和插件热重载不同

命令行宿主重新发现扩展,主要目的是更新注册与运行资源。它不保证保留模块变量。后文 Chord 的热重载则有契约、对象引用和状态迁移协议,不能把两者都归结为“清除 import 缓存”。

需要保留的数据先写入适合的存储,需要关闭的资源按所有者释放;把两件事分开,才能判断重载成功意味着哪些结果。

依据与版本

代码以已发布的 1.5.0 包导出、类型声明和加载器为准。在线主分支会继续变化,遇到接口差异时先核对版本。