DeepSeek Harness 的 Cordis 事件派发:文档四种、源码是五种

2026-08-16

翻 DeepSeek Harness 的文档时,如果你先读 docs/cordis-primer.md、再读 docs/cordis-tutorial/04-events.md,会在同一件事上读到两个数字:前者说事件派发模式有四种,后者开口就说是五种。多出来的那一个叫 bail

这不是什么惊天动地的事,但它恰好卡在一个很难受的位置上——派发模式是事件的公开契约,你给一个 harness 事件挂监听器之前,得先知道它是按哪种模式派发的。契约文档的条目数对不上,值得花几分钟自己核一遍,而不是随便挑一处相信。

先把丑话说在前面:deepseek-harness 这个仓库建立于 2026-08-13,我们采集的快照 47f9438 是 2026-08-16 取的,前后只差三天;根 package.json 里版本是 0.1.0-rc.5,GitHub 上一个 Release 都没有;README.md:9-11 有独立的 Developer preview 小节,用全大写写着 THERE WILL BE COMPATIBILITY-BREAKING CHANGES.。所以下面提到的每一处行号、每一个类型定义,都可能在你读到这篇的时候已经变了。以仓库当前内容为准。

差异在哪四处

我们把涉及这件事的位置逐条列出来,只陈述内容,不猜哪一处「才是对的」。

第一处,docs/cordis-primer.md 这个文件全文 44 行。:17 的原句是 Every event can have one of the following dispatch mode and can only be dispatched by these methods accordingly.,紧接着 :19-24 是一张四列表格(Mode / Awaited? / Dispatch Order / Has Return Value?),行只有四条:emitwaterfallparallelserial没有 bail

第二处,docs/cordis-tutorial/04-events.md(144 行)。 :82 明写 emit is one of five dispatch modes,:84-90 是一张五行的表:

模式调用语义(文档原文要点)
emitctx.emit(name, ...args)同步广播;返回的 promise 与返回值既不 await 也不收集
parallelawait ctx.parallel(name, ...args)所有监听器并发跑,一起 await
serialawait ctx.serial(name, ...args)按序 await;第一个非 null/false/undefined 的返回值胜出并中止其余
bailctx.bail(name, ...args)serial 的同步版本
waterfallctx.waterfall(name, ...args, next)环绕式中间件

第三处,源码 vendor/cordis/src/events.ts(352 行)。 :32 那一行原文是:

export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'

五个成员。而且五个派发方法在同一个文件里各有声明位置:parallel:44emit:53serial:63bail:73waterfall:86。生成的 API 文档 docs/cordis-api/events.md:204 给出的 DispatchMode 也是五个成员。

第四处,构建期的校验文案。 packages/typert/generator/src/cordis-catalog.ts:200 的报错文案里,要求补上的标签写的是 '@mode emit|bail|waterfall|parallel|serial'——同样是五个。

也就是说,四种那个口径出现在 primer 一处;五种这个口径出现在 tutorial、生成的 API 文档、源码类型定义、生成器的报错文案四处。以我们实读的仓库状态为准,vendor/cordis/src/events.ts:32 的类型里确实有 bail。差异到此为止,我们不推断哪一处更早写成、也不评价。

顺带一提,同一张 primer 表格还有一处措辞值得留意:docs/cordis-primer.md:22waterfall 标的是 Awaited? No,而 04-events.md:123-124 的示例里写的是 await ctx.waterfall(...)docs/cordis-api/events.md:110 的签名返回类型是 ReturnType<Events[K]>。这三处措辞不同,但 primer 那一列「Awaited?」的确切判定口径我们没有读懂,所以只记录,不解释。

bail 按文档说的是什么

tutorial 那张表对 bail 只给了一句话:serial 的同步版本。而 serial 的语义在同一张表里写得比较全——按注册顺序依次 await,第一个返回非 null/false/undefined 值的监听器胜出,并中止其余监听器。

把这两句拼起来读,bail 就是同样的「按序执行、第一个给出有效返回值的赢、后面的不再跑」,只是不带 await。文档给的信息就这么多,我们没有逐行读懂 vendor/cordis/src/events.ts 里派发的具体实现(listener 过滤、Context.filter 如何参与、waterfall 的 next 怎么组装),所以不在这里替它补细节。

有一点倒是可以从文档语义里明确:bailwaterfall 是两码事,别照着 waterfall 的习惯写 bail 监听器。04-events.md:96 讲 waterfall 时说,每个监听器拿到的参数外还多一个 next() 续延,不调 next() 就直接短路后续链条(文中称之为 veto);04-events.md:138 更把这条写成本仓库的常驻规矩:只观察或只加注解的 waterfall 监听器必须next(),在日志监听器里忘了这一句会静默吞掉下游所有人的默认行为。AGENTS.md:106 把同一条写成了硬规矩。bail 这边表里没有 next,短路是靠返回值本身表达的。

为什么翻遍事件矩阵一行 bail 都看不到

还有一处内容会加深「四种」这个印象:仓库里那份生成的事件清单,确实一行 bail 都没有。

docs/event-producer-consumer.md 是生成文件,:1-2 写着 Generated by scripts/gen-doc-graphs.ts - do not edit by hand,用 pnpm run gen-doc-graphs 重新生成。它用一张表列出每个 harness 事件的 Mode / 声明位置 / 派发方 / 监听方。我们用 Python 数了这张表:harness 事件共 56 行,Mode 分布是 emit 41、waterfall 13、serial 1、parallel 1——没有 bail。文件末尾第二张「非 harness 或未声明的事件字符串」表只有 4 行,全是 internal/*

但如果直接对 packages/ 下所有非 .d.ts.ts 文件跑正则 @mode\s+(\w+) 数一遍,结果是:emit 65、waterfall 19、bail 5、parallel 3、serial 2(这是纯文本计数,不区分注释与字符串)。bail 不是零。

逐条查看能定位到的是 packages/client/ui-input-trigger/src/types.ts:223-256 里声明的 4 个 @mode bail 事件:slash/input-begin-command:233)、slash/input-insert-reference:240)、slash/input-consume-token:247)、slash/input-insert-text:255)。正则数出来的是 5 处,我们只核到了这 4 个事件声明的位置,另一处在哪没有查出来。

那份矩阵自己是有边界声明的:docs/event-producer-consumer.md:6 自述覆盖的是「harness-owned event」,:76 自述是「resolved from the repository TypeScript Program」。但它为什么不含 client 侧这 4 个 bail 事件,我们没有查清它的收录边界,也没有去读 scripts/gen-doc-graphs.ts。这里就停在「两处内容不同」。

你自己怎么核一遍

不用信任何一篇转述,按下面这个顺序,看四个文件就够了:

  1. 打开 vendor/cordis/src/events.ts,跳到第 32 行看 DispatchMode 类型有几个成员——这是运行时框架自己的类型定义,是最贴近实现的一处。
  2. 在同一个文件里确认五个派发方法的声明确实都在::44:53:63:73:86
  3. 打开 docs/cordis-tutorial/04-events.md,看 :82 那句话和 :84-90 那张表,拿到每种模式的调用形式与语义描述。
  4. 最后再翻 docs/cordis-primer.md:17-24,你会看到那张四行的表。

如果你手上的仓库版本和我们的快照对不上(行号是会飘的),就直接在仓库里搜 DispatchMode 这个标识符,以及 @mode 这个标签。

什么情况下说明你遇到的不是这件事

写这类核对最容易犯的错,是把所有「模式对不上」的现象都归到这一条上。下面几种情况和 primer 那张表无关:

  • 你在某个事件上挂了监听器却完全没被调用。 更可能是 fiber 状态问题:inject 里列的服务只要有一个还没就绪,Cordis 会让插件停在 PENDING、什么都不打印、也不崩溃(docs/cordis-tutorial/03-services.md:59:72)。06-composition-and-hmr.md:63 也写了同一件事:inject 了没人提供的服务的插件会永远等待、什么也不打印,PENDING 是合法状态;该章还给出了遍历 ctx.registry.values()runtime.fibers、按 fiber.state 找出停在 FiberState.PENDING 的插件的做法。
  • 你的 waterfall 监听器之后的默认行为没跑。 那多半是没调 next(),见上文 04-events.md:138
  • 你在 docs/cordis-api/inherited.md 里看到的行号点不到源码那一行。 那是另一件事,我们对照过:该文件里写的 internal/* 事件行号与 vendor/cordis/src/events.ts 实读位置整体偏移,且清单里少了 internal/config(源码 events.ts:329interface Events 实读是 9 条)。这属于另一处差异,和派发模式的条目数没关系。
  • 你想用的模式压根不在五个里面。 那就不是数量差异,而是你要的东西 Cordis 没提供。

写自己的事件时留一句

AGENTS.md:104 把 typed events 的做法写成了硬规矩:用 TypeScript 声明合并声明事件,事件的 JSDoc 需要带 @mode 和 payload 的 @paramdocs/cordis-primer.md:26 解释了 @mode 标签的作用——让生成的目录能拿声明去核对派发点。也就是说,你标的 @mode 不只是给人看的注释,它参与生成与校验链路;packages/typert/generator/src/cordis-catalog.ts:200 那句列全五种模式的报错文案就是这条链路的一部分。

这也是为什么值得把「到底几种」搞清楚:写事件声明的时候,你填进 @mode 的那个词必须是这五个之一。至于该选哪一个,取决于你这个事件要不要 await、要不要返回值、要不要短路——项目没有给出通用建议,我们也不替你定。

顺带记一条对中文读者有用的:docs/cordis-tutorial/ 下 8 个英文 .md 全部配了 .zh.md.i18n.yamldocs/cordis-api/ 6 个英文 .md 里有 5 个有中文版,唯一没有的是 inherited.md。以上均为 2026-08-16 快照 47f9438 的实读状态。

延伸阅读


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。