DeepSeek Harness 源码精读 02:Cordis 内核的 2696 行

第 02 章 Cordis 内核的 2696 行

代码基线:commit 8f86b979a1(2026-10-01)| 0.2.0-rc.1 | 本系列讲次:M1

本章导读

这一章要把"一切皆插件"这句话,落到 2696 行具体代码上。

vendor/cordis/src/ 一共九个文件、2696 行。就这么点代码,组织起了 328 个包、35.6 万行产品代码。这个比例是本仓库最值得注意的一件事,所以本章逐文件读它:Service 基类 115 行、Context 146 行、事件总线 352 行、注册表 337 行、Fiber 754 行。

读完你应该能回答三个问题:ctx.foo 到底发生了什么?「加载顺序由依赖决定」是用什么机制实现的?waterfall 和 serial 差在哪、为什么 AGENTS.md 单独为 waterfall 立了一条铁律?

本章会推翻一个很常见的想象。你 book 里那篇《第三章 Cordis 内核》说"启动顺序由依赖声明决定",结论对,机制猜错了——它设想了一个调度器。真实实现是 fiber.ts:611-639 里的一串字符串:每个插件算出一串"我依赖的服务分别由哪个 fiber 提供",服务换人了这串就变了,于是自动卸载再加载。没有队列,没有调度器,只有状态机在比较字符串。

学习要点

  • Cordis 内核的九行文件分布,先记住位置再读内容
  • epoch 字符串是依赖驱动加载的真正实现,也是热插拔不重启的底层
  • 派发模式是五种不是四种(多一个同步版 bail)
  • ToolGuard 之所以单调,是因为它没有 allow 返回值——能用类型表达的不变量就别用文档表达
  • 事件分发的上下文过滤,是后面所有 agent scope 机制的地基

本章目标

把"Cordis 有五个观念"从记忆变成能指着源码行号复述。读完你应该能回答:

  1. ctx.foo 到底发生了什么?(答案在 reflect.ts:136-171,不在 context.ts)
  2. "加载顺序由依赖决定"是用什么机制实现的?(不是调度器,是 fiber.ts:611-639 的 epoch 字符串)
  3. waterfall 和 serial 差在哪?为什么 AGENTS.md 单独为 waterfall 立了一条铁律?

一、地图:vendor/cordis/src/ 全部内容

fiber.ts      754   插件的一生:状态机 + 可逆效果 + epoch  
reflect.ts    418   ctx 代理的 get/set/has 陷阱、服务存储、notify、mixin  
events.ts     352   事件总线:五种派发模式 + 作用域过滤  
registry.ts   337   插件注册表、inject 解析、@Inject 装饰器  
utils.ts      287   DisposableList / symbols / createCallable  
logger.ts     271   日志  
context.ts    146   interface Context + class Context(两者并存)  
service.ts    115   Service 基类  
index.ts       16   导出面  
              ───  
              2696  

书稿第二章说"Cordis 是两万行内核"这个印象是对的(加上 loader / include / hmr / schemastery / cosmokit 一共约 6000 行)。但真正值得注意的数字是另一面:dsh 用 2696 行的内核,组织起了 328 个包、35.6 万行产品代码。


二、service.ts(115 行)——knowly 说它"极简",这是错的

knowly 那篇的判断是:"Service 基类只提供'绑定到上下文'这一个核心功能,没有抽象方法、没有必须实现的回调列表"。

前半句对,后半句严重低估。 实际有四块知料:

2.1 构造函数只做三件事(service.ts:42-59)

constructor(protected ctx: Context, name: string) {  
  name ??= this.constructor['provide'] as string  
  let self = this  
  if (self[symbols.invoke]) {  
    self = createCallable(name, joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker)  
  }  
  self.ctx = ctx  
  self.name = name  
  self.ctx.reflect.provide(name, self, this[symbols.check])  
  return self          // ← 返回另一个对象!构造函数可以换掉 this  
}  

两个非平凡点:

  • 构造函数可以返回另一个对象(第 58 行)。带 [symbols.invoke] 的服务(比如可调用的 ctx.logger())会被 createCallable 包成函数对象,此时 this 已经不是原来的实例了。
  • 注册是 reflect.provide 的效果,所以服务随 fiber 卸载自动消失(第 57 行注释明说)。

2.2 [symbols.resolveConfig](service.ts:86-102)——knowly 完全没提

这是"拦截配置"真正落地的地方:

[symbols.resolveConfig](base?: T, head?: T): T {  
  let intercept = this.ctx[Context.intercept]  
  const configs: any[] = []  
  while (this.name in intercept) {              // 沿原型链往上走  
    if (Object.hasOwn(intercept, this.name)) configs.unshift(intercept[this.name])  
    intercept = Object.getPrototypeOf(intercept)  
  }  
  if (base) configs.unshift(base)  
  if (head) configs.push(head)  
  return this['Config']?.merge  
    ? this['Config'].merge(...configs)  
    : Object.assign({}, ...configs)  
}  

语义:同一个服务实例,在不同消费者眼里可以带着不同的配置。base 最低优先级、head 最高,靠近 root 的祖先先应用。

这就是 knowly 那篇里说的"每个消费者配置",但它写成了 Inject 装饰器支持配置拦截——准确的位置是这里,装饰器只是入口。

2.3 [symbols.filter](service.ts:61-63)

protected [symbols.filter](ctx: Context) {  
  return ctx[symbols.isolate][this.name] === this.ctx[symbols.isolate][this.name]  
}  

一行,但它是 isolate 语义的判据:两个同名服务是否"同一个",就看它们的 isolate 标签是否相等。

2.4 static [Symbol.hasInstance](service.ts:104-114)

替换了原生 instanceof,沿原型链手写循环。目的是跨 realm / 跨 cordis 副本也能识别为同一个 Service 类。context.ts:61-68 的 Context.is 用的是同一手法(Symbol.for('cordis.is') 全局品牌)。


三、context.ts(146 行)——knowly 说"Context 其实是一个接口,不是一个完整的类"

这句话是错的。 源码里 interface Context(第 16-33 行)和 class Context(第 42 行)同时存在,靠 TypeScript 声明合并共存:

  • interface 那一半描述"可以从 ctx 读到哪些属性",并被所有核心服务和插件通过 declare module './context.ts' 扩展——所以 ctx.tools、ctx.llm 这些属性不是某个包里定义的,是全仓库往这一个 interface 上合并出来的。
  • class 那一半是运行时实现,constructor(第 71-84 行)装好五个内置服务,然后 return self——一个 Proxy:
const self = new Proxy<this>(this, ReflectService.handler)  

教学点:context.ts 只负责"造出一个被代理的容器",代理的逻辑全在 reflect.ts。knowly 那篇把 ctx.tools 的解析说成"context.ts 的 Proxy get 拦截器按服务名查找"——方向反了。

3.1 extend / isolate / intercept 三个原语

方法 行 做什么
extend(meta) 99-107 Object.create(getTraceable(this, this)) + 覆盖 meta 里的自有属性。父上下文不被修改
isolate(name, label?) 121-125 克隆 isolate 映射,把 name 指向新标签(默认 Symbol(name))。同名服务从此互不可见
intercept(name, config) 141-145 克隆 intercept 映射,追加一条服务级配置。它不改服务行为,只改配置解析结果

knowly 那篇把 intercept 说成"在查找路径上加一层拦截配置,比如给所有插件注入的 HTTP 客户端统一加一个代理设置"——"统一加工"这个描述会误导。真实语义是"这一层之下的消费者,各自合并到一份配置",配合 2.2 的 resolveConfig 使用。


四、events.ts(352 行)——五种派发模式,不是四种

4.1 勘误:knowly 列了四种,漏了 bail

第 32 行的类型定义写得很清楚:

export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'  
模式 异步? 返回值 实现在 语义
emit 否 void 194-196 同步触发,不等返回值
parallel 是 void(失败抛 AggregateError) 183-187 Promise.allSettled,全部落定才继续
serial 是 第一个 bail 值 204-209 逐个 await,遇到 bail 立刻停
bail 否 第一个 bail 值 217-222 同步版 serial
waterfall 否 最外层监听器的返回值 234-243 每个监听器包住剩余链条

bail 和 serial 是一对,只差 async。 这是最容易混的一处,也是最该记住的一处——因为 ctx.on() 自己就用 bail 派发 internal/listener(第 296 行)来让监听器注册可被拦截。

4.2 isBailed:只有三个值不算 bail

export function isBailed(value: any) {  
  return value !== null && value !== false && value !== undefined  
}  

0 和 '' 都算 bail(实测已验证)。这意味着一个监听器 return 0 会截断 serial 链——这是真实存在的坑。

4.3 最重要的遗漏:上下文过滤(events.ts:165-175)

dispatch(type: string, args: any[]) {  
  const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null  
  const name: string = args.shift()  
  if (!name.startsWith('internal/')) {  
    this.emit('internal/dispatch', type, name, args, thisArg)  
  }  
  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))  
}  

这是 dsh agent scope 的地基。 当派发带一个 thisArg(一个 Context)时,只有 isolate 标签匹配的监听器才会被调用——除非监听器声明了 { global: true }。

对照 docs/glossary.md 的词条:

  • scope carrier = 这里传入的 thisArg
  • scoped dispatch = 第 171-173 行的过滤规则
  • "Registry-subject events may remain deliberately unfiltered" = { global: true }(events.ts:116)

knowly 那篇一个字都没提这一层。 而它恰恰是理解"dsh 的 subagent 为什么只能看见自己的工具"的钥匙。

4.4 on() 的两道防线(events.ts:288-302)

this.ctx.fiber.assertActive()                        // ① fiber 已卸载就抛 INACTIVE_EFFECT  
listener = this.ctx.reflect.bind(listener)           // ② 监听器被 trace 包装  
const result = this.bail(this.ctx, 'internal/listener', name, listener, options)  
if (result) return result                            // ③ 可被拦截:返回非空就替换注册  

internal/dispatch(第 169 行)则在每个非 internal 事件派发前触发一次——这就是你 cordis preset 里 cordis_inspect_* 工具的数据来源。


五、registry.ts(337 行)——knowly 的核心判断是对的

knowly 那段"判断标准只有一个:这个对象有没有 apply 方法"——完全正确,源码第 8-10 行:

function isApplicable(object: Plugin) {  
  return object && typeof object === 'object' && typeof object.apply === 'function'  
}  

但完整形状是三种(第 92-95 行):函数、类、{ apply } 对象。resolve()(第 222-228 行)先判 typeof plugin === 'function'——函数插件优先级最高,一个既是函数又有 apply 的对象按函数处理。

5.1 Plugin.Base 的五个字段(registry.ts:100-111)

interface Base<T = any> {  
  name?: string                                    // 诊断显示名  
  Config?: StandardSchemaV1<any, T>                // standard-schema 校验器  
  inject?: Inject                                  // 需要哪些服务  
  provide?: string | string[]                      // 提供哪些服务名  
  intercept?: Dict<boolean>                        // ← knowly 没提  
}  

intercept 声明"我消费哪些服务的 intercept 配置",配合 Service[symbols.resolveConfig] 使用。

5.2 ctx.plugin() 返回的是可 await 的 Fiber

const wrapped = Object.create(fiber) as Fiber & PromiseLike<Fiber>  
wrapped.then = (onFulfilled, onRejected) => fiber.await().then(onFulfilled, onRejected)  

await ctx.plugin(...) 等的是加载落定,失败时抛配置校验或启动错误(fiber.ts:704-710)。这就是上面动手脚本第 5 条能 await gated 的原因。

5.3 @Inject 装饰器的两种用法(registry.ts:37-60)

  • 打在类上 → 汇入静态 inject 映射
  • 打在方法上 → 推迟到声明的服务可用后才调用该方法

第三种情况直接抛错:'@Inject() can only be used on class or class methods'。


六、fiber.ts(754 行)——核心是 epoch,不是"逐个调用移除方法"

6.1 六个状态(fiber.ts:147-154)

PENDING  等待所需服务  
LOADING  插件回调正在运行  
ACTIVE   已加载并对外提供  
FAILED   回调或配置抛了  
DISPOSED 已被移除,不能重启  
UNLOADING 清理器正在运行  

6.2 epoch:依赖驱动加载的真正实现

knowly 那篇写"启动顺序由依赖声明决定"——结论对,但它把机制想象成了一个调度器。真实机制是一个字符串:

_refresh() {                                          // fiber.ts:611  
  let epoch = ''  
  for (const name of Object.keys(this.inject)) {  
    const impl = this._store[name]  
    if (!impl) { epoch = INACTIVE; break }            // 有任何一个依赖缺席 → INACTIVE  
    epoch += ':' + impl.fiber.uid                     // 否则把每个依赖方的 fiber uid 串起来  
  }  
  this._setEpoch(epoch)  
}  
  
_setEpoch(epoch: string) {                            // fiber.ts:625  
  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  
    }  
  })  
}  

一句话:每个插件算出一串"我依赖的那些服务分别由哪个 fiber 提供",服务换人了这串就变了,fiber 自动卸载再加载。没有队列,没有调度器,只有一个状态机在比较字符串。

这个设计的直接好处:某个 Provider 被 patch 掉(换个 fiber 提供同名服务),所有依赖它的插件自动全部卸载;patch 撤销,它们自动全部重新加载。这正是书稿 §1.5 说的"热插拔不重启"的底层机制,也是 M0 里 HMR 能工作的原因。

6.3 Effect 的五种形态(fiber.ts:83-93)

type Effect<T> = SyncEffect<T> | AsyncEffect<T>  
type SyncEffect<T>  = Disposable<T> | Iterable<Disposable<T>>  
type AsyncEffect<T> = Promise<Disposable<T>> | AsyncIterable<Disposable<T>>  

_execute(fiber.ts:356-400)逐个分派,生成器分支是真实在用的——reflect.ts:366 的 mixin() 就是 this.ctx.fiber.effect(function* () {...})。

6.4 清理的两条规则

  • 单个 effect 内部:严格逆序(fiber.ts:431,disposables.splice(0).reverse())。实测 A B gen(G1,G2) → G2 G1 B A
  • 整个 fiber 卸载:并发(fiber.ts:676,Promise.all(...)),每个 disposer 各自 try/catch,单个失败只记日志不影响同伴(fiber.ts:683-685)

6.5 配置校验发生在 waterfall 之后

private _resolveConfig(config: any) {                 // fiber.ts:641  
  config = this.context.waterfall(this, 'internal/config', config, () => config)  // ← 先过 waterfall  
  return this.runtime ? resolveConfig(this.runtime, config) : config              // ← 再校验  
}  

resolveConfig(fiber.ts:50-62)用 standard-schema,并且明确拒绝异步校验(throw new TypeError('Async config validation is not supported'))。

knowly 勘误:那篇写"失败的配置抛出带行号的 ValidationError"。没有行号。 ValidationError 构造器(fiber.ts:27-35)格式化的是 - {message} (at {path.join('.')})——是配置路径,不是行号。

6.6 错误纪律(fiber.ts:287-295 的注释值得完整读)

this.inertia itself should never reject — both _reload and _unload swallow their own work errors via ctx.logger.error. If it does reject, the only remaining cause is the logger itself failing... Let the rejection propagate; process-level crash is the honest outcome.


七、勘误:knowly《第三章 Cordis 内核》四处错误

# 原文 实际
C-01 "Context 其实是一个接口(interface),不是一个完整的类" 两者并存:interface Context(16-33)+ class Context(42)。接口那一半是被全仓库声明合并扩展的扩展点
C-02 "当你写 ctx.tools 时……触发一个代理的 get 拦截器,它按服务名查找已注册的服务并返回"(归给 context.ts) 代理逻辑在 reflect.ts:136-171;context.ts 只负责造出这个代理
C-03 "Cordis 的事件总线支持四种派发模式:emit/waterfall/parallel/serial" 五种,多一个 bail(同步版 serial),且 ctx.on() 内部就在用它
C-04 "失败的配置抛出带行号的 ValidationError" 带的是配置路径(path.join('.')),不是行号

两处重大遗漏(不是错,是缺口):

# 缺口 为什么重要
M-01 完全没提上下文过滤(events.ts:171-173) 这是 dsh agent scope 的实现基础;不理解它就看不懂 subagent 为什么看不见父 agent 的工具
M-02 没提 epoch 机制,把"依赖决定启动顺序"讲成了调度 真实机制是 fiber.ts:611-639 的字符串状态机。理解它才能理解 HMR 为什么能批量重载

仍然准确、可以直接复用的部分:五个观念的划分、四种(应为五种)派发模式的语义描述、isolate 与 intercept 的区分思路、"插件生命周期的每一步都对应一个可撤销的注册"、"配置文件顺序只是审美问题"。


八、更重要的发现:dsh 用的不是上游 Cordis

vendor/README.md 记了 22 条本地改动,其中直接改 Cordis 内核的有四条:

# 改动 为什么重要
6 cordis/src/fiber.ts 生命周期加固:effect 的 wrapper 在 setup 体运行之前就挂上所有权列表;setup 同步失败回滚已收集的清理;UNLOADING 期间拒绝创建 effect(PENDING/LOADING 仍合法);子 fiber 在 internal/plugin 发布前就拿到父方持有的 disposer **闭环了你的 开发大坑_dshmarket重复挂载**那类"卸载期注册逃逸"问题
11 include/src/index.ts 提取出 applyEntryPatches 和 entryListSchema 两个导出 **这就是 dsh --dump-config能存在的原因**——配置工具复用同一套 patch 算法,绝不重写。同时修了一个上游 bug:原先insert` 进来的行无法被同一 patch 列表里的后续 patch 配置
15 惰性配置解析(移植 cordiverse/cordis#41):保留 fiber 原始配置,等注入的服务激活后才通过 internal/config 解析 这就是 M0 里那些 disabled: !!js '!ctx.get(''profileContext'')' 的来历——它们是延迟求值的表达式,不是加载时算一次的布尔
19 Loader 按"拥有哪个 module-job API"识别 Node 版本,而不是按大版本号 上游把 24.0–24.11.1 误判为 v2,导致 dsh web 服务出一个空的客户端图——这是一个真实的线上 bug 修复

教学点:读 vendored 代码时必须先读 vendor/README.md 的 "Local modifications"。否则你会把 DSH 的加固当成 Cordis 原生行为,也会把上游 bug 当成设计。



本章实验(附录)

九、动手验证(七条,本机全部通过)

export PATH="/opt/homebrew/bin:$PATH"  
cd /Users/ygs/ygs/deepseek-harness  
node --import tsx/esm "ygsdoc/学习笔记/labs/M1-cordis-lab.ts"  

实测输出:

1) 注册后      : A↑ B↑  
   效果标签树  : ["A","B","gen"]  
   dispose 后  : A↑ B↑ G2↓ G1↓ B↓ A↓   ← 严格逆序:G2 G1 B A  
2) isBailed    : null=false false=false undefined=false 0=true ''=true  ← 0 和空串都算 bail  
3) waterfall   : 返回=undefined 链路=VETO(没调next)  
4) isolate     : root 消费者=A-value | child 消费者=B-value | root 再问=A-value  
5) 依赖缺失    : started=false state=0 uid=1  ← PENDING,不报错也不启动  
6) 服务补齐    : started=true state=2  ← 自动 LOADING→ACTIVE  
7) 只声明命名空间服务: state=2  ← 已激活(容器座位 remote 没声明,activate 不检查它)  
   实际读取时报 : cannot get property "remote" without inject  

第 7 条值得单独说:它精确复现了你 开发大坑_cordis只写命名空间服务漏了容器座位 里记录的现场——插件 inject 只声明了 remote.workspacePublish,激活成功(state=2 即 ACTIVE),但一点就崩。

为什么激活时不报错? 因为 inject 门控只检查"我声明的那些名字在不在"(fiber.ts:611-623),而 ctx.remote 是一次属性读取(reflect.ts:136-171),走的是另一条路径。声明和读取是两道独立的关卡。

M1 通过标准:能解释清楚"为什么插件能激活成功却一用就崩"——即 inject 门控与属性读取走的是 fiber.ts:_refresh 和 reflect.ts:handler.get 两条独立路径。


实验脚本:labs/M1-cordis-lab.ts

运行(在仓库根目录):

export PATH="/opt/homebrew/bin:$PATH"  
cd /Users/ygs/ygs/deepseek-harness  
node --import tsx/esm "dsh-源码精读/labs/M1-cordis-lab.ts"  

预期输出(本机实测,任何一行对不上就说明基线变了):

1) 注册后      : A↑ B↑  
   效果标签树  : ["A","B","gen"]  
   dispose 后  : A↑ B↑ G2↓ G1↓ B↓ A↓   ← 严格逆序:G2 G1 B A  
2) isBailed    : null=false false=false undefined=false 0=true ''=true  ← 0 和空串都算 bail  
3) waterfall   : 返回=undefined 链路=VETO(没调next)  
4) isolate     : root 消费者=A-value | child 消费者=B-value | root 再问=A-value  
5) 依赖缺失    : started=false state=0 uid=1  ← PENDING,不报错也不启动  
6) 服务补齐    : started=true state=2  ← 自动 LOADING→ACTIVE  
7) 只声明命名空间服务: state=2  ← 已激活(容器座位 remote 没声明,activate 不检查它)  
   实际读取时报 : cannot get property "remote" without inject  

本章的勘误条目见 附录 A · 勘误总表。