在前面的基础篇中,我们学会了如何写一个插件、给它添加工具、配置参数,最后把它打包成一个可分享的组合包。这一路走来,你一直在“用”插件——但你可能还不清楚,当框架加载你的插件时,背后到底发生了什么。

这一章,我们将揭开这层幕布。

每一个 Harness 插件,从被框架发现到最终被卸载,都会经历一条完整的状态流转路径。这条路径由 Cordis 框架精确管理,确保插件在正确的时机加载、在依赖就绪后启动、在需要时安全卸载。

理解这条路径,不仅是深入 Harness 开发的必经之路,更是你排查问题、设计健壮插件的核心能力。读完本章,你将知道:为什么我的插件没加载?为什么它打印不出日志?HMR为什么能工作? ——这些问题的答案,都在插件的生命周期里。

什么是 Fiber?——每个插件都有的“身份证”

在 Cordis 框架中,每一个被加载的插件实例都拥有一个 Fiber。

Fiber 是什么?简单来说,它是插件在运行时的一个记录对象,里面存着这个插件的所有重要信息:

  • 当前处于什么状态(PENDING、LOADING、ACTIVE……)。

  • 父上下文是谁(这个插件是由谁创建的)。

  • 依赖了哪些服务(inject 中声明的那些)。

  • 配置的快照(加载时使用的配置值)。

  • 所有需要清理的资源(通过 ctx 注册的一切)。

可以把 Fiber 想象成插件的“身份证”和“档案袋”——它记录了插件从诞生到消失的全过程。框架通过 Fiber 来管理每个插件,就像学校通过学生档案来管理每个学生一样。

给初中级开发者的提示:你通常不需要直接操作 Fiber——框架替你管理好了。但理解 Fiber 的存在,能帮你理解“框架是怎么知道插件当前在干什么的”。

六种状态:插件的一生

一个插件从被声明到最终消失,会经历六个状态。我们用一条主线串起来看:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                      ↘ FAILED
PENDING(等待中)——插件被发现了,但还不能启动

这是插件的“出生”状态。框架已经知道了这个插件的存在——比如在 cordis.yml 中声明了它——但还不能调用它的 apply 函数。

为什么不能?最常见的原因是:依赖的服务还没准备好。

比如你的插件写了 inject = ['tools', 'llm'],那么框架会等到 tools 服务和 llm 服务都成功加载并进入 ACTIVE 状态后,才会把你的插件从 PENDING 移入 LOADING。

给初中级开发者的提示:如果你启动 Harness 后发现某个插件“没反应”,日志里也找不到它的加载信息——先去检查它的 inject 声明的那些服务是不是都正常加载了。这是 PENDING 状态最常见的场景。

LOADING(加载中)——apply 正在执行

依赖已经就绪了,框架开始调用你的 apply 函数。

在这个状态下:

  • 你的 apply 函数中的代码正在执行。

  • 你在 apply 里通过 ctx 注册的所有东西——工具、事件监听、服务——正在被一一注册。

  • 如果 apply 执行过程中抛出了异常,插件会直接进入 FAILED 状态。

LOADING 是一个瞬时状态——它只在 apply 函数执行期间存在。一旦 apply 正常执行完毕,插件就进入了 ACTIVE。

ACTIVE(运行中)——插件正常工作

这是插件的“黄金时期”。apply 已经执行完毕,所有注册都已完成,插件正在正常运行。

在这个状态下:

  • 插件注册的工具可以被模型调用。

  • 插件注册的事件监听器可以接收事件。

  • 插件提供的服务可以被其他插件消费。

插件会一直停留在 ACTIVE 状态,直到触发卸载——可能是配置变更、HMR、手动卸载,或者它依赖的某个服务消失了。

FAILED(失败)——apply 执行出错

如果 apply 函数抛出了错误,或者配置校验失败,插件会进入 FAILED 状态。

进入 FAILED 后,插件不会再被重试。框架会记录错误信息,并继续加载其他插件。你需要修复代码或配置后,重新启动(或触发HMR)来让插件重新加载。

重要提示:FAILED 和 UNLOADING 是两条不同的路径。FAILED 是从 LOADING 直接跳转过来的——插件还没完全启动就失败了。而 UNLOADING 是从 ACTIVE 跳转过来的——插件正常运行后被卸载。

UNLOADING(卸载中)——清理正在进行

当插件需要被卸载时——无论是因为配置变更、HMR、手动调用 fiber.dispose(),还是因为依赖的服务消失了——框架会将插件从 ACTIVE 移入 UNLOADING。

在这个状态下:

  • 所有通过 ctx 注册的资源开始被清理。

  • 事件监听器被移除。

  • 工具被注销。

  • ctx.effect() 中注册的清理函数被执行。

清理的顺序是:反向注册顺序——后注册的先清理。但多个异步清理函数是并发执行的,没有串行完成的保证。如果你有依赖顺序的清理逻辑,应该把它们放在同一个 ctx.effect() 中,在清理函数内部按顺序执行。

DISPOSED(已销毁)——完全消失

所有清理工作都完成了。插件已经从框架中完全移除,所有资源都已释放。

DISPOSED 是插件的终点状态。到了这里,插件就像从来没有存在过一样——没有残留的监听器、没有泄露的内存、没有悬空的定时器。

状态流转图

把上面六个状态串起来,就是完整的生命周期:

                        ┌─────────── ──────────┐
                        │                                           │
                        ▼                                           │
PENDING ──依赖就绪──▶ LOADING ──apply成功──▶ ACTIVE ──触发卸载──▶ UNLOADING ──清理完成──▶ DISPOSED
                             │                                               │
                             └──apply失败──▶ FAILED ◀─────────┘
                                                     │
                                                     └──(修复后重新加载,回到 PENDING)

两条关键路径:

  1. 正常路径:PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED。

  2. 失败路径:PENDING → LOADING → FAILED(从 LOADING 直接跳转)。

依赖驱动的加载:为什么 inject 如此重要

我们在之前的章节中用过 inject=['tools'],但当时只是简单提了一句“框架会确保服务就绪”。现在有了生命周期的视角,我们可以更深入地理解它的工作原理。

当一个插件声明了 inject,框架会做这样几件事:

  1. 检查所有依赖的服务:看看 tools、llm 这些服务是否已经在 ACTIVE 状态。

  2. 如果都就绪:把插件从 PENDING 移入 LOADING,调用 apply。

  3. 如果有任何一个未就绪:插件停留在 PENDING,等待。

  4. 如果某个依赖服务在运行时消失了(比如被HMR了):插件自动从 ACTIVE 回到 DISPOSED(注意,这里不走 UNLOADING 的完整路径,而是直接销毁),然后当服务恢复时再重新加载。

第4点尤其重要:依赖服务的变化会自动触发依赖方插件的重新加载。你不需要写任何代码来“监听服务变化”或“重新初始化”——框架替你做了。

给初中级开发者的理解:可以把 inject 理解成一种“契约”。你告诉框架“我需要什么”,框架保证“在你开始工作之前,这些东西都准备好了;如果这些东西中途出了问题,我会先让你休息,等它们恢复后再让你重新上班”。

这种设计让插件之间的协作变得极其稳健。你永远不需要担心“我调用 ctx.tools 的时候它还没准备好”这种问题。

自动清理:为什么你不用手动写 cleanup

我们在第一章就接触过“自动清理”这个概念——通过 ctx 注册的任何东西,在插件卸载时都会被自动清理。

现在,结合生命周期,我们可以更完整地理解这个机制。

什么是 Effect?

在 Cordis 中,所有通过 ctx API 进行的注册都被称为 Effect。Effect 的核心特征是:注册时绑定到当前插件的 Fiber,卸载时自动撤销。

以下这些操作都是 Effect:

操作

卸载时自动发生

ctx.on(event, handler)

事件监听器被移除

ctx.tools.register(tool)

工具被注销

ctx.llm.registerAdapter(...)

LLM 适配器被注销

ctx.plugin(childPlugin)

子插件被递归卸载

ctx.effect(() => cleanup)

返回的清理函数被执行

你几乎不需要手动调用 ctx.effect() ——因为内置的注册 API 本身就已经是 Effect 了。只有在管理框架不感知的资源时——比如原生 Node.js 的定时器、TCP 连接、文件监听器——才需要用 ctx.effect() 包装。

清理的顺序

卸载时,清理按反向注册顺序执行——后注册的先清理。但要注意:多个异步清理函数是并发执行的,没有串行完成的保证。

如果你的清理逻辑有顺序依赖(比如必须先关闭数据库连接、再关闭连接池),应该把它们放在同一个 ctx.effect() 中:

ctx.effect(() => {
    const conn = createConnection()
    const pool = createPool(conn)
    return async () => {
        // 按顺序清理:先关 pool,再关 conn
        await pool.close()
        await conn.close()
    }
})

这样就能保证清理顺序可控。

嵌套上下文:插件里面套插件

我们在基础篇中写的所有插件,都是直接通过 cordis.yml 加载的——它们是顶层插件。

但 Cordis 还支持一种更灵活的模式:在插件内部动态加载子插件。

import type { Context } from '@deepseek-ai/cordis'

export const name = 'parent-plugin'

export function apply(ctx: Context) {
    // 在父插件中动态加载一个子插件
    const fiber = ctx.plugin(function childPlugin(ctx: Context) {
        console.log('child plugin loaded!')
    })
    
    // fiber 是子插件的运行时句柄
    // 可以通过 fiber.dispose() 手动卸载它
}

ctx.plugin() 做了两件事:

  1. 创建子 Fiber:子插件拥有自己独立的 Fiber 和生命周期。

  2. 继承父上下文:子插件可以访问父插件的上下文——包括父插件注册的服务。

子插件和父插件的关系
  • 子插件有自己的生命周期状态(PENDING → LOADING → ACTIVE → ...)。

  • 子插件可以有自己的 inject 依赖。

  • 当父插件被卸载时,所有子插件会被递归卸载。

  • 你也可以通过 fiber.dispose() 手动提前卸载子插件。

这种嵌套机制让插件可以动态组合——一个插件可以根据运行时的条件,决定是否加载某些子功能。

手动卸载:fiber.dispose()

有时候,你可能想在插件生命周期结束之前主动卸载它。比如:

  • 某个功能在特定条件下不再需要了。

  • 你想在测试中验证插件的清理逻辑。

  • 你需要手动控制资源的释放时机。

这时可以用 fiber.dispose():

import type { Context } from '@deepseek-ai/cordis'

export function apply(ctx: Context) {
    // 加载一个子插件,拿到它的 fiber
    const fiber = ctx.plugin(somePlugin)
    
    // 某个条件满足时,手动卸载它
    ctx.on('some-event', async () => {
        await fiber.dispose()
        console.log('plugin manually unloaded')
    })
}

fiber.dispose() 的保证:

  • 插件拥有的所有注册都被移除。

  • 子插件被递归卸载。

  • 返回的 Promise 在所有异步清理完成后才 resolve。

这意味着 await fiber.dispose() 之后,你可以确信这个插件已经完全消失了。

HMR:生命周期的实战应用

我们在基础篇的打包与安装中简单提过HMR。现在,结合生命周期,你可以真正理解它是怎么工作的了。

当 @deepseek-ai/cordis-plugin-hmr 插件被加载后,它会在后台监控你的源文件变化。当你保存一个文件时,HMR 插件会做这样几件事:

  1. 卸载旧插件:将旧版本的插件从 ACTIVE 移入 UNLOADING,执行所有清理。

  2. 清除模块缓存:清除 Node.js 中对应模块的缓存。

  3. 加载新代码:重新加载修改后的文件。

  4. 运行新的 apply:用新代码创建一个新插件,走一遍完整的生命周期。

为什么 HMR 能工作得这么干净?

因为所有注册都是 Effect。卸载旧插件时,所有通过 ctx 注册的资源都被自动清理了。新插件加载时,一切从零开始注册。不会有“旧监听器残留”或“工具重复注册”的问题。

你不需要为 HMR 写任何特殊代码——你写的插件天然就支持 HMR。

给初中级开发者的提示:HMR 是开发阶段的神器。修改代码后保存,Harness 会自动重新加载你的插件,你不需要手动重启服务。这在调试和迭代时能节省大量时间。

总结

本章我们深入了 Cordis 框架的核心——插件的生命周期管理。核心收获如下:

概念

说明

Fiber

每个插件实例的运行句柄,记录状态、依赖、配置和资源

六种状态

PENDING → LOADING → FAILED/ACTIVE → UNLOADING → DISPOSED

PENDING

依赖未就绪,等待中

LOADING

依赖就绪,apply 正在执行

ACTIVE

插件正常运行

FAILED

apply 执行出错

UNLOADING

正在清理资源

DISPOSED

完全卸载

依赖驱动加载

声明 inject 后,框架确保依赖服务就绪才加载

Effect

通过 ctx 的注册操作,自动绑定到当前 Fiber,卸载时自动撤销

嵌套上下文

ctx.plugin() 创建子插件,继承父上下文但有独立生命周期

fiber.dispose()

手动卸载插件,返回的 Promise 在清理完成后 resolve

HMR

利用生命周期机制,修改代码后自动卸载旧插件、加载新插件

理解生命周期,你就理解了 Harness 插件系统的“骨架”。无论是排查问题、设计插件架构,还是理解热更新、依赖注入等高级特性,这些知识都是基础中的基础。

下一章,我们将学习如何让插件对外提供服务,以及如何通过依赖注入构建可扩展的插件体系。