DeepSeek Harness でプラグイン開発を行う上で、避けて通れない二つの言葉があります。スコープ分離イベントシステムです。前者が解決するのは「リソースをどう分けるか」であり、後者が解決するのは「メッセージをどう通すか」です。もしあなたのプラグイングループに二組の Bash 実行器、二組のログ設定、二組の権限ポリシーがあるなら、それらは互いに見えず、それぞれ独立してインスタンス化されなければなりません。そして、互いに知らない十数個のプラグインをある拡張ポイントで協調動作させたいとき、それらが互いをハードコードで呼び出すわけにはいきません。Cordis が出す答えはこうです。isolate でサービスを分離し、scope で可視性を管理し、emit / bail / serial / waterfall の五つのディスパッチモードでイベント通信のセマンティクスを明確に切り分ける。この記事は、実行可能な最小実験ディレクトリ scratch-plugin/cordis.yml から出発し、「同じ shell サービスがどうやって二つのインスタンスに分裂するのか」と「イベントがどうやってリスナー側からトリガー側へ届くのか」という二本の主線を一度に徹底解説します。これにより、実プロジェクトでプラグイングループを分離しつつ、必要なときにはそれらを連携させることができるようになります。

isolate フィールドはどうやって同じ shell サービスを二つのインスタンスに分裂させるのか

まず、多くの人が Cordis を初めて使うときに陥る直感の罠を見てみましょう。それは、プラグイングループはプラグインに見栄えの良いプレフィックスを付けるための単なる「名前空間」だと思い込むことです。実際には、プラグイングループの真の力は、それがサービスインスタンスの境界になり得る点にあります。デフォルトでは、Cordis コンテナ内のあるサービスはシングルトンです。誰が require しても同じオブジェクトを取得します。これはほとんどの場面では問題ありませんが、状態を持ち、設定に差異があり、リソースを占有するサービス(たとえばタイムアウト設定付きの Bash 実行器)になると、シングルトンの共有は災難になります。group-a は Bash のタイムアウトを 5 秒にしたい、group-b は 60 秒にしたい。もし同じインスタンスを共有すると、後から設定したものが先の設定を上書きし、挙動が予測不能になります。

isolate フィールドは、このシングルトンのセマンティクスを打ち破るために使います。書き方はキーと値のペア形式で、キーはサービス名、値はブール値です。たとえば isolate: { shell: true } です。ある group で isolate: { shell: true } を宣言すると、あなたは実際に Cordis にこう伝えています。この group の境界内では、shell というサービスはグローバルシングルトンを使わず、この group 専用に別途インスタンス化される。group 内のすべてのプラグインは shell サービスを注入するとき、この group 専用のものを取得します。group 外のプラグインが見るのは、依然として彼ら自身のもの、あるいはグローバルのものです。つまり、二つの group はそれぞれの shell インスタンスに対して安心して異なる設定を行え、互いに干渉しません。

これを理解する鍵は、「サービス」と「サービスインスタンス」を分けて見ることです。サービスは契約(名前、インターフェース、セマンティクス)であり、インスタンスは実行時オブジェクト(設定、状態、リソースハンドル)です。isolate がない場合、契約とインスタンスは一対一のグローバルマッピングです。isolate を加えると、契約は一対多になり、インスタンスの粒度はプラグイングループまで下がります。頭の中でプログラミング言語の静的変数とインスタンス変数に例えることができます。isolate なしはグローバルな静的シングルトンのようであり、isolate ありは各オブジェクトがそれぞれメンバーフィールドを持つようなものです。このメンタルモデルは、後で scope と isolate の役割分担を理解する上で非常に重要です。

もう一つ見落としやすい細かい点があります。isolate の分離には方向性があります。それは分離されたサービスをすべての group に対してプライベートにするのではなく、isolate を宣言したこの group に独立したインスタンスを保持させるだけです。他に分離を宣言する group がなければ、それらは依然としてデフォルトインスタンスを共有します。したがって、マルチ group アーキテクチャを設計するとき、自問すべきは「分離するかどうか」ではなく、「どの group が独立インスタンスを必要とし、どの group が共有できるか」です。通常のやり方はこうです。設定に差異がある、状態の書き込みがある、ライフサイクルを独立管理する必要があるサービスは分離する傾向にし、純粋関数的なツールサービスや読み取り専用のメタデータサービスはリソース節約のために共有を続ける。この経験則は二つの極端を避けるのに役立ちます。何もかも分離してインスタンスが膨張しメモリを浪費するか、何も分離せず設定が互いに汚染し合いデバッグで人生を疑うか、です。

@deepseek-ai/cordis-plugin-group と group: true:プラグインをグループ化する宣言的な書き方

isolate のセマンティクスを理解したところで、次に解決すべきは「隔離は誰にぶら下がるのか」です。答えは @deepseek-ai/cordis-plugin-group というプラグインで、これは隔離機能を宣言的な設定として包み込みます。注意すべきは、グループ化が隔離を有効にする前提条件であるという点です——普通のプラグインに isolate を付け足しただけで多インスタンスに分裂することを期待してはいけません。isolate は group プラグインの属性であり、まずグループが存在しなければ、グループは隔離を宣言できません。

cordis.yml における group エントリは次のようになっており、三つの重要なフィールドが関わります:

  • id:グループの一意な識別子で、例えば group-agroup-b です。これは単なる名前ではなく、ログ、診断、設定参照の際の位置のアンカーでもあります。複数のグループの id は一意でなければならず、そうでなければ読み込み段階で競合が報告されます。
  • name:ここには固定で '@deepseek-ai/cordis-plugin-group' と書き、このエントリが group プラグインによって読み込まれることを示します。言い換えれば、name は「このエントリをインスタンス化するのにどのローダーを使うか」を決めます。
  • group:ブール値で、必ず true でなければなりません。このフィールドは明示的なスイッチであり、このエントリが普通のプラグインではなく「グループコンテナ」であることを Harness に伝えます。多くの初心者がこの行を書き忘れ、その結果 config 内の子プラグインがフラットなエントリとして扱われ、隔離が当然ながら有効になりません。

この三点のうち最も過小評価されやすいのが、group: true の明示性です。なぜ自動推論にしないのか?それは Cordis の設定スタイルが明示的な宣言を好み、一行余分に書くほうが魔法のような推論をしないよりよいからです。明示的である利点は、設定ファイルを読む人が「これは普通のプラグインだ」なのか「これはグループだ」なのかを一目で区別でき、さらにツールチェーンが静的解析や依存グラフの描画を行う際にも境界を正確に識別できることです。group: true を括弧の記号だと考えてください。それは隔離の作用範囲を区切り、括弧の内側はグループの内部世界、括弧の外側は外部世界です。

では isolate はどこに書くのか?それはグループエントリ自身の階層、つまり idnamegroup と同じレベルに書き、ある子プラグインの設定に押し込むのではありません。これはよくある階層の間違いです:ある人は「隔離はこの子プラグインのサービスを対象にしている」と感じるため、無意識に isolate を子プラグインの config に書いてしまいます。しかしセマンティクス的には逆です——隔離はグループの属性であり、グループがそのメンバーのために統一的に申請するものです。グループ内には複数のプラグインがあり、それらはすべて shell サービスを消費するかもしれません。グループが一度 isolate を宣言すれば、グループ内のすべての shell の消費が本グループのインスタンスを指します。したがって正しい階層の観念は、isolate は「コンテナ」に属し、「メンバー」には属さないということです。

また注意しておきたいのは、name フィールドの書き方は scope プレフィックスを含め、パッケージ名と完全に一致していなければならないという点です。ローカルで monorepo やミラーを使っている場合、パッケージ名が異なることがあります。その場合は、group の name と子プラグインの name の両方がリゾルバから見つけられるようにする必要があります。解決に失敗した場合の挙動は、静かにフォールバックするのではなく、ロード段階で直接エラーを投げることが多いため、この種の問題はたいていすぐに発見できます。

timeoutMs を 5000 から 60000 へ:設定の差異で分離が本当に効いているかを検証する

仕組みの説明が済んだら、次は検証です。分離のようなものは「設定したように見えて実際には分離されていない」ことが最も怖く、分離を検証する最も素朴で最も信頼できる手段は、観測可能な設定の差異を作り、その振る舞いがそれぞれの設定に従っているかを観察することです。素材の例は非常に典型的です。group-a の dsh-bash-local には timeoutMs: 5000 が設定され、group-b の同名プラグインには timeoutMs: 60000 が設定されています。同じサービス、同じプラグイン名で、timeout の数値だけが異なります。

ここでの鍵は「同名で設定が異なる」という構成です。分離が効いていれば、group-a で開始された Bash 呼び出しは 5 秒前後でタイムアウトし、group-b で開始された呼び出しは 60 秒まで実行できます。2 つの group のタイムアウト挙動は互いに独立しており、一方を変更しても他方には影響しません。分離が効いていなければ(たとえば isolate を書き忘れたり、isolate を間違った階層に書いたりした場合)、2 つの group は同じ shell インスタンスを共有するため、後からロードされた設定が先にロードされたものを上書きし、2 つの group の挙動は同じ数値に収束します。この収束現象こそが、分離が失敗している証拠です。

この検証を再現可能な実験にするには、次の順序で操作することをおすすめします。

  1. 5 秒を明確に超え、かつ 60 秒よりはるかに短いコマンドを用意します。たとえば 10 秒スリープするスクリプトです。この時間の選び方には意味があります。5 秒より長いことで group-a がタイムアウトを引き起こし、60 秒より短いことで group-b が正常に完了できます。10 秒はちょうど 2 つのしきい値の中間に位置し、識別度が最も高くなります。
  2. group-a のプラグインでこのコマンドを開始し、結果がタイムアウト失敗であることを確認します。実際の所要時間を記録すると、10 秒ではなく 5 秒付近になるはずです。
  3. group-b のプラグインで同じコマンドを開始し、正常に完了すること、実際の所要時間が約 10 秒であることを確認します。
  4. 対照実験を行います。group-b の timeoutMs を 3000(5 秒未満の別の差異)に変更し、再起動して再度観察します。group-b がタイムアウトし始め、group-a が影響を受けなければ、分離がさらに裏付けられます。
  5. 反証実験を行います。いずれかの group の isolate 宣言を一時的に削除し、再起動して、2 つの group の挙動が互いに影響し始めるかを観察します。この手順は、「分離が一体何を変えるのか」についての筋肉記憶を築くのに役立ちます。

この手順で強調すべきエンジニアリング上の細部が 1 つあります。検証時には、2 つの group のプラグインが確かにそれぞれの group 内でロードされていることを保証する必要があります。両方のプラグインを同じ group に書いてしまうと、isolate の有無にかかわらず同じインスタンスを共有し、実験の結論が誤ったものになります。したがって、ディレクトリ構造と設定の階層は実験の目的と一致していなければなりません。これが次の小節で展開する内容でもあります。

cordis.yml における config 配列のネスト構造:group 内のプラグインがどのように段階的にインスタンス化されるか

Cordis の設定で最も注意深く扱う必要があるのは階層です。なぜなら、それは平坦なキーと値の表ではなく、ネストされた config 配列を使っているからです。group のエントリでは、config は配列であり、配列の各要素が group 内の 1 つのメンバーを表します。メンバー自身も nameconfig を持つことができ、その config がさらにそのメンバー自身のパラメータを記述します。これにより、group → メンバー → メンバーパラメータという再帰的なツリー構造が形成されます。

まず意味をそろえて、混同を避けましょう:

階層フィールド意味よくある間違い
グループ項目id / name / groupこれがグループコンテナであること、その一意な識別子とローダーを宣言するgroup: true を書き忘れ、グループが通常のプラグインに退化する
グループ項目isolateこのグループがどのサービスを独立してインスタンス化するかを宣言する誤ってメンバーの config に書き込み、隔離が効かなくなる
グループ項目config(配列)このグループが読み込むメンバーの一覧で、順番にインスタンス化される配列ではなくオブジェクトとして書き、解析に失敗する
メンバー項目nameメンバープラグインのパッケージ名または相対パスパスを書き間違え、読み込み段階でエラーになる
メンバー項目configそのメンバーのパラメータ(timeoutMs など)サービスレベルの設定をこの階層に書き、意味がずれる

読み込み順序は「深さ優先、配列順」です。あるグループが読み込まれるとき、Cordis は config 配列内の要素の順番に従ってメンバーを順にインスタンス化します。まず @deepseek-ai/dsh-bash-local を読み込んでその timeoutMs を適用し、次に ./src/plugin-a.ts を読み込みます。この順序は重要です。なぜなら、後ろのメンバーは通常、前のメンバーが提供するサービスを注入するからです。順序が逆になると、後から読み込まれたプラグインは初期化時に shell サービスを取得できず、「注入が空」または「サービスが未登録」というエラーが発生する可能性があります。したがって、次の実践原則があります:サービスを提供するプラグインを前に書き、サービスを消費するプラグインを後ろに書く。これは多くのフレームワークの考え方と一致しますが、Cordis はそれを純粋な設定上の約束事にしており、追加の糖衣構文はありません。そのため、なおさら自分で意識して守る必要があります。

isolate と config 層の分離についてもう一度強調します。これは最もつまずきやすい落とし穴です。この構造を見てください:グループ項目の isolate はコンテナレベルの宣言であり、「このグループ内の shell が独立したインスタンスかどうか」を決めます。一方、メンバー項目の config にある timeoutMs はメンバーレベルのパラメータであり、「この shell インスタンスの具体的な挙動」を決めます。両者がそろって初めて完全になります。isolate はインスタンスを分離する役割を担い、メンバー config は分離後のインスタンスにパラメータを埋める役割を担います。isolate だけを書いてメンバーに異なるパラメータを書かなければ、二つのインスタンスは互いに独立していても設定はまったく同じで、実験では差異が観察できず、隔離が効いていないと誤解させるでしょう。メンバーに異なるパラメータだけを書いて isolate を書かなければ、二つのグループはインスタンスを共有し、後から書いたパラメータが先に書いたものを上書きし、これも実験では差異が観察できません。したがって、隔離を検証するには、正しい isolate 宣言と差異のあるメンバーパラメータの両方が必要で、どちらも欠かせません。

では、ネストの階層を書き間違えるとどうなるでしょうか。通常は二つの現れ方があります。一つは、読み込み段階で直接解析エラーが投げられる場合です。たとえば config 配列をオブジェクトとして書いてしまい(角括弧ではなく波括弧を使った)、パーサーがそれをメンバーの並びとして扱えません。もう一つは、読み込みは通るものの意味がずれる場合です。たとえば timeoutMs をグループ項目自身の config に置いてしまい、その結果それはグループコンテナのパラメータでもどのメンバーのパラメータでもなく、黙って無視され、目に見える現象は「確かに 60 秒に設定したのに効果がない」となります。二つ目のほうが、エラーを出さないためにより見つけにくいです。習慣としておすすめしたいのは、設定後に各数値がどの階層に属しているかを確認し、「この数値は誰のためのものか」と自問することです。答えが「特定のメンバーのため」なら、それはそのメンバーの config にあるべきです。答えが「このグループ全体のため」なら、それはグループ項目の階層にあります。

scratch-plugin/cordis.yml:最小限で動くサービス隔離実験ディレクトリ

理論をいくら語っても、実際に動くディレクトリにはかないません。素材で示されたパスは scratch-plugin/cordis.yml で、この命名自体に意味があります。scratch は「下書き、捨ててもよいもの」を暗示しており、これを隔離実験に使うということは、これが検証目的の小さなプロジェクトであり、本番用ディレクトリではないということを意味します。実験と本番を分けておくのは良いことです。なぜなら、本番環境を汚染する心配なく、設定を大胆に削除したり、パラメータを変更したり、反証を行ったりできるからです。

最小限で動く隔離実験ディレクトリは、次のように構成することをおすすめします:

scratch-plugin/
├── cordis.yml          # メイン設定、group-a と group-b を定義
└── src/
    ├── plugin-a.ts     # group-a のメンバープラグイン、shell を消費
    └── plugin-b.ts     # group-b のメンバープラグイン、shell を消費

対応して、cordis.yml の内容は、先ほど繰り返し分解した2組の設定です。group-a は isolate: { shell: true } を宣言し、そのメンバーには @deepseek-ai/dsh-bash-localtimeoutMs: 5000)と ./src/plugin-a.ts が含まれます。group-b も同様に isolate: { shell: true } を宣言し、メンバーには同名のプラグインですが timeoutMs: 60000 のものと、./src/plugin-b.ts が含まれます。この設定を完全に貼り付けると次のようになります:

# ファイルパス:scratch-plugin/cordis.yml
# 2つのプラグイングループ group-a と group-b を定義し、それぞれが独自の shell サービスを隔離する
- id: group-a
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true   # このグループ内の shell サービスを独立してインスタンス化させる
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 5000
    - name: './src/plugin-a.ts'
- id: group-b
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 60000
    - name: './src/plugin-b.ts'

相対パスについて、見落としやすい細かい点が1つあります。./src/plugin-a.ts./ は設定ファイルを基準に解決されます(通常はプロジェクトルートまたは設定のあるディレクトリ)。ツールチェーンによって解決の基準が多少異なる場合があります。安全な方法は、まず最小限の設定で1つのプラグインだけを読み込み、パスが解決できることを確認してから、2つ目のグループを追加することです。パスの解決に問題がある場合、エラーは通常「モジュールが見つからない」といったもので、比較的わかりやすいです。もう1つの実用的なコツ:実験の初期段階では group-a だけを書き、group-b は書かないようにすることです。まず単一グループ内で isolate と timeoutMs が有効になることを確認し、その後2つ目のグループを追加して隔離効果を観察します。こうすることで、「設定階層の誤り」と「隔離が効いていない」という2種類の問題を切り分けて特定できます。最初から両方のグループを有効にすると、問題が起きたときにどの層が間違っているのか判断しにくくなります。

起動した後は、プラグイン内でいくつかの診断情報を出力することもできます。たとえば plugin-a と plugin-b の初期化時に、現在の shell インスタンスのある識別子やタイムアウト値を出力する、といったことです。ここで注意すべきは、資料には具体的なインスタンス ID インターフェースが示されていないため、ある固定のインスタンス識別フィールドが存在すると仮定してはいけないということです。より確実な検証方法は、やはり前節で説明した動作検証(10 秒のコマンドで 5 秒 / 60 秒という 2 つのしきい値にぶつける)であり、ある内部 ID を読むことではありません。動作検証は内部実装に依存しないため、長期的にはより信頼できます。

scope と isolate の役割分担:誰が可視性を決め、誰がインスタンス数を決めるのか

これは記事全体で最もはっきりと整理する必要がある概念の組です。多くの開発者はプラグインの問題を調査するとき、scope と isolate を混同してしまい、その結果として方向性を完全に誤ります。両者の責務は直交しています。scope は可視性を管理し、isolate はインスタンス数を管理します。

まず scope について説明します。scope が答えるのは「このサービスが誰に見えるか」です。これは可視性の障壁です。あるサービスがある scope 内で登録されれば、その scope 内で注入できます。scope 外のプラグインからそれを見ることは、存在しないものを見るようなものです。scope には階層を持たせることができ、木構造を形成します。子 scope は通常、親 scope のサービスにアクセスできますが、その逆はできません。scope の価値は権限とカプセル化にあります。あるツールをあるサブシステムにだけ開放したいなら、それを対応する scope に登録します。あるグローバルな能力をすべてのプラグインに開放したいなら、ルート scope に登録します。

次に isolate について説明します。isolate が答えるのは「このサービスにインスタンスがいくつあるか」です。これは可視性を変えず、変えるのはインスタンス化の粒度です。あるサービスがすべてのグループから可視であっても(可視性に問題はない)、各グループがそれぞれ独立したインスタンスを持つ(隔離が有効になる)ことがあります。逆に、あるサービスがグローバルに 1 つのインスタンスしか持たない(隔離なし)一方で、scope を通じて一部のプラグインにだけ可視であることもあります。2 つの仕組みは自由に組み合わせることができ、4 つのケースを形成します:

構成の組み合わせ可視性インスタンス数典型的な用途
isolate なし + 広い scopeグローバルに可視シングルトンステートレスなツール、読み取り専用メタデータ
isolate なし + 狭い scope特定の scope からのみ可視シングルトンサブシステム専用だが内部的に共有される能力
isolate + 広い scope各グループから可視各グループに 1 つ構成差異のあるステートフルなサービス(shell など)
isolate + 狭い scope特定の scope からのみ可視その scope 内に 1 つ権限制約と独立した構成の両方が必要な機密性の高いサービス

この表を理解する鍵は、可視性とインスタンス数は 2 つの独立した次元であり、一方の仕組みでもう一方の仕組みの問題を解決しようとしてはいけないということです。あるサービスを他のグループから見えないようにしたいなら、isolate を追加するだけでは役に立ちません。isolate はインスタンスを分裂させることだけを担当し、視線を遮ることは担当しないので、他のグループは依然としてそれを見ることができ、自分たちのインスタンスを取得できます。あるサービスを各グループで異なる構成にしたいなら、scope を調整するだけでも役に立ちません。scope は視線を遮ることだけを担当し、インスタンスが分裂するかどうかは関知しません。したがって問題を調査するときは、まず自分に問いかけてください。自分が望んでいるのは「見えないこと」なのか、それとも「それぞれ 1 つずつ」なのか。前者なら scope を使い、後者なら isolate を使い、両方なら両方を設定します。

もう一つのよくある誤解は「isolate した後はインスタンス間で通信できない」というものです。そうではありません。隔離されるのはインスタンスであり、通信能力ではありません。2つのグループのプラグインは依然としてイベントシステム(次の小節のテーマ)を通じて互いに通信できます。ただし、それぞれが受け取るのは自分自身のサービスインスタンスだけです。隔離が解決するのはリソースの帰属であり、結合ではありません。結合の解消はイベントに任せます。この2つをはっきり分けておくと、アーキテクチャの考え方がずっとすっきりします。

ctx.on と ctx.emit:イベントシステムの両端とコールバック登録のタイミング

隔離について説明したので、次は通信に切り替えます。Cordis のイベントシステムには両端しかありません:リスナー側の ctx.onトリガー側の ctx.emit です。この両端が、Cordis プラグイン間の疎結合な通信の中核メカニズムを構成しています。Harness は拡張可能な拡張ポイントを実現するためにイベントを大量に使用しています。つまり、フレームワーク内の多くの「自分のロジックを挿入できる」位置は、本質的にはイベントです。

リスナー側はコールバックを登録し、トリガー側はすべてのリスナーにブロードキャストします。基本形は次のとおりです:

// イベントを監視する:コールバックを登録する
ctx.on('event-name', (payload) => {
  // イベントを処理する
})

// イベントをトリガーする:すべてのリスナーにブロードキャストする
ctx.emit('event-name', payload)

この2行は単純に見えますが、エンジニアリング上、必ずよく考えるべき問題がいくつかあります。1つ目は登録タイミングです。ctx.on はイベントがトリガーされる前に実行されなければなりません。そうでなければ、登録したコールバックはそのトリガーを受け取りません。これは、リスナー側を通常プラグインの初期化段階(たとえばプラグインの setup や apply ロジック内)に置く必要があることを意味します。「使うときに初めて登録する」ような遅延分岐に置くのではありません。もしあなたのプラグインがあるイベントに依存しているのに、それを初回呼び出し時に初めて実行される位置に登録しているなら、最初のイベントトリガーを必ず逃します。これは非常に隠れたバグの一種です。単一フローのテストでは現れず、並行またはタイミングに敏感なシナリオでのみ露呈する可能性があるからです。

2つ目の問題はライフサイクルとクリーンアップです。リスナーは無料ではありません。登録したら、いつ解除するかを考える必要があります。プラグインがアンロードされた場合、あるコンテキストが破棄された場合でも、リスナーがイベントにぶら下がったままだと、すでに無効になったオブジェクトに対してコールバックが呼ばれたり、メモリ内のリスナーリストがどんどん長くなったりします。Cordis はコンテキストベースのイベント登録機能を提供しています。リスナーはそれを登録したコンテキストにバインドされ、コンテキストが破棄されるとリスナーも無効になります。これは重要なアーキテクチャ上の利便性です:ctx.on を「あるコンテキストの生存期間中に監視する」ものとして理解するのであり、「グローバル配列にコールバックを詰め込む」ものとして理解するのではありません。前者は自動的にクリーンアップされ、後者は手動で管理する必要があります。コードを書くときは、コンテキストにバインドされた登録方法を優先すると、大量のクリーンアップロジックを省けます。

3つ目の問題はpayload の約束事です。events の記事タイトルには (payload) という仮引数しか示されておらず、payload の構造は規定されていません。これは意図的です。Cordis のイベントシステムは payload の型を強制せず、具体的なイベントの payload はイベントの定義者が決めます。プラグイン開発者として、どのイベントを消費するにしても、イベント定義側の約束に従って payload を読む必要があります。自分で拡張ポイントを定義するときも、payload のフィールドをドキュメントに明確に書く必要があります。エンジニアリング上の推奨は:payload は位置引数ではなく拡張可能なオブジェクトを使うことです。そうすれば、将来フィールドを追加しても既存のリスナーを壊しません。同時に、payload には十分なコンテキスト情報(たとえば発生元の識別子、ターゲット、設定)を含めるべきで、リスナーがわざわざグローバル状態を調べ直さなくて済むようにします。これは複数グループの隔離シナリオで特に重要です。隔離後は各グループが互いのグローバル状態を見られない可能性があり、payload がほぼ唯一の信頼できる情報チャネルになるからです

もう一つ、隔離と呼応する問題があります。それは、イベント自体が隔離されているのかという点です。これは多くの人が思わず追问する点です。ここでは分けて考える必要があります。素材はイベントの両端である API と配布モードについてのみ説明しており、イベントが isolate グループ間で自動的に隔離されるかどうかは説明していません。したがって、堅実なエンジニアリング設計としては、イベントがグループに応じて自動的に隔離されると仮定しないこと、また、すべてのグループが必ず相手のイベントを受け取れると仮定しないことです。もしあなたの Plugin グループが厳密なイベント隔離を必要とするなら、イベント名または payload にグループ識別子を含め、リスナー側でフィルタリングすべきです。もしグループ間通信を望むなら、明確な公共イベント名を使用してください。「イベントがグループ間で可視かどうか」を、暗黙に何らかのデフォルト動作に依存するのではなく、あなたが明示的に設計すべき意思決定ポイントとして扱うことです。そうすれば、フレームワークが今後どう進化しても、あなたのコードは安定します。

5 つの配布モードの全体像:emit / bail / serial / waterfall それぞれの意味論

on と emit の両端を学んだ次に直面するのは、Cordis イベントシステムの真髄です。それは配布モードです。素材のタイトルでは 5 つが挙げられています:emit、bail、serial、waterfall(厳密には、タイトルに現れるのはこの 4 語に「など 5 種類」を加えたものです。この 4 つの命名規則から見ると、それらは戻り値の処理と中断動作に関するいくつかの基本意味論を表しています)。なぜ複数のモードが必要なのでしょうか。それは「1 つのイベントが複数のリスナーに購読される」という事柄に対して、異なる場面で期待される動作がまったく異なるからです。あるときは通知型のブロードキャストで、誰も誰にも影響を与えないことを望み、あるときは「最初の人が有効な結果を出したら止まる」ことを望み、あるときは順序処理で結果を蓄積できることを望み、あるときは各リスナーが入力を書き換えて次へ渡せることを望みます。これらの意味論を、各イベントが独自に取り決めるのではなく、異なる配布モードとして作ることで、Plugin エコシステムの協調ルールを統一し、予測可能にできます。

以下では、比較表で各モードの戻り値処理と中断動作を整理します。表で強調しているのは「複数のリスナーが同時に存在するとき」の動作です。なぜなら、そここそがモード間の差異が本当に現れる戦場だからです:

モード戻り値の処理中断動作典型的な意味論適用場面
emitブロードキャスト型で、各リスナーの戻り値は通常集約されない単一のリスナーの結果によって中断しない通知、ブロードキャストログ記録、状態報告
bail最初の意味のある戻り値を取得したら返すいずれかのリスナーが結果を出した後に短絡する競争、短絡最初に利用可能な処理結果を取得する
serial順序どおりに実行し、各リスナーの戻り値を収集する順序実行で、通常は 1 つずつ処理する直列蓄積複数の参加者が順に結果を貢献する必要がある場合
waterfall前のリスナーの出力を次の入力にするチェーンに沿って伝播し、段階的に書き換えるパイプライン、変換チェーン内容の書き換え、設定の上書き、ミドルウェア的な処理

1 つずつ展開します。emit は最も基本的なモードで、意味論は「私にこのことが起きた。関心がある者は言ってくれ」です。その中心的な期待は、リスナー同士が互いに干渉しないことなので、戻り値について強い取り決めをせず、あるリスナーの結果によって他のリスナーを中断することもありません。このモードの典型的な用途はログ、メトリクス、状態ブロードキャストです。それらの共通点は「複数の消費者が互いに独立している」ことであり、1 つのリスナーでエラーが起きても、他のリスナーが通知を受け取ることを妨げるべきではありません。

bail のセマンティクスは「先に結果を出した方を使い、後続は実行しない」。これは「複数のプラグインが同じことを処理できるが、必要な結果は一つだけ」という場面に適しています。たとえば、複数の戦略プラグインがとある意思決定に対して提案を出せるが、最初の有効なものを取ればよい、というケースです。後続のリスナーは、すでに誰かが答えを出しているので計算を無駄にする必要がありません。bail モードではリスナーの実行順序に注意が必要です——順序が不定であれば「最初のもの」も不定になり、高並行下では挙動のドリフトを引き起こす可能性があります。したがって bail を使う際は、リスナーの順序を制御可能にするか、複数の結果が互換的でどれを選んでもよいことを保証するかのどちらかが必要です。

serial のセマンティクスは「整列して、一つずつ、結果を集める」。emit との違いは結果が集約されるかどうか、bail との違いはショートサーキットするかどうかにあります。ある拡張ポイントが複数のプラグインに順番に内容を貢献させ、すべての貢献を保持する必要がある場合、serial が自然な選択です。たとえばレポートを生成し、複数のプラグインがそれぞれ一章を追加し、最終的にすべての章を連結する、というケースです。serial モードでは、単一のリスナーの失敗が後続に影響するかどうかに注意が必要です。約束が「一つ失敗したらチェーン全体を中断する」であれば、失敗したプラグインが他の貢献者を巻き添えにします。約束が「それぞれ独立で、失敗は記録するだけで他に影響しない」であれば、レポートは一章欠けてもなお使用可能です。これは拡張ポイントを設計する際に明確にする必要があり、さもなければ本番環境での散発的なデータ欠損になります。

waterfall は最も強力で、最も誤用されやすいモード。そのセマンティクスは「入力がリスナーチェーンに沿って流れ、各リスナーは前の出力を見ることができ、書き換えて次に渡せる」というもので、ミドルウェアやパイプラインにとてもよく似ています。これは内容の書き換え系の場面に適しています。たとえば、あるプラグインが設定にデフォルト値を補い、次のプラグインが補われた値に基づいてさらに上書きし、その次が最終検証を行う、というケースです。waterfall の順序は極めて重要です。なぜなら入力には流れる向きがあり、順序が変われば結果も変わるからです。使用時には二つの規律が必要です。第一に、各リスナーは自分が payload を変更するかどうかを明確にすべきで、変更しないならそのまま透過させ、不完全なオブジェクトを返して後続のプラグインを困らせてはいけません。第二に、上流の変化を許容すべきです。なぜならあなたの入力は任意の上流リスナーによって書き換えられている可能性があるので、フィールドが必ず存在すると仮定してはいけません。

では、モードを選ぶ考え方とは何でしょうか。自分に二つの問いを立ててください。第一に、複数のリスナーの結果を集約する必要があるか?集約しないなら emit、集約するなら serial。第二に、ショートサーキットを許容するか?許容し、最初の結果だけが必要なら bail、一つずつ変換し、出力がすなわち入力なら waterfall。この二つの問いに明確に答えれば、モードはほぼ決まります。最後に、モードをまたぐ共通の問題を強調します。どのモードであれ、リスナーの例外処理には統一した約束が必要です。素材はあるリスナーがエラーを投げたときに他のリスナーがどうなるかを規定していないので、エンジニアリング上は自分のリスナー内部で適切に try/catch を行うべきです。特に並行に呼び出される計装系のコールバックでは、捕捉されない例外が emit のブロードキャスト時に呼び出しスタック全体に影響する可能性があります。「リスナーは未処理の例外を投げるべきではない」をチームの規律とすることで、多くの不可解な現場を省けます。

ここまでで、隔離と通信という二本の主軸がそれぞれ展開されました。isolate を使って shell サービスを各グループでそれぞれインスタンス化し、group: true でグループの境界を画定し、timeoutMs の差異で隔離が本当に効いていることを検証し、scope と isolate の役割分担で可視性とインスタンス数という二つの直交する次元を整理します。同時に ctx.on / ctx.emit で通信の両端を組み立て、emit / bail / serial / waterfall の比較で正しいディスパッチ・セマンティクスを選びます。次の段落では、これらの仕組みをより複雑な実際の協調シナリオに置き、複数の隔離グループがイベントを通じて安全に互いに協調する方法、そして設定とコードがどう連携して隔離の失敗とイベントストームを避けるかを議論します。

前回は isolategroup の組み合わせを分解して詳しく説明し、「同一サービス、複数インスタンス、グループ単位で可視」という主線を明確にしました。今回は視点を別の軌道に切り替えます。プラグイン同士が実際に対話するためのチャネル、つまりイベントシステムです。さらに重要なのは、見落とされがちな疑問に答えることです。スコープ分離を行ったとき、イベントブロードキャストも壁の外に隔離されてしまうのでしょうか?

waterfall のペイロードの流れ:あるリスナーが次のリスナーの入力をどう書き換えるか

waterfall は、5 つのイベント配信モードの中で最も「パイプライン」に近いものです。その中心ルールはただ一つです。各リスナーの戻り値は、次のリスナーへの入力ペイロードとしてそのまま渡されていきます。何も返さない場合(undefined を返す場合)、次のリスナーは上流から渡されたペイロードをそのまま受け取り、チェーンは途切れません。

この仕組みのエンジニアリング上の価値は、「加工」という処理を差し替え可能な複数の区間に分割できることです。たとえば、あるリクエストがシステムに入ると、最初のリスナーがデフォルトのタイムアウトを補い、2 番目のリスナーが認証ヘッダーを注入し、3 番目のリスナーがパラメータ検証を行い、4 番目のリスナーが計測を行います。各リンクは自分の区間だけに関心を持ち、前後に誰がいるかを知る必要はありません。これはミドルウェアモデルとほぼ同型ですが、違いは waterfall のチェーンが配列に固定で書かれているのではなく、イベント名によって動的に集約されることです。

注意すべき細かい点があります。waterfall のペイロードは「リンクごとに置換」であり、「リンクごとにマージ」ではありません。つまり、2 番目のリスナーが返したオブジェクトは、受け取ったペイロードを完全に置き換えるのであり、それに追加されるのではありません。したがって、変更した 1 つのフィールドだけを返すと、上流のフィールドは失われます。そのため、実際にリスナーを書くときの安全な方法は、入力をまず展開し、その上で自分が関心のあるフィールドを上書きすることです。たとえば、{ timeouts: 8000 } だけを返すのではなく、{...payload, timeoutMs: 8000} を返します。「後続のリスナーが前のフィールドを取得できない」という多くのバグの根本原因はここにあります。

もう一つの落とし穴は非同期です。リスナーが Promise を返す場合、チェーンは Promise が解決した後にのみ値の受け渡しを続けます。つまり、遅いリスナーが waterfall 全体を停滞させます。タイムアウトに敏感なシナリオ(たとえば timeoutMs: 5000 の shell 呼び出し経路)では、チェーン上のリスナーが軽量でブロッキング IO を行わないことを保証するか、重い処理を emit のサイドパスに逃がす必要があります。

次のコードは、そのまま TypeScript プラグインに貼り付けて、典型的な waterfall の加工チェーンを実演できます。ペイロードの継承と順序の観測を含みます。

// ファイルパス:scratch-plugin/src/waterfall-demo.ts
import type { Context } from '@deepseek-ai/cordis'

export const name = 'waterfall-demo'

export function apply(ctx: Context) {
  // 第 1 リンク:デフォルトのタイムアウトを補う。他のフィールドを保持することに注意
  ctx.on('harness:command:before-run', async (payload) => {
    console.log('[waterfall #1] 受信ペイロード:', JSON.stringify(payload))
    return { ...payload, timeoutMs: payload.timeoutMs ?? 5000 }
  })

  // 第 2 リンク:origin マーカーを注入。同様に浅いコピーでマージ
  ctx.on('harness:command:before-run', (payload) => {
    console.log('[waterfall #2] 前のリンクの結果:', JSON.stringify(payload))
    return { ...payload, origin: 'plugin-waterfall-demo' }
  })

  // 第 3 リンク:観測のみ。返さず、ペイロードはそのまま透過
  ctx.on('harness:command:before-run', (payload) => {
    console.log('[waterfall #3] 最終ペイロード:', JSON.stringify(payload))
    // return なし — チェーンは #2 の戻り値を引き続き使用
  })
}

実行すると、ログは厳密に #1 → #2 → #3 の順で出力され、#3 では timeoutMsorigin の両方のフィールドを同時に確認できます。これが waterfall の「リレー」形態です。つまり、戻り値が次の走者への引き継ぎ物になります。

bail の短絡セマンティクス:最初の非空戻り値が後続リスナーをどう終了させるか

bail のセマンティクスは一文で要約できます。最初に「非空値」を返したリスナーが、そのままディスパッチチェーン全体を終了し、後続のリスナーはイベントを受け取らなくなります。ここでの「非空値」とは、undefinednull 以外のすべての戻り値を指します。注意すべきは、false0、空文字列 '' を返した場合も有効な戻り値と見なされて短絡を引き起こす可能性があることです。具体的な判定は使用しているバージョンによりますので、コードを書く際にこれらの境界値に依存して「インターセプトしない」意図を表現しないでください。

状態遷移の観点から見るとより明確です。チェーン全体には 2 つの状態しかありません——伝播継続終了済みです。イベントは最初、伝播継続状態にあり、リスナーを 1 つ通過するたびに 1 回判定します。戻り値は非空か? 非空なら終了済み状態に切り替わり、以降はすべてスキップされます。空なら伝播継続状態を維持します。一度終了済み状態に入ると後戻りはできず、たとえ後続のリスナーがより適切な結果を返せたとしても、実行される機会はありません。

この特性は、拡張ポイントの順序に対して暗黙の要求を課します。「どちらが先でどちらが後か」が直接「どちらがどちらを否决できるか」を決めるため、bail 型拡張ポイントのリスナー登録順序はランダムではなく、明確な契約でなければなりません。エンジニアリング上は 2 つの対応方針があります。第一に、優先度フィールド(たとえば priority)を約定し、登録側が明示的に宣言する。第二に、相互排他的な意思決定ロジックを同じリスナーに収束させ、プラグイン間の否决競争を減らす。最も危険な状況はこれです。2 つのプラグインがどちらも自分が唯一の意思決定者だと思い込んだ結果、後から登録された方が永遠に短絡の機会を奪えず、「明らかにインターセプト値を返したのに、システムはそのまま実行を続けた」という形で現れます。

bail は特に 3 つのことに適しています。権限インターセプト、キャッシュヒット時の直接返却、サーキットブレーカーの事前チェックです。それらの共通点は——ヒットした時点で終局であり、後続の段階でさらに加工する必要がないことです。

serial と並行トリガー:リスナーの実行順序が副作用に与える影響

serial の鍵となる特徴は直列実行です。リスナーは次々に実行され、前の 1 つが完了してから(その非同期部分の解決を含む)次の番になります。これにより非常に重要な保証が得られます——実行順序が確定しているため、複数のリスナーが同じ外部状態に書き込む必要がある場合でも、並行上書きは発生しません。

これに対して emit のブロードキャスト動作があります。これはイベントをすべてのリスナーに同時に配布するもので、「通知」セマンティクスであり、「処理」セマンティクスではありません。emit は戻り値を気にせず、リスナー間の前後関係も約束しません(具体的なスケジューリングの詳細はランタイム実装に依存します)。したがって、「順序があり、戻り値があり、直列の副作用がある」ことが必要な場面では、emit で間に合わせるべきではありません。

ここには非常に踏みやすいエンジニアリングの落とし穴があります。timeoutMs: 5000 のような短いタイムアウトの経路で、emit でブロードキャストし、そのうちのどれかのリスナーがこっそりリモート呼び出しを行った場合、emit は待機しないため、呼び出し側はリモート結果が返る前にすでに次のステップへ進んでいる可能性があります。その結果が「ログには確かに実行されたと出ているのに、状態は永続化されていない」というものです。この種の問題は調査に極めて時間がかかるため、イベント選定の段階でセマンティクスをはっきりと考えておく必要があります。

4 つのモード(waterfall と bail を含む)の違いを並べて見るために、以下の表はそのまま選定の対照表として使えます:

モード戻り値の扱いショートサーキット実行順序典型的な用途
emit戻り値を無視しないブロードキャスト、順序は保証しない通知、ログ、トラッキング
serial基本的に無視、順番に待機しない厳密に直列順序性のある副作用の初期化、登録
bail最初の非空が有効する、かつ不可逆ショートサーキット点まで直列権限インターセプト、キャッシュヒット、サーキットブレーカー
waterfall次の段への入力として使用しない厳密に直列ペイロード加工、ミドルウェア的な処理

この表をチームのドキュメントに貼っておけば、「この拡張ポイントにはどの種類のイベントを使うべきか」という不毛な議論を大量に省けます。

Harness の拡張ポイントがイベントの上に構築されている理由:疎結合な通信のトレードオフ

Harness が多くの拡張ポイントをイベント機構の上に構築している根本的な理由は、プラグイン同士が互いを認識しないことにあります。プラグイン A はプラグイン B を import する必要がなく、B のクラス名、メソッド名、コンストラクタ引数を知る必要もありません。A はあるイベント名にコールバックを 1 つ登録することだけを担い、B は適切なタイミングでその名前を発火することだけを担います。両者が共有する契約は 1 つの文字列(イベント名)とペイロードの取り決めに縮小され、これは最も結合度の低い協調の形です。

この設計の利点は拡張性において遺憾なく発揮されます。新しいプラグインを追加しても既存コードを一切変更する必要がなく、正しいイベント名を監視していれば自動的にフローに組み込まれます。逆に、プラグインを削除しても呼び出し側にコンパイルエラーは発生しません。せいぜいあるイベントのリスナーが 1 つ減り、挙動が「追加加工をしない」に退化するだけで、クラッシュはしません。長期的な進化が必要な Agent フレームワークにとって、この「増やしても減らしても主経路を壊さない」という性質は極めて貴重です。

しかし代償も明確です:デバッグコストが著しく上がるのです。呼び出し関係がコード上に明示的に現れないため、「定義へジャンプ」でイベントの発火点を見つけることはできず、イベント名の文字列をグローバル検索するしかありません。イベント名の綴りに微妙な違いがあったり、ペイロードのフィールド名が一致しなかったりしても、問題は起動段階では露見せず、ある機能が「静かに効かない」という形で現れます。先に述べた ctx.on('event-name', ...)ctx.emit('event-name', payload) という基本用法こそ、命名規約を最も確立すべき場所です。

実務上の対策は 3 つあります。1 つ目は、イベント名に統一プレフィックスを設けること(例えば harness:command:before-run のような階層的命名)で、名前の衝突を避けます。2 つ目は、よく使うペイロード構造を共有の型ファイルとして定義し、TypeScript にコンパイル時に型の不一致を捕まえさせることです。3 つ目は、重要な拡張ポイントに「リスナー数」の実行時ログを追加し、想定上限を超えたときに警告を出して、ある変更が誤って二重登録されるのを防ぐことです。

隔離とイベントの交差領域:グループ間イベントは隔離によって遮断されるのか

これは記事全体の中で最も誤解されやすい部分です。多くの人は、isolate を使って shell サービスを二つのインスタンスに分ければ、イベントブロードキャストもそれに伴って分離されると思いがちですが——実際はそれほど単純ではありません。isolate が隔離するのはサービスインスタンスです。それが決めるのは「異なるプラグイングループが同じオブジェクトを受け取るのか、それともそれぞれのコピーを受け取るのか」ということです。一方、イベントの配信可視範囲は、イベント自体がどのスコープ(scope)に紐づいているかによって決まります。

言い換えれば、隔離とイベントは直交する二つの軸です。shell を二つに分けても、影響を受けるのは ctx.shell という参照が指すインスタンスだけです。しかし、group-a 内で ctx.emit('some-event', payload) を呼び出した場合、そのブロードキャストが group-b のリスナーに届くかどうかは、イベントチャネルがルートスコープにバインドされているか、グループ内スコープにバインドされているかによって決まります。ルートにバインドされていればグループ間で可視となり、グループ内にバインドされていれば、そのグループのプラグイン集合の中だけで流れます。

これにより、典型的なトラブルシューティングのシナリオが生まれます。group-a のあるプラグインがイベントを発行し、group-b のプラグインがそれを受け取って応答することを期待したのに、group-b 側がずっと反応しない。このとき、最初に疑うべきは「bail がイベントをショートカットした」ではなく、スコープの境界です。以下のバッチ演習設定を使えば、この現象を素早く再現できます——二つのグループがそれぞれ shell を隔離しつつ、イベントチャネルはデフォルトのままです:

# ファイルパス:scratch-plugin/cordis.yml
# 二つのプラグイングループ group-a と group-b を定義し、それぞれが独自の shell サービスを隔離する
- id: group-a
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true          # このグループ内の shell サービスを独立してインスタンス化させる
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 5000  # group-a の Bash タイムアウトは 5 秒
    - name: './src/plugin-a.ts'

- id: group-b
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config:
        timeoutMs: 60000 # group-b の Bash タイムアウトは 60 秒
    - name: './src/plugin-b.ts'

二つのグループの timeoutMs の違いに注目してください:group-a は 5000、group-b は 60000 です。隔離が有効になれば、group-a 内のプラグインがどう shell を呼び出しても、受け取るのは 5 秒タイムアウトのインスタンスであり、group-b の 60 秒設定に汚染されることは決してありません。これこそが隔離が解決しようとしている問題です——同一サービス、異なるインスタンス、異なる設定。

イベントについては、イベントも厳密にグループ内に制限したいのであれば、ルート ctx で操作するのではなく、グループのスコープ上でリスナー登録とトリガーを明示的に行う必要があります。判断の核心となる問いは:自分の ctx はどこから来たのか? です。グループ内のプラグインの apply が受け取る ctx は自然とグループのスコープを帯びており、それを使って登録したリスナーはそのグループ内に収まります。ルート ctx や層をまたいで渡された参照を使って登録すると、グループ外に出てしまいます。

調査チェックリスト:分離が効いていないときにまず確認すべき5つのポイント

「設定に isolate と書いたのに、2つのグループがまだ同じサービスインスタンスを共有しているようだ」と気づいたときは、やみくもに設定を変更せず、以下の手順に沿って項目ごとに確認してください。ほとんどの失敗パターンをカバーできます:

  1. group 宣言の確認:分離を担うエントリに確かに group: true が付いていることを確認します。group セマンティクスを持たない通常のプラグインエントリでは、isolate フィールドを担う主体がなく、書いても無意味です。
  2. isolate フィールドの確認:分離されるサービス名が isolate の下に書かれており、その綴りがサービス登録名と一致していることを確認します。素材での書き方は isolate: { shell: true } で、キー名はサービス識別子に対応し、値はブールスイッチです。
  3. config 階層の確認:分離宣言とプラグイン設定の階層関係に注意します。サービス設定(たとえば timeoutMs)は、それが属するプラグインエントリの下に置く必要があり、group のトップレベルに平坦化してはいけません。そうしないと未知のフィールドとして黙って無視されます。
  4. インスタンス名前空間の確認:2つのインスタンスが確かに2つの独立したオブジェクトであり、同じオブジェクトが2つのグループから参照されているのではないことを確認します。プラグインの apply 内でインスタンスの特徴(たとえば自動増分 id や設定スナップショット)を出力して検証できます。
  5. スコープ帰属の確認:分離したいイベントリスナー/トリガーが、ルート ctx ではなくグループ内の ctx にぶら下がっていることを確認します。イベントがグループをまたいで可視かどうかはこのステップで決まり、isolate フィールドとは関係ありません。

このチェックリストをチームのトラブルシューティングマニュアルに定着させれば、「分離失效」のような問題の平均特定時間を非常に短く抑えられます。

2026年9月の視点:Harness プラグイン分離とイベントディスパッチの最新実践動向

2026年9月から振り返ると、Harness は複数グループ・複数インスタンスの設定ガバナンスにおいて、かなり安定した実践的コンセンサスをすでに形成しています。第一に分離粒度の細分化です。初期にはサービスディレクトリ全体に対して一括で isolate を有効にするのが習慣でしたが、現在は本当に差異化された設定を必要とするサービスのみを分離する傾向にあります。たとえば shell(タイムアウトが異なる)、ネットワーク出口(プロキシが異なる)、キャッシュ(容量が異なる)などです。すべてのサービスを分離すると、かえって不要なインスタンス膨張とメモリオーバーヘッドを招きます。

第二にイベント境界の取り決めの明示化です。プラグインの数が増えるにつれ、「デフォルトで可視」によってイベントを伝えるのはますます危険になります。A グループのイベントが予期せず C グループに受信されて副作用を引き起こす、といった問題はマルチインスタンス環境では再現が極めて困難です。そのため、ますます多くのチームがイベントを分類し始めています:グローバルイベント(グループ間ブロードキャスト、ルートスコープを通り、フレームワークレベルの通知に使用)とローカルイベント(グループ内を流れ、グループスコープを通り、ビジネスロジックに使用)です。分類後は、イベント名にも区別可能な階層プレフィックスを付け、静的検索を容易にします。

第三に設定即契約という考え方です。isolate がインスタンス境界を決める以上、各グループ内の各サービスの重要パラメータ(先に繰り返し言及した 5000 と 60000 の2つのタイムアウト段階など)は公開契約として管理されるべきです:設定に書き、バージョン管理に載せ、ドキュメントに対照表として列挙します。こうすれば、「なぜ A グループのコマンドは5秒で切れるのか」と誰かに聞かれたとき、答えは設定を見れば一目瞭然で、プラグインのソースコードを探し回る必要はありません。

第四に可観測性の補完です。イベント呼び出しチェーンは見えないため、実行時のリスナー数、ディスパッチモードのヒット統計、ショートサーキット発生率といった指標が特に重要になります。これらを収集することは、「見えない呼び出し関係」にダッシュボードを取り付けることに等しいです。これはイベント機構に固有のデバッグコストに対する体系的な補償です。

まとめとベストプラクティス

記事全体の要点を実行可能なチェックリストに圧縮しました。プロジェクトレビューの checklist にそのまま落とし込むことをおすすめします:

  • 分離はサービスインスタンスにのみ作用し、イベントには作用しない:isolate は「どのインスタンスを取得するか」を決め、イベントの可視範囲はスコープによって決まります。この2つは分けて考えるべきです。
  • group + isolate はセットで使う:isolate は group エントリに載せる必要があり、group: trueisolate: { shell: true } はペアで登場させる必要があります。
  • サービス設定はプラグインエントリの下に置くtimeoutMs: 5000 のようなパラメータは、具体的なサービスプラグインの config にぶら下げる必要があります。階層を間違えると黙って無視されます。
  • waterfall は展開してマージしてから返すのを忘れない{ ...payload, ...change } を使ってフィールドの欠落を避けます。返した値がそのまま次の段階の入力になります。
  • bail は順序契約に依存する:最初の非空の戻り値で終了するため、登録順序を明示的に取り決める必要があります。ランダムな順序に依存してはいけません。
  • serial は順序付きの副作用がある場面で使う:直列保証が必要なときに emit で済ませないでください。emit はブロードキャスト通知のセマンティクスです。
  • イベント選定はコードを書く前にセマンティクスを考える:通知には emit、加工には waterfall、インターセプトには bail、順序付き副作用には serial を使います。
  • イベント名は階層的に命名し、型を共有する:統一プレフィックスで名前の衝突を防ぎ、TypeScript の型でコンパイル時にペイロードの不一致を捕捉します。
  • グループ間通信ではまずスコープの帰属を確認する:リスナーとトリガーがグループ内の ctx にぶら下がっているのか、ルート ctx にぶら下がっているのかを確認します。これがグループ間イベントが機能しない第一の容疑点です。
  • 分離が効かないときは5ステップで確認する:group 宣言 → isolate フィールド → config 階層 → インスタンス名前空間 → スコープの帰属。
  • 可観測性を構築する:リスナー数とショートサーキット発生率を統計し、目に見えないイベントチェーンにダッシュボードを備え付けます。
  • 分離の粒度は必要に応じて細かくする:差異のある設定が必要なサービスのみを分離し、インスタンスの膨張を避けます。

ここまで来ると、スコープ分離とイベントシステムという2本の線がつながります:分離が司るのは「同じサービスがどうやってそれぞれ自分のインスタンスを取得するか」であり、イベントが司るのは「プラグイン同士が互いを意識せずに対話する方法」です。これらを分けて理解し、相互検証することで、複数のプラグインが同じ Harness の中で平和的に共存し、衝突しないようにできます。