Cordis のプラグインモデルにおいて、プラグインが「宣言される」ところから「完全にクリーンアップされる」までの間は、曖昧な実行期間ではなく、観測可能で追跡可能、かつ再現可能な状態遷移の連鎖である。この連鎖の担い手が Fiber である。それはプラグインインスタンスの実行単位であると同時に、フレームワークが「今何ができて、次に何をすべきか」を判断する唯一の根拠でもある。Fiber の状態機械を理解することは、依存駆動型のプラグインをうまく書けるかどうか、ホットリロード(HMR)のシナリオでリソースをきれいに出入りさせられるかどうか、そしてアプリケーションのアンロード時に宙に浮いたリスナーやリークした接続を避けられるかどうかを直接左右する。本記事の前半 1/2 は状態機械そのものに焦点を当てる。Fiber スコープの定義、メインパス上の 5 つの状態の逐次的な分解、FAILED への進入条件、そして inject フィールドが「手作業での起動順序の編成」を「宣言的な依存駆動」へと変える仕組みである。後半 2/2 では、ネストされたプラグインコンテキストと自動リロードの完全な閉ループをさらに掘り下げる。強調しておきたいのは、本記事のすべての結論が同じ一連の事実に基づいていることだ。状態遷移は単方向のメインチェーンであり、依存は遷移を駆動する動力源であり、dispose の締めくくりは Fiber インデックスに沿って一つずつ実行されなければならない。

Fiber スコープとは何か:プラグインインスタンスの状態コンテナとクリーンアップの根拠

Fiber を理解するには、まずそれを「1 つの .ts ファイル」や「1 つのエクスポート関数」と区別する必要がある。ソースコードのレベルで書くのは 1 つのモジュールと 1 つの apply(ctx) 関数だが、Cordis ランタイムの内部では、プラグインを 1 つロードするたびに対応する Fiber スコープが作成される。Fiber はこのプラグインインスタンスが現在どのライフサイクル状態にあるかを記録し、そのインスタンスが実行時に生み出したすべての登録の痕跡——リスナー、ツール、そして ctx.effect() で登録された各種の副作用——を保持する。言い換えれば、Fiber はプラグイン「のコード」ではなく、プラグイン「の今回のロード」の実行時のアイデンティティである。

それを実行単位と呼ぶことには工学的な意味がある。1 つのプラグインは、1 回のプロセスライフサイクルの中で複数回ロードされることがある。1 回目は通常の起動、2 回目はホットリロードによる再ロード、3 回目はある依存サービスが一時的に消えた後に復活したことによる再ロードである。これら 3 回のロードで、モジュールのコードは同一だが、フレームワークはそれぞれの状態とリソースを別々に担うために、互いに隔離された 3 つの Fiber を必要とする。Fiber のような隔離単位がなければ、アンロード時に重要な問いに答えられない。今回クリーンアップすべきは、いったいどの登録の一群なのか?

まさにこれが、Fiber が同時にクリーンアップの根拠でもある理由だ。プラグインがアンロードのプロセスに入ると、フレームワークはプラグインが何を登録したかを推測する必要もなければ、プラグイン作者に順序を合わせるための逆登録コードを手書きさせる必要もない。フレームワークはその Fiber をインデックスとして直接使い、その名義で登録された disposer を一つずつ実行する。ctx.effect() が意味を持つのは、まさにそれが「副作用」を現在の Fiber スコープに紐づけるからであり、その結果として副作用は「プラグインとともに消える」というセマンティクスを自然に獲得する。このモデルは次のように理解できる。Fiber は登録の台帳であり、apply は記帳のプロセスであり、dispose は台帳に従って 1 件ずつ消し込むプロセスである。

次の図は、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。これは一方向に進むチェーンであり、「一方向」という言葉を理解することは、5 つの名前を覚えることよりも重要です。状態は巻き戻らず、アンロード後に再び 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 によるアンロードのトリガーです(第 3 節でそれぞれ展開します)。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 が呼び出されたか」と「登録が有効になったか」という2列のずれの関係です。LOADING と ACTIVE の間には関数の戻りが一度挟まっており、登録の「有効化」はちょうどこの隙間に落ちます。この隙間を理解すれば、なぜ多くのプラグインのバグがホットリロード時に露呈するのかが分かります。

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 段階で「すでに無くなった」ものとして扱われると、2 か所でつまずきます:

  • 重複登録の競合:旧インスタンスのディスポーザーがまだ実行し終わっていないのに、新インスタンスが同じコンテキスト内で同名のツールを登録してしまう。フレームワーク側から見るとキー競合または上書きとなり、症状としては「ホットリロード後にツールの挙動がおかしい」という形で現れます。実際、ホットリロードは旧インスタンスが本当に 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 になったプラグインが後から依存関係を失った場合の行き先は FAILED ではなく UNLOADING である、ということです。この境界はトラブルシューティング時に非常に役立ちます。後の「状態機械で観測する」小节で詳しく展開します。

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 された契約であり、フレームワークはプラグインがコンテキストに追加され、まだユーザーコードが一切実行されていない段階でこれを読み取ることができます。これこそがフレームワークが「依存関係の編成」を行える前提なのです。資料はこの仕組みの価値も明示しています。サービス依存によってロード順序を表現し、起動順序を手動で編成する必要がないのです。

5行目の export function apply(ctx: Context) は実行エントリポイントです。素材における LOADING の説明は「依存関係が準備完了、apply を実行中」であり、ACTIVE の説明は「apply が正常に戻り、登録が有効になった」です。この二つの文を合わせると、非常に実用的なタイミングの推論が得られます:apply の呼び出しはすべての必須サービスが準備完了した後に発生し、apply の戻りは登録が有効になる前に発生する。 つまり、apply は「同期的に登録宣言を完了する」フェーズであり、この関数本体で行った登録アクションは、正常に戻った瞬間に有効と見なされます。

6行目から8行目はコメントですが、そこには可用性の保証が示されています:「ここに到達した時点で、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 に入ります。ここに至って初めてプラグインは本当に「実行中」と見なされます。

もし3番目のステップで 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 "== 閉ループ検証終了:各ラウンドで重複登録の警告がなければ、状態遷移は期待どおり =="

このスクリプトの骨組みで実際に埋める必要があるのは 3 つの観測点であり、その存在意義は「自動リロード」を単なる説明から再現可能なアサーションに変えることにある。毎回のラウンドで完全なアンロードと再ロードが見えなければならない。もしあるラウンドでロードだけが見えてアンロードが見えない、あるいはリロード後に重複登録が現れるなら、それは状態遷移に問題が起きているということだ。

ホットリロード(HMR)がアンロードをトリガーするときの状態遷移と調査チェックリスト

これまでに、UNLOADING のトリガー源を 3 つ挙げてきた:依存関係の消失、dispose された、HMR によるアンロード。最初の 2 つは実行時の自然な変化であり、3 つ目は開発中の能動的な行為だが、まさにこれが「偽のアンロード」を最も生みやすい場面である。

理由は難しくない。HMR の仕組みはこうだ。ソースコードを変更すると、新しいモジュールバージョンが引き継ぐ必要があり、フレームワークは古いインスタンスをアンロードする必要がある。そこで古い Fiber が UNLOADING に押し込まれる。しかしここには非常に古典的な心理的罠がある—開発者は「ホットリロード完了」の表示を見ると、古いインスタンスがすでに DISPOSED になったと勝手に思い込む。実際には、HMR の表示は新しいバージョンがロードされたことだけを示し、古いインスタンスの disposer が実行完了したことは示していない。もし古いインスタンスが UNLOADING で詰まっていると、その登録はまだコンテキストに残っており、新しいインスタンスもそのまま登録に成功できる(キーが異なるか、フレームワークが許容している可能性があるため)。その結果、2 つのプラグインの副作用を同時に抱えることになる。二重のタイマー、二重のリスナー、二重のツール項目である。症状はたいてい「1 行コードを変えただけでログが 2 回出る」という形で現れ、ホットリロードを繰り返すほど増えていき、最後にはプロセスを再起動しないと復旧しなくなる。

したがって、HMR を UNLOADING を観測する最良のテストケースとして扱おう。HMR のときだけ確認せよという意味ではなく、ここが最も頻繁にトリガーされ、残留が最も露呈しやすいからである。以下のチェックリストは、プラグインコードを変更し終えた毎回にそのまま使える:

  1. 古い Fiber が本当に DISPOSED に到達したことを確認する。 判断基準は「すべての disposer が実行完了したこと」であり、「アンロードがトリガーされたこと」ではない。UNLOADING に入るログだけが見えて、disposer の完了が見えないなら、問題はそこにある。
  2. disposer 自体がハングしないことを確認する。 disposer の中で非同期結果を待つ、何らかのコールバックを待つ、あるいは外部サービスの応答に依存する動作は、UNLOADING を無期限に留まらせる可能性がある。dispose 経路の動作は「できるだけ早く返す」を原則とすべきである。
  3. 登録がきれいにクリーンアップされていることを確認する。 素材によれば、Fiber は「アンロード時に登録をクリーンアップする根拠」である。したがって確認項目は、古いインスタンスが登録したツール、リスナー、effect が Fiber の DISPOSED とともにすべて消えているかであり、孤児エントリとして残っていないかである。
  4. 新しいインスタンスが完全に新しいラウンドであることを確認する。 リロード後のインスタンスは PENDING から始まり、完全なロード経路をたどる。もし新しいインスタンスがある段階を飛ばしたり、古いインスタンスの内部状態を再利用したりしているのを観測したなら、分離ができていないということだ。
  5. apply が再入可能であることを確認する。 HMR を繰り返した後、モジュールレベルの変数が重複して加算されていないか、外部リストに重複して push されていないかを確認する。再入可能性はリロード場面での必須要件であり、加点要素ではない。
  6. FAILED と UNLOADING が混同されていないことを確認する。 HMR の途中でうっかり apply を壊してしまうと、新しいインスタンスは直接 FAILED に落ちる可能性がある。このとき ACTIVE は見えず、正常なアンロード経路も見えるべきではない—診断時にはまず「ロード失敗」と「アンロード残留」というまったく異なる 2 種類の症状を区別しなければならない。

「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 の後に未準備のサービスを使用することとして現れる。