当你把一个 DSH 插件从「本地能跑」推进到「别人敢用」,真正的分水岭就出现了:分发方式决定构建产物从哪里来,防御性编程决定边界情况会不会把整个 Agent 搞挂,事故复盘决定同一个坑会不会踩第二次。这三件事看起来分属部署、编码、文化三个层面,但在 DSH 的工程体系里它们是一条链:发布方式选错会在用户侧踩坑,边界 bug 会在上线后爆发,而不写复盘的团队会反复用同样的方式重新发现同一个缺陷。本篇是《发布 DSH 插件 + 防御性编程 + 事故复盘:把插件做成可信赖的资产》的上半部分,先把三条分发路径的构建产物与授权差异讲透,再把 prepare 脚本的构建链路与正交结果上报的防御性写法逐字段拆开,最后进入复盘四问与「什么样的 bug 才值得写复盘」的门槛判断。读完这一半,你应当能回答一个问题:为什么一个 178 个绿色单元测试、100% 行覆盖率的插件,仍然可能在编辑器连上的第一秒崩溃。

npm / tarball / Git:三种分发路径的构建产物与授权差异

本地安装跑通之后,下一步是把插件分发给别人。这里有一个容易被忽略的前提:发布到公共注册表不是必须的。DSH 官方文档给出了三种分发途径——npm 发布、tarball 交付、Git 安装。它们的用户安装命令看起来只是参数不同,但底层交付的东西完全不同,而这个差异会直接决定用户拿到手的是可加载的代码,还是会加载失败的源码。

先看三条安装命令。第一条是 dsh plugin add your-package,用户在安装时从 npm 注册表拉取的是一个已经发布过的包,包里带着构建好的 lib/ 目录,也就是预构建产物。第二条是 dsh plugin add ./hello-plugin-0.1.0.tgz,用户拿到的是你用 pnpm pack 打出来的压缩包,包内的内容同样是你打包时刻已经构建好的结果。第三条是 dsh plugin --profile demo add github:you/hello-plugin,注意这里的来源是 GitHub 仓库,pnpm 克隆下来的是源码,而不是构建产物

这三条路径对构建产物的要求互不相同,判断它们的核心问题是:用户那一侧到底有没有人在运行你的 build 脚本。npm 与 tarball 这两条路径,用户在安装时不需要任何构建授权,因为产物在你发布的那一刻就已经固定下来了;Git 安装则最灵活——用户可以直接指向某个分支、某个 commit、某个 fork,但代价是他这一侧必须真的把源码编译出来,这就撞上了「构建脚本」这道坎。

示意图
三种发布途径对比图:npm 发布、tarball 交付与 Git 安装分别交付预构建 lib/、pnpm pack 包与源码,以及各自对构建授权的不同要求。

把上面的差异整理成一张表会更直观。请注意表中最后一列——「是否需要构建授权」——它才是区分 Git 与其他两条路径的关键变量:

方式用户安装命令安装到的是什么是否需要构建授权
npm 发布dsh plugin add your-package预构建的 lib/ 代码不需要
tarball 交付dsh plugin add ./hello-plugin-0.1.0.tgzpnpm pack 打出的包不需要
Git 安装dsh plugin --profile demo add github:you/hello-plugin源码(不是构建产物)需要(pnpm ≥ 10)

为什么 Git 路径会额外需要构建授权?根因在于 pnpm 从 10 开始对依赖的构建脚本执行做了收紧。当你从 Git 安装一个包时,pnpm 需要在安装过程中运行该包的构建脚本,而这类脚本默认处于需要显式授权的状态。换句话说,npm 与 tarball 之所以「不需要授权」,不是因为它们更高级,而是因为它们根本不需要在用户机器上跑构建——产物已经是现成的。Git 安装把「构建」这个动作转移到了用户侧,授权问题就随之而来。

这对插件作者意味着什么?如果你希望零摩擦分发,npm 与 tarball 是更省心的选择,用户一条命令装完就能用,不必理解构建流程,也不会遇到授权弹窗。如果你的插件面向的是愿意折腾源码、需要跟踪某个分支的开发者,Git 安装的灵活性值得这个代价,但你必须把 prepare 脚本写好——因为没有它,TypeScript 包到手时没有 lib/ 输出,加载会直接失败。

Git 安装为什么必须带 prepare:从源码到发布入口的构建链路

Git 安装拉取的是源码。这句话的后果比它听起来更严重:安装流程里没有任何环节会替你运行 build 脚本。很多人以为「我在 package.json 里明明写了 build 命令,安装时怎么没生效」,问题就出在这里——build 是一个需要你手动调用的脚本,而不是安装生命周期的一部分。

但 pnpm 在 Git 安装后确实会运行一个特定的脚本:prepare。这正是插件作者必须利用的钩子。作者这一侧要做的第一件事,就是提供一个 prepare 脚本,让 pnpm 在 Git 安装完成后从源码构建出发布入口。当用户执行 dsh plugin --profile demo add github:you/hello-plugin 时,pnpm 把仓库克隆下来、装好依赖,然后触发 prepare,你的代码在这一刻才真正被编译成可加载的形态。

这引出了 prepare 脚本最重要的设计约束:它必须自包含。所谓自包含,是指它不能假设自己运行在一个「仅开发环境才有的上下文」里。最典型的反例是 monorepo checkout——在你的开发仓库里,插件包旁边可能躺着共享的 TypeScript 配置、根目录的 workspace 依赖、被其他包引用的类型定义,这些东西在本地跑起来毫无问题,因为 monorepo 把一切都摆好了。但用户从 Git 安装你的插件时,他拉下来的可能只是这个包本身,或者是一个没有完整 monorepo 结构的仓库快照,任何对「旁边有别的包」「根目录有某个配置」的假设都会在用户侧变成构建失败。

prepare 还要注意另一层:它是在安装时运行的,不是在发布时运行的。这意味着它面对的环境是不确定的——用户的 Node 版本、包管理器版本、平台都可能与你的开发机不同。所以 prepare 里做的事情越少越好,只做「把 src 转译成发布入口」这一件事,不要把类型检查、lint、测试、文档生成这些开发期的动作塞进去。把 prepare 缩到最小,就是把用户在安装阶段可能踩的坑缩到最小。

小结一下 Git 安装这条链路上,作者与用户各自的责任:

  • 作者侧:提供一个自包含的 prepare 脚本,在 pnpm 完成 Git 安装后自动运行,把源码构建为发布入口。
  • 作者侧:prepare 不得依赖仅开发环境才有的上下文,例如旁边存在一份 monorepo checkout。
  • 用户侧:在 pnpm ≥ 10 下需要对构建脚本进行授权,这不是可绕过的步骤。
  • 共同前提:Git 路径交付的是源码而非构建产物,因此「能 clone 下来」不等于「能加载起来」。

prepare 脚本实战:用 tsdown 专用配置直接转译 src/

turtle-ui 是一个可以直接参考的可用例子。它的 prepare 运行一份专用的 tsdown 配置,直接转译 src/,不做项目引用,也不做类型检查。这三个设计选择都不是随意的,每一条都在消解前面提到的「自包含」风险。

先看「专用配置」。turtle-ui 为发布路径单独准备了一份 tsdown 配置,而不是复用开发时那份。开发配置里往往挂着项目引用、增量缓存、类型声明生成等为本地开发提速的东西,这些恰恰是最依赖 monorepo 上下文的。专用配置则把所有外部依赖砍掉,只保留「把 src/ 里的 TypeScript 转译出去」这一条链路。

再看「直接转译 src/」与「不用项目引用」。TypeScript 的项目引用(project references)要求被引用的项目真实存在于磁盘上、并且已经构建过——在 monorepo 里这没问题,在用户的安装目录里就未必成立。直接转译 src/ 绕过了整个引用图,不需要预先构建任何依赖包,也不依赖 tsconfig 里的 references 字段。

最后是「不做类型检查」。类型检查是开发期的质量门禁,应该在 CI 和本地提交前完成,而不是放在用户安装插件的路径上。把类型检查留给开发流程,prepare 只负责产出可运行的 JavaScript,这样即使用户的 TypeScript 版本与作者不同,也不会因为一个类型报错而让整个安装失败。

下面是一份可直接粘贴使用的 package.json scripts 片段,来自 dsh-hello-plugin,它把 prepare 绑定到专用配置上:

{
  "name": "dsh-hello-plugin",
  "scripts": {
    "prepare": "tsdown -c tsdown.publish.ts"
  }
}

这段配置只有一行脚本,但信息量很足。tsdown -c tsdown.publish.ts 显式指定了配置文件,避免 tsdown 自动去捡默认的开发配置;配置文件名里的 publish 也是一个明确的信号,提醒后续维护者这份配置服务于发布路径而非本地开发。当你修改开发配置时,不会意外改变用户安装时的构建行为。

把这套做法沉淀成检查清单,可以这样自查:

  1. package.json 里是否存在 prepare 脚本,且指向一份专用构建配置?
  2. 这份配置是否直接转译 src/,而不是依赖已构建的引用项目?
  3. 是否关闭了项目引用,避免要求磁盘上存在其他包?
  4. 是否排除了类型检查,把质量门禁留在开发流程?
  5. 把仓库单独 clone 到一个空目录、再执行一次安装,prepare 能否独立成功?

最后一条自测最有效:把插件仓库单独 clone 到一个没有 monorepo 结构的空目录,跑一次 Git 安装。如果 prepare 里藏着对「旁边那个包」的依赖,这一步会立刻暴露出来,而不是等用户来替你发现。

正交结果独立上报:timedOut、signal、exitCode 为什么不能嵌套

插件能分发了,接下来要解决的是那类「平时测不出来、一上线就出事」的边界 bug。官方文档把它们归纳成「来之不易的缺陷类别规则」——每一条都来自真实发布或差点发布过的缺陷。第一个也是最重要的防御性模式,是正交结果独立上报

什么叫正交?一个结果可以同时具有多种性质,而这些性质之间并没有从属关系。文档给了一个极好的例子:进程可能已经超时,却仍以退出码 0 结束,因为它捕获了终止信号。如果代码里只把「退出码非零」当作失败的唯一标志,或者把 timedOut 的上报嵌套在 exitCode 的分支里,这个进程就会被误判为正常成功,而实际上它早已被超时机制终止。

示意图
防御性编程坏示例与好示例对照图:结果报告、dispose 停稳、凭据擦除、链接删除、回调隔离五个模式的错误写法与正确写法并排展示。

这就是「正交结果独立上报」要解决的问题。文档明确指出:每个独立事实(timedOut、signal、exitCode)都应单独上报,千万不要把一个标志的上报嵌套在另一个标志的分支里。因为一旦嵌套,调用方就会失去对事实的完整视野——它只能看到外层分支允许它看到的东西,而提前终止的运行恰恰会落在嵌套结构的缝隙里。

为什么这三个字段是正交的?逐个分析:

  • timedOut:由你自己在超时定时器里维护,表示「你曾主动发起过终止」,它与进程最终怎么结束无关。
  • signal:进程被哪个信号终止。超时后你发出 SIGTERM,进程如果没处理这个信号,就会带着这个 signal 结束。
  • exitCode:进程的退出码。关键在于,一个捕获了 SIGTERM 并选择优雅退出的进程,可以返回 0——这在语义上完全合法,但绝不代表这次运行「成功」。

三者可组合出的状态空间,正是嵌套写法会丢失的部分。考虑下面这几种组合:

timedOutsignalexitCode真实语义
falsenull0正常成功
falsenull非 0正常失败
trueSIGTERM0已超时,但进程捕获信号后优雅退出(最易误判)
trueSIGTERMnull已超时,进程被信号杀死

第三行就是那个经典陷阱。如果调用方的判断逻辑写成「exitCode === 0 则视为成功」,这一行会被归入成功;正确的做法是同时检查 timedOut 与 signal。把三个独立事实平铺返回,调用方才有机会做出正确判断;把它们嵌套起来,你就替调用方做了错误的简化。这也是为什么文档强调这些规则要在「编写生命周期、并发、子进程或清理代码之前」先读一遍——不是编码风格的偏好,而是防止一个简单边界情况把整个 Agent 搞挂。

run.ts 防御性写法:从 spawn 到 close 的字段维护清单

把上面的原则落到具体代码,参考 packages/my-shell/src/run.ts 的实现。它做的事情是运行一个子进程,并正交上报三个独立事实——timedOut、signal、exitCode。整段代码不长,但每一个字段的维护时机都有讲究,值得逐项拆解。

先看接口定义。RunResult 里把三个事实各自声明为独立字段,注释里也明确标注了它们「独立事实 1/2/3」的地位:

// 文件路径:packages/my-shell/src/run.ts
// 运行一个子进程,并正交上报三个独立事实:timedOut、signal、exitCode。
import { spawn, type ChildProcess } from 'node:child_process'

export interface RunResult {
  timedOut: boolean            // 独立事实 1:是否超时
  signal: NodeJS.Signals | null // 独立事实 2:是否被信号终止
  exitCode: number | null       // 独立事实 3:退出码
  stdout: string
  stderr: string
}

export function run(argv: string[], timeoutMs: number): Promise<RunResult> {
  return new Promise((resolve, reject) => {
    const child: ChildProcess = spawn(argv[0], argv.slice(1), {
      stdio: ['ignore', 'pipe', 'pipe'],
    })

    let stdout = ''
    let stderr = ''
    child.stdout.on('data', (d: Buffer) => (stdout += d))
    child.stderr.on('data', (d: Buffer) => (stderr += d))

    // 独立事实 1 单独维护:超时是一个标志,与退出码无关。
    let timedOut = false
    const timer = setTimeout(() => {
      timedOut = true
      child.kill('SIGTERM') // 超时触发终止
    }, timeoutMs)

    child.on('close', (code, signal) => {
      clearTimeout(timer)
      // 三个字段各自独立返回:进程可能 timedOut=true 且 exitCode=0,
      // 因为它在超时后捕获了 SIGTERM 并以 0 退出。
      resolve({ timedOut, signal, exitCode: code, stdout, stderr })
    })

    child.on('error', reject)
  })
}

逐项说明这段代码里的维护点:

  1. stdio 配置stdio: ['ignore', 'pipe', 'pipe']。stdin 设为 ignore,避免子进程从父进程继承输入流导致意外的交互或挂起;stdout 与 stderr 都设为 pipe,这样它们才会以 data 事件的形式流回父进程,也才有可能被累积进结果。
  2. stdout / stderr 累积:在两个 data 事件里用字符串拼接。注意 data 回调收到的是 Buffer,这里靠模板字符串的隐式转换得到文本;累积必须在监听器挂载的同一时刻开始,否则会丢掉早于监听的数据。
  3. 独立 timedOut 标志let timedOut = false 声明在 Promise 作用域里,而不是从 exitCode 推导出来。这是整个模式的核心——超时是一个由你主动设置的事实,不是从进程结局反推的猜测。注释也写得很明确:这个标志与退出码无关。
  4. 定时器与 SIGTERMsetTimeout 到期后先置 timedOut = true,再调用 child.kill('SIGTERM')。先置标志再发信号,保证无论进程对信号作何反应,超时这个事实都已经被记录。
  5. clearTimeout 时机:在 close 回调的第一行就 clearTimeout(timer)。否则进程正常结束后,那个定时器还会继续计时,最终在结果已经 resolve 之后触发一次多余的 kill,甚至造成对已回收资源的操作。
  6. close 回调中的 resolve 字段顺序resolve({ timedOut, signal, exitCode: code, stdout, stderr })。三个独立事实平铺在同一个对象里,谁也不嵌套在谁的分支下;close 回调同时提供 code 与 signal 两个参数,正好对应用户关心的两个结局维度。

还有一处容易漏掉:child.on('error', reject)spawn 本身失败(例如可执行文件不存在)会走 error 事件,而不是 close。如果不监听 error,这个 Promise 会永远挂起,调用方等不到任何结果——这本身就是另一类「简单边界情况拖垮整个 Agent」的场景。run.ts 把它 reject 出去,让失败变成显式错误而不是无限等待。

dispose 停稳、凭据擦除、链接删除、回调隔离:四个易漏的清理动作

结果上报解决的是「怎么把事实说清楚」,接下来要解决的是「怎么把资源收干净」。文档归纳的五个防御性编程模式里,除结果报告外的四个都围绕生命周期、并发、子进程与清理代码展开:dispose 停稳、凭据擦除、链接删除、回调隔离。它们共同服务于一个目标——防止一个简单的边界情况把整个 Agent 搞挂。

先看 dispose 停稳。插件被卸载或系统关闭时,dispose 不是「通知一下」就完事,而是要保证所有由它启动的东西都真正停下来。要检查的边界条件包括:正在运行的子进程是否被终止并等待回收、定时器是否全部清除、监听器是否全部解除、异步任务是否被正确取消而不是任其悬空。常见的坏味道是 dispose 里只改了某个标志位,却没有等待正在进行中的操作结束——「停稳」两个字强调的是「等它真的停」,而不是「发出停止指令」。

其次是 凭据擦除。插件在运行期可能接触 API key、token、临时凭证,这些值在生命周期结束后仍然可能残留在内存对象、日志缓冲或调试输出里。要检查的边界条件包括:dispose 或生命周期终点是否显式清空了持有凭据的字段、错误路径上是否会把凭据一起打进日志、序列化结果时是否会带上不该带的内容。凭据擦除的关键在于「错误路径也要擦」,因为异常发生时最容易顺手把上下文整个 dump 出去。

第三是 链接删除。插件可能创建临时文件、软链接、监听端口或其他外部可见的引用,这些不是进程内对象,GC 不会替你收拾。要检查的边界条件包括:创建与删除是否成对、异常中断时是否仍有清理机会、删除失败是否被显式处理而不是静默吞掉。这里与事故复盘 0004 的教训相通——把「部分成功」误当成「完全成功」,是清理逻辑里最典型的误判

第四是 回调隔离。你注册进框架的回调,运行在框架的调用栈里,任何异常都可能影响调用方的流程。要检查的边界条件包括:回调里抛出的异常是否被限定在自身范围、回调是否假设了某些外部状态一定存在、回调被并发触发时是否共享了可变状态。文档把这条放在「并发」这一组里,正是因为回调常被并发调用,共享的可变状态是回调隔离里最容易被忽视的雷

把这四个动作整理成一份检查清单,方便你在编码前对照:

清理动作要解决的类别关键边界条件
dispose 停稳生命周期子进程终止并回收、定时器清除、监听器解除、异步任务取消后等待结束
凭据擦除清理代码持有字段显式清空、错误路径不打日志、序列化不带敏感值
链接删除清理代码创建与删除成对、异常中断仍有清理机会、删除失败不静默
回调隔离并发异常不外溢、不假设外部状态、并发触发不共享可变状态

这四类动作有一个共同的判断标准:如果它出问题的方式是「平时测不出来,一上线才炸」,那它就属于这一组。它们不是功能实现,而是功能在没有出错时也一直在默默维持的秩序;一旦某个边界被打破,暴露出来的往往不是一处小报错,而是整个 Agent 无法继续运行。

复盘四问:什么坏了、机制是什么、为何安全网没拦住、新增了什么防护

防御性编程能减少缺陷,但不能消灭缺陷。DSH 仓库的做法是把每一次「不该出现却出现了」的 bug 都写成事故复盘(postmortem),并留下测试、文档与规则层面的防护。需要先明确复盘的适用对象:它记录的是出现在真实用户、已合并的 PR、已发布的版本中的 bug,也就是出现在不该出现的地方的 bug

示意图
事故复盘流程与测试金字塔图:从事故发现到四问梳理,再到单元测试、覆盖率门禁、真实 API e2e、快照四层防护的沉淀路径。

复盘的价值不在那行修复代码,而在于回答四个问题。文档给出的四问结构如下,每一问都有明确的回答对象:

  1. 什么坏了:用一个简短段落让忙碌的读者在三十秒内吸收要点。这一问决定了复盘的可读性,写不好就会变成没人看的长文。
  2. 机制是什么:用直白的话说清根因,不归咎个人。注意这一问要求的是机制层面的解释,而不是「谁写错了」。
  3. 为什么每道安全网都没拦住:找出测试、工具、约定的缺口,而非一次性笔误。这一问把「一个 bug」上升到「一类漏洞」。
  4. 新增了什么防护:测试、AGENTS.md 规则、ADR,让同类 bug 下次明确报错。

第三问是整套复盘方法的重心。值得关注的是为什么我们的流程放过了它,而不仅仅是那一行修复。以复盘 0001 为例:插件多写了一个 export default apply,Loader 的 unwrapExports 取到了裸函数,把命名空间上的 inject 整个丢掉了,导致编辑器(Zed)一连上、第一个 session/new 就报 cannot get property "agents" without inject。这一问的答案不是「作者手滑」,而是「178 个绿色单元测试 + 100% 行覆盖率都在,但所有测试都通过手动 ctx.plugin(...) 挂载,绕过了真实 Loader 的加载路径」。于是新增的防护也不是简单删掉那个 default export,而是删除 default export、增加无需 key 的真实 Loader 冒烟测试、并立下规则「测试真实入口路径,行覆盖率不等于行为覆盖率」

复盘 0002 同样典型:作者用 disabled: !!js ... 想条件启用文件系统插件,但 Cordis 只在插件 config 内部对 JS 表达式求值;直接读 disabled 配置项时,它看到的是一个 truthy 对象,于是七个文件系统场景调用了注册表里不存在的工具,返回 UNKNOWN_TOOL。第三问的答案是「快照刷新把确定性回放当成了行为正确——它证明了回归被稳定复现,却没证明文件系统工具真的注册了」。新增防护包括改用显式文件系统 overlay、静态配置守卫拒绝 Loader 配置项元数据里的表达式节点、快照框架拒绝结构化 UNKNOWN_TOOL 结果。

复盘 0003 与 0004 则分别指向「Web 组合未向模型提供当前 GUI、规范 URL 或运行模式的身份信息」与「沙箱结果类型只能表达一组子字符串,无法表达 Landlock 失败必须退出码 125 + 一行致命诊断」这类类型与信息层面的缺口。四个案例放在一起,能总结出一条共同经验:测试必须走真实入口路径。手动挂载、mock 一切、把快照刷新当验收,都会让「单元全绿、产品却坏了」成为可能。

这也解释了 DSH 的分层测试策略为什么是四层,每层都在补上一层抓不到的盲区:

层级命令抓什么
单元测试pnpm run testvitest 跑包内测试,优先边界、错误路径、事件顺序、并发竞态
覆盖率门禁pnpm run test:coverage按文件 100% 覆盖;未覆盖行往往是该删除的死代码
真实 API e2epnpm run test:e2e带密钥调用真实提供方 API;缺密钥自动跳过,keyless CI 保持绿色
快照pnpm run test:snapshot / test:web无密钥预期输出覆盖对外行为;浏览器快照用 Chromium 回放比较

因为仓库是 DeepSeek 自己的,还有一条特别原则:推理在这里很便宜,不要吝惜真实 API 测试。无密钥测试只能证明底层通路,只有带密钥运行才能证明 agent 能对接真实模型正常工作。价值最高的是冒烟测试——启动真实示例、发送一条提示词、检查外部世界——它们能捕获「单元测试全绿、产品却坏了」这一类 mock 无法发现的问题。行覆盖率是必要条件,永远不是充分条件;它证明行被执行过,不证明功能按交付预期工作,0001 案例里 100% 覆盖率仍然放过了两个集成 bug,就是最好的注脚。与之配套的另一条原则是验证外部世界,而非自我报告:e2e 断言应重新运行命令或从外部重新读取文件,对 agent 自身输出做关键词探测会让作弊的 agent 通过,断言未修改的文件应逐字节一致。

什么样的 bug 才值得写复盘:隐蔽、系统性、重新发现代价高

既然复盘这么有价值,是不是每个 bug 都该写一篇?文档给出的答案是否定的。只有当 bug 同时满足三个条件时才写复盘:隐蔽、系统性、重新发现的代价高。这三条门槛把复盘从「流水账」拉回到「资产」。

第一条是隐蔽:机制不显而易见,细心的工程师也得费力重新推导。像 0002 里 disabled: !!js 被当作 truthy 对象读取、0004 里把无害的 landlock-run: partial enforcement 通知与非零退出码组合误判为沙箱故障——这些根因都不会在报错信息里直接写出来,必须顺着链路回推才能看清。相反,一个变量拼错、一个空指针,看一眼堆栈就明白,它不需要一篇复盘来记录机制。

第二条是系统性:逃逸原因是测试、工具、约定的缺口。这条门槛最容易被忽略,但它才是复盘真正要沉淀的东西。0001 的逃逸原因是「所有测试都绕过真实 Loader」,0003 的逃逸原因是「Web 组合未向模型提供当前 GUI 的身份信息」,0004 的逃逸原因是「测试矩阵从不构造通知后跟非零子进程退出的组合」——它们都不是一次性的笔误,而是整个质量体系里的一块空白。一次性笔误没有系统性,写下来也无法转化为防护,因为下次犯的会是另一个笔误,而不是同一个缺口。

第三条是重新发现的代价高:消耗了真实调试时间,下次还会如此。如果一个问题每次出现都要花大量时间重新定位,那么把它固化下来就有明确回报。这也是文档强调「复盘是一份回顾性的失败记录」的原因——它服务于未来的调试者,而不是给过去的一次失误定罪。

三条门槛之外,还要明确复盘不写什么:不属于「不该出现的地方」的 bug 不写。如果一个 bug 出现在开发分支、还没来得及合并,那它顶多算一次普通的修 bug,不需要单独立档。门槛机制的本质是让复盘保持稀缺——只有当每一篇复盘都值得被认真读完,复盘文化才立得住

那新增的防护到底落在哪里?文档给出的落点是测试、AGENTS.md 规则与 ADR。测试是让同类 bug 下次在 CI 里明确报错;AGENTS.md 规则是把约束写进协作约定,让后续的改动者在动手前就看见;ADR 则是记录架构层面的决策及其理由。三者与四问中的第四问一一对应——如果一篇复盘写完了却没有新增任何防护,那它就只是一次情绪宣泄,而不是工程资产

回到这篇的上半程:我们拆开了 npm、tarball、Git 三条分发路径在构建产物与授权上的根本差异,解释了为什么 Git 安装必须靠 prepare 脚本、以及这个脚本为什么必须自包含;我们看了 turtle-ui 用专用 tsdown 配置直接转译 src/ 的实战做法,也逐字段走完了 run.ts 里从 spawn 到 close 的维护清单;最后进入事故复盘的四问结构与三道门槛。下半程要继续深入的部分是:文档纪律如何防止「文档与源码漂移」——也就是 verify-type-equiv 门禁与「一个事实一个家」这两条机制具体怎么运作;以及如何动手写出一段真正有用的三十秒执行摘要。

上半段我们把插件从本地跑通推进到了可分发的资产:npm、tarball、Git 三条发布途径各有代价,prepare 脚本必须自包含,否则 Git 安装拉下来的源码永远长不出 lib/。但可分发只是第一层——真正决定一个插件能不能长期可信的,是它在边界条件下的表现,以及维护者有没有能力从一次事故里提炼出系统性防护。这一段的四个真实复盘、四层测试策略、两条文档纪律,就是 dsh 仓库把「插件即资产」落成工程事实的全过程。

复盘 0001:export default 丢掉 inject,178 个绿灯测试为何没拦住

这是四篇复盘里最刺痛人的一篇,因为它发生在一个看起来不可能出错的地方。什么坏了:编辑器(Zed)一连上 dsh 的 ACP 服务器,第一个 session/new 请求就直接报 cannot get property "agents" without inject。注意这个报错的措辞——它不是说某个字段是 undefined,而是说在 missing inject 的情况下访问了 agents。也就是说,插件命名空间上原本应该挂着的那份 inject 声明,在加载后整个消失了。

机制非常具体,值得逐帧还原。插件多写了一个 export default apply。在正常的模块导出里,这看起来只是多一个导出、无伤大雅;但 dsh 的 Loader 在装载插件模块时会走一个叫 unwrapExports 的步骤——它的职责是从模块导出对象里取出「插件本体」。当模块存在 default 导出时,unwrapExports 会优先取 default,于是它拿到的是那个裸函数 apply,而不是携带了命名空间元数据(包括 inject 声明)的完整导出对象。命名空间上的 inject 就这样被整个丢掉了。

于是链路变成:Zed 连接 → ACP 服务器收到 session/new → 需要解析 agents 服务 → 插件声明的 inject 已经不存在 → 报 cannot get property "agents" without inject。整个过程没有任何一行代码「写错了逻辑」,错误完全来自导出形态与加载器契约之间的错配。

为什么安全网没拦住?这是全文最需要高级读者停下来想三十秒的地方:仓库当时有 178 个绿色单元测试 + 100% 行覆盖率。数字非常漂亮,但它们全部通过手动 ctx.plugin(...) 挂载插件来构造上下文。手动挂载意味着:测试自己把插件对象直接递给 ctx.plugin,跳过了 Loader 的 unwrapExports 这一步。换句话说,测试验证的是「插件对象被挂载后行为正确」,而线上发生的是「模块被 Loader 加载后插件对象长什么样」。这两件事之间隔着一个关键函数,而所有测试都从它后面开始跑。

这就是行覆盖率不等于行为覆盖率最锋利的注脚:那 178 个测试确实把每一行都执行过了,包括 plugin 主体里的每一行,但没有一行测试走过真实入口路径。覆盖率证明代码被执行,行为覆盖率证明交付路径被验证,二者不可互相替代。

新增防护有三条,层层收紧:

  • 删除 default export——从源头消除 Loader 误取裸函数的可能性,把「导出形态」这个隐式契约变成硬约束。
  • 增加无需 key 的真实 Loader 冒烟测试——不再手动 ctx.plugin,而是让测试走完整的 Loader 装载流程。这条测试不需要任何密钥,因此可以在 keyless CI 里常驻,成本极低、拦截力极高。
  • 沉淀规则「测试真实入口路径,行覆盖率不等于行为覆盖率」——写进 AGENTS.md,让后来者在新增测试时默认先问「我有没有走真实入口」。

对写插件的人,这篇复盘的直接结论是:不要在你的插件模块里随手加 export default;如果确实需要默认导出,先确认 Loader 的 unwrapExports 语义。更普遍的经验是:任何「加载器 + 模块形态」的组合都可能藏这种坑,为它写一条走真实装载路径的冒烟测试,比补一百个单元测试都值。

复盘 0002:一个字面量 !!js 对象如何永久禁用文件系统快照工具

什么坏了:文件系统快照工具在七个文件系统场景里全部失效,所有这些场景都在调用注册表里根本不存在的工具,统一返回 UNKNOWN_TOOL。也就是说工具不是「执行失败」,而是「压根没注册进来」。

机制是什么:作者想用配置项 disabled: !!js ... 来条件启用文件系统插件。这是 YAML 里常见的自定义标签写法,意图是「这个 disabled 的值由一段 JS 表达式求值决定」。问题出在 Cordis 只在插件 config 内部对 JS 表达式求值——只有在 config 对象的解析上下文里,这类表达式节点才会被真正执行成布尔值。而当有人直接读取顶层配置项 disabled 时,看到的是一个 truthy 的对象(那个尚未求值的表达式节点本身),而不是 false。

对象是 truthy 的,于是「条件启用」被静态地判定为「禁用」,文件系统插件被永久禁用。七个场景因此全部撞上未注册工具,返回 UNKNOWN_TOOL。

这里有一个很容易被忽略的分层事实,值得单独列一张表讲清楚:同一个 disabled 字段,在不同读取位置语义完全不同。

读取位置看到的值真值判断结果后果
插件 config 内部(Cordis 求值上下文)JS 表达式求值后的布尔值按表达式真实结果条件启用生效
直接读取顶层 disabled 配置项未求值的字面量 !!js 对象truthy插件被静态禁用,工具不注册

为什么安全网没拦住?快照框架把「确定性回放」当成了「行为正确」。快照测试的逻辑是:把一份预期输出存下来,之后每次运行比对是否一致。它确实证明了「回归被稳定复现」——每次跑出来都是同一个结果,非常确定。但它完全没有证明「文件系统工具真的注册了」。当 bug 本身是确定性的(永远是 UNKNOWN_TOOL),快照反而会把错误当成基线固化下来,一致性掩盖了正确性。

新增防护同样三条:

  • 改用显式文件系统 overlay——不再依赖脆弱的表达式条件,把「启用/禁用」表达为显式结构。
  • 静态配置守卫拒绝 Loader 配置项元数据里的表达式节点——在配置解析阶段就把这类危险的求值节点拦下,让它在启动时报错,而不是在运行时静默降级。
  • 快照框架拒绝结构化 UNKNOWN_TOOL 结果——给快照加一条语义规则:如果输出里出现 UNKNOWN_TOOL 这种表明「工具没注册」的结构化结果,直接判定失败,不允许把它当作合法基线。

对插件作者的启示:配置表达式的作用域必须显式,不要把「在 A 处会求值」当成「在任何地方都会求值」;凡是条件启用类开关,优先用显式 overlay 而不是嵌入式表达式。另外,给快照测试加语义断言(拒绝特定结构化错误),是把「一致」升级为「正确」的低成本手段。

复盘 0003:Web agent 验收了替代服务器,而非承载会话的 GUI

什么坏了:agent 修改了 GUI 源码,却不知道当前会话对应哪个 URL、由哪个进程承载——于是它「验收」了一个完全不相干的服务器,还自信地宣布通过。

机制是什么:整个过程有几个连续的错误。第一步,agent 访问裸 Vite 服务,拿到一个 HTTP 200,就把它当作成功信号;但那个 200 返回的其实是白屏——HTTP 状态码正确不代表页面正确。第二步,它随后去验收另一个端口上的替代 dsh web 服务器,把那个替代实例的表现当成了自己要修的那个 GUI 的表现。第三步,也是最致命的,它从未探测 3081 端口,而承载当前会话的真正 GUI 就在那里。

三个动作串起来看,agent 不是「修错了」,而是「认错了对象」:它不知道自己的改动应该在哪个地址被验证,于是随手抓住一个能返回 200 的东西就开始打分。

为什么安全网没拦住?根因在于身份信息缺失:Web 组合没有向模型提供当前 GUI、规范 URL 或运行模式(开发/生产)的任何信息。模型在缺少「我是谁、我该在哪里被验证」的前提下,只能靠端口扫描式的猜测。第二个问题是回归测试把「进程超时」当成「快速失败」——本意是想让失败更快暴露,结果制造了误报,掩盖了真实问题。

新增防护:

  • 启动器发布规范环回 URL 与实际运行模式——通过环境变量 + 提示词区段把「规范 URL」「生产/开发模式」显式告知模型,让它不用猜。
  • 独立 Vite 服务模式在配置阶段拒绝启动——从机制上防止 agent 误把裸 Vite 当成验收目标。
  • 分层真实路径测试覆盖 CLI、提示词、运行时事实与浏览器 HMR——把「agent 该知道什么」变成可测试的断言。

这篇复盘对做 Agent 工程的人价值极高:工具的正确性依赖身份上下文。当模型需要操作一个 Web 应用时,必须把「规范地址 + 运行模式」作为运行时事实注入,否则它会用状态码这类弱信号做决策。同时,用超时冒充失败是一种常见的测试反模式,它把「慢」和「错」混为一谈。

复盘 0004:Landlock 部分强制执行通知导致子进程失败被误归类

什么坏了:在较旧的 Landlock ABI 内核上,ripgrep 在没有匹配时以退出码 1 正常结束——这是 ripgrep 的既定语义(1 表示无匹配,2 才是错误)。但 dsh 把这个正常的「无匹配」呈现为 SANDBOX_UNAVAILABLE 沙箱故障,把一次成功的搜索报告成了环境崩坏。

机制是什么:launcher 在旧内核上会打印一行无害通知:landlock-run: partial enforcement (older Landlock ABI),表示内核支持部分强制。而 harness 的判定逻辑用的是一个不区分大小写的 landlock-run: 子串匹配,并且把这个子串与「任意非零退出码」组合起来判断——只要输出里出现这个前缀、同时进程以非零码退出,就判定为 runner 失败。ripgrep 的无匹配退出码 1 正好满足这个组合,于是被误归类为沙箱不可用。

为什么安全网没拦住?沙箱结果类型只能表达一组子字符串,无法表达更精确的契约:「Landlock 失败必须同时满足退出码 125 + 一行致命诊断」。测试矩阵也从不构造「通知后跟非零子进程退出」这个组合——所有测试要么测通知、要么测失败退出,从来没有把两者放在一起。缺口就在于组合场景没人覆盖。

修复的核心是 RunnerFailureRule,它把「什么样的情况才算 runner 失败」表达成一组结构化字段,而不是一个模糊的子串:

字段含义解决的问题
允许退出码白名单化的退出码集合(例如 Landlock 失败必须为 125)杜绝「任意非零码都算失败」的宽泛匹配
逐行致命签名必须逐行匹配的致命诊断文本区分无害通知行与真正的致命行
精确排除的信息性行明确列出属于信息性、不参与判定的行(如 partial enforcement 通知)让「通知后跟非零退出」不再被误判

同时,文件系统搜索改用 ctx.subprocess 直接跑打包好的 ripgrep,不再经过沙箱化 bash——把「沙箱判定」和「搜索执行」这两件事解耦,从路径上避免这类误分类。

四篇复盘放在一起,能提炼出一条共同经验:测试必须走真实入口路径。手动挂载、mock 一切、把快照刷新当验收,都会让「单元全绿、产品却坏了」成为可能。0001 是加载路径被绕过,0002 是快照把确定性当正确性,0003 是身份上下文缺失 + 超时冒充失败,0004 是组合场景无人覆盖——四张脸,一个病根。

测试四层:pnpm run test / test:coverage / test:e2e / test:snapshot 各抓什么

知道了病根,就要靠分层策略去堵。dsh 仓库的测试是分层的,每一层专门补齐上一层抓不到的盲区——不是简单叠加,而是分工。

层级命令抓什么不抓什么
单元测试pnpm run testvitest 跑包内测试,优先边界、错误路径、事件顺序、并发竞态真实装载路径、真实 API 行为
覆盖率门禁pnpm run test:coverage按文件 100% 覆盖;未覆盖行往往是该删除的死代码行为正确性——行被执行 ≠ 功能按预期工作
真实 API e2epnpm run test:e2e带密钥调用真实提供方 API,验证 agent 能对接真实模型无密钥环境下自动跳过
快照pnpm run test:snapshot / test:web无密钥预期输出覆盖对外行为;浏览器快照用 Chromium 回放比较语义正确性——需额外规则拒绝 UNKNOWN_TOOL 之类结构化错误

单元测试是第一道网,也是最容易写歪的一道。它的重点不是「把每行都跑到」,而是优先覆盖边界、错误路径、事件顺序、并发竞态——这些正是平时测不出来、一上线就出事的类别。写单元测试时,问自己「我构造的是不是最刁钻的输入顺序」,比问「我覆盖了多少行」更有价值。

覆盖率门禁按文件要求 100%。这里有个容易被忽略的副产品:未覆盖的行往往是该删除的死代码。如果你的 100% 门禁让你被迫为某一行写测试,而你想不出这个行为什么时候会发生,那大概率这行本身就不该存在。覆盖率是一种「发现冗余」的工具,不只是一项 KPI。

真实 API e2e 需要密钥,缺密钥时自动跳过,从而让 keyless CI 保持绿色——这是它能在开源/多云环境常驻的前提。但仓库有一条特别原则值得抄到自己项目里:推理在这里很便宜,不要吝惜真实 API 测试。因为无密钥测试只能证明底层通路是通的;只有带密钥运行,才能证明 agent 能对接真实模型正常工作。

快照层用无密钥的预期输出覆盖对外行为,浏览器快照用 Chromium 回放比较。它的价值是抓「对外契约的意外漂移」,但如复盘 0002 所示,它必须配合语义级拒绝规则,否则会把错误固化成基线。

在所有层级之上,价值最高的是冒烟测试:启动真实示例、发送一条提示词、检查外部世界。它们能捕获「单元测试全绿、产品却坏了」这一类 mock 根本无法发现的问题。再强调一次那个结论:行覆盖率是必要条件,永远不是充分条件。它证明行被执行过,不证明功能按交付预期工作。0001 的 178 个绿灯 + 100% 覆盖率仍然放过了两个集成 bug,就是最好的注脚。

验证外部世界而非自我报告:e2e 断言与逐字节比对

有了分层还不够,断言的写法同样决定成败。这里有一条铁律:验证外部世界,而非自我报告。

什么叫自我报告?就是 e2e 跑完之后,去读 agent 自己的输出文本,看里面有没有出现「成功」「已完成」这类关键词。这种做法有一个致命漏洞:一个会作弊(或只是过度自信)的 agent,只要在输出里写上正确关键词,就能通过测试。测试实际上在奖励「说得好听」,而不是「做得正确」。更隐蔽的是,即使 agent 不作弊,它的自我描述也可能与真实状态不一致——复盘 0003 里 agent 验收了错误的服务器却宣布通过,就是一次典型的「自我报告与外部真相脱节」。

正确的做法是:

  • e2e 断言应重新运行命令,或从外部重新读取文件——不信任 agent 说了什么,只信任可独立观测的状态。
  • 断言未修改的文件应逐字节一致——不是「大致没变」,不是「内容包含」,而是 byte-for-byte 相同。这一条能抓住那些「顺手格式化了一下」「不小心改了行尾」的隐蔽副作用。
  • 用结构化结果判定成败,而不是自然语言。UNKNOWN_TOOL 这类结构化信号应该被测试框架直接识别为失败,而不是让模型去描述它。

把这条原则和复盘 0001、0004 连起来看:0001 的病是测试走了假入口(自我报告式的挂载),0004 的病是判定逻辑太宽泛(子串匹配),它们的解药都指向同一个方向——用可独立核验的外部事实验收,而不是用进程内部的自述或模糊匹配。断言外部副作用(文件内容、进程退出码的精确契约、URL 的真实响应体)比断言内部状态更难作弊,也更接近用户感知。

文档纪律:verify-type-equiv 门禁与一个事实一个家

代码之外,文档是第二个容易腐化的资产。dsh 仓库的文档不是「写完就完」,而是有机制防止文档与源码漂移

第一道门禁叫 verify-type-equiv。它的工作方式很硬核:用 TypeScript 解析器从源码里提取类型声明的符号,以及这些声明附带的 JSDoc,然后断言文档中的代码块同时匹配两者。这意味着文档里粘贴的类型定义不是手抄的文本,而是被门禁验证过的镜像。当你改动一个已记录的类型声明或它的 JSDoc 时,门禁会失败,直到你同步更新文档中的粘贴内容。这样就杜绝了「文档抄的是旧版」这种最常见的漂移。

第二道纪律叫 一个事实一个家:每个事实只在一个文件里维护,其余文件引用它。具体到工具 schema,它的「真源」在 adding-a-tool.md,其它页面引用而不复制。这样做的好处是:改一处,处处一致;复制三份,改了两份就会开始说谎。

中文与英文文档通过双语配对维护,更新时有明确的顺序:先跑 pnpm run gen-doc-graphs 更新英文,再更新中文并验证配对。顺序不能反,因为图表生成以英文为基准。

把这套纪律平移到你自己的项目:把唯一事实放在一个地方,别让同一个配置散落在三份文档里。一旦发现某个配置项在多个地方被描述,立刻指定一个真源,其余位置改成引用。更进一步,如果你也在写类型相关的文档,把「文档中的类型片段必须能被解析器验证与源码等价」做成门禁,收益会远超预期。

2026 年 9 月最新实践:把复盘四问、正交上报与 prepare 自包含写进 CI 门禁

复盘文化最容易被误解成「写文档」。实际上,一篇复盘只有当它产出了新的防护机制时才算完整。dsh 仓库的四问问得很清楚:

复盘四问要回答什么
什么坏了用一个简短段落让忙碌的读者在三十秒内吸收要点
机制是什么用直白的话说清根因,不归咎个人
为什么每道安全网都没拦住找出测试、工具、约定的缺口,而非一次性笔误
新增了什么防护测试、AGENTS.md 规则、ADR,让同类 bug 下次明确报错

注意第一个问题里的三十秒:这不是修辞。忙碌的读者需要在半分钟内理解概要,所以摘要的公式是坏什么 → 用直白的话说根因 → 为什么逃逸 → 可长期沿用的教训。同时,不是所有 bug 都值得写复盘,只有同时满足三个条件才写:隐蔽(机制不显而易见,细心的工程师也得费力重新推导)、系统性(逃逸原因是测试、工具、约定的缺口)、重新发现的代价高(消耗了真实调试时间,下次还会如此)。这个门槛把复盘从「流水账」变成了「值得归档的资产」。

到了 2026 年 9 月,真正让复盘产生复利的做法,是把它的结论固化成 CI 检查项与 AGENTS.md 规则,而不是留在文档里靠人自觉。下面这份 AGENTS.md 片段可以直接抄进你的仓库,配合 CI 使用:

# AGENTS.md(片段):可复用的硬规则

## 测试
- 任何新增插件,必须附带一条**走真实 Loader 装载路径**的冒烟测试;
  禁止只用手动 ctx.plugin(...) 覆盖加载行为。
- 单测优先覆盖:边界值、错误路径、事件顺序、并发竞态。
- 覆盖率按文件 100%;无法写出触发场景的行,视为待删除的死代码。
- e2e 断言必须验证**外部世界**:重新运行命令或从外部重读文件;
  禁止对 agent 自身输出做关键词探测。
- 断言未修改的文件时,必须逐字节一致(byte-for-byte),
  不允许「包含」或「大致相同」。

## 结果上报
- 子进程结果必须**正交独立上报**:timedOut / signal / exitCode
  各自一个字段,禁止把任一标志的上报嵌套在另一标志的分支里。

## 发布
- prepare 脚本必须**自包含**:不得依赖 monorepo checkout、
  不得使用项目引用、不得依赖仅开发环境才有的上下文。
- Git 安装路径必须能在干净环境下从源码构建出发布入口。

## 配置
- 禁止在 Loader 配置项元数据中使用表达式节点(!!js 之类);
  条件启用改用显式 overlay。
- 沙箱失败判定必须使用结构化 RunnerFailureRule:
  允许退出码 + 逐行致命签名 + 精确排除的信息性行。

与之配套的 CI 检查项(示意,按你的流水线语法改写即可):

# CI 流水线关键步骤(伪 YAML,按实际平台改写)
steps:
  - name: unit
    run: pnpm run test

  - name: coverage-gate
    run: pnpm run test:coverage   # 按文件 100%,未覆盖视为死代码

  - name: real-api-e2e
    run: pnpm run test:e2e        # 缺密钥自动跳过,keyless CI 保持绿色
    env:
      DSH_API_KEY: ${{ secrets.DSH_API_KEY }}

  - name: snapshot
    run: pnpm run test:snapshot   # 拒绝 UNKNOWN_TOOL 等结构化错误入基线

  - name: web-snapshot
    run: pnpm run test:web        # Chromium 回放比较

  - name: doc-graph
    run: pnpm run gen-doc-graphs  # 先更新英文文档图表

  - name: verify-type-equiv
    run: pnpm run verify-type-equiv  # 文档类型块必须与源码符号 + JSDoc 等价

把这三件事写进 CI 后,四类缺陷会从「靠人记得」变成「跑不过就报错」:

  1. 测试真实入口路径——0001 的 default export 丢掉 inject,如果当时有走 Loader 的冒烟测试,Zed 连上之前就会红。
  2. 正交结果独立上报——timedOut、signal、exitCode 各自独立返回,调用方才不会把「超时后捕获 SIGTERM 并以退出码 0 结束」误判成正常成功。子进程可能同时 timedOut=true 且 exitCode=0,这两个事实必须都能被看见。
  3. prepare 必须自包含——Git 安装拉的是源码不是构建产物,没有任何环节自动跑你的 build 脚本;如果 prepare 依赖旁边的 monorepo,用户侧拿到的就是没有 lib/ 的 TypeScript 包,加载直接失败。

这样一来,复盘就不再是「事后追悼」,而是「把一次事故变成一套防护」的正向循环。真正有价值的问题始终是那句:这个 bug 的价值不在那行修复,而在于为什么流程放过了它,以及新增了什么防护让同类问题下次明确报错。

关于配图,这一段我们恰好围绕仓库给出的事故复盘流程与测试金字塔图展开:它把「发现事故 → 四问归因 → 新增防护」与「单元 / 覆盖率 / e2e / 快照」的金字塔分层放在同一张图里,直观说明防护应该加在哪一层。

总结与最佳实践

把整篇文章(发布 + 防御性编程 + 事故复盘)压缩成一份可执行清单。它既是给插件作者的验收表,也是给维护者的日常纪律。

发布与交付:

  • 三条分发途径按需选择:npm 与 tarball 交付预构建产物,用户侧不需要构建授权;Git 安装拉取的是源码,需要构建授权(pnpm ≥ 10)。
  • 走 Git 安装时,插件必须提供 prepare 脚本,让 pnpm 在安装后从源码构建发布入口。
  • prepare 必须自包含:不能假设外围有 monorepo checkout,不使用项目引用、不做类型检查、直接转译 src/。参考写法:"prepare": "tsdown -c tsdown.publish.ts"
  • 选错分发方式一定会在用户侧踩坑:让用户去做额外授权,或用源码当产物,都是可预期的失败。

防御性编程五模式:

  • 结果正交上报:timedOut、signal、exitCode 各自独立成字段,一个事实一个字段,绝不嵌套上报。
  • dispose 停稳:清理要等停稳,别让半边资源先释放。
  • 凭据擦除:用完即擦,不留残留。
  • 链接删除:删除时处理链接语义,避免误删或悬空。
  • 回调隔离:一个回调的异常不能拖垮整个 Agent。总目标只有一句:别让一个简单边界情况把整个 Agent 搞挂。

复盘文化:

  • 四问定型:什么坏了 / 机制是什么 / 为什么每道安全网都没拦住 / 新增了什么防护。
  • 只有隐蔽、系统性、重新发现代价高三个条件同时满足才值得写复盘。
  • 复盘必须产出可验证的防护:一条测试、一条 AGENTS.md 规则、一份 ADR。
  • 四个已验证的教训:不要多余的 export default(0001);不要依赖配置表达式的作用域(0002);给 Agent 注入规范 URL 与运行模式(0003);沙箱失败判定要用结构化规则而非子串(0004)。

测试与验证:

  • 四层分工:单元抓边界/错误/顺序/竞态;覆盖率按文件 100% 并顺手发现死代码;带密钥 e2e 证明真实模型对接;无密钥快照与 Chromium 回放抓对外漂移。
  • keyless CI 保持绿色的前提是 e2e 缺密钥时自动跳过。
  • 验证外部世界,而非自我报告:e2e 重新运行命令或从外部重读文件;不对 agent 自身输出做关键词探测;未修改文件逐字节一致。
  • 记忆点:行覆盖率是必要条件,永远不是充分条件。

文档与长期维护:

  • 用 verify-type-equiv 门禁保证文档里的类型块与源码符号 + JSDoc 等价,改动即报错。
  • 一个事实一个家:工具 schema 的真源在 adding-a-tool.md,其余页面引用而非复制。
  • 双语配对维护顺序:先 pnpm run gen-doc-graphs 更新英文,再更新中文并验证配对。

最后回到贯穿全系列的那句话:模型负责聪明,Harness 负责可靠。约束不是限制,而是让 Agent 可预测、可审计、可回放的基石。一切皆插件——把策略放在扩展点上而不是写进循环里,系统才能演进而不失控。真实入口路径胜过一切 mock——覆盖率、快照、冒烟测试各司其职,共同防止「全绿却坏了」。而故障不可怕,可怕的是不知道为什么:复盘文化把一次事故变成一套防护,让今天的插件,成为明天可以放心依赖的资产。