In the Cordis plugin model, the span between a plugin being "declared" and being "completely cleaned up" is not a vague runtime period, but an observable, traceable, and reproducible state-transition chain. The carrier of this chain is the Fiber: it is both the execution unit of a plugin instance and the sole basis on which the framework decides "what can be done now and what should be done next." Understanding the Fiber state machine directly determines whether you can write dependency-driven plugins well, whether you can let resources enter and exit cleanly in hot-reload (HMR) scenarios, and whether you can avoid dangling listeners and leaked connections when the application unloads. The first 1/2 of this article focuses on the state machine itself: the definition of the Fiber scope, a segment-by-segment breakdown of the five states on the main path, the entry conditions for FAILED, and how the inject field turns "manually orchestrating startup order" into "declarative dependency-driven behavior." The second 2/2 will continue with a deeper dive into nested plugin contexts and the complete closed loop of automatic reloading. It must be emphasized that all conclusions in this article rest on the same set of facts: state transitions form a unidirectional main chain, dependencies are the driving force behind transitions, and the wrap-up of dispose must be carried out one by one along the Fiber index.

What exactly is the Fiber scope: the state container and cleanup basis of a plugin instance

To understand Fiber, you must first distinguish it from "a .ts file" and "an exported function." At the source level, what you write is a module and an apply(ctx) function, but inside the Cordis runtime, each plugin loaded creates a corresponding Fiber scope. The Fiber records the lifecycle state the plugin instance is currently in and holds all the registration traces the instance produces at runtime—listeners, tools, and the various side effects registered via ctx.effect(). In other words, the Fiber is not the plugin's "code," but the plugin's runtime identity for "this particular load."

Calling it an execution unit has engineering implications. A plugin may be loaded multiple times within a single process lifecycle: the first time it starts normally, the second time it is reloaded due to hot reload, and the third time it is loaded again because some dependent service briefly disappeared and then recovered. Across these three loads, the module code is the same, but the framework needs three mutually isolated Fibers to carry their respective states and resources. Without an isolation unit like the Fiber, unloading would be unable to answer a key question: which batch of registrations is it that needs to be cleaned up this time?

This is precisely why the Fiber also serves as the cleanup basis. When a plugin enters the unload process, the framework does not need to guess what the plugin registered, nor does it need the plugin author to hand-write unregistration code to align the order. The framework directly uses that Fiber as an index and executes the disposers registered under its name one by one. The reason ctx.effect() is meaningful is precisely that it attaches "side effects" to the current Fiber scope, so side effects naturally acquire the semantics of "disappearing along with the plugin." You can understand this model as: the Fiber is the ledger of registrations, apply is the process of bookkeeping, and dispose is the process of writing off entries one by one according to the ledger.

The figure below summarizes the complete state flow of a Fiber from being declared to being destroyed; please read it alongside the segment-by-segment breakdown later in the text.

示意图
Fiber state machine: all states and transition directions of a plugin instance from declaration, loading, and running to unload cleanup.

To keep "the Fiber is a state container" from remaining abstract, let's first look at a runnable skeleton code snippet. It shows the information the plugin side needs to provide to the framework: an optional inject dependency declaration, and an apply that performs registration actions. You do not need to manually call any "load" or "unload" methods here—state transitions are advanced by the framework based on the Fiber.

// File path: scratch-plugin/src/fiber-observe.ts
import type { Context } from 'cordis'

// inject declares the services this plugin requires; the framework waits until all of them are ready before advancing to LOADING
export const inject = ['tools', 'llm']

export function apply(ctx: Context) {
  // Reaching the inside of apply means tools and llm are already ready (see the PENDING → LOADING criteria below)
  // Everything registered here is recorded under the current Fiber and released uniformly via the Fiber index on unload

  // 1) Register a tool: it comes to life with the plugin's ACTIVE state and dies with DISPOSED
  ctx.tools.register({
    name: 'echo_probe',
    description: 'Echoes the probe parameter, used to verify whether the plugin is ACTIVE',
    parameters: {
      type: 'object',
      properties: { text: { type: 'string' } },
      required: ['text'],
    },
    async execute(input: { text: string }) {
      return { ok: true, echoed: input.text }
    },
  })

  // 2) Register a side effect: ctx.effect() attaches the cleanup function to the current Fiber
  const timer = setInterval(() => {
    /* periodic health check */
  }, 30_000)

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

The most noteworthy thing about this code is that there is no symmetric "unregister" code inside apply at all. Cleanup actions are collected by ctx.effect() into the Fiber's ledger and settled all at once when the Fiber enters the unload phase. The direct benefit of this design is that plugin authors don't need to maintain ordering consistency between "what was registered" and "what to unload"—the order is determined by the framework based on the Fiber's records, not by a mental model hand-written by a person.

Segment-by-segment breakdown of the main path PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED

Fiber state transitions have a clear main path: PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED. This is a one-way forward chain, and understanding the word "one-way" matters more than memorizing the five names: states do not roll back, and after unloading there is no return to ACTIVE; if the same plugin needs to run again, that is a new load and a new Fiber, not the resurrection of the old Fiber.

Below, in transition order, we explain each state's preconditions and triggering actions clearly.

PENDING (declared, dependencies not ready). The plugin has already been added to the context, and the framework already knows it exists, but not all of the services declared by its inject are ready yet. At this point apply will not be called even once. PENDING is entered at the moment the plugin is added to the context while the inject services are not yet ready. It is not a transient state, but can be a waiting state that lasts a long time—if some dependency service never appears in the current runtime environment, the plugin will remain here for a long time, waiting quietly, rather than erroring out or being force-started on a timeout.

LOADING (dependencies ready, apply is executing). Once all required services are ready, the framework advances the Fiber from PENDING to LOADING, and in this state it calls apply(ctx). There is a detail here that is easy to overlook: LOADING covers the entire time window during which "apply is executing." In other words, if apply is a synchronous function, LOADING may be extremely brief; if apply contains asynchronous waits internally, LOADING will be correspondingly prolonged. Within this window, the plugin has already crossed the dependency threshold, but has not yet been recognized as running.

ACTIVE (plugin running). The trigger condition is apply returning normally. Only after it returns do the registrations completed by the plugin in apply actually take effect, and the Fiber enters ACTIVE. This point is the key watershed for understanding the entire state machine: before apply returns, registration actions are "already executed" but not yet recognized as "already in effect." Binding registration effectiveness to apply's return buys consistency at unload time—as long as the Fiber is in ACTIVE, you can be certain that the registrations on its ledger are a complete batch of records corresponding to the same successful load.

UNLOADING (plugin is unloading and releasing resources). There are three trigger sources for entering the unload state: a dependent service disappearing, being explicitly disposed, or HMR triggering an unload (Section 3 will elaborate on each). UNLOADING simultaneously bears two responsibilities: "state rollback" and "resource release." The framework marks the Fiber as unloading while executing cleanup along the Fiber index. Precisely because cleanup and state marking occur in the same phase, the unload process itself is also observable.

DISPOSED (fully unloaded). The trigger condition is that all disposers have finished executing. At this point, the listeners, tool registrations, and ctx.effect() cleanup functions under this Fiber's name have all finished executing, and the lifecycle of this plugin instance is formally terminated. After this, no action will fall on this Fiber again, unless it is reloaded as a new Fiber.

Here is a summary table of the key information for the five states, for easy cross-reference during troubleshooting:

StateMeaningEntry/trigger timingWhether apply has been calledWhether registrations are in effect
PENDINGDeclared, dependencies not readyPlugin joins the context, the injected services are not yet readyNoNo
LOADINGDependencies ready, apply is executingAll required services ready, the framework calls apply(ctx)Yes, currently executingNo
ACTIVEPlugin runningapply returns normally, registrations take effectYes, has returned normallyYes
FAILEDapply threw an exceptionAn error is thrown during apply execution, load failsYes, but exited abnormallyNo
UNLOADINGPlugin is unloading and releasing resourcesDependency disappears, is disposed, or HMR triggers unloadYesBeing revoked
DISPOSEDFully unloadedAll disposers have finished executingYesAll revocations complete

In this table, what deserves repeated scrutiny is the misalignment between the two columns "whether apply has been called" and "whether registration has taken effect." Between LOADING and ACTIVE lies a single function return, and the "taking effect" of registration falls exactly into this gap. Understand this gap, and you understand why many plugin bugs surface during hot reload.

After apply throws: the entry conditions for FAILED state and subsequent handling

The main path describes a smooth life, but engineering reality always has exceptions. Fiber prepares a dedicated state for this situation: FAILED. Its entry condition is very clear—during the LOADING phase, an exception is thrown while apply is executing, and loading fails. Note that the trigger here is "apply throws," not "missing dependency," nor "error during unload." Missing dependencies correspond to remaining in PENDING for a long time; problems during unload are matters of the UNLOADING/DISPOSED phase.

The difference between FAILED and the unload path deserves to be discussed separately. UNLOADING follows the route of "was once ACTIVE, now winding down," and its semantics are to revoke registrations that have taken effect, with cleanup based on entries already recorded in the Fiber ledger. FAILED, on the other hand, follows the route of "fell before it ever truly stood up": apply has not yet returned normally, and registrations have not yet been recognized as effective. Therefore, the focus of FAILED handling is not "revocation" but "cleaning up the residue left behind when apply was halfway through execution." This distinction is very practical: if the first half of apply registered a tool and the second half threw an error while reading model configuration, then from the framework's perspective, this load failed, and that tool registration should not continue to exist as an effective registration.

For plugin authors, FAILED provides a clear warning signal: any initialization action that might throw should be treated as a candidate trigger point for FAILED. Common failure sources include: missing fields when reading model configuration, failure to establish external connections, and assumptions about dependent services not holding. A special reminder: since apply throwing directly causes load failure, putting the initialization of "optional features" on the mandatory path of apply will drag the entire plugin into FAILED. A more robust engineering approach is to separate mandatory dependency initialization from optional enhancements: missing mandatory dependencies should naturally leave the plugin in PENDING; failures of optional enhancements should be degraded within apply rather than allowed to throw.

The following code demonstrates how to separate "may fail" initialization from "must succeed" registration, avoiding a hiccup in an optional capability from marking the entire plugin as FAILED:

// File path: scratch-plugin/src/apply-guard.ts
import type { Context } from 'cordis'

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

export function apply(ctx: Context) {
  // Required registration: if these actions throw, it means the plugin's core capability is unavailable, and letting it enter FAILED is reasonable
  ctx.tools.register({
    name: 'strict_tool',
    description: 'Core tool; registration failure is treated as plugin load failure',
    parameters: { type: 'object', properties: {}, required: [] },
    async execute() {
      return { ok: true }
    },
  })

  // Optional enhancement: degrade on initialization failure; never let the exception bubble out and cause FAILED
  try {
    const modelConfig = ctx.llm.config
    if (modelConfig?.enableProbe) {
      ctx.effect(() => {
        /* cleanup registered only when the enhancement is enabled */
      })
    }
  } catch (err) {
    // Degrade: log only, do not throw. The plugin can still enter ACTIVE normally
    ctx.logger?.warn?.('[apply-guard] Optional enhancement initialization failed, degraded', err)
  }
}

From this code we can distill a decision rule: for every call in apply that "might throw," you must first decide clearly whether it should be classified as FAILED or should be absorbed in place. If the main path defines the healthy life of a plugin, then FAILED defines the boundary that most deserves respect in an unhealthy life.

Semantics of the inject field: declare dependencies rather than manually orchestrate startup order

The driving force running through the entire state machine transition is the inject field on the plugin. Its semantics need to be stated very precisely: inject is the field a plugin uses to declare which services it needs; the framework reads it and decides accordingly when to call apply. Please be sure not to understand inject as "a list of callbacks after loading completes"—it describes a set of preconditions, not a sequence of actions.

The architectural shift this field brings is turning startup order from imperative orchestration into declarative constraints. In the traditional approach, you need to manually initialize services in order at some entry point, then start the modules that depend on them one by one; if the order is wrong, it errors, and if the order changes, you have to edit the orchestration code. Under the Cordis model, however, a plugin only declares "I need tools and llm," and the framework is responsible for advancing the Fiber to LOADING after all required services are ready. Startup order is no longer determined by some "main function," but is implicitly determined by the dependency relationships themselves.

The benefits of this shift can be listed as follows:

  • Order correctness is guaranteed by the framework: there is no need to write "start A first, then B" into a manually maintained startup script, reducing sporadic failures caused by writing the order incorrectly.
  • Plugins can be moved independently: because dependencies are expressed through inject, a plugin can be migrated between different contexts without also having to carry along a piece of initialization-order code.
  • Disappearance of a dependency triggers unload: declarative dependencies are bidirectional—the appearance of a service drives loading, and the disappearance of a service drives unloading, which lays the foundation for automatic reloading (see section 2/2 for details).
  • PENDING becomes an observable signal: if a plugin does not enter LOADING for a long time, it directly indicates that some dependent service is not yet ready, making the direction of investigation very clear.

A common misunderstanding needs to be clarified: inject declares required dependencies. From the behavior given in the material, the framework waits until all services listed in inject are ready before executing apply. Therefore, do not stuff "optional enhancements" into inject—doing so turns an optional capability into a loading threshold, and once that service does not appear, the plugin stays in PENDING forever, making troubleshooting even harder.

The root cause of remaining stuck in PENDING: apply is never executed when dependencies are not ready

PENDING is the state most easily misjudged in the entire chain. The surface symptom is "the plugin seems unresponsive," but the real reason is that the plugin has already been added to the context, but the required service has not yet appeared, so the framework does not advance the state, and apply will never be executed. There are two key points here that need to be emphasized separately.

First, "no response" does not equal "an error occurred." A plugin in PENDING throws no exception and will not enter FAILED. It is simply waiting honestly. Many beginners treat this situation as the plugin being written incorrectly, or the loader not taking effect, and thus go on to suspect the module path or the export method, wasting a great deal of time. The correct first reaction is to check whether the services listed in inject will actually be provided in the target environment.

Second, "it never appears" means "it stays in PENDING forever." The material clearly points out that if the dependent service never appears, the plugin remains in PENDING and apply will not be executed. No timeout mechanism or forced loading path is described here, so do not expect the framework to "help bring the plugin up" after some period of time. This design is actually conservative and correct: if a plugin declares that it needs the llm service, then forcibly executing apply in the absence of llm will only cause the plugin to repeatedly encounter undefined behavior at runtime.

Treating PENDING as a diagnostic tool can greatly improve troubleshooting efficiency. Below is a checklist for "a plugin stuck in PENDING"; executing it in order usually pinpoints the issue quickly:

  1. Confirm that the plugin's module is actually included in the context by the loader (otherwise it wouldn't even enter PENDING).
  2. Check item by item whether the service names listed in inject exactly match the names declared by the plugins that actually provide those services—a service name mismatch is a common cause of silent lingering.
  3. Confirm that the plugin providing the dependency service is not itself stuck in PENDING, otherwise the upstream of the dependency chain will be blocked as a whole.
  4. If that dependency truly will not be provided in a specific environment (for example, some streamlined runtime mode), consider removing it from inject and instead performing capability detection inside apply.

Behind this checklist is an important mental model: PENDING is an honest reflection of the dependency graph. If a plugin lingers in PENDING for a long time, it means an edge in the dependency graph has never been satisfied; the problem is most likely not in the plugin itself, but in the supplier of the dependency.

The criterion for entering LOADING: apply(ctx) is called only when all required services are ready

There is only one threshold from PENDING to LOADING, but it must be strictly satisfied: all required services declared in inject are ready. Once this criterion is met, the framework advances the Fiber to LOADING and initiates the apply(ctx) call. The keywords here are "all" and "only"—as long as even one dependency is not ready, it will continue to stay in PENDING; only when all are ready will this call be triggered.

This criterion is important because it gives plugin authors a very strong guarantee. Consider the example comment given at the beginning of this article: "By the time execution reaches here, ctx.tools and ctx.llm must already be ready." This is not an optimistic assumption, but a conclusion directly derived from the state machine criterion. In other words, inside apply there is no need to write defensive checks for "whether dependencies are ready"—if the dependencies are not ready, apply has no chance to execute at all. This can significantly simplify plugin implementation: you do not have to write protective branches like if (!ctx.tools) return.

But the other side of the guarantee is responsibility. Since apply being able to execute means the dependencies are complete, the inside of apply should focus on "using these dependencies to complete registration" rather than being distracted by handling missing dependencies. Leaving dependency-checking code in apply is not only redundant, but also masks real assembly errors—for example, if you mistakenly assume tools is optional and write a fallback branch, then when tools is truly not provided, the plugin silently degrades and the problem is hidden instead.

The following code puts the guarantee "entering LOADING means dependencies are complete" to use, demonstrating a style that requires no defensive checks while retaining explicit detection of optional capabilities:

// File path: scratch-plugin/src/loading-contract.ts
import type { Context } from 'cordis'

// These two are required dependencies: the framework guarantees apply is called only after both are ready
export const inject = ['tools', 'llm']

export function apply(ctx: Context) {
  // No need for if (!ctx.tools) return —— reaching this point proves both tools and llm are ready
  // Therefore you can directly read the model config and directly register tools

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

  ctx.tools.register({
    name: 'model_probe',
    description: `Reports the current model identifier; the model read at registration time is ${modelName}`,
    parameters: { type: 'object', properties: {}, required: [] },
    async execute() {
      return { model: modelName }
    },
  })
}

Looking at this piece of code together with the earlier discussion about FAILED, we can sketch out the complete contract of the LOADING phase: on entry, dependencies are guaranteed to be in place; on exit, it either returns normally (ACTIVE) or throws an error (FAILED). There is no third exit.

What ACTIVE means: registrations only truly take effect after apply returns normally

The condition for a Fiber to enter ACTIVE is that apply returns normally. Read the causality of this sentence correctly: it is not that the Fiber becomes ACTIVE because the registrations succeeded, but rather that apply returned, the framework on that basis deems this batch of registrations effective, and only then does the Fiber enter ACTIVE. This ordering determines that "registrations truly taking effect" is a fact that occurs after apply returns.

Why should the point of effect be placed after the function returns, rather than at the moment each registration call succeeds? From the perspective of state machine consistency, this choice makes ACTIVE a clean promise: as long as a Fiber is in ACTIVE, the registrations recorded in its ledger constitute a complete batch. Conversely, if a successful registration call counted as taking effect, then when apply throws an error midway, you would get a torn state where "some registrations have taken effect, yet the plugin has not entered ACTIVE," and at unload time it would be hard to define which part should be cleaned up.

This semantics gives plugin authors two direct behavioral guidelines:

  • Do not write timing assumptions into the middle of apply such as "after registering, it needs to be immediately usable by other plugins." Since the point of effect is bound to after apply returns, if you complete a registration inside apply and then, in subsequent code within the same apply, expect that batch of registrations to already be visible to the whole system, such an assumption is out of sync with the state machine semantics.
  • Design apply as an assembly process that is "either wholly successful or wholly failed." Because ACTIVE only recognizes "normal return," apply should organize registrations into a one-shot assembly as much as possible, rather than a pipeline that tolerates errors midway and lets each part go its own way. This is precisely the motivation for using try/catch in the previous section to absorb optional enhancements in place—so that the optional parts do not block apply's normal return.

Use a contrasting example to appreciate the difference: suppose a plugin needs to register two tools, A and B. In approach one, register A first, then register B, and finally return; if B throws an error, then A's registration will be treated as part of an ineffective batch, and the whole thing enters FAILED. In approach two, degrade B's failure internally to ensure apply can still return normally; then A takes effect, the plugin enters ACTIVE, and B's absence is presented in the form of logs. Which approach to choose depends on whether A and B "live and die together" or have "a clear primary-secondary relationship"—the state machine does not choose for you, but it defines the consequences of the choice very clearly.

The three trigger sources of UNLOADING: dependency disappearance, being disposed, HMR unload

There are three paths by which a plugin enters UNLOADING. They all ultimately converge on the same phase, but the triggering reasons differ, and the engineering responses each have their own emphasis.

Trigger source one: dependency disappearance. This is the other side of declarative dependencies. Since service readiness drives plugin loading, service disappearance naturally drives plugin unloading. This point is the premise for understanding automatic reloading: when the llm service that a plugin depends on is no longer available, the framework advances the plugin from ACTIVE to UNLOADING, releasing the resources it holds; and once that service recovers, this round of loading ends and, according to the criterion of dependency readiness, goes through PENDING → LOADING → ACTIVE again. The material summarizes this phenomenon as "when a depended-on service disappears, the plugin automatically unloads; after the service recovers, it can automatically reload," and the transition rules behind it are precisely this state machine.

Trigger source two: being disposed. This is an explicit unload request. Whether it is because the upper layer decides to reclaim a plugin, or because the application as a whole is shutting down, the framework pushes the Fiber into UNLOADING via dispose. For plugin authors, explicit dispose and dependency-disappearance unload carry the same responsibility for resource release—both require executing disposers along the Fiber index.

Trigger source three: HMR-triggered unload. In hot module replacement scenarios, to swap in the new version of the code, all registrations and side effects produced by the old version's load round must first be fully withdrawn; otherwise old listeners and new listeners will coexist, causing duplicate triggering. The reason HMR depends so heavily on the state machine is precisely that it needs to perform a thorough and precise cleanup indexed by Fiber. This point is also the key topic of part 2/2, so we will only touch on it here.

Regardless of which path it enters from, UNLOADING simultaneously bears the two responsibilities of "state rollback" and "resource release." This point is worth emphasizing: unloading is not merely a change of state marker; it is also the stage in which cleanup actions are actually executed. The framework executes disposers along the Fiber index, landing listeners, registered tools, and cleanup functions registered via ctx.effect() one by one. Only after all disposers have finished executing does the Fiber enter DISPOSED.

Here is a comparison of the three trigger sources and their characteristics, to make it easier to quickly categorize them during actual troubleshooting:

Trigger sourceTrigger conditionTypical scenarioWhether it auto-reloadsTroubleshooting focus
Dependency disappearanceA service declared via inject is no longer availableAn upstream plugin is unloaded or downgradedAfter the service recovers, it will go through the load flow againCheck changes in the availability of the upstream service, not the plugin's own code
Being disposedThe framework or upper layer explicitly initiates unloadReclaiming a plugin, application shutdownIt will not auto-reload unless load is initiated againConfirm whether disposers cover all registrations, to avoid leftovers
HMR unloadHot reload replaces plugin codeIterating on plugin implementation during developmentAfter replacement, the new version will be loadedWhether old and new registrations overlap, whether old side effects are fully cleaned up

This table reveals a fact that is often overlooked: unloading does not only happen when "you want to turn off the plugin." Merely because some dependency service briefly fluctuates, a plugin may undergo a complete unload and reload. This means both apply and dispose must have the robustness to be "re-enterable"—which is also why a mechanism like ctx.effect() that records cleanup into the Fiber ledger is so important. Hand-written unregistration logic may not show problems during a normal shutdown, but under the magnifying glass of repeated dependency fluctuations and multiple HMR replacements, any omission will be exposed in the form of "duplicate registration" or "ghost listeners."

At this point, the main trunk of the Fiber state machine is clear: PENDING waits for dependencies, LOADING executes apply, ACTIVE promises that registrations take effect, UNLOADING releases resources, and DISPOSED terminates the instance; outside the main path, exceptions during LOADING lead to FAILED. And what drives all these transitions is a declarative dependency like inject. Next, part 2/2 will zoom in on nested contexts and the complete closed loop of automatic reload: when plugins are nested inside plugins, and when dependency chains are nested in layers, how state advances layer by layer; and when service recovery triggers reload, how the framework guarantees that "the old one must first end cleanly before the new one begins."

In the previous section, we took apart the main path of the Fiber state machinePENDING → LOADING → ACTIVE → UNLOADING → DISPOSED — and followed the forward loading driven by inject dependencies all the way to the moment apply is called. But in Agent systems actually running in production, the trouble is never "how do we get it loaded" — it's "how do we confirm it has been cleaned up completely." In this section, we'll close out the second half of the state machine, reverse linkage, nested linkage, and observability all at once.

The final determination of DISPOSED: it only counts as settled once all disposers have finished executing

Many people make a very subtle mistake when reading the state table: they treat UNLOADING as "already unloaded." From the naming alone, UNLOADING is present continuous while DISPOSED is the completed state, but what is truly fatal in engineering is that UNLOADING is an intermediate state that is allowed to linger for a long time — possibly forever. It does not mean the resources have been returned.

According to the state definitions given in the source material: UNLOADING means "the plugin is being unloaded and is releasing resources," and its trigger conditions include the disappearance of dependencies, being disposed, or an HMR-triggered unload; DISPOSED means "fully unloaded," and it has a very hard precondition — all disposers have finished executing. Note that the keywords here are "all" and "finished executing," not "started executing."

Why is this distinction amplified in Agent development? Because what plugins do in this model is usually not pure computation, but rather things that are side-effectful, hold handles, and maintain external connections: tool entries registered on tools, model configuration listeners attached to llm, cleanup callbacks registered via ctx.effect(), and timers or long-lived connections opened by the plugin itself. If these things are treated as "already gone" during the UNLOADING phase, you'll trip up in two places:

  • Duplicate registration conflicts: the old instance's disposers haven't finished running, yet the new instance has already registered a tool with the same name in the same context. From the framework's perspective, this looks like a key conflict or overwrite, and the symptom is "weird tool behavior after hot reload." In fact, hot reload must wait until the old instance truly reaches DISPOSED before the new instance's registration has a clean place to land.
  • Leaks masked by state: you think it's DISPOSED, so you confidently stop tracking it; but in reality the plugin is stuck in UNLOADING, the disposer never completes because some async callback never returns, and the handle just hangs there. The criterion for being settled is always "all disposers have finished executing," not "the unload process has been triggered."

To turn this into a decidable rule, you can remember it like this: every transition in the state machine has an explicit entry condition. The entry condition for PENDING is "declared but required dependencies not yet ready," for LOADING it is "all required services ready, framework calls apply(ctx)," for ACTIVE it is "apply returns normally, registration takes effect," for FAILED it is "an error is thrown during apply execution," for UNLOADING it is "dependencies disappear, being disposed, or HMR triggered," and for DISPOSED it is "all disposers have finished executing." You'll notice that only DISPOSED's condition contains the word "finished" — that in itself reflects the designer's stance.

A practical mental model: think of a Fiber as a state container, not just a marker. The material states it clearly: a Fiber is "a state container for a plugin instance within the Cordis runtime," and it "records the lifecycle state of that plugin and serves as the basis for cleaning up registrations upon unload." In other words, what gets cleaned up on unload? The answer is "the registrations recorded by this Fiber." Conversely, only when all the disposal actions corresponding to these registrations have been executed does the Fiber qualify to be judged as DISPOSED. DISPOSED does not mean "I intend to unload," but rather "I have settled all my accounts."

Also, a reminder about the boundary of FAILED: FAILED is entered only when apply throws an exception during the LOADING phase. This means that a plugin that never reaches LOADING (for example, its dependencies are never satisfied and it stays stuck in PENDING) will not become FAILED; and a plugin that is already ACTIVE, if its dependencies later disappear, will go to UNLOADING rather than FAILED. This boundary is extremely useful when troubleshooting, and the later section "Observing with a State Machine" will elaborate on it.

A close reading of the scratch-plugin/src/my-plugin.ts example: the loading sequence of inject=['tools','llm']

Next, let's break down that minimal example from the material line by line. It is short enough to have only four lines of effective code, but each line corresponds to a decision on the state machine, so the information density is actually quite high.

// File path: scratch-plugin/src/my-plugin.ts
// Declares that this plugin needs the tools and llm services; apply will not execute until both are ready
export const inject = ['tools', 'llm']

export function apply(ctx: Context) {
  // By the time execution reaches here, ctx.tools and ctx.llm are guaranteed to be ready
  // You can safely register tools and read model configuration
}

The first and second lines are comments, but the causal relationship they convey is the single most important sentence to memorize in the entire lifecycle model: "apply will not execute until both are ready." Note the direction: it is not "check dependencies when apply executes," nor "execute first even if dependencies aren't ready, and report an error when they're used," but rather treating dependency readiness as a precondition before entering LOADING.

The third line, export const inject = ['tools', 'llm'], is the declaration itself. The material's terminology definition of inject is "the field through which a plugin declares its required service dependencies; the framework will wait until all these services are ready before executing apply." There are two easily overlooked details here: first, it is an array, semantically meaning "all required" rather than "any optional," so when tools is ready but llm is absent, the plugin will not enter LOADING and will honestly stay in PENDING; second, it is an exported contract, which the framework can read before the plugin is added to the context and before any user code has executed—this is precisely the prerequisite that enables the framework to do "dependency orchestration." The material also points out the value of this mechanism: express loading order through service dependencies, without needing to manually orchestrate startup order.

The fifth line, export function apply(ctx: Context), is the execution entry point. The material describes LOADING as "dependencies ready, executing apply," and ACTIVE as "apply returned normally, registration in effect." Together, these two statements yield a very practical timing inference: the call to apply happens after all required services are ready, and the return of apply happens before registration takes effect. In other words, apply is a phase that "synchronously completes the registration declaration," and the registration actions you perform inside this function body are considered in effect the moment it returns normally.

Lines six through eight are comments, but they provide a usability guarantee: "by the time execution reaches here, ctx.tools and ctx.llm are certain to be ready, so you can safely register tools and read model configuration." This statement is worth relying on as an invariant: when accessing ctx.tools and ctx.llm inside the apply function body, you do not need to write defensive branches like if (!ctx.tools) return;, nor do you need to retry or wait. The framework has already done the waiting for you outside the door from PENDING to LOADING.

Writing out how this code unfolds along the timeline, it looks roughly like this:

  1. The plugin is added to the context, the framework reads inject = ['tools', 'llm'], and the Fiber is set to PENDING. At this moment, not a single line of apply is executed.
  2. The tools service becomes ready first. At this point llm is not ready, so it remains PENDING and apply is not executed. This explains a lot of the confusion around "why does my plugin seem to do nothing": it's not that the plugin is broken, it's that it's waiting.
  3. The llm service becomes ready. Both required dependencies are now satisfied, the Fiber transitions from PENDING to LOADING, and the framework calls apply(ctx).
  4. Inside apply, ctx.tools and ctx.llm are accessed, and both are guaranteed to be available; actions such as registering tools and reading model configuration are performed.
  5. apply returns normally, the registration takes effect, and the Fiber enters ACTIVE. Only at this point is the plugin truly considered "running."

If apply throws an exception in step three, the destination is not ACTIVE but FAILED—this is the bypass explicitly defined by the material. In engineering terms, this means: do not equate "dependencies complete" with "plugin healthy." Having all dependencies only means it has a chance to enter LOADING; apply may still throw errors due to incorrect configuration format, registration key conflicts, and other reasons, ultimately landing in FAILED. When troubleshooting, these two states must be examined separately, and the handling actions are completely different.

Why service disappearance triggers automatic unload: the reverse coupling of dependency-driven loading

Having covered the forward direction, let's now reason in reverse. Since "all required services ready" is the threshold for entering LOADING, a very natural inference follows: if these required services are no longer ready while the plugin is running, the admission condition that previously held is broken. The state machine's response to this "condition no longer holds" is to push the plugin from ACTIVE into UNLOADING.

The material states this point very plainly: the triggers for UNLOADING include "dependencies disappearing, being disposed, or HMR triggering an unload"; the title of the accompanying section is exactly "Dependency-driven loading, automatic reloading, and nested contexts," and it directly raises two questions—"Why does a plugin automatically unload when a service it depends on disappears? And once the service recovers, why can it automatically reload?"

Here we need to establish a key understanding: dependencies are a bidirectional constraint, not a one-time admission ticket. Many developers coming from other plugin systems are used to understanding dependencies as "checked once at startup," after which a successful load decouples you from the dependency. But this model is not like that. Since the admission condition for loading is "all dependencies ready," that condition constitutes an implicit precondition for the plugin to remain ACTIVE; if the condition fails, the state must change accordingly. This explains why it doesn't require you to hand-write unload logic—unloading is not an action you invoke, but rather the state machine's automatic response to dependency changes.

Why is this design especially important for Agent systems? Think about real scenarios: the tools service may temporarily go offline and be rebuilt due to a hot configuration update, and the llm service may be briefly unavailable due to switching providers or renegotiating a connection. If a plugin continues to claim ACTIVE status while its dependencies have already failed, the entries it registered in the context will point to services that no longer exist—the errors thrown at call time are very far from the root cause, making diagnosis extremely costly. Automatically entering UNLOADING is essentially keeping "the validity of the registration" in sync with "the validity of the dependency."

Following this logic, there is another layer of meaning that is easy to overlook: the material says a Fiber "is also the basis for cleaning up registrations on unload." Stringing them together gives us—register when dependencies are ready, clean up registrations according to the Fiber record when dependencies disappear, and the signal that initiates this cleanup action is entering UNLOADING. Registration and cleanup are strung into a symmetrical chain by the same dependency declaration.

Below is a minimal illustration to verify this reverse chain. It does not depend on any specific product interface; it only demonstrates taking a service on ctx "offline—then restoring it" and observing the plugin lifecycle's response:

// File path: scratch-plugin/src/lifecycle-probe.ts
// Use an explicit probe plugin to observe: does taking a service offline push the plugin into the unload path?
export const name = 'lifecycle-probe'
export const inject = ['tools', 'llm']

export function apply(ctx: Context) {
  // Reaching here means both tools and llm are ready, and the plugin is after LOADING and before ACTIVE
  console.log('[probe] ACTIVE preconditions satisfied, starting registration')

  // When dependencies disappear, the framework will push this plugin into UNLOADING;
  // cleanup callbacks registered via ctx.effect will be executed during the unload phase,
  // and only after all callbacks have finished will this Fiber be judged DISPOSED.
  ctx.effect(() => {
    console.log('[probe] disposer executing: releasing this plugin's registrations and handles')
    return () => {
      console.log('[probe] disposer rollback finished, can be judged DISPOSED')
    }
  })
}

When verifying in a real environment, don't just look at whether the console prints the word "unload"; what you need to look at is whether the cleanup rollback has finished executing. Because by definition, only after all disposers have finished executing does it become DISPOSED's turn. If the log stops at "starting unload" but the corresponding "finished executing" never appears, then the plugin is stuck in UNLOADING, and you need to troubleshoot item by item according to the checklist given later.

Automatic Reload After Service Recovery: A Closed Loop from PENDING Back to ACTIVE

The forward direction is "ready → load," and the reverse direction is "disappear → unload." Connecting the two gives us the most elegant part of this model: after the service recovers and becomes ready again, the plugin goes through the full loading path once more, traveling from PENDING back to ACTIVE.

The value of this closed loop lies in "self-healing." The term used in the companion chapter—"automatic reload"—is precise. Note that it is not "resume," because the plugin has no intermediate "pause and continue" state to return to its original position; the correct definition is that the old round has already completed its unload, and a new round starts fresh from PENDING. Understanding this is crucial for troubleshooting: after a reload, what you see is a brand-new Fiber instance, not the original instance being woken up.

Let's write out the complete transition for this round:

  1. The service disappears, and the previous round moves from ACTIVE into UNLOADING.
  2. All disposers finish executing, the previous round's Fiber reaches DISPOSED, and the old registrations are cleaned up completely.
  3. The service recovers, and dependencies are once again satisfied. At this point the plugin regains admission eligibility, and the Fiber starts a new round from PENDING.
  4. Dependencies are ready, it enters LOADING, and the framework calls apply(ctx) again.
  5. apply returns normally, registrations take effect, and it reaches ACTIVE once more.

There is an ordering issue here that must be emphasized: the "cleaned up completely" in step 2 is the precondition for the "start fresh" in step 3 to proceed safely. If the previous round's disposers have not finished executing and the old registrations are still hanging around, then the registrations made in the new round's apply will face an unclean context. This is exactly why "the completion condition of DISPOSED" and "automatic reload" should be examined together—they are not two independent topics, but consecutive links in the same chain.

There is also a practical point: since a reload means "going through the loading path again," apply must be reentrant. That is, the registration actions inside apply should not assume they will only be executed once, nor should they depend on "global state left over from the previous execution." If you accumulate onto some module-level variable inside apply, or push onto an external list that was never cleared, you will see duplicates after a reload. This problem is especially insidious in failure-recovery scenarios, because during normal operation the plugin is loaded only once and the issue is invisible—it only surfaces when the service flaps.

To verify this closed loop clearly, you can prepare a minimal verification script covering the three states of "load → unload → reload," and observe whether the state transitions match expectations:

#!/usr/bin/env bash
# File path: scratch-plugin/scripts/verify-lifecycle.sh
# Purpose: Repeatedly trigger dependency offline and recovery, and observe whether the plugin can automatically
# complete unload (DISPOSED) and travel back to ACTIVE, with a focus on confirming there are no leftover registrations.
set -euo pipefail

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

echo "== Starting lifecycle closed-loop verification, ${LOOP} rounds total =="

for i in $(seq 1 "${LOOP}"); do
  echo "---- Round ${i} ----"
  echo "[1/3] Trigger dependency readiness, wait for the plugin to enter ACTIVE"
  # Replace this with your runtime loading command
  # Observation point: you should see apply being called and registrations taking effect

  echo "[2/3] Trigger dependency offline, wait for the plugin to complete unload"
  # Observation point: you should see the disposers execute, and after they finish, the Fiber becomes DISPOSED

  echo "[3/3] Ready again, confirm the plugin travels from PENDING back to ACTIVE"
  # Observation point: you should see a brand-new round of apply calls, with no registration conflicts

  echo "Round ${i} finished: confirm old registrations are cleaned up and new registrations have no conflicts"
done

echo "== Closed-loop verification finished: if there are no duplicate-registration warnings in any round, the state transitions match expectations =="

In this script skeleton, there are really three observation points you need to fill in, and its purpose is to turn "automatic reload" from a description into a repeatable assertion: every round must show a complete unload and reload. If some round only shows loading but no unloading, or duplicate registrations appear after reload, then the state transition has gone wrong.

State transitions and troubleshooting checklist when hot reload (HMR) triggers unloading

So far, three trigger sources for UNLOADING have been listed: dependency disappearance, being disposed, and HMR triggering unload. The first two are natural runtime changes, while the third is an active behavior during development, and it is precisely the situation most likely to create a "fake unload."

The reason is not hard to understand. HMR works like this: you change the source code, the new module version needs to take over, and the framework needs to unload the old instance. So the old Fiber is pushed into UNLOADING. But there is a very classic psychological trap here—when developers see the "hot reload complete" message, they assume the old instance has already been DISPOSED. In reality, the HMR message only indicates that the new version has loaded; it does not indicate that the old instance's disposers have finished executing. If the old instance is stuck in UNLOADING, its registrations are still in the context, and the new instance can still register successfully (because the keys may differ or the framework may tolerate it), so you end up with two plugins' side effects at the same time: double timers, double listeners, double tool entries. The symptom often appears as "change one line of code and the log prints twice," and the more you hot reload, the worse it gets, until you finally have to restart the process to recover.

Therefore, treat HMR as the best test case for observing UNLOADING: it is not that you should check only during HMR, but that this is where triggers happen most frequently and residue is most easily exposed. The checklist below can be used directly after every time you finish changing plugin code:

  1. Confirm that the old Fiber really reaches DISPOSED. The criterion is "all disposers have finished executing," not "unload has been triggered." If you can only see logs of entering UNLOADING but not disposer completion, then this is where the problem lies.
  2. Confirm that the disposers themselves will not hang. Any action in a disposer that waits for an asynchronous result, waits for some callback, or depends on an external service response may leave UNLOADING stuck indefinitely. Actions on the disposal path should follow the principle of "return as soon as possible."
  3. Confirm that registrations are cleaned up completely. The material says that Fiber is "the basis for cleaning up registrations on unload." So the check is: do the tools, listeners, and effects registered by the old instance all disappear together with the Fiber's DISPOSED, rather than remaining as orphaned entries?
  4. Confirm that the new instance is a completely fresh round. After reload, the instance starts from PENDING and follows the full loading path; if you observe that the new instance skips a stage, or reuses the old instance's internal state, then isolation has not been done properly.
  5. Confirm that apply is reentrant. After repeated HMR, check whether any module-level variables have been accumulated repeatedly or external lists have been pushed to repeatedly. Reentrancy is a hard requirement in reload scenarios, not a bonus.
  6. Confirm that FAILED and UNLOADING are not confused. If you accidentally break apply during HMR, the new instance may fall directly into FAILED. At that point you will neither see ACTIVE nor should you see a normal unload path—when diagnosing, first distinguish between these two completely different symptoms: "load failure" and "unload residue."

To turn "whether HMR has really settled" into a comparable judgment, the table below places several key states side by side according to "entry conditions" and "whether that state means it is safe to reload." Its use is: when you are ready to let a new instance take over, confirm that the old Fiber has landed in the position corresponding to the last row.

StateEntry Condition (based on the source material)Can a new instance safely take over at this point?Typical Misjudgment
PENDINGDeclared, but required dependencies are not ready; the injected services are not yet availableYes — apply has not yet executed, so no side effects have been producedMistakenly assuming the plugin is "broken" when it is actually just waiting for dependencies
LOADINGDependencies are ready, apply is executingCaution — registration actions are in progress, so concurrent takeover is not advisableTreating "started executing" as "already in effect"
ACTIVEapply returned normally, registration is in effectNo — the unload path must be traversed firstAssuming you can simply overwrite the registration, ignoring cleanup
FAILEDapply threw an exception, loading failedNeeds assessment — there may be no valid registration at allInvestigating a load failure as if it were an unload problem
UNLOADINGDependencies disappeared, dispose was called, or HMR triggered an unloadNo — the disposers have not finished executingTreating "started unloading" as "already fully unloaded"
DISPOSEDAll disposers have finished executingYes — the old registration has been cleaned up completelyThis is the only state where handover is safe

Memorize this table, and troubleshooting in HMR scenarios becomes a simple alignment exercise: see which line the old Fiber stopped at, then decide whether a new one can take over. As long as the old Fiber is not in DISPOSED, do not assume the context is clean.

Fiber State Coupling in Nested Plugin Scenarios

Once the state machine of a single plugin is clear, what really gets close to Agent engineering is the nested plugin scenario: a plugin registers or loads child plugins during its apply, so multiple Fibers coexist in the context, with dependency relationships among them. The companion chapter's title specifically calls out "nested context" precisely because this layer significantly changes the order in which states advance.

Let's start with the conclusion-level judgment: every loaded plugin has its own Fiber scope — this statement from the source material is the governing principle for nested scenarios. A Fiber exists per plugin instance, not per application. So parent and child plugins each hold their own state machine; the parent's ACTIVE does not automatically mean the child is ACTIVE, and vice versa.

Now let's look at how dependencies affect the order of advancement. Because inject declares that "the framework will wait until all these services are ready before executing apply," a dependency-determined advancement chain emerges in nested structures:

  • If the child plugin's inject includes a service that the parent plugin must provide, then the parent must first advance to a state where it can provide that service (per the source material's definition, that means apply returned normally and registration is in effect, i.e., ACTIVE) before the child can move from PENDING to LOADING. In this case the parent's exit precedes the child's entry, and the order is deterministic.
  • If the parent plugin registers the child plugin in its apply, but the child's dependencies all come from elsewhere and do not depend on the parent, then the child's advancement is logically not strictly ordered relative to the parent's completion of ACTIVE; the parent may still be wrapping up LOADING while the child is already waiting for its own dependencies.
  • On reverse unload, dependencies determine the order: if the parent plugin enters UNLOADING because its dependencies disappeared, the services it provides are no longer ready, and child plugins that depend on it will be pushed into UNLOADING as well. This is the cascading manifestation of "dependency disappearance triggers unload" in nested structures. The cascade flows along the dependency chain.
  • During cascading unload, each Fiber traverses its own second half independently. The parent's DISPOSED does not guarantee the child's disposers have finished executing, and the child's DISPOSED does not guarantee the parent has finished cleaning up. So in nested scenarios, the way to determine whether "the entire plugin tree has come to rest" is to check whether every Fiber has reached DISPOSED, not to check only the outermost one.

Here is a very real engineering pitfall: when the order of cascading disposal does not match the order of cleanup actions, it causes dangling cleanup. For example, a parent plugin creates some resource in apply and registers cleanup via ctx.effect(); the child plugin's cleanup action in turn depends on that resource still existing. If the parent's disposer runs first and releases the resource, the child will get an already-invalid handle when it runs its cleanup. Although the material does not specify a concrete execution-order convention, according to the state machine definition, each Fiber's cleanup boundary is determined by its own set of disposers. Therefore, the engineering-safe approach is: make the child plugin's cleanup actions depend only on what the child plugin itself created, and do not depend across layers on the parent plugin's resources; cross-layer dependencies turn cleanup order into an implicit contract.

Another pitfall is that the parent's FAILED state cascades to the child. If the parent plugin throws during the LOADING phase and enters FAILED, the service it was supposed to provide will never become ready; child plugins that depend on it will remain stuck in PENDING, and not a single line of apply will execute. The surface symptom is "the child plugin does nothing at all," but the root cause is the parent's FAILED state. When troubleshooting nested issues, the first action should be to follow the dependency chain upward and first confirm whether an upstream Fiber has landed in FAILED or PENDING, then look downstream. Looking only at leaf nodes will lead to completely wrong conclusions.

Here is a comparable ordering relationship for state propagation in nested scenarios:

ScenarioTriggerState progression orderBasis for judging whether the whole tree has settled
Parent becomes ready on its ownThe parent's inject is satisfiedParent PENDING → LOADING → ACTIVEThe parent reaches ACTIVE and registration takes effect
Child depends on parent serviceThe parent enters ACTIVE and provides the serviceOnly after the parent's ACTIVE prerequisites are complete does the child leave PENDINGThe child's apply is called and returns normally
Parent dependency disappearsThe parent's dependency goes offlineThe parent first enters UNLOADING, its service is no longer ready, and the child is pushed into UNLOADINGEvery affected Fiber reaches DISPOSED
Parent apply throwsThe parent throws during the LOADING phaseThe parent enters FAILED, its service never becomes ready, and the child remains stuck in PENDING for a long timeFirst locate the parent's FAILED state, then assess whether the child can still load

There is another easily overlooked observation point in nested scenarios: each Fiber's state is independently observable. This means that when you suspect a complex plugin tree is behaving abnormally, you can print out each Fiber's state to form a snapshot of "which node is stuck where." This is far more efficient than guessing from logs, and it leads directly into the topic of the next section.

Latest practice as of September 2026: using the Fiber state machine as an observation point for plugin health checks

At this point, we have explained the meaning of each state, the transition conditions, and the three propagation chains: forward, reverse, and nested. Putting this into current engineering practice, the most valuable thing is: stop judging plugin health only by log level, and instead use the Fiber state itself as an observable signal.

The reason is straightforward. Logs can tell you "a certain line of code was executed," but state can tell you "which stage of its lifecycle this plugin is currently in." And for an Agent system, the latter is what you really care about. Whether a plugin is working properly is equivalent to asking it: Is it ACTIVE right now? Is it stuck in PENDING or in FAILED? Did its last unload actually reach DISPOSED? These questions all have clear state-based answers and don't require reverse-engineering from logs.

Diagnosing by state can form a comparison table like the one below, where each row corresponds to a specific troubleshooting action:

  • Stuck in PENDING for a long time: This means "required dependencies are not ready." The troubleshooting direction is to check each service name declared in inject one by one—the example ['tools', 'llm'] in the material is a typical form. Confirm whether these two services are actually provided in the context; if it's a nested structure, also look upstream to see whether a parent plugin is stuck in FAILED, causing the service to never become ready. PENDING means "waiting," not "broken."
  • Entering LOADING but not reaching ACTIVE: This means apply did not return normally. The most common case is that apply throws an exception, in which case it falls into FAILED. According to the material's definition, FAILED specifically means "an error was thrown during apply execution, and loading failed," so the troubleshooting target is the registration logic and configuration-reading logic inside the apply function body.
  • Seeing FAILED: This is a loading failure, not an unload problem. It's important to distinguish it from UNLOADING, as their troubleshooting paths are completely different—the former investigates "why the first load didn't succeed," while the latter investigates "why cleanup can't finish."
  • Stuck in UNLOADING for a long time: This means the disposers have not all finished executing, so by definition it cannot be judged as DISPOSED. The troubleshooting target is whether there are any hanging actions in the disposer set.
  • Assuming unloaded before reaching DISPOSED: This is the core misjudgment repeatedly emphasized in this article, directly corresponding to the two types of problems: duplicate registration and resource leaks.

Turning this observation into a lightweight health-check output can be realized as the following TypeScript snippet that can be pasted and run directly. It doesn't connect to any specific implementation details, but instead converts the state machine into executable checks in the form of "state snapshot + assertions":

// File path: scratch-plugin/src/health-check.ts
// Purpose: Use the Fiber state machine as the observation point to perform health checks on plugins.
// Focus on three types of abnormal signals: prolonged PENDING, occurrence of FAILED, prolonged UNLOADING.

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

interface FiberSnapshot {
  plugin: string
  state: FiberState
  // Dependency declarations, from the plugin's inject field
  inject: string[]
}

// Health criteria:
// - PENDING: dependencies are not yet ready, which counts as "waiting"; need to verify the services listed in inject
// - ACTIVE: apply has returned normally and registration has taken effect; this is the plugin's normal working state
// - FAILED: an error was thrown during apply execution, which counts as a loading failure
// - UNLOADING: disposers have not finished executing, so it cannot be judged as unloaded
// - DISPOSED: all disposers have finished executing; only this is a state that can be safely reloaded
export function checkFiberHealth(snapshot: FiberSnapshot): string[] {
  const issues: string[] = []

  if (snapshot.state === 'PENDING') {
    issues.push(
      `[${snapshot.plugin}] stuck in PENDING: dependencies ${snapshot.inject.join(
        ', '
      )} are not all ready, apply will not execute`
    )
  }

  if (snapshot.state === 'FAILED') {
    issues.push(
      `[${snapshot.plugin}] entered FAILED: apply execution threw an error, need to check registration logic and configuration`
    )
  }

  if (snapshot.state === 'UNLOADING') {
    issues.push(
      `[${snapshot.plugin}] stuck in UNLOADING: disposers have not all finished executing, cannot yet be judged as DISPOSED`
    )
  }

  if (snapshot.state === 'ACTIVE') {
    issues.push(`[${snapshot.plugin}] normal: registration has taken effect`)
  }

  if (snapshot.state === 'DISPOSED') {
    issues.push(`[${snapshot.plugin}] fully stopped: all disposers have finished executing`)
  }

  return issues
}

The engineering value of this code lies in turning "state machine knowledge" into "reusable assertions." You can call it once before and once after a reload to form a complete handover check: before the reload, require all old Fibers to be DISPOSED; after the reload, require the target Fiber to reach ACTIVE. If either condition is not met, the state transition does not match expectations.

Going one step further, you can add a time dimension to health checks: both PENDING and UNLOADING should only be allowed to exist briefly. When a Fiber stays in PENDING longer than your configured threshold, the most likely explanation is that its dependency will never arrive (for example, an upstream FAILED); when a Fiber stays in UNLOADING longer than the threshold, the most likely explanation is a hung disposer. Using "state + duration" as the alert condition gets closer to the root cause than looking at state alone. Although the material does not give specific time values, the judgment method itself—"long-term residence in an intermediate state is abnormal"—is derived directly from the state definitions, because the condition for leaving PENDING is that dependencies become ready, and the condition for leaving UNLOADING is that the disposer finishes executing; on the normal path, both conditions should be satisfied very quickly.

Finally, here is a suggestion for team collaboration: review inject as the plugin's external contract. If dependency declarations are wrong or missing, the symptom is often not an error, but long-term PENDING or premature ACTIVE followed by use of a service that is not ready. The former makes the plugin silently fail to work, while the latter produces hard-to-locate errors at runtime. Having every plugin explicitly and completely declare what it needs is the prerequisite for making the state of the entire plugin tree predictable. This approach of "using the state machine as the observation point," in the engineering context of 2026, comes down to one plain sentence: If a question can be answered with one state, do not guess with a pile of logs.

Summary and Best Practices

Compress the whole article into an execution checklist you can stick at your workstation:

  • Recognize that a Fiber is a state container. Every loaded plugin has a Fiber scope, which carries the plugin's entire state from declaration, loading, and running to unloading, and is also the basis for cleaning up registrations during unload. Multiple plugins = multiple independent Fibers, especially in nested scenarios.
  • Memorize the main path. PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED, plus the bypass into FAILED when apply throws during the LOADING phase.
  • Distinguish the entry conditions of each state. PENDING = declared but dependencies are not ready; LOADING = dependencies are ready and apply is executing; ACTIVE = apply returned normally and registrations are in effect; FAILED = apply threw; UNLOADING = dependencies disappeared, dispose was triggered, or HMR was triggered; DISPOSED = all disposers have finished executing.
  • Never treat "started unloading" as "already fully unloaded." UNLOADING is in progress; only after all disposers have finished executing is it DISPOSED. This is the most important point in the article.
  • Understand that inject is a two-way constraint. Dependencies becoming ready drives PENDING → LOADING; dependencies disappearing drives ACTIVE → UNLOADING. Load order is expressed through dependencies; there is no need to manually orchestrate startup order.
  • After dependency recovery, it goes through a complete closed loop. When the service becomes ready again, the plugin goes from PENDING through the loading path again to ACTIVE, forming a self-healing state loop—it is "starting a new round," not "resuming in place."
  • Assume apply is reentrant. Do not do module-level accumulation in apply or write to an external list that has not been cleared, otherwise duplicate side effects will appear after reload.
  • Treat HMR as the most frequent unload test. After each plugin code change, check against the checklist: whether the old Fiber reaches DISPOSED, whether disposers are hung, whether registrations are cleaned up, whether the new instance goes through a completely new round from PENDING, whether apply is reentrant, and whether FAILED and UNLOADING are confused.
  • In nested scenarios, troubleshoot along the dependency chain. An upstream ACTIVE is the prerequisite for downstream leaving PENDING; upstream dependency disappearance cascades to drive downstream UNLOADING; upstream FAILED causes downstream to remain PENDING for a long time. To determine that the whole tree has settled, confirm that every Fiber has reached DISPOSED.
  • Use state for observability. Use PENDING / LOADING / ACTIVE / FAILED / UNLOADING / DISPOSED as observation signals, combined with duration thresholds, and treat "long-term residence in an intermediate state" as an abnormal signal; the checks fall into three questions: "are dependencies complete, did apply throw, and have disposers finished executing."
  • Run a closed-loop verification before release. Repeatedly trigger dependency offline and recovery, requiring each round to fully observe "unload to DISPOSED, reload to ACTIVE," with no duplicate registration warnings.
  • Review inject as a contract. Missing or incorrect dependency declarations often manifest as long-term PENDING or premature ACTIVE followed by use of a not-ready service, rather than an obvious error.