在 Cordis 插件模型里,一个插件从「被声明」到「被彻底清理」之间,并非一段模糊的运行期,而是一条可观测、可追踪、可复现的状态迁移链路。这条链路的载体就是 Fiber:它既是插件实例的执行单元,也是框架判断「现在能不能做、接下来该做什么」的唯一依据。理解 Fiber 状态机,直接决定了你能否写好依赖驱动的插件、能否在热重载(HMR)场景下让资源干净地进出,以及在应用卸载时避免悬空监听器与泄漏的连接。本文第 1/2 段聚焦状态机本身:Fiber 作用域的定义、主路径五个状态的逐段拆解、FAILED 的进入条件,以及 inject 字段如何把「手工编排启动顺序」变成「声明式依赖驱动」。第 2/2 段会继续深入嵌套插件上下文与自动重载的完整闭环。需要强调的是,本文所有结论都建立在同一套事实之上:状态迁移是单向主链路,依赖是驱动迁移的动力源,而 dispose 的收尾必须沿着 Fiber 索引逐一落地。

Fiber 作用域到底是什么:插件实例的状态容器与清理依据

要理解 Fiber,先要把它和「一个 .ts 文件」「一个导出函数」区分开。源码层面你写的是一个模块与一个 apply(ctx) 函数,但在 Cordis 运行时内部,每加载一个插件就会创建一个对应的 Fiber 作用域。Fiber 记录这个插件实例当前所处的生命周期状态,并持有该实例在运行期产生的全部注册痕迹——监听器、工具、以及 ctx.effect() 登记的各种副作用。换句话说,Fiber 不是插件「的代码」,而是插件「这一次加载」的运行期身份。

把它称为执行单元是有工程含义的。一个插件可能在一次进程生命周期里被加载多次:第一次正常启动、第二次因热重载重新加载、第三次因为某个依赖服务短暂消失后恢复而再次装载。这三次加载中,模块代码是同一份,但框架需要三个互相隔离的 Fiber 来分别承载它们各自的状态与资源。如果没有 Fiber 这样的隔离单元,卸载时就无法回答一个关键问题:这次要清理的,究竟是哪一批注册?

这正是 Fiber 同时作为清理依据的原因。当插件进入卸载流程时,框架不需要去猜测插件注册了什么,也不必让插件作者手写反注册代码来对齐顺序。框架直接以该 Fiber 为索引,把它名下登记过的 disposer 逐个执行。ctx.effect() 之所以有意义,恰恰是因为它把「副作用」挂到了当前 Fiber 作用域上,于是副作用天然获得了「随插件一起消失」的语义。你可以把这个模型理解为:Fiber 是注册的账本,apply 是记账的过程,dispose 是按账本逐条冲销的过程。

下面这张图概括了 Fiber 从被声明到被销毁的完整状态流转,请对照后文的逐段拆解一起阅读。

示意图
Fiber 状态机:插件实例从声明、加载、运行到卸载清理的全部状态与迁移方向。

为了让「Fiber 是状态容器」这件事不流于抽象,先看一段可直接运行的骨架代码。它展示了插件侧需要提供给框架的信息:一个可选的 inject 依赖声明,以及一个执行注册动作的 apply。你不需要在这里手动调用任何「加载」「卸载」方法——状态迁移由框架依据 Fiber 推进。

// 文件路径:scratch-plugin/src/fiber-observe.ts
import type { Context } from 'cordis'

// inject 声明本插件所需服务;框架会等待它们全部就绪后才推进到 LOADING
export const inject = ['tools', 'llm']

export function apply(ctx: Context) {
  // 能走到 apply 内部,说明 tools 与 llm 已就绪(详见后文 PENDING → LOADING 的判据)
  // 此处注册的一切都会记在当前 Fiber 名下,卸载时按 Fiber 索引统一释放

  // 1) 注册一个工具:随插件 ACTIVE 而生,随 DISPOSED 而灭
  ctx.tools.register({
    name: 'echo_probe',
    description: '回显探测参数,用于验证插件是否处于 ACTIVE',
    parameters: {
      type: 'object',
      properties: { text: { type: 'string' } },
      required: ['text'],
    },
    async execute(input: { text: string }) {
      return { ok: true, echoed: input.text }
    },
  })

  // 2) 注册副作用:ctx.effect() 把清理函数挂在当前 Fiber 上
  const timer = setInterval(() => {
    /* 周期性健康检查 */
  }, 30_000)

  ctx.effect(() => {
    clearInterval(timer)
  })
}

这段代码里最值得留意的是:apply 内部完全没有「反注册」的对称代码。清理动作被 ctx.effect() 收进了 Fiber 的账本,等到 Fiber 进入卸载阶段时统一结算。这个设计带来的直接收益是,插件作者不需要维护「注册了什么」与「卸载什么」之间的顺序一致性——顺序由框架按 Fiber 记录决定,而不是由人手写的心智模型决定。

主路径 PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 逐段拆解

Fiber 的状态迁移有一条明确的主路径PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED。这是一条单向前进的链路,理解「单向」二字比记住五个名字更重要:状态不会回退,卸载之后不会重新回到 ACTIVE;若同一插件需要再次运行,那是新一次加载、新的 Fiber,而不是旧 Fiber 的复活。

下面按迁移顺序,把每个状态的前置条件与触发动作讲清楚。

PENDING(已声明,依赖未就绪)。插件已经被加入上下文,框架已经知道它的存在,但它的 inject 所声明的服务还没有全部准备好。此时 apply 一次都不会被调用。PENDING 的进入时机是插件被加入上下文、而 inject 的服务尚未就绪的那一刻。它不是一个瞬态,而可能是一个持续很久的等待态——如果某个依赖服务在当前运行环境下始终不出现,插件就会长期停在这里,安静地等待,而不是报错、不是超时强启。

LOADING(依赖就绪,正在执行 apply)。当所有必需服务都就绪后,框架把 Fiber 从 PENDING 推进到 LOADING,并在此状态下调用 apply(ctx)。这里有一个容易被忽视的细节:LOADING 覆盖的是「apply 正在执行」的整个时间窗口。也就是说,apply 如果是同步函数,LOADING 可能极短;如果 apply 内部有异步等待,LOADING 就会相应拉长。在这个窗口内,插件已经越过了依赖门槛,但尚未被认定为运行中。

ACTIVE(插件运行中)。触发条件是 apply 正常返回。返回之后,插件在 apply 中完成的注册才真正生效,Fiber 进入 ACTIVE。这一点是理解整个状态机的关键分水岭:在 apply 返回之前,注册动作是「已执行」但尚未被认定为「已生效」的。把注册生效与 apply 返回绑定,换来的是卸载时的一致性——只要 Fiber 处于 ACTIVE,就能确定其账本上的注册是一批完整的、对应同一次成功加载的记录。

UNLOADING(插件正在卸载并释放资源)。进入卸载态有三种触发源:依赖服务消失、被显式 dispose、或 HMR 触发卸载(第三节会分别展开)。UNLOADING 同时承担「状态回退」与「资源释放」两项职责:框架一边把 Fiber 标记为卸载中,一边沿着 Fiber 索引执行清理。正因为清理与状态标记在同一阶段发生,卸载过程本身也是可观测的。

DISPOSED(已完全卸载)。触发条件是所有处置器执行完毕。走到这里,该 Fiber 名下的监听器、工具注册、ctx.effect() 清理函数都已经执行完,这个插件实例的生命周期正式终结。此后不会有任何动作再落到这个 Fiber 上,除非它被重新加载为一个新 Fiber。

把五个状态的关键信息汇总成表,便于对照排查:

状态含义进入/触发时机apply 是否已调用注册是否生效
PENDING已声明,依赖未就绪插件加入上下文,inject 的服务还没准备好
LOADING依赖就绪,正在执行 apply所有必需服务就绪,框架调用 apply(ctx)是,正在执行中
ACTIVE插件运行中apply 正常返回,注册生效是,已正常返回
FAILEDapply 抛出异常apply 执行过程中抛错,加载失败是,但异常退出
UNLOADING插件正在卸载并释放资源依赖消失、被 dispose、或 HMR 触发卸载正在被撤销
DISPOSED已完全卸载所有处置器执行完毕全部撤销完成

这张表里最值得反复看的,是「apply 是否已调用」与「注册是否生效」两列的错位关系。LOADING 与 ACTIVE 之间隔着一次函数返回,而注册的「生效」恰好落在这道缝上。理解这道缝,就理解了为什么很多插件 bug 会在热重载时暴露出来。

apply 抛异常之后:FAILED 状态的进入条件与后续处置

主路径描述的是顺利的一生,但工程现场总有异常。Fiber 为这种情况准备了一个专门的状态:FAILED。它的进入条件非常明确——在 LOADING 阶段,apply 执行过程中抛出异常,加载失败。注意这里的触发点是「apply 抛错」,而不是「依赖缺失」,也不是「卸载时出错」。依赖缺失对应的是长期停留在 PENDING;卸载时的问题是 UNLOADING/DISPOSED 阶段的事。

FAILED 与卸载路径的区别,值得单独拎出来讲。UNLOADING 走的是「曾经 ACTIVE 过,现在要收摊」的路线,它的语义是撤销已生效的注册,清理依据是 Fiber 账本上已经记下的条目。而 FAILED 走的是「还没真正立起来,就倒了」的路线:apply 尚未正常返回,注册尚未被认定为生效。因此 FAILED 的处置重点不是「撤销」,而是「把 apply 执行到一半时留下的残留收拾干净」。这个差别在实操中很实在:如果 apply 前半段注册了一个工具、后半段在读取模型配置时抛了错,那么从框架视角看,这次加载是失败的,那次工具注册不应被当成生效注册继续存在。

对插件作者而言,FAILED 给出了一条清晰的告警信号:凡是可能抛错的初始化动作,都应该被当作 FAILED 的候选触发点来对待。常见的失败源包括:读取模型配置时字段缺失、外部连接建立失败、对依赖服务的假设不成立。需要特别提醒的是,由于 apply 抛错会直接导致加载失败,把「可选功能」的初始化放进 apply 的必经路径上,会把整个插件拖入 FAILED。工程上更稳妥的做法是把强依赖初始化与可选增强分开:强依赖缺失本就该让插件停在 PENDING;可选增强失败则应在 apply 内部被降级处理,而不是任其抛出。

下面这段代码演示了如何把「可能失败」的初始化与「必须成功」的注册拆开,避免一个可选能力的抖动把整个插件打成 FAILED:

// 文件路径:scratch-plugin/src/apply-guard.ts
import type { Context } from 'cordis'

export const inject = ['tools', 'llm']

export function apply(ctx: Context) {
  // 必需注册:这些动作一旦抛错,说明插件核心能力不可用,让它进入 FAILED 是合理的
  ctx.tools.register({
    name: 'strict_tool',
    description: '核心工具,注册失败即视为插件加载失败',
    parameters: { type: 'object', properties: {}, required: [] },
    async execute() {
      return { ok: true }
    },
  })

  // 可选增强:初始化失败时降级处理,绝不让异常冒泡出去导致 FAILED
  try {
    const modelConfig = ctx.llm.config
    if (modelConfig?.enableProbe) {
      ctx.effect(() => {
        /* 仅当增强启用时登记的清理 */
      })
    }
  } catch (err) {
    // 降级:只记录,不抛出。插件仍然可以正常进入 ACTIVE
    ctx.logger?.warn?.('[apply-guard] 可选增强初始化失败,已降级', err)
  }
}

从这段代码可以提炼出一个判断准则:apply 里每一处「可能抛错」的调用,都要先想清楚它应该归到 FAILED 还是应该被就地消化。如果说主路径定义了插件健康的一生,FAILED 就定义了不健康的一生里最需要被尊重的那条边界。

inject 字段语义:声明依赖而非手动编排启动顺序

贯穿整个状态机迁移的动力源,是插件上的 inject 字段。它的语义需要说得非常准确:inject 是插件用来声明自己需要哪些服务的字段,框架读取它,并据此决定何时调用 apply。请务必不要把 inject 理解成「一个加载完成后的回调清单」,它描述的是一组前置条件,而不是一串动作。

这个字段带来的架构转变,是把启动顺序从命令式编排变成声明式约束。传统写法里,你需要在某个入口按顺序手动把服务初始化好,再依次启动依赖它们的模块;顺序错了就报错,顺序变了就要改编排代码。而在 Cordis 模型下,插件只声明「我需要 tools 和 llm」,框架负责在所有必需服务就绪后推进 Fiber 到 LOADING。启动顺序不再由某个「main 函数」决定,而是由依赖关系本身隐含地决定。

这种转变的收益可以列成一份清单:

  • 顺序正确性由框架保证:不需要把「先启 A 再启 B」写进人手维护的启动脚本,减少了顺序写错导致的偶发失败。
  • 插件可独立移动:由于依赖是通过 inject 表达的,插件可以在不同上下文之间迁移,而不必同时搬运一段初始化顺序代码。
  • 依赖消失会触发卸载:声明式依赖是双向的,服务出现驱动加载,服务消失驱动卸载,这为自动重载奠定了基础(详见第 2/2 段)。
  • PENDING 成为可观测信号:一个插件长期不进入 LOADING,直接指示了某个依赖服务尚未就绪,排查方向非常明确。

需要澄清一个常见误解:inject 声明的是必需依赖。从素材给出的行为看,框架会等待 inject 中列出的服务全部就绪后才执行 apply。因此不要把「可选增强」塞进 inject——那样会把一个可选能力变成加载门槛,一旦该服务不出现,插件就永远停在 PENDING,反而更难排查。

PENDING 停留不动的根因:依赖未就绪时 apply 绝不执行

PENDING 是整条链路里最容易被误判的状态。表面症状是「插件好像没反应」,真实原因是插件已加入上下文,但所需服务尚未出现,于是框架不推进状态,apply 绝不会被执行。这里面有两个要点需要分开强调。

第一,「没有反应」不等于「出错了」。处于 PENDING 的插件没有任何异常抛出,也不会进入 FAILED。它只是老老实实地等着。很多新手会把这种情况当成插件写错了、或者加载器没生效,从而去怀疑模块路径、怀疑导出方式,浪费大量时间。正确的第一反应是检查 inject 中列出的服务,在目标环境下是否真的会被提供。

第二,「一直没出现」意味着「一直停在 PENDING」。素材明确指出,如果依赖的服务一直没出现,插件就停留在 PENDING,不会执行 apply。这里没有描述超时机制或强制加载路径,所以不要指望框架在某段时间后「帮你把插件拉起来」。这个设计其实是保守而正确的:如果一个插件声明它需要 llm 服务,那么在 llm 缺席的情况下强行执行 apply,只会让插件在运行时不断遇到未定义行为。

把 PENDING 当成一个诊断工具,可以极大提升排查效率。以下是一份针对「插件停在 PENDING」的检查清单,按顺序执行通常能快速定位:

  1. 确认该插件的模块确实被加载器纳入了上下文(否则它连 PENDING 都不会进入)。
  2. 逐条核对 inject 中列出的服务名,与实际提供这些服务的插件所声明的名字是否完全一致——服务名不匹配是静默停留的常见原因。
  3. 确认提供依赖服务的那个插件自身没有停在 PENDING,否则依赖链上游会整体阻塞。
  4. 如果该依赖在特定环境(例如某种精简运行模式)下确实不会提供,考虑把它从 inject 中移出,改为在 apply 内部做能力探测。

这份清单背后是一个重要的心智模型:PENDING 是依赖图的诚实反映。一个插件长期停在 PENDING,说明依赖图里有一条边始终没被满足,问题多半不在插件本身,而在依赖的供给方。

进入 LOADING 的判据:所有必需服务就绪才调用 apply(ctx)

从 PENDING 到 LOADING 的门槛只有一个,但必须严格满足:inject 中声明的所有必需服务都已就绪。满足这个判据后,框架才把 Fiber 推进到 LOADING 并发起 apply(ctx) 调用。这里的关键词是「所有」和「才」——只要还有一项依赖未就绪,就会继续停留在 PENDING;只有全部就绪,才会触发这一次调用。

这个判据之所以重要,是因为它给了插件作者一个非常强的保证。请看本文开头给出的示例注释:「走到这里时,ctx.tools 和 ctx.llm 一定已经就绪」。这不是一句乐观的假设,而是状态机判据直接推出的结论。也就是说,apply 内部不需要写「依赖是否已就绪」的防御性检查——如果依赖没就绪,apply 根本没机会执行。这一点可以显著简化插件实现:你不必写 if (!ctx.tools) return 这类保护分支。

但保证的另一面是责任。既然 apply 得以执行意味着依赖齐备,那么 apply 内部就应该专注于「使用这些依赖完成注册」,而不是再分心处理依赖缺失。把依赖检查代码留在 apply 里,不仅多余,而且会掩盖真正的装配错误——比如你误以为 tools 是可选的,写了兜底分支,于是当 tools 真的没提供时,插件默默降级运行,问题反而被藏了起来。

下面这段代码把「进入 LOADING 即依赖齐备」这个保证用起来,展示了无需防御式检查的写法,同时保留对可选能力的显式探测:

// 文件路径:scratch-plugin/src/loading-contract.ts
import type { Context } from 'cordis'

// 这两个是必需依赖:框架保证二者同时就绪后才会调用 apply
export const inject = ['tools', 'llm']

export function apply(ctx: Context) {
  // 无需 if (!ctx.tools) return —— 运行到此处即证明 tools 与 llm 均已就绪
  // 因此可以直接读取模型配置、直接注册工具

  const modelName = ctx.llm.config?.model ?? 'default'

  ctx.tools.register({
    name: 'model_probe',
    description: `报告当前模型标识,注册时读取到的模型为 ${modelName}`,
    parameters: { type: 'object', properties: {}, required: [] },
    async execute() {
      return { model: modelName }
    },
  })
}

把这段代码和前一节关于 FAILED 的讨论放在一起看,就能勾勒出 LOADING 阶段的完整契约:进来时,依赖一定齐备;出去时,要么正常返回(ACTIVE),要么抛错(FAILED)。没有第三种出口。

ACTIVE 意味着什么:apply 正常返回后注册才真正生效

Fiber 进入 ACTIVE 的条件,是 apply 正常返回。请把这句话的因果读对:不是因为注册成功了所以 ACTIVE,而是因为 apply 返回了、框架据此认定这一批注册生效,Fiber 才进入 ACTIVE。两者的先后关系决定了,「注册真正生效」是一个发生在 apply 返回之后的事实。

为什么要把生效点定在函数返回之后,而不是定在每一次注册调用成功之时?从状态机一致性角度看,这个选择让 ACTIVE 成为一个干净的承诺:只要 Fiber 处于 ACTIVE,它账本上记录的注册就是一个完整批次。反过来,如果注册调用成功即算生效,那么 apply 在中途抛错时,就会出现「部分注册已生效、插件却未进入 ACTIVE」的撕裂状态,卸载时也难以界定应该清理哪一部分。

这个语义对插件作者有两条直接的行为指引:

  • 不要把「注册后需要立刻被其他插件使用」的时序假设写进 apply 中段。由于生效点绑定在 apply 返回后,你在 apply 内部完成注册、又在同一个 apply 的后续代码里期待这批注册已经对全系统可见,这种假设与状态机语义并不同频。
  • 把 apply 设计成「要么整体成功、要么整体失败」的装配过程。因为 ACTIVE 只认「正常返回」,apply 应当尽量把注册组织成一次成型的装配,而不是一条中途容错、各自为政的流水线。这正是前一节用 try/catch 把可选增强就地消化的动机——让可选部分不阻断 apply 的正常返回。

用一个对比例子来体会这个差别:假设插件要注册两个工具 A 和 B。写法一,先注册 A、再注册 B、最后返回,如果 B 抛错,那么 A 的注册将被视为未生效批次的一部分,整体进入 FAILED。写法二,把 B 的失败在内部降级,保证 apply 仍能正常返回,则 A 生效、插件进入 ACTIVE,B 的缺失以日志形式呈现。选择哪种写法,取决于 A 和 B 是「同生共死」还是「主次分明」——状态机没有替你选,但它把选择的后果定义得很清楚。

UNLOADING 的三种触发源:依赖消失、被 dispose、HMR 卸载

插件进入 UNLOADING 的路径共有三条,它们最终都汇聚到同一个阶段,但触发原因不同,工程上的应对也各有侧重。

触发源一:依赖消失。这是声明式依赖的另一面。既然服务就绪驱动插件加载,那么服务消失自然驱动插件卸载。这一点是理解自动重载的前提:当一个插件依赖的 llm 服务不再可用时,框架会把插件从 ACTIVE 推进到 UNLOADING,释放它持有的资源;而等到该服务恢复,这一轮加载结束、并按依赖就绪的判据重新走一遍 PENDING → LOADING → ACTIVE。素材把这一现象概括为「依赖的服务消失,插件会自动卸载;服务恢复后,又能自动重载」,其背后的迁移规则正是这套状态机。

触发源二:被 dispose。这是一个显式的卸载请求。无论是因为上层决定回收某个插件,还是因为应用整体关停,框架都会以 dispose 的方式推动 Fiber 进入 UNLOADING。对插件作者来说,显式 dispose 与依赖消失卸载在资源释放上的责任是一样的——都需要沿 Fiber 索引执行处置器。

触发源三:HMR 触发卸载。热模块替换场景下,为了把新版本代码换进来,必须先把旧版本这一轮加载产生的所有注册与副作用撤干净,否则旧监听器会和新监听器同时存在,造成重复触发。HMR 之所以强依赖状态机,正是因为它需要以 Fiber 为索引做一次彻底而精确的清理。这一条也是第 2/2 段的重点议题,这里先点到为止。

无论从哪条路径进入,UNLOADING 都同时承担「状态回退」与「资源释放」两项职责。这一点值得强调:卸载不只是一个状态标记的变化,它还是清理动作真正执行的阶段。框架沿着 Fiber 索引执行处置器,把监听器、注册的工具、以及 ctx.effect() 登记的清理函数逐条落地。等到所有处置器执行完毕,Fiber 才进入 DISPOSED。

把三种触发源与它们的特点整理成对比,便于在实际排障时快速归类:

触发源触发条件典型场景是否会自动重载排障关注点
依赖消失inject 声明的服务不再可用上游插件被卸载或降级服务恢复后会重新走加载流程检查上游服务的可用性变化,而非插件自身代码
被 dispose框架或上层显式发起卸载回收插件、应用关停不会自动重载,除非再次发起加载确认处置器是否覆盖全部注册,避免残留
HMR 卸载热重载替换插件代码开发期迭代插件实现替换后会加载新版本新旧注册是否重叠,旧副作用是否清干净

这张表揭示了一个常被忽视的事实:卸载并非只发生在「你想关掉插件」的时候。仅仅因为某个依赖服务短暂抖动,插件就可能经历一次完整的卸载与重载。这意味着 apply 与 dispose 都必须具备「可重复进入」的健壮性——这也是为什么 ctx.effect() 这种把清理挂进 Fiber 账本的机制如此重要。手写的反注册逻辑,在一次正常关停中可能看不出问题,但在依赖反复抖动、HMR 多次替换的放大镜下,任何一次遗漏都会以「重复注册」「幽灵监听器」的形式暴露出来。

至此,Fiber 状态机的主干已经清晰:PENDING 等待依赖,LOADING 执行 apply,ACTIVE 承诺注册生效,UNLOADING 释放资源,DISPOSED 终结实例;main path 之外,LOADING 中的异常通往 FAILED。而驱动这一切迁移的,是 inject 这种声明式依赖。接下来第 2/2 段会把镜头拉近到嵌套上下文与自动重载的完整闭环:当插件里再套插件、当依赖链分层嵌套时,状态如何逐层推进;以及服务恢复触发重载时,框架如何保证「旧的一定先干净地结束,新的才开始」。

上一段我们把 Fiber 状态机的主路径 PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 拆开讲了一遍,也顺着 inject 依赖驱动的正向加载走到了 apply 被调用的那一刻。但真正跑在生产里的 Agent 系统,麻烦从来不在「怎么加载起来」,而在「怎么确认它彻底走干净了」——下面这段我们就把状态机的后半程、反向联动、嵌套联动和可观测性一次性收口。

DISPOSED 的终局判定:所有处置器执行完毕才算停稳

很多人在读状态表的时候会犯一个很隐蔽的错:把 UNLOADING 当成「已经卸载完成」。从状态命名上看,UNLOADING 是现在进行时,DISPOSED 才是完成态,但工程里真正致命的地方在于,UNLOADING 是一个允许长时间停留、甚至可能停留到天荒地老的中间态,它不等于资源已经归还。

按素材给出的状态定义:UNLOADING 的含义是「插件正在卸载并释放资源」,触发时机包括依赖消失、被 dispose、或 HMR 触发卸载;DISPOSED 的含义是「已完全卸载」,并且有一个非常硬的前提条件——所有处置器执行完毕。注意这里的关键词是「所有」和「执行完毕」,不是「开始执行」。

为什么这个区别在 Agent 开发里会被放大?因为插件在这套模型里干的活通常不是纯计算,而是有副作用、有句柄、有外部连接的:注册到 tools 上的工具条目、挂在 llm 上的模型配置监听、通过 ctx.effect() 注册的清理回调、以及插件自己开出去的定时器或长连接。这些东西如果在 UNLOADING 阶段就被人当成「已经没了」来对待,你会在两处踩坑:

  • 重复注册冲突:旧实例的处置器还没跑完,新实例已经在同一份上下文里注册了同名工具,框架侧看到的就是键冲突或覆盖,症状表现为「热重载后工具行为诡异」。事实上,热重载要等旧实例真正走到 DISPOSED,新实例的注册才有一个干净的落脚点。
  • 泄漏被状态掩盖:你以为 DISPOSED 了,于是放心地不再追踪;但实际上插件卡在 UNLOADING,处置器因为某个异步回调没返回而永远不完成,句柄就一直挂着。停稳的判据永远是「处置器全部执行完毕」,而不是「卸载流程已被触发」。

把这一点落成可判定的规则,可以这样记:状态机里的每一次迁移都有明确的进入条件,PENDING 的进入条件是「已声明但所需依赖未就绪」,LOADING 的进入条件是「所有必需服务就绪、框架调用 apply(ctx)」,ACTIVE 的进入条件是「apply 正常返回、注册生效」,FAILED 是「apply 执行过程中抛错」,UNLOADING 是「依赖消失、被 dispose、或 HMR 触发」,DISPOSED 则是「所有处置器执行完毕」。 你会发现只有 DISPOSED 的条件里带「完毕」二字,这本身就是设计者的态度。

一个实用的心智模型:把 Fiber 想成一个状态容器,而不只是一个标记。素材里说得很清楚,Fiber 是「一个插件实例在 Cordis 运行时中的状态容器」,它「记录该插件的生命周期状态,也是卸载时清理注册的依据」。也就是说,卸载时要清理什么,答案是「依据这个 Fiber 记录下来的注册」。那么反过来,只有当这些注册对应的处置动作全部执行完,这个 Fiber 才有资格被判定为 DISPOSED。DISPOSED 不是「我打算卸载」,而是「我已经把账还清了」。

另外要提醒关于 FAILED 的边界:FAILED 只在 LOADING 阶段由 apply 抛异常进入。这意味着一个插件如果从来没走到 LOADING(比如依赖一直没齐,卡在 PENDING),它不会变成 FAILED;而一个已经 ACTIVE 的插件,之后如果依赖消失,它的去向是 UNLOADING 而不是 FAILED。这条边界在排查时非常有用,后面「以状态机做观测」的小节会展开。

scratch-plugin/src/my-plugin.ts 实例精读:inject=['tools','llm'] 的加载时序

接下来把素材里那段极简示例逐行拆开。它短到只有四行有效代码,但每一行都对应状态机上的一次判定,信息量其实很大。

// 文件路径:scratch-plugin/src/my-plugin.ts
// 声明本插件需要 tools 与 llm 两个服务,二者就绪前 apply 不会执行
export const inject = ['tools', 'llm']

export function apply(ctx: Context) {
  // 走到这里时,ctx.tools 和 ctx.llm 一定已经就绪
  // 可以放心地注册工具、读取模型配置
}

第一行到第二行是注释,但它给出的因果关系是整个生命周期模型里最该背下来的一句:「二者就绪前 apply 不会执行」。注意方向:不是「apply 执行时再检查依赖」,也不是「依赖没齐就先执行、用到时再报错」,而是在进入 LOADING 之前就把依赖齐备当作前置门槛

第三行 export const inject = ['tools', 'llm'] 是声明原文。素材对 inject 的术语定义是「插件声明所需服务依赖的字段,框架会等这些服务全部就绪后才执行 apply」。这里有两个容易被忽略的细节:其一,它是一个数组,语义上是「全部必需」而非「任一可选」,所以 tools 就绪而 llm 缺席时,插件不会进入 LOADING,会老老实实停在 PENDING;其二,它是 export 出来的契约,框架在插件被加入上下文、还没有执行任何用户代码之前就能读到它——这正是框架能做「依赖编排」的前提。素材里也点明了这套机制的价值:用服务依赖表达加载顺序,而不需要手动编排启动顺序

第五行 export function apply(ctx: Context) 是执行入口。素材对 LOADING 的描述是「依赖就绪,正在执行 apply」,对 ACTIVE 的描述是「apply 正常返回,注册生效」。两句话合起来给出了一条很实用的时序推论:apply 的调用发生在所有必需服务就绪之后,而 apply 的返回发生在注册生效之前。 也就是说 apply 是一个「同步完成注册声明」的阶段,你在这个函数体里做的注册动作,会在它正常返回的那一刻被认定为生效。

第六到第八行是注释,但它给出的是一份可用性保证:「走到这里时,ctx.tools 和 ctx.llm 一定已经就绪,可以放心地注册工具、读取模型配置」。这句话值得当成不变量来依赖:在 apply 函数体内部访问 ctx.toolsctx.llm,你不需要再写 if (!ctx.tools) return; 这类防御分支,也不需要做重试等待。框架已经替你把等待做在了 PENDING 到 LOADING 的门外。

把这段代码在时间轴上的展开写清楚,大致是这样的:

  1. 插件被加入上下文,框架读取到 inject = ['tools', 'llm'],Fiber 置为 PENDING。此刻 apply 一行都不会被执行。
  2. tools 服务先就绪。此时 llm 未就绪,仍然 PENDING,不执行 apply。这一条能解释很多「为什么我的插件好像没反应」的困惑:不是插件坏了,是它在等。
  3. llm 服务就绪。两个必需依赖全部满足,Fiber 从 PENDING 迁移到 LOADING,框架调用 apply(ctx)
  4. apply 内部访问 ctx.toolsctx.llm,二者必然可用;执行注册工具、读取模型配置等动作。
  5. apply 正常返回,注册生效,Fiber 进入 ACTIVE。至此插件才真正算「运行中」。

如果第三步里 apply 抛了异常,去向就不是 ACTIVE,而是 FAILED——这是素材明确定义的旁路。这一点在工程上意味着:不要把「依赖齐备」和「插件健康」画等号。依赖齐了只说明它有机会进入 LOADING,apply 里仍然可能因为配置格式不对、注册键冲突等原因抛错,最终落到 FAILED。排查时这两个状态要分开看,处理动作也完全不同。

服务消失为何自动卸载:依赖驱动加载的反向联动

正向说完了,现在把它反过来推。既然「所有必需服务就绪」是进入 LOADING 的门槛,那么一个很自然的推论是:如果这些必需服务在插件运行期间不再就绪,原先成立的准入条件就被打破了。 状态机对这种「条件不再成立」的响应,就是把插件从 ACTIVE 推进 UNLOADING

素材把这一点讲得很直白:UNLOADING 的触发时机包括「依赖消失、被 dispose、或 HMR 触发卸载」;配套章节的标题就是「依赖驱动加载、自动重载与嵌套上下文」,并且直接抛出了两个问题——「为什么依赖的服务消失,插件会自动卸载?服务恢复后,又为什么能自动重载?」

这里要建立一个关键认识:依赖是双向约束,而不是一次性的入场券。 很多从其他插件体系过来的开发者习惯把依赖理解为「启动时检查一次」,加载成功之后就与依赖解耦了。但这套模型不是这样。既然加载的准入条件是「依赖全部就绪」,那么这个条件就构成了插件持续处于 ACTIVE 的隐含前提;条件失效,状态就必须跟着变。这解释了为什么它不需要你手写卸载逻辑——卸载不是你去调用的动作,而是状态机对依赖变化的自动响应

为什么这个设计对 Agent 系统特别重要?想想真实场景:tools 服务可能因为一次配置热更新而暂时下线重建,llm 服务可能因为切换供应商或重新协商连接而短暂不可用。如果插件在依赖已经失效的情况下继续以 ACTIVE 自居,它注册在上下文里的那些条目就会指向已经不存在的服务——调用时爆出的错误离根因非常远,排查成本极高。自动进入 UNLOADING,本质上是让「注册的有效性」和「依赖的有效性」保持同步。

顺着这个逻辑,还有一层容易被忽略的含义:素材说 Fiber「也是卸载时清理注册的依据」。把它们串起来就是——依赖就绪时注册,依赖消失时按 Fiber 记录清理注册,而这个清理动作的启动信号就是进入 UNLOADING。注册与清理被同一份依赖声明串成了一条对称的链。

下面用一个最小化的示意来验证这条反向链路。它不依赖任何具体产品接口,只演示你对 ctx 的服务做一次「下线—恢复」,观察插件生命周期的响应:

// 文件路径:scratch-plugin/src/lifecycle-probe.ts
// 用一个显式的探针插件观察:服务下线时是否会推动插件进入卸载路径
export const name = 'lifecycle-probe'
export const inject = ['tools', 'llm']

export function apply(ctx: Context) {
  // 能走到这里,说明 tools 与 llm 均已就绪,插件处于 LOADING 之后、ACTIVE 之前
  console.log('[probe] ACTIVE 前置条件已满足,开始注册')

  // 依赖消失时,框架会推动本插件进入 UNLOADING;
  // 通过 ctx.effect 注册的清理回调会在卸载阶段被执行,
  // 全部回调执行完毕,本 Fiber 才判定为 DISPOSED。
  ctx.effect(() => {
    console.log('[probe] 处置器执行:释放本插件占用的注册与句柄')
    return () => {
      console.log('[probe] 处置器回滚执行完毕,可以判定 DISPOSED')
    }
  })
}

在真实环境里验证时,别只看控制台有没有打出「卸载」字样,要看的是清理回滚是否执行完。因为按定义,只有处置器全部执行完毕,才轮到 DISPOSED。如果日志停在「开始卸载」却迟迟没有对应的「执行完毕」,那这个插件就是卡在 UNLOADING,需要按后面给出的清单去逐项排查。

服务恢复后的自动重载:从 PENDING 重新走到 ACTIVE 的闭环

正向是「就绪 → 加载」,反向是「消失 → 卸载」,那么把两者接起来就得到了这个模型最漂亮的部分:服务恢复后重新就绪,插件会再次经历完整的加载路径,从 PENDING 重新走到 ACTIVE。

这条闭环的价值在于「自愈」。素材在配套章节里用的词很准确——「自动重载」。注意它不是「恢复」,因为插件并没有一个「暂停再继续」的中间态可以回到原位;正确定义是旧的一轮已经走完卸载,新的一轮重新从 PENDING 开始。理解这一点对排查非常重要:重载后你看到的是一个全新的 Fiber 实例,而不是原来的实例被唤醒。

把这轮的完整迁移写出来:

  1. 服务消失,上一轮从 ACTIVE 进入 UNLOADING
  2. 处置器全部执行完毕,上一轮 Fiber 走到 DISPOSED,旧注册被清理干净。
  3. 服务恢复,依赖重新齐备。此时插件重新具备准入条件,Fiber 从 PENDING 开始新一轮。
  4. 依赖就绪,进入 LOADING,框架再次调用 apply(ctx)
  5. apply 正常返回,注册生效,重新到达 ACTIVE

这里面有一个必须强调的顺序问题:第 2 步的「清理干净」是第 3 步「重新开始」能安全进行的前提。 如果上一轮的处置器还没执行完,旧注册还挂着,那么新一轮在 apply 里做的注册就会面对一个不干净的上下文。这正是把「DISPOSED 的完成条件」和「自动重载」放在一起看的理由——它们不是两个独立话题,而是同一条链上的前后环节。

另外还有一个实务上的点:既然重载是「重新走一遍加载路径」,那么 apply 就必须是可重入的。也就是说,apply 里的注册动作不应该假设自己只会被执行一次,也不应该依赖「上次执行残留的全局状态」。如果你在 apply 里往某个模块级变量上做累加、或者往一个没有清空的外部列表里 push,重载之后就会看到重复项。这个问题在故障恢复场景下特别隐蔽,因为平时只加载一次,看不出问题,偏偏在服务抖动时暴露。

为了把这条闭环验证清楚,可以准备一份最小化的验证脚本,覆盖「加载→卸载→重载」三态,观察状态迁移是否符合预期:

#!/usr/bin/env bash
# 文件路径:scratch-plugin/scripts/verify-lifecycle.sh
# 用途:反复触发依赖的下线与恢复,观察插件是否能自动完成
# 卸载(DISPOSED)并重新走到 ACTIVE,重点确认没有残留注册。
set -euo pipefail

PLUGIN="scratch-plugin/src/lifecycle-probe.ts"
LOOP=3

echo "== 开始生命周期闭环验证,共 ${LOOP} 轮 =="

for i in $(seq 1 "${LOOP}"); do
  echo "---- 第 ${i} 轮 ----"
  echo "[1/3] 触发依赖就绪,等待插件进入 ACTIVE"
  # 这里替换为你的运行时载入命令
  # 观察点:应看到 apply 被调用、注册生效

  echo "[2/3] 触发依赖下线,等待插件完成卸载"
  # 观察点:应看到处置器执行,且执行完毕后 Fiber 变为 DISPOSED

  echo "[3/3] 再次就绪,确认插件从 PENDING 重新走到 ACTIVE"
  # 观察点:应看到全新一轮的 apply 调用,且注册无冲突

  echo "第 ${i} 轮结束:确认旧注册已清理、新注册无冲突"
done

echo "== 闭环验证结束:若每轮均无重复注册告警,说明状态迁移符合预期 =="

这个脚本骨架里真正需要你填的是三处观察点,而它存在的意义是把「自动重载」从一句描述变成一个可重复执行的断言:每一轮都必须能看到完整的卸载与重新加载,如果某轮只看到加载没看到卸载、或者重载后出现重复注册,那就是状态迁移出了问题。

热重载(HMR)触发卸载时的状态流转与排查清单

到目前为止,UNLOADING 的触发源列了三个:依赖消失、被 dispose、HMR 触发卸载。前两个是运行时的自然变化,第三个是开发期的主动行为,而它恰恰是最容易制造「假卸载」的场合。

原因不难理解。HMR 的工作方式是:你改了源码,新的模块版本要接手,框架需要把旧实例卸掉。于是旧 Fiber 被推入 UNLOADING。但这里存在一个非常经典的心理陷阱——开发者看到「热重载完成」的提示,就默认旧实例已经 DISPOSED 了。实际上,HMR 提示只说明新版本已经加载,不说明旧实例的处置器已经执行完。如果旧实例卡在 UNLOADING,它的注册还留在上下文里,新实例照样能注册成功(因为键可能不同或框架做了容忍),于是你同时拥有了两个插件的副作用:双份定时器、双份监听、双份工具条目。症状往往表现为「改一行代码,日志打印两遍」,而且越热重载越多,最后需要重启进程才恢复。

所以,把 HMR 当作观测 UNLOADING 的最佳测试用例:它不是要你只在 HMR 时才检查,而是因为这里触发最频繁、残留最容易暴露。下面这份清单可以直接用在每次改完插件代码之后:

  1. 确认旧 Fiber 真的走到了 DISPOSED。 判据是「所有处置器执行完毕」,不是「卸载已触发」。如果只看得到进入 UNLOADING 的日志,看不到处置器完成,那问题就在这里。
  2. 确认处置器本身不会悬挂。 任何在处置器里等待异步结果、等待某个回调、或依赖外部服务响应的动作,都可能让 UNLOADING 无限期停留。处置路径上的动作应当以「尽快返回」为原则。
  3. 确认注册被清理干净。 素材说 Fiber 是「卸载时清理注册的依据」。所以检查项是:旧实例注册的工具、监听器、effect 是否都随 Fiber 的 DISPOSED 一并消失,而不是残留成孤儿条目。
  4. 确认新实例是全新的一轮。 重载后的实例从 PENDING 开始,走的是完整加载路径;如果你观察到新实例跳过了某个阶段,或者复用旧实例的内部状态,说明隔离没有做到位。
  5. 确认 apply 可重入。 反复 HMR 之后,检查有没有模块级变量被重复累加、外部列表被重复 push。可重入性是重载场景下的硬性要求,不是加分项。
  6. 确认 FAILED 与 UNLOADING 没有被混淆。 HMR 过程中如果你顺手改坏了 apply,新实例可能直接落到 FAILED。此时既看不到 ACTIVE,也不该看到正常的卸载路径——诊断时要先区分「加载失败」和「卸载残留」这两类完全不同的症状。

为了把「HMR 是否真的停稳」变成可比较的判断,下面这张表把几个关键状态按「进入条件」和「该状态是否意味着可以安全重载」做一次并排对照。它的用法是:当你准备让新实例接手时,去确认旧 Fiber 已经落在最后一行对应的位置。

状态进入条件(依据素材)此时能否安全让新实例接手典型误判
PENDING已声明,但所需依赖未就绪;inject 的服务还没准备好可以——apply 尚未执行,没有任何副作用产生误以为插件「坏了」,实际只是在等依赖
LOADING依赖就绪,正在执行 apply谨慎——注册动作正在进行中,不宜并发接手把「开始执行」当成「已经生效」
ACTIVEapply 正常返回,注册生效不可以——必须先经过卸载路径以为直接覆盖注册即可,忽略清理
FAILEDapply 抛出异常,加载失败需甄别——可能根本没产生有效注册把加载失败当成卸载问题去排查
UNLOADING依赖消失、被 dispose、或 HMR 触发卸载不可以——处置器尚未执行完毕把「开始卸载」当成「已经卸载干净」
DISPOSED所有处置器执行完毕可以——旧注册已清理干净这是唯一可以放心交接的状态

把这张表记牢,HMR 场景下的排查就变成了一个简单的对位动作:看旧 Fiber 停在哪一行,再决定能不能让新的接手。 只要旧 Fiber 不在 DISPOSED,就不要假设上下文是干净的。

嵌套插件场景下的 Fiber 状态联动

单个插件的状态机讲清楚之后,真正贴近 Agent 工程的是嵌套插件场景:一个插件在其 apply 过程中注册或加载子插件,于是上下文中同时存在多个 Fiber,它们之间还带着依赖关系。配套章节的标题里专门点出了「嵌套上下文」,就是因为这一层会显著改变状态推进的顺序。

先说结论层面的判断:每个被加载的插件都拥有一个 Fiber 作用域,素材这句话是嵌套场景的总纲。Fiber 是按插件实例存在的,不是按整个应用存在的。所以父子插件各自持有自己的状态机,父插件的 ACTIVE 不自动等于子插件 ACTIVE,反之亦然。

再来看依赖如何影响推进顺序。因为 inject 声明的是「框架会等这些服务全部就绪后才执行 apply」,那么在嵌套结构里就会出现一条由依赖关系决定的推进链

  • 如果子插件的 inject 里包含父插件需要提供的服务,那么父插件必须先推进到能够提供该服务的状态(按素材定义,就是 apply 正常返回、注册生效即 ACTIVE),子插件才可能从 PENDING 进入 LOADING。此时父的推出先于子的进入,顺序是确定的。
  • 如果父插件在 apply 里注册子插件,但子插件的依赖全部来自别处、并不依赖父,那么子的推进逻辑上与父的 ACTIVE 完成不构成强制先后,父可能还在 LOADING 收尾,子已经在等自己的依赖。
  • 反向卸载时,依赖关系决定顺序:父插件若因依赖消失而进入 UNLOADING,它提供的服务随之不再就绪,依赖它的子插件会跟着被推动进入 UNLOADING。 这就是「依赖消失触发卸载」在嵌套结构里的级联表现。级联的方向是顺着依赖链走的。
  • 级联卸载时,每个 Fiber 各自走各自的后半程。父的 DISPOSED 不保证子的处置器已经执行完毕,子的 DISPOSED 也不保证父已经清理完。所以嵌套场景下,判断「整棵插件树是否停稳」的方法是检查每一个 Fiber 是否都到达 DISPOSED,而不是检查最外层那一个。

这里有一个非常现实的工程坑:级联卸载的顺序与清理动作的顺序不一致时,会造成清理悬空。 举例来说,父插件在 apply 里创建了某个资源、并通过 ctx.effect() 注册了清理;子插件的清理动作又依赖这个资源还存在。如果父的处置器先执行、把资源释放了,子再执行清理时就会拿到一个已经失效的句柄。虽然素材没有给出具体的执行顺序约定,但按状态机的定义,每个 Fiber 的清理边界是由它自己的处置器集合决定的,因此工程上的稳妥做法是:让子插件的清理动作只依赖子插件自己创建的东西,不要跨层依赖父插件的资源;跨层依赖会让清理顺序变成隐性契约。

另一个坑是父的 FAILED 会连锁影响子。如果父插件在 LOADING 阶段抛异常进入 FAILED,它原本要提供的服务就永远不会就绪;依赖它的子插件会一直停在 PENDING,apply 一行都不执行。表面症状是「子插件毫无反应」,但根因在父的 FAILED。排查嵌套问题时的第一动作,应该是顺着依赖链往上看,先确认上游 Fiber 有没有落在 FAILED 或 PENDING,再看下游。只看叶子节点会得出完全错误的结论。

把嵌套场景下的状态联动整理成可对照的次序关系:

场景触发状态推进次序判断整棵树是否停稳的依据
父亲自就绪父的 inject 满足父 PENDING → LOADING → ACTIVE父到达 ACTIVE,注册生效
子依赖父服务父进入 ACTIVE 提供服务父 ACTIVE 前置完成后,子才离开 PENDING子的 apply 被调用且正常返回
父依赖消失父的依赖下线父先进 UNLOADING,其服务随之不再就绪,子被推动进入 UNLOADING每一个受影响的 Fiber 都到达 DISPOSED
父 apply 抛错父在 LOADING 阶段异常父进入 FAILED,其服务始终未就绪,子长期停在 PENDING先定位父的 FAILED,再评估子是否还有加载可能

嵌套场景还有一个容易忽视的观测点:每个 Fiber 的状态是独立可观测的。这意味着当你怀疑一个复杂插件树行为异常时,可以把每个 Fiber 的状态打出来,形成一张「哪个节点卡在哪」的快照。这比对着日志猜要高效得多,也直接引出下一小节的主题。

2026 年 9 月最新实践:以 Fiber 状态机为观测点做插件健康检查

到这里,我们讲清了每个状态的含义、迁移条件,以及正向反向和嵌套三条联动链路。把这些落到当下的工程实践,最有价值的一件事是:别再只用日志级别判断插件健康,改用 Fiber 状态本身作为可观测信号。

原因很直接。日志能告诉你「某行代码被执行了」,但状态能告诉你「这个插件此刻处于生命周期的哪个阶段」。而对 Agent 系统而言,后者才是你真正关心的问题。一个插件是不是在正常工作,等价于问它:现在是 ACTIVE 吗?它卡在 PENDING 还是在 FAILED?它上次卸载有没有真的到 DISPOSED?这些问题都有明确的状态答案,不需要从日志里反推。

按状态做分类诊断,可以形成下面这样一份对照,每一行都对应一个具体的排查动作:

  • 长期停在 PENDING:说明「所需依赖未就绪」。排查方向是沿着 inject 声明的服务名逐个核对——素材例子里的 ['tools', 'llm'] 就是典型形态。确认这两个服务是否真的在上下文中被提供;如果是嵌套结构,还要往上游看是不是父插件卡在 FAILED 导致服务始终未就绪。PENDING 是「在等」,不是「坏了」。
  • 进入 LOADING 后没有到达 ACTIVE:说明 apply 没有正常返回。最常见的是 apply 抛异常,此时会落到 FAILED。按素材定义,FAILED 专指「apply 执行过程中抛错,加载失败」,所以排查目标就是 apply 函数体内的注册逻辑和配置读取逻辑。
  • 看到 FAILED:这是加载失败,不是卸载问题。要重点区分它与 UNLOADING 的排查路径完全不同——前者查「为什么第一次加载就不成」,后者查「为什么清理走不完」。
  • 长期停在 UNLOADING:说明处置器还没有全部执行完毕,因此按定义不能判定为 DISPOSED。排查目标是处置器集合里有没有会悬挂的动作。
  • 未到达 DISPOSED 就以为已卸载:这是本文反复强调的核心误判,直接对应重复注册与资源泄漏两类问题。

把这套观测做成一个轻量的健康检查输出,可以落成下面这段可直接粘贴运行的 TypeScript 片段。它不去连接任何具体实现细节,而是以「状态快照 + 断言」的方式,把状态机转成可执行的检查:

// 文件路径:scratch-plugin/src/health-check.ts
// 用途:以 Fiber 状态机为观测点,对插件做健康检查。
// 关注三类异常信号:长期 PENDING、出现 FAILED、长期 UNLOADING。

type FiberState =
  | 'PENDING'
  | 'LOADING'
  | 'ACTIVE'
  | 'FAILED'
  | 'UNLOADING'
  | 'DISPOSED'

interface FiberSnapshot {
  plugin: string
  state: FiberState
  // 依赖声明,来自插件的 inject 字段
  inject: string[]
}

// 健康判据:
// - PENDING:说明依赖尚未就绪,属于「在等」,需要核对 inject 中列出的服务
// - ACTIVE:apply 已正常返回、注册生效,是插件的正常工作状态
// - FAILED:apply 执行过程中抛错,属于加载失败
// - UNLOADING:处置器尚未执行完毕,不能判定为已卸载
// - DISPOSED:所有处置器执行完毕,这才是可以安全重载的状态
export function checkFiberHealth(snapshot: FiberSnapshot): string[] {
  const issues: string[] = []

  if (snapshot.state === 'PENDING') {
    issues.push(
      `[${snapshot.plugin}] 停留在 PENDING:依赖 ${snapshot.inject.join(
        ', '
      )} 未全部就绪,apply 不会执行`
    )
  }

  if (snapshot.state === 'FAILED') {
    issues.push(
      `[${snapshot.plugin}] 进入 FAILED:apply 执行抛错,需检查注册逻辑与配置`
    )
  }

  if (snapshot.state === 'UNLOADING') {
    issues.push(
      `[${snapshot.plugin}] 停留在 UNLOADING:处置器未全部执行完毕,尚不能判定 DISPOSED`
    )
  }

  if (snapshot.state === 'ACTIVE') {
    issues.push(`[${snapshot.plugin}] 正常:注册已生效`)
  }

  if (snapshot.state === 'DISPOSED') {
    issues.push(`[${snapshot.plugin}] 已停稳:处置器全部执行完毕`)
  }

  return issues
}

这段代码的工程价值在于它把「状态机知识」变成了「可复用的断言」。你可以把它接到重载前后各调一次,形成一次完整的交接检查:重载前要求所有旧 Fiber 是 DISPOSED,重载后要求目标 Fiber 到达 ACTIVE。任一条不满足,就说明状态迁移不符合预期。

再往前一步,可以给健康检查加上时间维度:PENDING 和 UNLOADING 都应当只被允许短暂存在。当某个 Fiber 在 PENDING 停留超过你设定的阈值,最可能的解释是它的依赖永远不会来了(例如上游 FAILED);当某个 Fiber 在 UNLOADING 停留超过阈值,最可能的解释是处置器悬挂。把「状态 + 持续时间」作为告警条件,比单纯看状态更接近根因。素材虽然没有给出具体的时间数值,但「长期停留于中间态即异常」这个判断方式本身是从状态定义直接推出的——因为 PENDING 的解除条件是依赖就绪、UNLOADING 的解除条件是处置器执行完毕,这两个条件在正常路径下都应当很快达成。

最后补一个面向团队协作的建议:把 inject 当作插件的对外契约来审阅。依赖声明写错或写少,症状往往不是报错,而是长期 PENDING 或过早 ACTIVE 后使用到未就绪的服务。 前者会让插件悄悄不工作,后者会在运行时爆出难以定位的错误。让每个插件显式、完整地声明它需要什么,是让整棵插件树状态可预测的前提。这套「以状态机为观测点」的做法,落到 2026 年当下的工程语境,就是一句朴素的话:能用一个状态回答的问题,不要用一堆日志去猜。

总结与最佳实践

把全文压缩成一份可以贴在工位上的执行清单:

  • 认识 Fiber 是状态容器。 每个被加载的插件都拥有一个 Fiber 作用域,它承载插件从声明、加载、运行到卸载的全部状态,也是卸载时清理注册的依据。多个插件 = 多个独立 Fiber,嵌套场景尤其如此。
  • 背下主路径。 PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED,以及 LOADING 阶段 apply 抛错时进入 FAILED 的旁路。
  • 分清每个状态的进入条件。 PENDING = 已声明但依赖未就绪;LOADING = 依赖就绪、正在执行 apply;ACTIVE = apply 正常返回、注册生效;FAILED = apply 抛错;UNLOADING = 依赖消失、被 dispose 或 HMR 触发;DISPOSED = 所有处置器执行完毕。
  • 永远不要把「开始卸载」当成「已经卸载干净」。 UNLOADING 是进行时,只有处置器全部执行完毕才算 DISPOSED。这是全文最重要的一条。
  • 理解 inject 是双向约束。 依赖就绪推动 PENDING → LOADING;依赖消失推动 ACTIVE → UNLOADING。加载顺序靠依赖表达,不需要手动编排启动顺序。
  • 依赖恢复后走的是完整闭环。 服务重新就绪,插件从 PENDING 重新经历加载路径到达 ACTIVE,形成可自愈的状态闭环——是「重新开始一轮」,不是「原地恢复」。
  • 假设 apply 可重入。 不要在 apply 里做模块级累加或往未清空的外部列表写入,否则重载后会看到重复副作用。
  • 把 HMR 当成最频繁的卸载测试。 每次改完插件代码,按清单核对:旧 Fiber 是否到达 DISPOSED、处置器是否悬挂、注册是否清理干净、新实例是否从 PENDING 走全新一轮、apply 是否可重入、FAILED 与 UNLOADING 是否被混淆。
  • 嵌套场景顺着依赖链排查。 上游 ACTIVE 是下游离开 PENDING 的前提;上游依赖消失会级联推动下游 UNLOADING;上游 FAILED 会让下游长期 PENDING。判断整棵树停稳,要确认每一个 Fiber 都到达 DISPOSED。
  • 用状态做可观测性。 以 PENDING / LOADING / ACTIVE / FAILED / UNLOADING / DISPOSED 为观测信号,配合持续时间阈值,把「长期停在中间态」当作异常信号;检查项落在「依赖是否齐、apply 是否抛错、处置器是否执行完」三个问题上。
  • 发布前跑一遍闭环验证。 反复触发依赖下线与恢复,要求每一轮都完整看到「卸载到 DISPOSED、重载到 ACTIVE」,且没有重复注册告警。
  • 把 inject 当契约审阅。 依赖声明缺失或写错,症状常常是长期 PENDING 或过早 ACTIVE 后用到未就绪的服务,而不是明显的报错。