# Pod の上流イベント報告 (子 → 親) ## 背景 spawned Pod(子)のライフサイクルに親 Pod が反応する仕組みが必要。反応には 2 系統ある: 1. **system 処理**: `spawn_pods.json` 更新、`scope.lock` 整合、孫 Pod の把握 2. **LLM 通知**: 親 LLM に「子でこれが起きた、次どうする?」を考えさせる これを実現する通信 primitive を分離して定義する。既存の `Method::Notify`(LLM 向け自由テキスト注入)と混ぜない。 ## 依存 - `tickets/spawn-pod-tool.md`: 済。callback address の受け渡しと spawn 記録 - `tickets/scope-lock.md` 済に含まれる: allocation の delegate/reparent は既存 - `Method::PodEvent` の追加は `protocol` crate の拡張 ## 設計 ### 新しい primitive ```rust pub enum Method { Run { input: String }, Notify { message: String }, // 人間・tool → LLM 文脈、副作用なし(本チケットで source を削除) PodEvent(PodEvent), // 新: 子 → 親、typed なライフサイクル報告 Resume, Cancel, Shutdown, GetHistory, } pub enum PodEvent { /// 子が1ターン終えて IDLE になった TurnEnded { pod_name: String }, /// 子で Worker 実行エラーが発生した Errored { pod_name: String, message: String }, /// 子が停止した ShutDown { pod_name: String }, /// 子が孫 Pod に scope を又貸しした ScopeSubDelegated { parent_pod: String, // 又貸し元(= 子自身) sub_pod: String, // 孫 Pod の名前 sub_socket: PathBuf, // 孫 Pod の socket path scope: Vec, // 又貸しされた scope(`protocol` crate に移動) }, } ``` ### `Method::Notify` の `source` 削除 現状 `Method::Notify { source: String, message: String }` の `source` フィールドを削除して `Method::Notify { message: String }` にする。`PodEvent` が typed な子 → 親報告を担うようになったことで、`Notify` は本来の「人間・tool が LLM 文脈に自由テキストを注入する」役割に純化する(発信者の識別は不要になる)。 影響範囲: - `protocol::Method::Notify` の定義変更、serde round-trip テストの更新 - Controller main loop の `Method::Notify { source, message } => pod.push_notification(source, message)` を `pod.push_notification(message)` 形へ変更 - `Pod::push_notification(source, message)` のシグネチャから `source` を落とす(呼び出し元を追って整理) - 既存テスト(`controller_test.rs` の Notify ケース、`pod_comm_tools_test.rs` など)の入力を新シグネチャに揃える ### `ScopeRule` / `Permission` の移動 `ScopeRule` と `Permission` は wire 型として `PodEvent::ScopeSubDelegated` で protocol を経由する必要があるため、現状の `manifest` crate から `protocol` crate へ移動する。`manifest` は `protocol::ScopeRule` を re-export するか、単に `protocol::ScopeRule` を直接参照する形に切り替える。移動により `protocol` → `manifest` の逆依存が発生しないようにする。 ### 子(発信側) 子の Controller が以下のタイミングで親 socket に一発接続して `Method::PodEvent` を送信し、即切断する(fire-and-forget)。 | タイミング | variant | |---|---| | `RunEnd { result: Finished }` 発行時 | `TurnEnded` | | Worker 実行エラー発生時(後述) | `Errored` | | shutdown シーケンス(controller loop 終了直前) | `ShutDown` | | `SpawnPod` tool 成功直後 | `ScopeSubDelegated` | `Errored` の対象は **`run_with_cancel_support` 内で Worker 実行が失敗した `Event::Error`** に限定する。`AlreadyRunning` / `NotPaused` / `NotRunning` のような method 拒否応答はクライアント向けの一時的なフィードバックであり、親に通知すべき子のライフサイクルイベントではない。実装時は発火点を worker エラー経路に絞ること。 送信は非同期 spawn で発射し、await しない。接続失敗はログのみで続行(親が落ちていても子は生きる)。 親 socket のアドレスは spawn 時に `--callback ` で受け取って保持している(既存)。 ### 又貸しの親連鎖 `ScopeSubDelegated` は**直接の親にのみ送る**。曾孫が現れた場合: 1. 孫 (C) が曾孫 (D) を `SpawnPod` する 2. C は自分の親 (B) に `ScopeSubDelegated { parent_pod: C, sub_pod: D, ... }` を送る 3. B は D を自分の `spawned_pods.json` に追加し、さらに B の親 (A) へ `ScopeSubDelegated { parent_pod: B, sub_pod: D, ... }` を再発射する これを再帰的に繰り返すことで、`scope.lock` の `delegated_from` チェーンと一致した形で root まで D の存在が登録される。各 Pod は「自分の直接の子+紹介された孫(= 更に下の Pod も含む)」を把握し、孫より下の階層は子経由で管理される。 再発射は `ScopeSubDelegated` 受信時の system 処理の一部として行う(後述の表に反映)。 ### 親(受信側) 親 Controller の main loop に `Method::PodEvent` ハンドラを追加: ```rust Method::PodEvent(event) => { // (1) system 処理 — variant ごとに固有 apply_event_side_effects(&event, &spawned_registry, &lock_path).await; // (2) LLM 通知 — variant を文字列にレンダリングしてバッファへ let text = render_event(&event); pod.push_notification(text); } ``` variant 別の (1) の中身: | variant | system 処理 | |---|---| | `TurnEnded` | なし | | `Errored` | なし(LLM に判断させる) | | `ShutDown` | `spawned_registry.remove(pod_name)`、scope lock を flock して該当 allocation を `release_pod` で解放 | | `ScopeSubDelegated` | `spawned_registry.add(SpawnedPodRecord { sub_pod, sub_socket, scope, ... })` で孫を追加。続けて自分の親(いれば)へ `ScopeSubDelegated { parent_pod: self, sub_pod, ... }` を再発射(親連鎖参照) | (2) の `render_event` は一箇所に集約し、`format!("Pod `{pod_name}` finished a turn.")` のような短い human-readable 文字列を返す。 ### Controller の配線変更 現状 `PodController::spawn` 内で `SpawnedPodRegistry` は tool 登録のためだけに生成され、main loop の `method_rx` 処理ループには渡っていない。`Method::PodEvent` ハンドラを追加するには: - `spawned_registry: Arc` を main loop に `clone` で持ち込む - `scope_lock::default_lock_path()` を event 処理時に呼ぶ(毎回 open するので保持は不要) - 親 callback socket(自分の親、トップ Pod では `None`)を `Pod::from_manifest_spawned` 経由で既に保持している値から再利用(再発射で使う) `apply_event_side_effects` は Controller の main loop から呼ぶ非同期関数として定義する。 ### 失敗時のフォールバック - 子 → 親の PodEvent 送信が失敗しても諦める(再試行しない) - 親が再起動した場合や送信漏れた場合は、親の `ListPods` ツール(既存)による health check + `scope_lock::reclaim_stale` の stale 回収で不整合を解消する - これは「コールバックは最適化、ポーリングが真のフォールバック」という方針の継続 ## 決定事項 - **順序保証は求めず、ハンドラを冪等・遅延到着に強くする**: fire-and-forget の unix socket 接続は順序を保証しない。`TurnEnded` 直後に `ShutDown` が届いても、逆順で到着しても親側で成立するように `apply_event_side_effects` を設計する。具体的には: - `ShutDown` 受信時、すでに registry から削除済みでもエラーにしない(`release_pod` の `UnknownPod` を swallow する既存挙動を踏襲) - `TurnEnded` が `ShutDown` より後に届いても、該当 Pod が既に registry にいなければ render だけして終える(LLM 向け通知は出る、system 処理は no-op) - `ScopeSubDelegated` で孫が既に registry にいたら上書きせず no-op(`DuplicatePodName` を swallow) - **`ScopeSubDelegated` の親連鎖は直接の親のみ + 再発射**: 上記「又貸しの親連鎖」セクション参照。曾孫以上は再帰的に再発射で root まで届く - **送信の接続タイムアウト**: `SpawnPod` / pod-comm-tools と揃えて 5 秒固定 ## 完了条件 - `Method::PodEvent(PodEvent)` が `protocol` crate に追加され、serde round-trip テストが通る - 子の Controller が 4 種の variant を適切なタイミングで親 socket に送信する。`Errored` は Worker 実行エラーにのみ限定されることを確認 - 親の Controller が variant ごとの system 処理を実行し、レンダリングした文字列を LLM 通知バッファに流す - `ScopeSubDelegated` 受信後、孫 Pod が親の `spawned_pods.json` に現れ、親が更に上位親を持つ場合は上位へ再発射される - `ShutDown` 受信後、該当 Pod が親の registry から消え、scope lock からも解放される - イベント到着順が入れ替わっても副作用が安全(冪等)であることを単体テストで確認 - 送信失敗しても子プロセスが続行する - 各 variant の送受信を検証する単体テスト ## 範囲外 - リモート親への送信(SSH 越し)。ローカル Unix socket のみ - 配信保証(at-least-once / exactly-once) - 親再起動時の「見逃したイベント」の再送。ポーリングで補う前提