Main Chain A · Step 03
boot():条目表变成活的插件树
这一页进入 Cordis 运行时。boot()(packages/boot/app-boot/src/index.ts:970)把 02 页的条目表挂成一棵活的插件树:每个条目变成一个 fiber,插件在 fiber 上注册服务、挂事件监听、起副作用。
示例本次示例:dsh web 挂出的真实插件树(节选)
# == @deepseek-ai/dsh-base
- id: timer
name: '@deepseek-ai/cordis-plugin-timer'
# == @deepseek-ai/dsh-base, patched by @deepseek-ai/dsh-web-app
- id: hmr
name: '@deepseek-ai/cordis-plugin-hmr'
config:
root:
- .
disabled: true ← web-app 把 hmr 禁掉了(04 页 279 行补 watch-only 的原因)
# == @deepseek-ai/dsh-base
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: session
name: '@deepseek-ai/dsh-session'
- id: typert
name: '@deepseek-ai/dsh-typert-registry'
- id: typert-loader
name: '@deepseek-ai/dsh-typert-loader'
- id: typert-gateway
name: '@deepseek-ai/dsh-api-gateway'
- id: session-title
name: '@deepseek-ai/dsh-session-title'
config:
fallbackMaxWords: 5
fallbackMaxBytes: 40
maxTitleBytes: 80
...
# 共 135 个条目(base 78 行 + web-app 的 UI 条目 + 平台禁用等)
# 每个 - id: 一行就是一棵将要挂载的 fiber
# boot(NAME, rootConfig, patches, prepare)
# 764 行: const ctx = new Context() ← 所有 135 个 fiber 的宿主
# 771 行: await ctx.plugin(Loader) ← 装配置→fiber 的引擎
# 774 行: await mountRootInclude(...) ← 挂载上面 135 个条目
# 782 行: await ctx.get('loader')?.await() ← 等 135 个 fiber 全部 settle
# 784 行: await assertEntriesActivated(...)← 有 FAILED/PENDING 就 fail-loud
# 如果一切正常:135 个插件已激活,注册了 ctx.llm / ctx.shell / ctx.tools / ...
下一站:04 runProfile——看这棵树怎么被信号与热更新守护。
§1boot():九行编排
970export async function boot(
971-976 binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?,
977): Promise<Context> {
977 const ctx = new Context()
978 const startupLogs: StartupLogRecord[] = [] // 0.1.6-alpha.2 新增启动日志收集
980 const diagnostics = new Context() // 独立 context:要活得比 root disposal 长
981-987 diagnostics.logger.exporter({ levels: { default: 2 }, export: ... }) // 只收 warn/error
990 let stage = 'host preparation failed'
991 try {
992 ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
993 ctx.provide('dshHomePath', dshHomePath) // cordis.patch.yml 里 !!js 可用的值
996-998 // 拦下 internal/update 的 restart promise 并记录失败
999 await ctx.plugin(Loader) // 挂 Loader 服务
1000 await prepare?.(ctx) // 宿主预备:在配置树条目挂载前跑
1001 stage = 'plugin tree failed to load'
1002 await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl, binName)0.1.7:多了 binName
1008 await ctx.get('loader')?.await() // 等整棵树 settle
1009 if (ctx.get('loader') === undefined) return ctx // 树已被 surface 主动销毁
1010 await auditStartupEntries(ctx, binName)
1011 return ctx
1012 } catch (cause) {
1016 await ctx.fiber.dispose() // 失败时销毁半成品树
1017-1020 if (cause instanceof StartupError) { // 把启动日志挂给调用方见 00 页 §5
1018 cause.startup = { configurationPath: absoluteConfigPath, messages: startupLogs }
1023 let deepest: unknown = cause
1024 const seen = new Set<Error>() // 环状 cause 也要能终止
1029 const stack = deepest instanceof AggregateError ? ... : ...
1032 throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause })
1034-1036 } finally { await diagnostics.fiber.dispose() } // 收集器最后收摊实现「活得比 root 长」
1037}
整个 dsh 的运行时根在这里诞生:new Context()——Cordis 的依赖容器(§2 详述)。之后的一切(Loader、插件树)都挂在这一个对象上。
baseUrl 是配置文件的目录(转成 file:// URL)——Loader 用它解析配置里的相对 specifier。这就是 02 页说的「空 cordis.yml 只为锚定 baseUrl」的锚定点。
dshHomePath 是 ctx.provide 的第一个值——它是 cordis.patch.yml 里 !!js 表达式可以引用的变量。provide 在配置树条目挂载之前,所以所有插件解析启动期环境值时读到的都是同一个不可变快照。
ctx.plugin(Loader):Loader 是 Cordis 的一个服务(vendored,vendor/loader/src/index.ts),名字 'loader'。它是「把配置行变成 fiber」的引擎——ctx.loader.create({...}) 就是往树里挂一个新插件。
prepare 回调:宿主预备。04 页的 runProfile 用它 provide 环境快照和命令行参数——时机在「Loader 装上之后、任何配置树条目挂载之前」,保证所有插件读到的环境值一致。
两段式错误标签:prepare 抛错是「host preparation failed」(宿主的问题);之后抛错是「plugin tree failed to load」(插件树的问题)。诊断信息一眼定位故障归属。
mountRootInclude:把 Include 服务(vendored cordis-plugin-include)挂为 id='include' 的 cordis:include builtin。它做的事:读 root 配置 + 把 patches overlay 叠上去。到这里,02 页的条目表终于变成了「会被挂载」的配置。
loader.await():等所有条目 settle。挂载是异步的(插件可以互相等待服务可用),这里阻塞到整棵树稳定。
一个微妙分支:surface(应用本体,比如 headless 的 one-shot runner)可能在树还没挂完时就完成了任务并 dispose 整棵树——此时 loader 服务已随树销毁,get('loader') 是 undefined,直接返回。这是「快速 one-shot 的正常退出」,不是错误。
最终审计:auditStartupEntries(§4)——0.1.6 起分级:必需条目失败才抛错(抛的是 StartupError),可选条目失败只警告。
0.1.6-alpha.2 新增:启动日志收集器。一个独立的 Context(927 行)挂 logger exporter,只收 warn/error 级别。注释点明关键约束——「The collector must outlive root disposal to retain asynchronous cleanup errors」,所以它在 1034-1036 行的 finally 里才 dispose,而不是跟着 root 一起走。
把日志挂给调用方:若是 StartupError,把配置路径与收集到的日志挂到 cause.startup 再 rethrow。其余错误走原来的路径(1023-1033 行)——它们没有结构化的插件诊断可挂。apps/cli 的 reportStartupFailure 就是这些字段的消费者(见 00 页 §5)。
新增 internal/update 拦截:Cordis 的 Fiber.update() 会丢弃 restart promise,这里把它接住并记录失败。注释原文:「Fiber.update() discards the restart promise. Observe it before the waterfall returns」——否则热重载期间的 restart 失败会静默消失。
失败路径:先 dispose 半成品树(Cordis fiber 的 dispose 是幂等的,重复调用返回同一个已结算结果,所以这个 await 不会 reject 并盖掉 cause),然后挖错误链最深的 cause 拼进诊断——启动报错要带「真正的失败点」的栈,而不是包壳链。
§2Context:运行时根
boot() 里 new Context() 的这个 Context 定义在 vendor/cordis/src/context.ts(146 行)。它是被 Proxy 包裹的依赖容器:ctx.get('llm') / ctx.plugin(X) / ctx.provide(k, v) / ctx.on(event, fn) 都在这里。具体机制分布在三个 vendored 文件:
| 文件 | 职责 | 关键符号 |
|---|---|---|
vendor/cordis/src/context.ts | Context 类本体:DI 容器 + 作用域(extend/isolate)+ 拦截(intercept) | Context、baseUrl、extend、isolate |
vendor/cordis/src/registry.ts | 注册机制:plugin/inject/get 的实现 | RegistryService、Inject、Plugin |
vendor/cordis/src/fiber.ts | 插件实例的生命周期:状态机 + 可逆副作用 + 销毁 | Fiber、FiberState、CordisError |
vendor/loader/src/index.ts | Loader 服务:配置行 → fiber | Loader、builtins |
vendor/loader/src/config/entry.ts | 配置行的运行时形态 | Entry、EntryOptions |
vendor/include/src/index.ts | 补丁语义:!!js 方言 + applyEntryPatches | entryListSchema、PatchOptions、Include |
在 dsh 的代码里,你每天会看到三种 Context 用法:
- 服务获取:
ctx.get('shell')、ctx.llm.stream(...)(类型层由 declaration merging 提供)。 - 服务注册:
class X extends Service { constructor(ctx) { super(ctx, 'name') } }——注意注册点是 Service 基类,不是ctx.provide()。 - 作用域:
ctx.extend({ agent: this })产生 agent 作用域(07 页),ctx.isolate(...)产生隔离 realm(15 页)。
§3fiber:条目的生命周期
配置树里每个 Entry 挂载后就是一个 fiber(vendor/cordis/src/fiber.ts)——插件实例的生命周期载体。FiberState 枚举是审计的依据:
| 状态 | 含义 | boot 审计如何处理 |
|---|---|---|
ACTIVE | 插件已激活,注册与副作用生效中 | 正常 |
PENDING | 还在等依赖(inject 的服务未就绪) | 报错并列出缺失的服务名 |
FAILED | init 抛出,进入失败 | await 后取其 rejection 原因报错 |
dsh 代码里到处可见的「可逆副作用」契约就在这里:插件的一切注册(服务、事件监听、effect)都登记在自己的 fiber 上,fiber 销毁时全部逆转。这就是为什么换 provider 不需要重启进程——销毁旧 fiber 就把它的所有注册撤干净了。
§4启动审计:必需 vs 可选
923export async function auditStartupEntries(
924 ctx: Context,
925 binName: string,
926 warn: (line: string) => void = line => void process.stderr.write(line),
927): Promise<void> {
928 const failures = await inactiveEntries(ctx)
929 const required = new Set(failures.filter(({ entry }) => entry === bootstrapIncludes.get(ctx)单个 Set 取代两个数组
930 || requiredStartupEntryIds.has(entry.options.id)).map(({ entry }) => entry))
931 if (required.size > 0) {必需失败 → 抛 StartupError
932 throw new StartupError(startupDiagnostic(binName, failures, required), failures.map(({ entry, outcome }) => ({
933 id: entry.options.id, module: entry.options.name, required: required.has(entry), fiberState: ..., outcome,
934 })))
935 }
936 if (failures.length > 0) warn(activationDiagnostic(binName, failures))全为可选 → 只警告
937}
auditStartupEntries(0.1.6-alpha.1 从 assertEntriesActivated 改名而来),第三个参数 warn 默认写 stderr。改名不只是措辞——「assert」表达的是「不合规就崩」,「audit」表达的是「审计并分级处理」,这正是行为的变化。
inactiveEntries(822-860 行)收集所有未激活条目。「收集」与「处置」拆成两个函数——收集只描述事实(每条带 entry 和 outcome),处置才决定严重性。
0.1.6-alpha.2 的简化:旧版用 required/optional 两个数组分拣;现在只建一个 required Set——「哪些是必需的」是唯一需要判定的信息,其余自然是可选。判据两条:失败条目是 bootstrap Include 本身、或 id 在 requiredStartupEntryIds 白名单里。
抛 StartupError 而非普通 Error(0.1.6-alpha.2 改):除了人类可读的消息(startupDiagnostic),还携带结构化的 entries——每条含 id、module、是否必需、fiber 状态、失败原因。这让上层能做机器可读的处理,而不只是打印。
全是可选失败就只警告。这是 0.1.6-alpha.1 引入的松动:一个你没在用的可选插件失败不该阻止整个 harness 启动。警告仍可见,没有静默。
| 版本 | 名称 | 白名单项数 | 失败处理 |
|---|---|---|---|
| 0.1.6-alpha.1 | auditStartupEntries | 9 | 必需抛普通 Error,可选 warn(两个数组分拣) |
| 0.1.6-alpha.2 | auditStartupEntries | 7 | 必需抛 StartupError(带结构化 entries),可选 warn(单个 Set 判定) |
白名单(744-752 行)现在是 agent-loop、webserver、modules、connection、headless-runner、acp、sdk-jsonrpc-server——比 alpha.1 少了两项。
这条改动的设计含义:dsh 的插件树是「配置驱动」的——用户可以 disable 行、可以不给可选插件装依赖。旧的全有或全无审计把「配置错误」和「应用残缺」当成一回事。现在的分界线是:少了它,dsh 还是不是 dsh?是则警告,否则崩。
与 00 页 §5 的接力:这里抛出的 StartupError 会被 bin.ts 接住(bin.ts:45-49),交给 reportStartupFailure 把完整报告写进 $DSH_HOME/logs/。而报告里的启动日志正是 03 页 boot 里那个收集器(925-934 行)捕获的——三个文件合起来才构成完整的失败诊断链路。