KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · Cordis 内核:把插件系统做成依赖响应式的 — keel 龙骨
dsh 的插件系统不是自己写的一套,而是 vendored 进来的 Cordis(vendor/cordis/src/,context.ts 146 行、fiber.ts 754 行、reflect.ts 418 行、registry.ts 337 行、events.ts 352 行)。它最反直觉的一点是:插件的加载与卸载不是被 API 调用驱动的,而是被「我依赖的服务现在在不在」这件事驱动的。调用 ctx.plugin(p) 只是「申
dsh 的插件系统不是自己写的一套,而是 vendored 进来的 Cordis(vendor/cordis/src/,context.ts 146 行、fiber.ts 754 行、reflect.ts 418 行、registry.ts 337 行、events.ts 352 行)。它最反直觉的一点是:插件的加载与卸载不是被 API 调用驱动的,而是被「我依赖的服务现在在不在」这件事驱动的。调用 ctx.plugin(p) 只是「申请进入」,真正决定 p 是否运行的是依赖快照的指纹。
这一章拆开这套状态机的四个零件:Proxy context、plugin/fiber、inject 的 epoch 指纹、服务与事件。
Context为什么必须是 Proxy,extend/isolate/intercept三件套各自在改什么;ctx.plugin()用什么当身份 key,为什么同一个 callback 的多次加载共享一条 Runtime;- epoch 指纹怎么把「依赖集合变化」压缩成一次字符串比较;
- 五种事件派发各自适合什么,第一个参数是 Context 时发生了什么。
一、Context 是一个 Proxy,不是容器
context.ts: Context.constructor 最后一行是 return self,而 self 是 new Proxy<this>(this, ReflectService.handler)(第 74 行)。也就是说每次 new Context() 拿到的都不是那个实例本身,而是包着它的代理;this.root = self 让所有子上下文共享同一个根。
所有普通属性读取都落进 reflect.ts: ReflectService.handler.get:
get: (target, prop, ctx: Context) => {
if (isSpecialProperty(prop)) return Reflect.get(target, prop, ctx)
if (Reflect.has(target, prop)) return getTraceable(ctx, Reflect.get(target, prop, ctx))
const error = new Error(`cannot get property "${prop}" without inject`)
// …按 isolate 标签沿 fiber 链向上找 store,找不到就抛这个 error
}
isSpecialProperty 放行四类名字:symbol、保留字(prototype/then)、数字字符串、下划线开头。这就是为什么内部字段全都写成 _store/_hooks——它们必须绕过服务解析。
三件套都建子上下文,且都不改父:
extend(meta)用Object.create(getTraceable(this, this))做原型继承,再把meta的自有属性defineProperty到子对象上。setPhase式的局部覆盖靠的就是这一步。isolate(name, label)复制一份 isolate map(Object.create(this[symbols.isolate]))并写入label ?? Symbol(name)。传递同一个 label 给两次isolate()会让两者共享作用域——这是「换个 provider 但不影响父作用域」的唯一手段。intercept(name, config)同理复制 intercept map,插件加载时按名合并进该服务的解析配置。
二、plugin 的身份是 callback 函数对象
registry.ts: RegistryService.plugin 的第一件事是 this.resolve(plugin):函数插件返回自身,{apply} 对象插件返回 plugin.apply。这个返回值就是 Map 的 key:
let runtime = this._internal.get(callback)
if (!runtime) {
runtime = { name, callback, fibers: new DisposableList(), Config: plugin.Config }
this._internal.set(callback, runtime)
}
const fiber = new Fiber(this.ctx, config, Inject.resolve(plugin.inject), runtime, getOuterStack)
结论有两层:同一个 callback 的多次 ctx.plugin() 共享一条 Plugin.Runtime(Plugin.Runtime.fibers 是一个 DisposableList<Fiber>);而不同 config 的同名插件会各自建 Fiber(Config 校验在 Fiber 侧做,见 fiber.ts: resolveConfig)。delete(callback) 会一次性 dispose 该 runtime 的所有 fiber 并删除记录。
plugin() 返回的不是裸 Fiber,而是 Object.create(fiber) 再挂一个 then,所以 await ctx.plugin(...) 能等到加载完成或配置校验失败(Fiber.await() 里 while (this.inertia) await this.inertia 之后 rethrow _error)。
三、inject 的 epoch:把依赖变化压成一个字符串
Fiber 的加载状态由三个私有方法串起来:
_checkImpl(name):this.ctx.reflect._getImpl(name, true)拿到实现;若有impl.check谓词且返回假,或谓词抛错,就delete this._store[name];否则写进_store。_refresh():为每个 inject 名拼指纹——epoch += ':' + impl.fiber.uid;任何一个依赖缺失,epoch 直接是常量'__INACTIVE__'(INACTIVE)。_setEpoch(epoch):与_runner.epoch比较,只在跨越 INACTIVE 边界时驱动生命周期。
private _setEpoch(epoch: string) {
const oldEpoch = this._runner.epoch
if (epoch === oldEpoch) return
this._runner.epoch = epoch
if (this.inertia) return
this._updateState(() => {
if (epoch !== INACTIVE && oldEpoch === INACTIVE) {
this.inertia = this._reload(); return FiberState.LOADING
} else {
this.inertia = this._unload(); return FiberState.UNLOADING
}
})
}
关键在 epoch += ':' + impl.fiber.uid:依赖是否换了提供者(uid 变了)也算一次变化,会触发一次 unload→reload。而 _store 是快照(_reload 里 this.store = { ...this._store }),插件运行期读到的是稳定的一份,不会被中途换掉。
反向唤醒在 reflect.ts: ReflectService.notify:服务上下线时遍历 ctx.registry.values() 的每一条 runtime 的每一个 fiber,name in fiber.inject 且 isolate 标签匹配的,_checkImpl(name) 后 _refresh()。provide() 的 disposer 里调的就是它,并且 await Promise.allSettled(fibers.map(f => f.await())) 等依赖者卸完——服务提供者下线是一个同步等待依赖者的动作,不是发个通知就走。
四、Service 与事件
service.ts: Service 的构造器只做三件事:确定 name、把实例写成 self.ctx/self.name、调 self.ctx.reflect.provide(name, self, this[symbols.check])。于是 class X extends Service { constructor(ctx) { super(ctx, 'x') } } 就等于「注册一个随当前 fiber 生命周期的服务」。[symbols.resolveConfig] 沿 intercept 原型链从根向外合并配置;[Symbol.hasInstance] 手写实现,让跨 realm 的 instanceof 仍可用。
事件总线 events.ts: EventsService 提供五种派发:
| 模式 | 语义 |
|---|---|
emit |
同步调用全部 listener,忽略返回值与 Promise |
parallel |
并发 await 全部,任一失败抛 AggregateError |
serial |
顺序 await,遇到 bail 值(非 null/false/undefined)返回并停 |
bail |
同步顺序调用,遇到 bail 值返回并停 |
waterfall |
最后一个参数是 next,listener 不调 next() 即为否决 |
dispatch(type, args) 会先看 args[0]:如果是对象或函数,就把它当成 thisArg 摘出来。这个 thisArg 上的 [Context.filter] 决定了哪些 listener 会被调用:
const filter = thisArg?.[Context.filter]
return (this._hooks[name] || [])
.filter(hook => hook.global || !filter || filter.call(thisArg, hook.ctx))
.map(hook => hook.callback.bind(thisArg))
默认过滤器来自 Service.prototype[symbols.filter],判据是「listener 所属 ctx 与本次 ctx 的 isolate[name] 标签相同」。所以 ctx.emit(ctxForThisScope, 'some/event', ...) 天然只通知同一隔离域的订阅者,除非 listener 显式声明 { global: true }。
五、FiberState 与卸载的三道闸
fiber.ts: FiberState 是一个六值常量枚举:PENDING(等依赖)/ LOADING(callback 在跑)/ ACTIVE(已挂载并已 provide)/ FAILED(配置或 callback 抛错)/ DISPOSED(uid 已清空,不可再启动)/ UNLOADING(disposer 在跑)。对外可见的 Fiber.status 只在 ACTIVE 与其他之间翻转时发 internal/status,这是为了避免一场 unload→reload 里刷出四条状态噪声。
卸载路径上有三道闸,任一存在都会让「用已卸载的 fiber 注册新资源」失败:
Fiber.assertActive():uid === null即抛CordisError('INACTIVE_EFFECT')。ctx.effect()、ctx.on()、ctx.provide()开头都调它。Fiber.effect()若发现state === FiberState.UNLOADING,也抛同一个错误。这挡的是「卸载过程中插件代码又注册新资源」。_reload()在await Promise.resolve()之后重新检查this._runner.epoch === oldEpoch。注释写得很直白:一个在检查点之前排队的 disposer 可能已经把这个 epoch 作废了,那么绝不能为一个过期 epoch 跑插件代码。
ctx.plugin() 的发布顺序也值得记:先 parent.fiber.effect(() => {...}) 拿到 disposer 并把自己 push 进 runtime.fibers,再 this.context.emit('internal/plugin', this),最后才 _checkImpl + _refresh。注释解释了原因——「Publish only after the parent owns a fully assigned disposer」,因为一个同步 observer 可能在通知回调里立刻 dispose 掉这个 fiber 或它的父 fiber。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| 代理上下文 | vendor/cordis/src/context.ts: Context.constructor |
第 74 行 new Proxy(this, ReflectService.handler),返回值就是 self |
| 代理陷阱 | vendor/cordis/src/reflect.ts: ReflectService.handler |
get 先查自有属性,未命中走 internal/get waterfall 解析服务 |
| 特殊属性放行 | vendor/cordis/src/reflect.ts: isSpecialProperty |
symbol / prototype / then / 数字串 / _ 前缀绕过代理 |
| 子作用域 | vendor/cordis/src/context.ts: Context.extend |
Object.create(getTraceable(this, this)),父不被修改 |
| 服务隔离 | vendor/cordis/src/context.ts: Context.isolate |
复制 isolate map 写 label ?? Symbol(name);同 label 即同作用域 |
| 配置拦截 | vendor/cordis/src/context.ts: Context.intercept |
复制 intercept map,插件加载前按名合并 |
| 插件注册 | vendor/cordis/src/registry.ts: RegistryService.plugin |
callback 函数对象为 Map key;同 callback 复用 Runtime |
| 运行时记录 | vendor/cordis/src/registry.ts: Plugin.Runtime |
{name, fibers, callback, Config} |
| 依赖解析 | vendor/cordis/src/fiber.ts: Fiber._checkImpl |
逐个解析;impl.check 为假或抛错即视为缺失 |
| epoch 指纹 | vendor/cordis/src/fiber.ts: Fiber._refresh |
epoch += ':' + impl.fiber.uid;缺依赖直接 INACTIVE |
| 自动挂卸 | vendor/cordis/src/fiber.ts: Fiber._setEpoch |
只在跨 INACTIVE 边界时 _reload() / _unload() |
| 反向唤醒 | vendor/cordis/src/reflect.ts: ReflectService.notify |
遍历全部 runtime.fibers 重算依赖并 await 它们卸完 |
| 服务基类 | vendor/cordis/src/service.ts: Service |
构造器调 ctx.reflect.provide(name, this, this[Service.check]) |
| 五路派发 | vendor/cordis/src/events.ts: EventsService |
emit / parallel / serial / bail / waterfall |
| 隔离过滤 | vendor/cordis/src/events.ts: EventsService.dispatch |
首参为对象即作 thisArg,取其 [Context.filter] 过滤 listener |
| 内部事件表 | vendor/cordis/src/events.ts: Events |
internal/plugin、internal/status、internal/get、internal/update 等 |
| 生命周期枚举 | vendor/cordis/src/fiber.ts: FiberState |
PENDING/LOADING/ACTIVE/FAILED/DISPOSED/UNLOADING |
| 失效断言 | vendor/cordis/src/fiber.ts: Fiber.assertActive |
uid === null 抛 CordisError('INACTIVE_EFFECT') |
| 过期 epoch 拦截 | vendor/cordis/src/fiber.ts: Fiber._reload |
await Promise.resolve() 后复查 epoch,过期就不跑插件代码 |
关键取舍
用 Proxy 承担服务解析,代价是「未注入就报错」变成运行时行为。ctx.foo 在类型上永远存在(declare module 合并进来),但运行时若当前 fiber 没注入 foo,抛的是 cannot get property "foo" without inject。好处是依赖声明与读取点彻底解耦(插件作者不需要持有服务句柄);代价是忘写 inject 只在运行时报错,且错误信息需要 enhanceError 裁掉代理栈帧才可读。
epoch 只区分 INACTIVE 与非 INACTIVE,代价是「换了提供者」也会整插件重启。_setEpoch 的判据是 epoch !== INACTIVE && oldEpoch === INACTIVE,反方向同理。因此依赖的服务由另一个 fiber 接管(uid 变化)时,插件会被完整 unload 再 reload,而不是热替换。这是刻意的:插件持有的是 _store 快照,热替换会让快照与真实提供者不一致。
provide 的 disposer 同步等待依赖者卸完,代价是服务下线可能阻塞。notify 返回 fiber 列表后 disposer 里 await Promise.allSettled(fibers.map(f => f.await()))。这把「下线顺序」变成了确定性事实,但一个卸载很慢的依赖者会拖住提供者的关闭路径。Fiber.dispose 里的 while (this.inertia) await this.inertia 同理。
事件派发把 thisArg 与事件名共用同一个 args 数组,代价是调用约定隐式。dispatch 靠 typeof args[0] === 'object' || 'function' 来区分「有没有传 ctx」,所以任何以对象开头的事件参数都会被误判。这要求所有事件名后的事件参数都不能是裸的、会被当作 thisArg 的对象——实际代码里事件参数一律包成单个 payload 对象({turn, step, signal}),正是为了绕开这条隐式规则。
@Inject 装饰器对方法的作用是「延迟到依赖可用才调用」,代价是初始化顺序不可见。registry.ts: Inject 在方法装饰器里往 symbols.initHooks 压一个闭包,闭包体是 ctx.inject(inject, ctx => value.call(...))。也就是说装了这个装饰器的方法,其调用时机由依赖可用性决定,读代码时看不到调用点。收益是「构造函数里不能拿服务」这条限制被绕开了。
自测题
_setEpoch的判据只区分 INACTIVE 与非 INACTIVE。如果依赖的服务被另一个 fiber 接管(uid 变化)但仍在 ACTIVE,插件会发生什么?为什么不做「只更新_store而不重启」的优化?ReflectService.handler.get里对特殊属性直接Reflect.get。如果某个插件的服务叫then,会发生什么?Cordis 为什么把then与prototype一起列进保留字?ctx.isolate('llm', label)两次传同一个 label 会共享作用域。请构造一个「同一个进程里两套llm服务互不干扰」的配置,并说明Service[symbols.filter]在其中起什么作用。provide()的 disposer 会await所有依赖者卸完。如果某个依赖者的_unload里又去读同一个服务,会得到什么?为什么代码注释强调「ensure self access before dependencies cleanup」?- Cordis 提供
waterfall,但agent-loop的agent/turn-stopping用的是serial。请从「一个 listener 能不能否决后续 listener」的角度解释什么时候该用哪个。