03 · Cordis:服务、事件与可卸载效果的逐函数阅读¶
本章研究仓库内 vendor/cordis/src,而非随手安装另一个 Cordis 最新版。DSH vendor 带有本地修改记录,包名 rescope 到 @deepseek-ai;课程断言的对象是本次锁定源码。rc.2 与 main 的这组 Cordis 文件内容一致。Vendor 说明。
3.1 Context 构造到底做了什么¶
Context.constructor() 创建 isolate/intercept 的无原型表,把自己包装进 ReflectService.handler 的 Proxy,设 root 为 proxy,再创建根 Fiber、Reflect、Registry、Events、Logger 服务。根 Fiber 状态直接为 ACTIVE,普通插件则先 PENDING。构造尾部清空根 Fiber 在内部服务初始化期间收集的注册记录,所以不要把整个框架初始化过程当成你插件的一组待卸载业务效果。构造源码。
extend(meta) 通过原型继承建立子 Context,将 meta 的属性描述符复制过去,父对象不被改动。这是 Fiber 子 Context、scope 和 isolate 的基础。它不是把所有服务深复制。
isolate(name, label) 给新的 isolate 表增加名称→Symbol 标签。未传标签时新建 Symbol;两次显式使用同一 Symbol 会加入同一个服务 realm。intercept(name, config) 则在子 intercept 表记录服务配置,等服务解析配置时合并;它不替换服务实例,也不自动创造隔离。子 Context 三种操作。
3.2 普通属性读取为何要求 inject¶
ReflectService.handler.get 先放行 symbol、保留名、数字名和 _ 开头的内部属性;对象真实已有的属性走追踪读取;已声明 accessor 由 getter 处理;根 Context 可直接读非 strict 服务。普通插件如果读动态服务名,进入 internal/get waterfall,再沿 Fiber 的 store/inject/parent 查找。
如果该服务在当前 fiber.inject 中却尚未激活,报 cannot get required service ... in inactive context。如果向祖先查找时跨过该服务的 isolate 标签,或没有任何声明依赖的祖先,就报 without inject。这个设计避免插件加载时偶然能读到某个全局实例、热更新以后却读取另一实例。Proxy 精确实现。
ctx.get(name, strict=true) 是明确绕过 inject 要求的可选读:缺少或 provider 不 ACTIVE 时返回 undefined。它适合可选能力,例如 approval;如果你的功能不能缺少该服务,应声明 inject,不能把 undefined 静默当成功。
3.3 provide 与 Service 的完整生命周期¶
provide(name, value, check) 在 effect 内声明 service property、确定 isolate label,创建 {name,value,fiber,check},拒绝同 realm 重复 provider。它在 provider 自己的 store 中保留实现,以便构造期间自访问。只有 provider ACTIVE 后,依赖才按可用性看到它。
返回的异步 disposer 先从 reflect.store 删除实现,notify 依赖者并 allSettled 等待相关 fibers,然后从自己的 store 删除。这个顺序保留 provider 的自访问直到消费者清理完成。set 仅允许最初提供它的 Fiber 改值;它不是全局任意写服务的 setter。provide/notify。
Service.constructor(ctx,name) 直接调用 provide,因此一个 new Service 子类在插件 Fiber 内构造后,其提供关系已归入该 Fiber。[Service.invoke] 允许 callable service,追踪元数据维持调用 Context;[Service.filter] 将事件限制在服务 isolate realm;resolveConfig(base,head) 按根到近处合并 intercept,最后 head 优先。如果服务声明 Config.merge 用它,否则 shallow assign。Service 全文。
3.4 Registry:Plugin Runtime 不等于 Fiber¶
Registry.resolve 接受 function/class 或有 apply 方法的对象。Runtime 按 executable callback 身份建立 Map,保存共有 Config 和 fibers 列表;每一次 ctx.plugin() 则创建新的 Fiber。两个调用同一插件并不会自然成为一个 singleton 实例。
ctx.plugin 返回一个以 Fiber 为原型的 PromiseLike wrapper,wrapper.then 等待原 Fiber 的 await()。所以可以 await ctx.plugin(...) 等待当时的加载/卸载工作结束;PENDING 表示依赖暂不可用,并不等于接口保证“await 永远等到未来 provider 出现”。需要服务消费者真实激活时,应检查对应 lifecycle 或完整组合验收。Registry.plugin。
ctx.inject(deps, callback) 是 {inject,apply} 临时插件的简写。依赖消失时 callback 的所有 effects 会卸载;依赖换成新提供者后再运行 callback。缓存 callback 外部的旧服务引用可能绕过这种重新绑定。
Inject.resolve 把数组声明变为名称→null,普通对象声明变为名称→intercept config;类 decorator 继承的 inject 表按原型合并。@Inject 放在方法上则注册初始化 hook,将该方法延迟到所需服务可用后执行。依赖标准化。
3.5 Fiber 的激活状态机¶
_checkImpl(name) 查服务实现及可选 check predicate;check 抛错记录日志并当作不可用。_refresh() 将所有 inject 的 provider fiber.uid 连成 epoch;缺少一个服务就使用 INACTIVE。_setEpoch 比較 epoch,选择 reload 或 unload;已有 inertia 时只更新目标 epoch,正在进行的转换收敛后再继续。依赖和 epoch。
_reload() 先快照 store,并经过 Promise 微任务检查 epoch 尚未失效,才运行 internal/config waterfall、同步 standard-schema 配置验证和 _execute(plugin)。插件失败保存 _error,设置 epoch INACTIVE;await() 等 inertia 清空后再抛原始 startup/config 错误。加载/等待。
_unload() 对顶层 _disposables.clear() 用 Promise.all 启动清理,每个失败记录日志,再根据最新 epoch 返回 PENDING/FAILED/DISPOSED 或重新 LOADING。不要把它描述成“全插件所有 disposer 总按逆序串行执行”:逆序串行规则成立于一个复合 effect 内收集的 disposers;Fiber 顶层兄弟 effects 是并行卸载。因此关闭日志句柄之前必须等 loop 的代码,要放进一个明确的 composite owner。卸载实现。
restart() 设置 INACTIVE,再刷新依赖,再 await;update(config,noSave) 保存 raw config,未激活时延迟解析;已激活时先解析/验证,再运行 internal/update waterfall,下游默认更新 config 并 restart。某个 update listener 不调用 next 可接管更新。async config validation 目前明确不支持,返回 Promise 会 TypeError。配置验证,update。
3.6 Effect 的 setup、rollback、teardown¶
ctx.effect(execute,label) 立即执行 setup。可返回 disposer、Promise
执行前 wrapper 已放进 owner 的 _disposables,所以 setup 中可重入卸载也能找到它。generator 每 yield 一个 disposer 就被收集;后续 setup 抛错时已收集资源回滚。异步 setup 尚未完成而 dispose 到来时,使用 setup barrier 等它得到最终 disposer 后再清理,避免先报告退出再泄露迟到资源。
一个 effect 内的 dispose 对收集列表 reverse 后串行等待 async disposer;wrapper 单次有效,effectInertia 让结构拥有者还能加入另一个调用方已经开始的异步清理。metadata 存 label/children,可用 fiber.getEffects() 检查。它管理的是归属,不保证业务清理不抛,也不能硬终止永不解决的业务 Promise。
示意的复合拥有方式如下。startWorker() 是你自己的 API 占位符,此段不是发布 SDK 示例:
ctx.effect(function* () {
const worker = startWorker()
yield () => worker.releaseHandles()
yield async () => { await worker.stopAndDrain() }
}, 'my worker')
卸载先 stopAndDrain,再 releaseHandles。若把这两个写成两个独立 ctx.effect,Fiber 会并行启动它们,不能得到同样保证。注册器 tools.register() 或 ctx.on() 本身就是 effect;不必为了卸载再复制一个裸全局数组。AgentLoop 复合拥有者的实际范例。
3.7 五种分发模式,逐个按源码判读¶
| 方法 | 等待 | 中止条件 | 异常 |
|---|---|---|---|
| emit | 不 await 返回 Promise | 不看返回值 | 同步 throw 可中断后续 listener;产品层可自行 contained |
| parallel | Promise.allSettled 全部 |
无 bail | 收集 reject 后抛 AggregateError |
| serial | 逐 listener await | 不是 null/false/undefined 就 bail | reject 直接退出 |
| bail | 同步 | 同上 | 同步 throw 退出 |
| waterfall | 取决于 listener/next 返回值 | 不调用 next 就跳过余下链 | 外层决定如何处理 |
isBailed(0) 为 true,isBailed('') 也是 true;只有 null、false、undefined 是“不阻止”。parallel 内 dispatch 传入的是字符串 emit,因此 internal/dispatch 的 mode 记录有这一实现细节,不能从诊断标签猜它不等待。isBailed,分发函数。
dispatch 先取可选 thisArg,再取 name,非 internal 事件会产生 internal/dispatch,之后按 thisArg 的 Context.filter 筛选 hook;global hook 跳过筛选,回调绑定 thisArg。DSH scopeTarget 额外组合 service realm 和 scope 祖先关系,真实主体仍在 payload 中,不能把 routing carrier 当 Agent。筛选实现,scopeTarget。
3.8 Waterfall 不是传统“上一个返回值自动传给下一个”¶
实现先收集 cbs、弹出最后参数作为 inner,将一个共享 next 函数加回参数列表,然后 next 每次 shift 一个 callback,最后调用 inner。listener 必须显式调用 next 才执行下游,所有层获得同一组 payload 参数;返回值由上层决定是否保留或改写。完整 12 行算法。
正确的请求观察器通常 const result = await next(); return result;拒绝器可直接返回自己的 {kind:'reject'} 而不 next。日志观察器只打印后返回 undefined,在 waterfall 中会吞掉默认行为。next 没有通用 once 检查;不要调用两次企图重试,这会推进同一队列,最终可能多次调用 inner。重试应使用对应的 request-error 决策。
写一个 pre-step wrapping listener 改写 messages 时,必须保留 startsRequestSeries 等已有字段:
ctx.on('agent/pre-step', async (payload, next) => {
const decision = await next()
if (decision.kind === 'reject') return decision
return { ...decision, messages: decision.messages.filter(keepMessage) }
})
keepMessage 是教学占位函数;如果这种改写使第一步消息为空,默认循环仅关闭已打开的 turn,不发模型请求。不要把输入收集、是否进入、model 请求重试当成同一个层次。
3.9 on/once 与卸载¶
on assertActive、reflect.bind 回调,先允许 internal/listener 接管,否则在 effect 中插入 Hook;boolean options 是 prepend 简写。once 第一次调用前先 disposer,再转发原 listener,因此递归触发也不会重复。unregister 按 callback 身份删除。卸载后调用 on/effect 会 INACTIVE_EFFECT。注册实现。
对外公开的 Cordis emit 与 DSH 的 session/event 不是相同错误政策。Session 会先取得 listener 快照,然后提交日志,再逐监听器 contained 调用,所以 observer 的 throw/reject 不会让已经提交的事件消失;其他 typed live event 可能直接沿 loop 抛错。自己的插件不能默认所有事件都“异常被吞”。Session 容错观察器。
3.10 练习与核查问题¶
实现一个插件提供 catalog,另一个 inject catalog 后注册工具,再卸载 provider:工具应随 consumer 停用,重挂 provider 后再次贡献。再做 scope 与 isolate 两套实验:scope 同名工具遮蔽,isolate 两服务实例并存。最后故意忘记 next,确认请求路径实际被截断。离线实验章提供运行环境;这些实验检测生命周期语义,不能用“能打印 hello”代替。