一个 export default 让依赖注入失效:DeepSeek Harness 的 ACP 事故完整还原
先说清楚前提:DeepSeek Harness 的 README 第 9 到 11 行自述该项目处于 developer preview(开发者预览) 阶段,并且用大写强调「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」。下面提到的每一个文件路径、字段名、行号,都是仓库快照 47f9438(版本 0.1.0-rc.5)里的样子,随时可能变。我们没有安装、没有运行过它,所有内容都是读源码和读文档读出来的。
现象:错误信息把两个 bug 藏在了同一句话里
docs/postmortem/0001-acp-default-export-drops-inject.md 记的事是这样的:ACP 服务器(examples/acp-agent,包名 @deepseek-ai/dsh-acp)在真实编辑器 Zed 连上来的瞬间崩溃。编辑器最先发的两个 RPC 全挂——session/new 返回 Internal error: cannot get property "agents" without inject,session/load 返回一模一样的句式,只是名字换成了 sessionPersistence。
文档自述当时有 178 个绿色单元测试、100% 行覆盖率。复盘里那句总结值得抄下来:覆盖率证明代码行被执行过,它不能说明功能按交付方式是否正常工作。
麻烦的地方在于,这两条报错长得几乎一样,但根因完全无关。第一个是一行多余的导出,第二个是 Cordis 的服务解析拓扑。文档的时间线也写了,调查一开始追的是第二个理论(看起来合理,机制也真实存在),直到在 vendor 目录的 reflect.ts 里给 fiber 遍历打了桩、跑了真实子进程,才发现异常抛在插件加载时、在 ROOT fiber 上、没有 shadow 参与——理论被跟踪结果推翻了。
第一条路径:Loader 怎么把命名空间吃掉的
packages/acp/acp/src/index.ts 是个命名空间插件:name、inject、Config、apply 各自是独立的具名导出。你现在打开这个文件(一共 436 行),第 42 行是 export const name = 'acp',第 44 行是 export const inject = ['agents'](第 43 行是给 inject 的那行注释)。全文没有 export default——事故里多出来的正是这一行。
关键机制在 vendor/loader/src/index.ts 第 192 到 199 行,方法叫 unwrapExports,连大括号带注释一共八行:
unwrapExports(exports: any) {
if (isNullable(exports)) return exports
exports = exports.default ?? exports
// https://github.com/evanw/esbuild/issues/2623
// https://esbuild.github.io/content-types/#default-interop
if (!exports.__esModule) return exports
return exports.default ?? exports
}
exports.default ?? exports 出现了两次,优先取 .default。只要模块上有默认导出,这里解析出来的就是那个裸的 apply 函数。裸函数身上没有 inject、没有 name、没有 Config——这三样是模块命名空间上的兄弟导出,unwrap 到 .default 的那一步把整个命名空间连带扔了。Loader 接下来是按一个空的 inject 去建这个插件的 fiber 的。
于是 apply 跑在一个没有注入任何服务的 fiber 里。翻到同一个文件第 106 到 108 行,你会看到 apply 体的头两行有一段注释和一句赋值:
// ACP handlers execute outside this plugin's injection scope, so capture the
// injected service during apply rather than reading it lazily in a callback.
const agents = ctx.agents
注释自述的意思是,ACP 的 handler 在这个插件的注入作用域之外执行,所以要在 apply 阶段就把服务抓住,而不是在回调里懒读。这个写法本身没问题,问题是当 inject 被丢掉之后,第 108 行这句就成了整个崩溃的触发点:ctx.agents 沿 fiber 树往上找,一路找不到,走到根 fiber 就抛了那句 without inject。修复只有一个动作——删掉 export default apply。
这里要顺手记一处文档与源码的不一致:复盘正文的代码块里,inject 写的是 ['agents', 'sessions', 'sessionPersistence'] 三项;而我们在 packages/acp/acp/src/index.ts 第 44 行实读到的是 ['agents'] 一项,旁边注释写的是 bridge 自己创建并拥有 agent,其余关注点由 agent composition 承载。两处不一致,以实读的源码为准。原因我们不猜。
第二条路径:fiber 只往祖先方向走
删掉那行之后 session/new 通了,session/load 还在 sessionPersistence 上抛。这一个才是真的 shadow 机制。
session/load 会调 agents.resume(...),落到 AgentLoop.resume()。翻 packages/core/agent-loop/src/index.ts 第 297 行,static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'],五项,确实不含 sessionPersistence。复盘写明了这是刻意的:注入它会让不做持久化的演示永远挂着等一个不会加载的后端。这个服务由一个独立的兄弟插件提供,属于机会性读取。
服务访问走的是 vendor/cordis/src/reflect.ts 里的上下文代理。看第 152 到 166 行,两条路是分叉的:
if (!ctx.fiber.runtime) return ctx.reflect.get(prop, false)
这是第 152 行的提前绕过。测试代码从顶层直接调 ctx.agents.resume(...) 时,ctx.fiber.runtime 是 null,直接走全局 store 查找,压根不看 fiber 拓扑,服务当然找得到。
而从真实插件 fiber 里经 shadow 到达时,走的是下面那个 while 循环。第 155 行起点是 (ctx[symbols.shadow] as Context ?? ctx).fiber,也就是从 AgentLoop 自己的构造 fiber 起步;循环体里第 163 行 if (!fiber.runtime) throw error,最后一行 fiber = fiber.parent.fiber。整个遍历只向祖先方向走。sessionPersistence 既不在 AgentLoop 的 fiber store 里,也不在它通往 root 的任何祖先上——它在一条兄弟分支上——遍历撞到根 fiber 就抛。
同一个读法,从顶层测试里成,从插件 fiber 里不成。测试和生产走的不是同一条路,这是本次事故两个 bug 共同的形状。
今天源码里读到的三种写法
复盘给的处方是:对插件机会性读取、但没写进 static inject 的服务,用 ctx.get(name),绝不用 ctx.<name>。ctx.get 的实现在 reflect.ts 第 233 到 243 行,签名是 get(name: string, strict = true),内部的 _getImpl 第 241 行有一句 if (strict && impl.fiber.state !== FiberState.ACTIVE) return——默认严格,非活跃的后端读出来是 undefined,不会在 teardown 期间被交回给调用方。
packages/core/agent-loop/src/index.ts 里现在三种读法同时存在,值得对着看:
| 位置 | 写法 | 场景 |
|---|---|---|
| 第 359 行 | ctx.get('sessionPersistence') | 配置里给了 sessionId 时才去要持久化 |
| 第 371-372 行 | ctx.inject(['sessionPersistence'], childCtx => … childCtx.sessionPersistence) | 显式建子 fiber,把它当必需服务等 |
| 第 654 行 | this.runtime.ctx.get('sessionPersistence') | resume() 开头,取不到就抛「cannot resume」 |
第三行还有一处和文档对不上:复盘的「新增防护措施」里写的是 AgentLoop.resume 使用 this.ctx.get('sessionPersistence'),我们实读到的是 this.runtime.ctx.get(...)。runtime 是什么?同文件第 316 到 317 行声明了 private readonly runtime: { ctx: Context },注释自述是「用一个普通持有者,防止 Cordis 经调用方 shadow 重新追踪这个 factory 的依赖上下文」。两处表述不一致,以源码为准,我们不推断为什么没同步。
守卫落在了哪里,以及怎么自己核
复盘列的防护措施里,最能自查的是这两项。
一是无 key 的真实 stdio e2e。打开 examples/acp-agent/tests/acp.e2e.ts,第 42 行的 describe 写着 acp-agent over real stdio (no key required),第 68 行的 it 写着 session/new succeeds over real stdio (no model call)。文档自述这条在恢复 export default apply 之后会失败,验证过。它不需要 API key,因为 session/new 触达 factory 但不调模型。
二是导出形状的直接断言。packages/examples/acp-demo/tests/acp-agent.spec.ts 第 264 行起有个用例,标题就叫「has the namespace-plugin export shape (no stray default)」,里面先 expect('default' in acpAgent).toBe(false),再拿 Object.create(Loader.prototype) 造一个 loader,对模块跑一遍 unwrapExports 的往返断言,确认 unwrap 结果就是模块本身、name 还是 'acp-demo'、Config 还在。我们在仓库里数了一下,这类 'default' in 断言现在散落在 41 个文件里、共 42 处。
为什么守卫落在 acp-demo 而不是 acp 自己?docs/testing.md 的「Test the real entry path」一节(第 31 行起)第二条把条件写死了:对于没有 inject 的插件(bundle / composition 类),即使默认导出替换掉了必需的具名导出,Loader smoke 依然会保持绿色,所以必须补显式的 expect('default' in mod).toBe(false) 加 unwrapExports 往返断言,而且要「制造回归、看它变红、再撤回」来证明这个守卫真的会守。packages/examples/acp-demo/src/index.ts 正是这种形状——第 28 行有 name、第 79 行有 Config,唯独没有 inject。另外 packages/examples/acp-demo/tests/load-path.e2e.ts 里,文件顶部说明块的第 22 行和用例中第 122 行的行内注释都直接点名了这份 0001 复盘,是从测试反查文档的现成线索。
顺带一提,packages/acp/ 这一组目录下面只有 acp 一个包。仓库里 packages/*/* 是两层结构,别把分组目录数当成包数。
什么情况说明不是这个原因
如果你在自己的 Cordis 插件里撞上 cannot get property "X" without inject,按下面顺序排掉:
- 先看模块有没有
export default。有,且插件是命名空间形式(name/inject/Config/apply),那基本就是第一条路径。判定动作是对模块跑一遍unwrapExports的往返断言,看返回的还是不是模块本身。 - 如果没有默认导出、
inject也确实声明了这个名字,那第一条路径不成立。转去看这个服务是不是由兄弟 fiber 提供的、读取点是不是在一个从别处拿来的代理里被调用的——也就是第二条路径。 - 如果报错句式是
cannot get required service "X" in inactive context(reflect.ts第 160 行那条分支),那是活跃状态问题,不是拓扑问题,上面两条都不对。 - 如果只在真实进程里挂、在单测里怎么都复现不出来,先别改代码:看你的测试是不是像
packages/acp/acp/tests/harness.ts第 167 到 171 行那样手搭插件对象挂载的——那里现在仍然是ctx.plugin({ name: 'acp-test', inject: [...AcpPlugin.inject], apply })。手工把inject递进去的挂载方式永远不会调用unwrapExports,因为它只由 Loader 调用。这类测试再绿,也不能证明插件按它被加载的方式能工作。
最后一条不是这个原因的信号:如果本地跑得好好的、CI 一跑就挂(或者反过来),先怀疑模块解析。复盘里记了一笔,e2e 的子进程从临时 cwd 启动,tsx 向上找不到仓库根 tsconfig 的 paths 映射,dsh-* 的 import 就悄悄回退到已构建的 lib/;防护措施是在 spawn 时设 TSX_TSCONFIG_PATH 指向仓库 tsconfig,让解析与 cwd 无关,保证测试跑的是源码而不是可能陈旧的构建产物。这种情况下你看到的绿色,和代码正确与否没什么关系。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。