M層 自動復旧(Orphan Recovery)設計書

M層常駐プロセスが処理途中で消えた際、担当スレッドへ返信を出し直す仕組み
Issue #545 起票元: platform M層 DELEGATE 作成: W層 architect 日付: 2026-07-03 フェーズ: 見立てる/仕立てる(設計のみ・実装なし) 前提: CEO承認済 core reliability → auto-merge 非対象

この設計書の読み方(CEO向け 3行サマリー)

目次

1. 目的

M層常駐プロセスが処理途中で消失した際に、担当していた Slack スレッドへ返信を確実に出し直す(=自動復旧)仕組みを設計する。現状は「消失の検出」だけが行われ、復旧(返信の出し直し)が行われないため、CEO の最後の指示に対して沈黙が発生する。この沈黙をゼロにすることがゴール。

2. 制約

3. 現状(確認済みの事実)

全て実コードで確認済み。ドキュメントではなくソースを一次情報とした。

要素挙動根拠(file:line)
タスク状態 ACTIVE = {received, classifying, routing, pending_approval, queued, executing, formatting, waiting_for_response, rerouting}。終端 = completed / error / stale。 task_tracker.py:21-47
永続化 タスクは JSON にアトミック保存(tmp→rename)。ただし prompt[:200] に切り詰めて保存original_task(M層へ渡した文字列)は全長で保持。 task_tracker.py:223-236, 305-331
起動時クリーンアップ _cleanup_stale(): 起動時に ACTIVE のまま残ったタスクを STALE に(retry_count≥3 なら直接 ERROR)。STALE+notified → ERROR。 task_tracker.py:113-141
稼働中の孤児検出 _registry_writer() が 5秒ループ。get_orphaned_active_tasks()(EXECUTING/FORMATTING かつ registry に task_id 無し・updated_at から60秒超・未通知・channel有)を ERROR にマーク。ここが問題 app.py:3739-3786 / task_tracker.py:283-303
STALE 自動リトライ _retry_stale_batch(): STALE のみ対象。min_age=300s、最大3スレッド、未通知のみ。Slackに「中断されていました。自動リトライ中…」を投稿し _retry_thread を発火。 app.py:3816-3858
リトライ駆動の呼び出し _retry_stale_batch_notify_stale_threads() 経由で起動時に1回だけ(+5分後の遅延1回)呼ばれる。稼働中の定期駆動が存在しないここが問題 app.py:3861-3873, 4290
再実行の実体 _retry_thread(): スレッド最終タスクの prompt_respond で再実行。newer_active ガード(新しい QUEUED/EXECUTING/COMPLETED があればスキップ)、retry_count 引継ぎ、clear_stale_for_retry app.py:3887-3964
WAITING 再開 WAITING_FOR_RESPONSE のスレッドに返信が来ると、IF分類をスキップして M層を --resume で再開(find_waiting_task)。 app.py:1259-1290 / task_tracker.py:419-435
send_prompt self-heal F-stdin(BrokenPipe→restart再送)、F-eof(stdout EOF→restart 1回だけ再送)、F-rate(RateLimit→アカウント切替 1回)、F-abs-timeout(900s→restart するが raise)、F-heartbeat(無出力→即 raise、リトライ対象外) persistent_session.py:530-638, 675-685
外部Monitor経路 3線 Monitor が orphan_retry/orphan_error/waiting_stale コマンドを投入 → app.py が STALE化+リトライ/ERROR確定。 app.py:3987-4088
シグナル処理 SIGTERM/SIGHUP/SIGINT で event loop を stop(finally でクリーンアップ)。in-flight タスクを退避・再開マークする処理は無い app.py:4187-4206

4. 現状の状態遷移図

タスクの状態遷移と、各終端に至る復旧経路の「有無」を示す。赤い破線が復旧の断絶点

stateDiagram-v2 direction TB [*] --> EXECUTING: send_prompt 実行中 EXECUTING --> COMPLETED: DONE/MAX_ROUNDS EXECUTING --> WAITING: APPROVAL_NEEDED/CONSULT WAITING --> EXECUTING: CEO返信 (--resume 再開) EXECUTING --> ERROR: 稼働中の孤児検出
(_registry_writer) EXECUTING --> STALE: 起動時 _cleanup_stale
(ACTIVE→STALE) STALE --> EXECUTING: _retry_stale_batch
(起動時のみ・返信出し直し) ERROR --> ERROR: 自動復旧なし
(沈黙) STALE --> ERROR: retry_count≥3 / notified ERROR --> [*] COMPLETED --> [*] note right of ERROR 稼働中に落ちると ここへ入り、誰も 再実行しない = 沈黙 end note note right of STALE 再実行の対象だが 駆動は起動時のみ end note
図1: 現状の状態遷移。EXECUTING→ERROR(稼働中孤児)と、STALE の駆動が起動時限定である点が沈黙の原因。

5. 根本原因(核心のギャップ)

2つの箱の食い違い+駆動の欠落。
  1. 箱の食い違い: 稼働中の孤児検出は EXECUTING/FORMATTING を ERROR にする(app.py:3780-3784)。しかし自動リトライは STALE しか見ない(app.py:3825)。→ 稼働中に落ちたタスクは永久に再実行されない
  2. 駆動の欠落: STALE を拾う _retry_stale_batch起動時にしか呼ばれないapp.py:4290)。稼働中に STALE が生まれても、次の bot 再起動まで再実行されない。

実例(thread=1782971925.798899, 2026-07-02 15:01〜15:57 に orphan 5回以上)は、この2点の合わせ技。bot再起動が処理中のM層を巻き込んで落とし、①孤児→ERROR で再実行対象外になったか、②STALE化しても稼働中の駆動が無く沈黙、のいずれか(またはbot再起動ループ)で、CEO の最後の指示に返信が出なかった。

補足: send_prompt の self-heal(F-eof/F-heartbeat 等)が最終的に失敗すると例外が呼び出し側へ伝播し、タスクは EXECUTING のまま残る → 5秒後に孤児検出 → ERROR。つまり「3つのトリガ(EOF/無出力/孤児消失)」はすべて最終的に同じ ERROR の沈黙に収束する。復旧の統一ポイントは「STALE 化 + 定期駆動 + 冪等な返信出し直し」の一本化。

6. 論点1: 復旧トリガの定義と再開点

3つのトリガそれぞれについて、現状の到達先と、あるべき「スレッドへ返信を出し直す再開点」を定義する。

トリガ発生源(現状)現状の到達先あるべき再開点
(a) stdout EOF send_prompt F-eof(1回 restart 再送)。再送も失敗すると raise。persistent_session.py:618-628 例外伝播 → EXECUTING 残留 → 孤児検出 → ERROR タスクを STALE に落とし、定期駆動で _retry_thread(--resume で会話継続、返信出し直し)
(b) heartbeat 無出力 F-heartbeat はリトライ対象外で即 raise(900s基準・#695)。persistent_session.py:675-685 例外伝播 → EXECUTING 残留 → 孤児検出 → ERROR 同上。無出力=プロセスは生存の可能性 → restart 後 --resume 再開
(c) orphan プロセス消失 _registry_writer が EXECUTING/FORMATTING を検出。app.py:3767-3786 直接 ERROR(再実行されない) STALE に変更し、定期駆動で再実行
統一原則: 3トリガとも「EXECUTING/FORMATTING で処理未完のまま担当プロセスが失われた」状態。復旧の唯一の入口を「タスクを STALE にする + 単一の定期リカバリ駆動」に集約する。個別トリガごとに別々の復旧ロジックを作らない(Less is more)。

7. 論点2: 再開情報の永続化

落ちても再開できるために必要な情報と、現状の保持状況。

情報用途現状判定
task_idタスク特定永続化済十分
thread_ts / channel返信先スレッド永続化済十分
route(final_route)どのM層で再開するか永続化済(WAITING遷移時に final_route 保存)十分
状態(executing/waiting)再開の種別判定永続化済十分
最後のprompt再実行の入力prompt[:200] に切詰め。全長は未保持要注意
original_taskM層へ渡した元タスク全長で永続化済十分
retry_count / notified冪等・上限制御永続化済十分
prompt 切詰め問題: _retry_threadlast_task.prompt(=200文字)を再送するため、長い指示は途中で切れた指示で再実行される。ただし M層は --resume で会話履歴を保持しているため、実務上は「[前回処理が中断されました。続きから応答してください]」の短い再開プロンプトで足りるケースが多い。
設計判断: 新規に全長prompt を永続化する前に、まず --resume 前提の短い再開プロンプト方式を採る(§12)。session jsonl 破損で --resume が使えない場合の保険として、全長prompt の永続化は Phase 3 のオプションとする(YAGNI。まず最小で解く)。

※ 現状の永続化は「新しい永続層の追加は不要」レベルに揃っている。核心は「持っている情報を再実行へ繋ぐ配線」の問題であり、状態管理の作り直しではない。

8. 論点3: 二重返信・無限ループ防止(最重要)

復旧で最も危険なのは「同じスレッドに2回返信」「再起動が誘発する連鎖リトライ」。多層ガードで守る。既存の部品を最大限流用する。

#ガード効果既存/新規
G1状態ゲート: STALE のみリトライ可。COMPLETED/EXECUTING/新QUEUED は除外完了済・実行中への二重発火を防ぐ既存(get_unnotified_stale_threads
G2notified フラグ: リトライ前に mark_notifiedget_unnotified_stale_threads が未通知のみ抽出1タスク=リトライ通知1回既存(task_tracker.py:271-281
G3newer_active ガード: スレッドに新しい QUEUED/EXECUTING/COMPLETED があればスキップCEOが既に再送・処理継続中なら復旧しない既存(app.py:3917-3933
G4min_age: 一定秒数未満の若いタスクは対象外生成直後の即リトライ=無限ループ防止既存(既定300s。孤児用に短縮を検討)
G5retry_count / MAX_RETRY_COUNT=3: スレッド単位のハード上限連鎖リトライの打ち切り既存(task_tracker.py:18
G6MAX_STALE_RETRIES=3 スレッド/回: 1回の駆動で最大3スレッド再起動直後の一斉ファンアウト抑制既存(app.py:3829-3836
G7単一駆動原則: 稼働中リカバリ駆動を1本に限定。Monitor コマンドも同じ駆動へ集約(並列ループを作らない)複数ループの競合による二重返信を構造的に排除新規
G8復旧通知の冪等キー: {thread_ts}:{retry_count}:recovery。既存の Issue コメント冪等キー({thread_ts}:{_retry_count}:result)と同型同一ラウンドの復旧通知を1回に新規(既存パターン踏襲)
連鎖の根源への注意: bot再起動が処理中プロセスを殺す → 全 in-flight が孤児化 → 起動時 _cleanup_stale で一斉 STALE → 一斉リトライ → その最中にまた再起動… という連鎖が実例(15:01〜15:57)。G5/G6 の上限に加え、スレッド単位の指数バックオフ(同一スレッドの復旧間隔を 60s→120s→240s と広げる)を新規に入れて連鎖を減衰させる。

9. 論点4: bot再起動との相互作用

bot再起動は「必ず孤児を生む」構造的トリガ。ここを丁寧に扱わないと復旧が連鎖を悪化させる。

flowchart TB A["bot 再起動
(launchd/手動)"] --> B["処理中 M層プロセスが
巻き込まれ死亡"] B --> C["全 in-flight が孤児化"] C --> D{"再起動 or 稼働中?"} D -->|"起動時"| E["_cleanup_stale
ACTIVE→STALE"] D -->|"稼働中"| F["_registry_writer
孤児→STALE (改修後)"] E --> G["単一リカバリ駆動
(定期・冪等)"] F --> G G --> H{"retry_count
< 3 ?"} H -->|"Yes"| I["スレッドへ返信出し直し
--resume 再開"] H -->|"No"| J["フォールバック通知
『中断しました、再送を』"]
図2: 再起動・稼働中の両経路を単一のリカバリ駆動へ合流させる。

10. 論点5: 復旧不能時のフォールバック

無言で止めないことが要件。復旧が上限に達したら必ず一言返す

11. アプローチ比較(代替案)

発散→収束の結果。評価軸: 復旧の確実性 / 実装リスク / 遅延 / 保守性。

概要ProsCons採否
案A
STALE統一+定期駆動
孤児→STALE に統一し、稼働中も定期的に _retry_stale_batch を回す。既存部品を配線し直す。 既存資産最大流用・変更が小さい・経路が一本化され冪等ガードが効く 最短で数十秒の復旧遅延(ポーリング間隔) 採用(骨格)
案B
失敗箇所インライン復旧
route_to_manager の send_prompt 失敗(EOF/無出力/timeout)を捕捉し、その場で再投稿+リトライ。 復旧が速い(ポーリング待ちなし) bot自体が死ぬケースは捕捉不能・_retry_thread とロジック二重化 採用(Phase2 の高速化補完)
案C
外部Monitor駆動のみ
3線 Monitor の orphan_retry に全面依存。 本体と関心分離 プロセス外依存でレイテンシ・単一障害点増・in-process ギャップは残る 却下
案D
watchdog監視スレッド再導入
専用スレッドでプロセス生死を能動監視し復旧。 能動検出で速い #655/#660 で削除した方針に逆行・自己killレース増幅(#662) 却下

結論: 案A を骨格に、案B を高速化の補完として段階導入。案C/D は方針・リスク面で却下。

12. 推奨案と復旧フロー

推奨: 案A(STALE統一+単一定期駆動)+ 案B(インライン高速化・後続)

  1. 孤児→STALE に統一_registry_writer)。ERROR ではなく STALE にし、起動時 _cleanup_stale と同一の意味に揃える。
  2. 単一リカバリ駆動を稼働中も回す_retry_stale_batch を「起動時だけ」から「定期(例: 60秒間隔の専用ループ or 既存5秒ループに間引き)」でも呼ぶ。Monitor コマンドもこの駆動へ集約(G7)。
  3. 孤児STALE 用の min_age を短縮。孤児検出はすでに「60秒超 EXECUTING」を含意するため、通常の restart-STALE(300s)と別に、孤児由来 STALE は 60〜120s で拾えるようにする(早期復旧と無限ループ防止の両立)。
  4. --resume 前提の短い再開プロンプト_retry_thread を駆動(§7)。全長prompt永続化はまず不要とする。
  5. 冪等キー G8 + スレッド指数バックオフで二重返信・連鎖を抑止。
  6. 上限到達で明示フォールバック(§10)。サイレントERRORを廃し、スレッドと alert_channel に一言返す。
sequenceDiagram participant M as M層プロセス participant R as _registry_writer(5s) participant T as TaskTracker participant D as リカバリ駆動(定期) participant S as Slackスレッド M-->>R: 処理中に消失(孤児) R->>T: 孤児→STALE (改修: ERROR廃止) Note over D: 60秒間隔で駆動 D->>T: get_unnotified_stale_threads(min_age) T-->>D: STALEスレッド (未通知/上限内) D->>T: mark_notified (G2) D->>S: 「中断→自動リトライ中」(冪等キー G8) D->>M: _retry_thread → --resume 再開 alt 復旧成功 M->>S: 返信を出し直し M->>T: COMPLETED else retry_count≥3 D->>S: 「中断しました、もう一度送ってください」 D->>T: ERROR + alert_channel通知 end
図3: 推奨復旧フロー(シーケンス)。孤児→STALE→単一駆動→冪等な出し直し→上限でフォールバック。

13. 変更対象ファイルと関数

ファイル関数変更内容種別
bot/app.py _registry_writer(3767-3786) 孤児検出の遷移先を ERRORSTALE に変更。構造化ログ(route/task_id/thread/理由)を #691 観測系に合わせて出力。 改修
bot/app.py 新規 _recovery_driver(or 既存ループへ間引き追加) 稼働中に _retry_stale_batch を定期駆動(例60s)。Monitor コマンドもこの単一駆動へ集約(G7)。 新規
bot/app.py _retry_stale_batch(3816) 孤児由来 STALE 用に min_age を短縮パラメータ化。上限超過時の明示フォールバック通知を追加。スレッド指数バックオフ導入。 改修
bot/task_tracker.py get_unnotified_stale_threads(238) 孤児由来と再起動由来を区別する起点フィールド(例 stale_origin)を追加し、min_age を出し分け可能に。 改修
bot/task_tracker.py _cleanup_stale(113)/ update ERROR 確定時に「フォールバック通知が必要」なタスク集合を app.py へ返せるようにする(サイレントERROR廃止のため)。 改修
bot/app.py _handle_orphan_retry/_handle_waiting_stale(4058-4088) 独自リトライを廃し、単一リカバリ駆動へ委譲(G7 の一本化)。 改修
bot/app.py _retry_thread(3887) 復旧通知に冪等キー G8 を適用。--resume 前提の短い再開プロンプト対応。 改修
bot/app.py SIGTERM ハンドラ(4187)— Phase3 (任意)in-flight EXECUTING を recovery_needed で STALE 保存してから loop stop。 新規(任意)

※ 新しい永続層・DB・監視スレッドの追加は不要。既存の TaskTracker(JSON) と既存リトライ部品の「配線し直し+一本化」が主。

14. 段階導入の順序

Phase内容閉じる問題リスク
Phase 0
観測性のみ
孤児→STALE化の構造化ログ+復旧失敗時の alert_channel/スレッド通知だけ先に入れる(リトライ挙動は変えない)。#691 と整合。 サイレントERROR(無言で止まる)を解消
Phase 1
核心の配線
孤児→STALE 統一 + 稼働中の単一定期駆動 + 保守的 min_age(120s) + G5/G6/G8 + 上限フォールバック。 「検出だけで復旧しない」核心ギャップ
Phase 2
高速化補完
案B インライン復旧(send_prompt 失敗の即時再投稿)。ポーリング遅延を短縮。 復旧遅延(数十秒)
Phase 3
上積み
graceful シャットダウンマーク +(必要なら)全長prompt永続化。#662 自己killレース修正の後に実施。 再起動連鎖・--resume破損時の保険 中〜高
推奨: まず Phase 0(無言解消・低リスク)を独立PRで入れ、観測ログで復旧対象の実発生を確認してから Phase 1 の自動リトライを有効化する。段階的に安全弁を確認しながら攻める。

15. リスク・懸念

リスク影響対処
#662 自己killレース未修正のまま攻めた自動リトライ 復旧で立てたプロセスが _do_start pgrep+SIGKILL で即kill → 復旧ループ悪化 前提条件: #662 の自己killレース修正を Phase1 と先行/同時に。未修正なら min_age を大きめ+バックオフを厚めに。
二重返信 CEO へ同じ返信が複数 G1-G8 の多層ガード。特に G7 単一駆動・G8 冪等キー。
リトライストーム(再起動連鎖) 一斉リトライで負荷・レート制限誘発 G5(上限3)/G6(3スレッド/回)+スレッド指数バックオフ。
切詰めpromptでの再実行 不完全な指示で復旧応答 --resume 前提の短い再開プロンプト。破損時保険は Phase3。
孤児検出の誤検知(false positive) 生存中タスクを誤って復旧 registry 未登録ノイズ(researcher C9)に注意。min_age+registry 突合+newer_active(G3)。#691 のログで実測してから閾値調整。
WAITING タスクの扱い 返信待ちを孤児と誤認して勝手に再実行 WAITING はプロセス非保持のため孤児対象外(現状 get_orphaned_active_tasks は EXECUTING/FORMATTING のみ=正しい)。WAITING の中断は Monitor waiting_stale の TTL 経路に委ね、二重化しない。

16. 設計完了チェックリスト

前提条件

副作用・影響範囲

代替案(却下理由)

内容採否理由
案ASTALE統一+単一定期駆動採用
案B失敗箇所インライン復旧採用(Phase2)高速だが bot 全死は捕捉不能・単独では不十分
案C外部Monitor駆動のみ却下プロセス外依存・in-process ギャップが残る
案Dwatchdog再導入却下#655/#660 方針に逆行・#662 レース増幅

リスク・懸念(要約)


M層への報告要点