前置:第 02 课 | 预计时长:45–60 分钟 | 动手环节:必须完成
你将学会
- emit / parallel / serial / bail / waterfall 五种模式的准确语义与适用场景;
- waterfall 的 around 中间件形态,以及仓库级强制约定「监听器必须调用
next()」; prepend/global注册选项什么时候用。
问题引入
第 02 课解决了「谁先激活」。这一课解决「事件发生时,多个监听器如何协作」。
dsh 的事件不是一种泛型广播,而是五种语义——同一个 on() 注册,按事件名声明的模式被调度。选错模式的后果从「副作用顺序错乱」到「整条链被意外否决」不等,所以这是写插件的语法课。
正文
3.1 统一的短路判定
一切从 isBailed 开始(vendor/cordis/src/events.ts:13-15):
function isBailed(value) {
return value !== null && value !== false && value !== undefined
}
返回值非 null / false / undefined 即视为「短路」。serial 与 bail 模式用它决定停不停。这个闭集要背下来:返回 0、'' 都算短路,返回 null 不算。
3.2 五种模式语义总表
所有模式经统一 dispatch()(events.ts:165-175),支持 thisArg 与上下文过滤。语义差异只在调度:
| 模式 | 调度 | 返回值处理 | 典型用途 |
|---|---|---|---|
emit | 同步循环,不 await | 忽略 | 纯通知:session/event、turn/start |
parallel | Promise.allSettled 并发 | 任一拒绝 → AggregateError | 互不依赖的副作用 |
serial | 逐个 await | 遇到 bail 值即停,返回该值 | 顺序敏感、可否决的异步决策 |
bail | 同步逐个 | 同 serial | 同步版本的可否决决策 |
waterfall | 按注册序由外向内包装 | 末参 next,链式委托 | 拦截 / 改写 / 否决(around 中间件) |
对应的伪代码(events.ts:194-243,节选):
emit(name, ...args) {
for (listener of dispatch('emit', args)) listener(...args)
// 陷阱:某监听器同步抛错会饿死后续监听器
}
async serial(name, ...args) {
for (listener of listeners) {
value = await listener(...args)
if (isBailed(value)) return value // 停止,返回该值
}
}
3.3 waterfall:由外向内的中间件链
这是 dsh 最重要的模式。末位参数被替换为最内层的 next,监听器按注册序从外向内逐层包裹真正的实现(events.ts:234-243):
waterfall(...args) {
cbs = dispatch('waterfall', args) // 注册的监听器,按序
inner = args.pop() // 最内层:事件发起方给的实现
next = () => {
cb = cbs.shift() ?? inner // 取下一环;没有监听器了就落到实现
return cb(...args)
}
args.push(next) // next 作为末位参数传给外层
return next()
}
仓库级强制约定(根 AGENTS.md):waterfall 监听器必须调用 next() 来委托;不调用即短路否决整条链。 两个典型用法:
// 1) 包裹校验:调 next() 并检查其结果(packages/llm/llm/src/invariant.ts:88)
ctx.on('llm/stream', (_options, next) => validateStream(next(), fail),
{ global: true, prepend: true }) // prepend=插到队首(最外层)
// 2) 否决/改写:不调 next(),直接返回自己的决策
// 如 tools/pre-execute 返回 {kind:'deny'},见第 09 课
3.4 注册选项
prepend:插到队首。校验/不变量类监听器用它,保证先于业务监听器看到调用。global:跨上下文生效。宿主与子上下文(第 02 课的extend)之间的监听选择。once:一次性。
3.5 事件词表靠声明合并扩展
dsh 的事件名都有类型(如 SessionEventMap、LLM 事件),通过 TypeScript declaration merging 扩展(第 02 课的 Proxy 访问同理)。仓库约定 JSDoc 必须标 @mode(用了五种中的哪种)与 payload @param。语义总表的权威是 docs/cordis-primer.md:19-25,waterfall 细则在 :31。
动手环节
- 五模式实验(
scratch/lesson03.ts):对同一个事件名分别用五种模式各挂 3 个监听器(其中 serial/bail 用例里让第 2 个返回真值;waterfall 用例里让第 2 个不调next()),运行并记录每种模式的输出顺序与短路行为。 - 找真实用法:在仓库里
grep -rn "prepend: true" packages/ | head,解释每个命中处为什么需要插队。 - 找否决用法:读
packages/core/tools/src/index.ts:1474附近的tools/pre-execute瀑布,找出「不调next()」的监听器长什么样(第 09 课会展开)。
自检清单
-
isBailed('')是 true 还是 false?isBailed(null)呢? - 五个模式各自举出一个 dsh 里的真实事件(提示:
session/event、agent/turn-stopping、tools/pre-execute、llm/stream)。 - 写一个「禁止在周末执行任何工具」的监听器,应该挂在哪个事件、用什么模式、要不要
prepend?调用不调用next()? -
emit的「饿死陷阱」是什么?为什么 dsh 仍然在高频路径(如session/event)用它?
常见误解
- 「waterfall 监听器忘调
next()会继续走默认实现」——不会,那是直接否决。这也是仓库用门禁和文档反复强调的约定。 - 「parallel 会把结果按序聚合给我」——它只关心全部完成/拒绝聚合,不保序;要顺序结果用 serial 自己收集。
- 「事件模式是调用方可选的」——模式声明在事件词表(事件类型的一部分),监听方只能遵守。
延伸阅读
- 维基:《event-dispatch-modes》
- 仓库:
docs/cordis-primer.md(第 19-25 行是语义总表)、docs/cordis-tutorial/04-events.md - 下一课:第 04 课 · 一次 Turn 的完整旅程