上一篇跟着一次 turn 经过了十几种插件:模型适配器装请求,prompt 与 tools 在 step 前汇合,持久化订阅 SessionEvent,approval 和 sandbox 截住副作用,Web 再观察同一运行。问题是:这些模块凭什么可以替换,却不会在启动顺序、依赖或清理上彼此踩踏?

答案不是某个“插件管理器”对象,而是两层互补协议。启动前,applyEntryPatches() 把多个来源合成一棵确定配置树;启动后,Cordis 的 Context、Service 和 Fiber 决定能力在哪个作用域可见、何时激活、由谁释放。

阅读契约。读完以后,你应该能判断:一个 profile 覆盖为什么要重述完整 config;!!js 为什么不能在 config dump 阶段求值;extend() 与 isolate() 怎样产生不同的 service 解析;缺少 injection 时插件为什么停在 PENDING;以及配置重组与模块替换为什么必须进入同一 HMR 队列,却不能被视为同一种事务。

证据边界。本文固定在 ddefc45。DSH 把带补丁的 Cordis fork 放在 vendor/,因此 Context、Service 与 Fiber 的结论直接来自同一仓库快照;它证明的是组合和清理语义,不等于任意第三方插件都安全或兼容。

一、启动前先把多层配置压成一棵树

1.1 同一个算法同时服务 boot 和 dump

vendor/include 导出纯函数 applyEntryPatches(data, patches, warn)。composeEntries()、实际 boot include 与 dsh --dump-config 都调用它,而不是各自实现一套近似合并。于是诊断输出与真正挂载的树共享相同的 id 索引、插入顺序和警告规则。

这个约束很重要。若 dump 使用深合并、boot 使用整对象替换,一份看似正确的诊断配置仍可能启动成另一棵树。DSH 宁愿让错误尽早暴露,也不接受“显示值”和“运行值”分叉。

1.2 patch 是有顺序的操作,不是 YAML 深合并

Cordis patch 语义:id 定位后 config 整体替换,insert 增加新条目,双感叹号 js 在挂载时求值,dump config 保留表达式,未匹配 id 进入 warning

PatchOptions 只有两类核心动作:按 id 找到条目并替换字段,或用 insert 添加条目。对 DSH 用户最容易踩的坑是:id-targeted patch 会替换整个 config,不会逐字段深合并。要保留的 bundle 字段必须重新写出。

- id: llm-deepseek
  config:
    model: deepseek-chat
    apiKey: !!js process.env.DEEPSEEK_API_KEY
- insert:
    - id: local-plugin
      name: ./local-plugin.mjs

!!js 也不是 YAML 解析时立即执行的任意代码。Parser 把它保存成 expression node;Loader 等条目声明的 injections 可用以后,才在该条目自己的 context 中通过 internal/config 解析。--dump-config 则原样打印表达式,避免诊断命令因环境或副作用改变配置。

同一列表中,较早 insert 的条目会立刻进入 id 索引,后续 patch 可以继续配置或禁用它。未匹配的 id 不会静默吞掉,而是交给 warn();config dump 还会标出对应 layer。这让拼错插件 id 变成可见错误信号。

二、Context 决定服务在哪个局部世界可见

2.1 根 Context 先安装四个内建服务

Context 构造时创建根 Fiber,并安装 reflect、registry、events 与 logger。Context 本身是 Proxy:普通属性读取经过 service resolver,ctx.plugin()、ctx.inject()、ctx.on() 等方法则由内建服务混入。

所以插件拿到的 ctx 不是全局 service bag。它是一份带父链、isolation map、intercept map 与当前 Fiber 的作用域视图。两个插件即使都读取 ctx.llm,也可能因 scope label 不同而得到不同 provider。

2.2 extend 继承,isolate 改写某个 service 的解析域

Cordis service 作用域:extend 沿用同一 label 解析到同一实现,isolate 为 llm 改用独立 label,通过 provide 注册另一个实现;注册随所属 Fiber 清理

extend(meta) 创建原型继承的 child context,父对象不被修改;没有 isolation 变化时,服务读取沿用同一 label。isolate(name, label) 则复制 isolation map,只为指定 service 写入新 label。下游对这个名字的 provide/get 都进入新的解析域,其他服务仍继承父 scope。

这正适合 Agent runtime:可以为一段 subtree 替换 LLM、session store 或 approval provider,而不必复制整个应用。相同 label 还可以让两条分支显式加入同一隔离域。intercept(name, config) 则不换实现,只把祖先到当前 scope 的 service config 按顺序合并。

操作改变什么不改变什么
extend()给 child context 增加 metadata。不改 service label,不修改父 context。
isolate()为一个 service name 改写解析 label。其他服务继续沿父 scope 解析。
intercept()给某个 service 的插件配置增加局部覆盖。不替换 service provider。
provide()在当前 label 注册实现。实现随 owning Fiber 卸载,不变成永久全局。

三、Service 把能力注册和所有权绑在一起

3.1 构造 Service 就登记 provider

Service 基类在构造函数里调用 ctx.reflect.provide(name, this, check)。这个 provide 不是裸写 Map:注册动作成为 owning Fiber 的 effect,Fiber 卸载时 provider 自动移除。

export class SessionStore extends Service {
  constructor(ctx: Context) {
    super(ctx, 'sessions')
  }
}

await ctx.plugin(SessionStore)
const store = ctx.get('sessions')

这消除了“插件卸载了但 service 还指向旧实例”的悬挂状态。可调用 service、Config schema 与 intercept config 也继续绑定在同一对象契约上。消费者只声明注入名字,不直接 import 具体 provider。

3.2 injection 是激活条件,不是启动后的空值检查

Plugin runtime 声明需要的 service 后,Registry 创建 Fiber。依赖未满足时 Fiber 保持 PENDING;provider 出现后进入 LOADING,保存实现快照,再解析与校验配置;回调与 init hooks 完成才变成 ACTIVE。provider 被替换或消失时,旧 Fiber 会卸载或回到等待路径,而不是让业务代码在任意位置撞上 undefined。

这让“启动顺序”从手工排序变成依赖图。配置文件的行顺序仍会影响 tree 与 hook 顺序,但 capability 未就绪不会被伪装成已激活插件。

四、Fiber 让插件生命周期成为结构化所有权

4.1 每个 effect 都归当前 Fiber 负责

Fiber 跟踪 validated config、required service snapshot、effects、disposers、错误和状态迁移。插件通过 ctx.on()、ctx.provide() 或 ctx.effect() 建立的资源都会进入当前 Fiber;卸载时按逆注册顺序发起 disposer,并等待它们全部结算。

逆序保证后注册的同步撤销动作先开始,但 _unload() 使用 Promise.all 等待这一批清理,不能推导出异步 disposer 串行完成。若必须等事件流彻底停止后再终止 worker,应把两步放进同一个 async disposer 并显式 await;重复处置则会等待已经开始的清理,不再执行一遍。

4.2 HMR 先统一调度,再区分两种更新

DSH HMR 将配置变更与模块变更放入同一队列:配置重组原 Include 后等待清理和 Loader;模块先导入候选,再卸载旧 Fiber 并激活替代,失败时尝试恢复旧实现

profile、home patch 与源码文件可能同时变化。如果两个刷新过程各自改 Loader,旧条目的清理和新条目的加载就会交错。当前 @deepseek-ai/dsh-hmr 用 runExclusive() 串行处理配置修改、Include 刷新和模块替换,并拒绝嵌套事务;包管理器的下载与安装留在队列外,成功后才进入配置应用阶段。

配置路径由 HMR 观察 profile 的 bundle 列表、profile patch 和 home patch,等待应用就绪后重读各层,再调用 reconcileProfilePatches() 更新原来的根 Include。它同时等待更新前持有的 Fiber 和当前 Loader:已经移出条目表的插件,也必须完成异步清理。新出现或改变的激活失败会拒绝这次应用,未改变的既有 inactive 条目作为 warning 返回;这不是整棵配置树自动回滚的保证。

源码模块替换采用另一条顺序:先重新导入候选模块,再删除旧注册、等待旧 Fiber 结算,最后注册并等待替代 Fiber。导入失败时恢复模块缓存;替代激活失败时撤销替代实现、恢复缓存,并尝试重新注册旧实现。这里存在旧实现已停止的窗口,恢复本身也可能失败,不能描述成“新代 ACTIVE 后才卸载旧代”的无缝交换。

例如启用一个缺少必需 Service 的插件,配置文件可能已经保存,而应用检查会报告 PENDING;修正依赖或禁用该条目才是后续动作。有 HMR 不等于任意已安装包版本都能热替换:Plugin Manager 对包版本替换仍返回需要重启。没有挂载 HMR 的 profile 则在下次启动读取新配置。

五、用四个问题定位 Cordis 装配故障

  1. 树是否合对:先用 --dump-config 看 id、插入位置和 layer 来源,确认 patch 是否整 config 替换。
  2. 模块是否解析:检查 profile 模块解析器、安装与 profile 的依赖来源,以及 entry 自己的 base URL。
  3. 依赖是否满足:查看 Fiber 是否停在 PENDING,以及 service name 的 isolation label 是否一致。
  4. 生命周期是否收口:检查失败 generation、provider replacement 和 dispose 是否完成,而不是只看插件 callback 是否执行过。

这四层分别对应 composition、resolution、injection 与 ownership。把它们都叫“插件没加载”,排障就会在 YAML、import、service 和资源泄漏之间来回猜。

下一篇会把镜头从插件树切到 SessionEvent:当 loop、工具和 UI 都是可替换插件时,什么才是跨插件共享的运行事实?DSH 的回答是——模型能看见的内容必须先进入可投影的事件日志。

参考源码