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 指纹、服务与事件。

  1. Context 为什么必须是 Proxy,extend/isolate/intercept 三件套各自在改什么;
  2. ctx.plugin() 用什么当身份 key,为什么同一个 callback 的多次加载共享一条 Runtime;
  3. epoch 指纹怎么把「依赖集合变化」压缩成一次字符串比较;
  4. 五种事件派发各自适合什么,第一个参数是 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——它们必须绕过服务解析。

三件套都建子上下文,且都不改父:

二、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 的加载状态由三个私有方法串起来:

  1. _checkImpl(name):this.ctx.reflect._getImpl(name, true) 拿到实现;若有 impl.check 谓词且返回假,或谓词抛错,就 delete this._store[name];否则写进 _store。
  2. _refresh():为每个 inject 名拼指纹——epoch += ':' + impl.fiber.uid;任何一个依赖缺失,epoch 直接是常量 '__INACTIVE__'(INACTIVE)。
  3. _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 注册新资源」失败:

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(...))。也就是说装了这个装饰器的方法,其调用时机由依赖可用性决定,读代码时看不到调用点。收益是「构造函数里不能拿服务」这条限制被绕开了。

自测题

  1. _setEpoch 的判据只区分 INACTIVE 与非 INACTIVE。如果依赖的服务被另一个 fiber 接管(uid 变化)但仍在 ACTIVE,插件会发生什么?为什么不做「只更新 _store 而不重启」的优化?
  2. ReflectService.handler.get 里对特殊属性直接 Reflect.get。如果某个插件的服务叫 then,会发生什么?Cordis 为什么把 then 与 prototype 一起列进保留字?
  3. ctx.isolate('llm', label) 两次传同一个 label 会共享作用域。请构造一个「同一个进程里两套 llm 服务互不干扰」的配置,并说明 Service[symbols.filter] 在其中起什么作用。
  4. provide() 的 disposer 会 await 所有依赖者卸完。如果某个依赖者的 _unload 里又去读同一个服务,会得到什么?为什么代码注释强调「ensure self access before dependencies cleanup」?
  5. Cordis 提供 waterfall,但 agent-loop 的 agent/turn-stopping 用的是 serial。请从「一个 listener 能不能否决后续 listener」的角度解释什么时候该用哪个。

进入 keel 阅读