M層常駐プロセスが処理途中で消失した際に、担当していた Slack スレッドへ返信を確実に出し直す(=自動復旧)仕組みを設計する。現状は「消失の検出」だけが行われ、復旧(返信の出し直し)が行われないため、CEO の最後の指示に対して沈黙が発生する。この沈黙をゼロにすることがゴール。
default_heartbeat_timeout 300s → 900s に延長済(コミット f2e2b4e)。無出力タイムアウトの基準がこの値。_write_no_response_log、_last_prompt/_active_route 記録)。復旧のログはこの観測系に合わせる。_do_start の pgrep+SIGKILL 自己killレース等)は本件と依存関係がある(後述 §9・§15)。全て実コードで確認済み。ドキュメントではなくソースを一次情報とした。
| 要素 | 挙動 | 根拠(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 |
タスクの状態遷移と、各終端に至る復旧経路の「有無」を示す。赤い破線が復旧の断絶点。
_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 の最後の指示に返信が出なかった。
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 に変更し、定期駆動で再実行 |
落ちても再開できるために必要な情報と、現状の保持状況。
| 情報 | 用途 | 現状 | 判定 |
|---|---|---|---|
| task_id | タスク特定 | 永続化済 | 十分 |
| thread_ts / channel | 返信先スレッド | 永続化済 | 十分 |
| route(final_route) | どのM層で再開するか | 永続化済(WAITING遷移時に final_route 保存) | 十分 |
| 状態(executing/waiting) | 再開の種別判定 | 永続化済 | 十分 |
| 最後のprompt | 再実行の入力 | prompt[:200] に切詰め。全長は未保持 | 要注意 |
| original_task | M層へ渡した元タスク | 全長で永続化済 | 十分 |
| retry_count / notified | 冪等・上限制御 | 永続化済 | 十分 |
_retry_thread は last_task.prompt(=200文字)を再送するため、長い指示は途中で切れた指示で再実行される。ただし M層は --resume で会話履歴を保持しているため、実務上は「[前回処理が中断されました。続きから応答してください]」の短い再開プロンプトで足りるケースが多い。
※ 現状の永続化は「新しい永続層の追加は不要」レベルに揃っている。核心は「持っている情報を再実行へ繋ぐ配線」の問題であり、状態管理の作り直しではない。
復旧で最も危険なのは「同じスレッドに2回返信」「再起動が誘発する連鎖リトライ」。多層ガードで守る。既存の部品を最大限流用する。
| # | ガード | 効果 | 既存/新規 |
|---|---|---|---|
| G1 | 状態ゲート: STALE のみリトライ可。COMPLETED/EXECUTING/新QUEUED は除外 | 完了済・実行中への二重発火を防ぐ | 既存(get_unnotified_stale_threads) |
| G2 | notified フラグ: リトライ前に mark_notified。get_unnotified_stale_threads が未通知のみ抽出 | 1タスク=リトライ通知1回 | 既存(task_tracker.py:271-281) |
| G3 | newer_active ガード: スレッドに新しい QUEUED/EXECUTING/COMPLETED があればスキップ | CEOが既に再送・処理継続中なら復旧しない | 既存(app.py:3917-3933) |
| G4 | min_age: 一定秒数未満の若いタスクは対象外 | 生成直後の即リトライ=無限ループ防止 | 既存(既定300s。孤児用に短縮を検討) |
| G5 | retry_count / MAX_RETRY_COUNT=3: スレッド単位のハード上限 | 連鎖リトライの打ち切り | 既存(task_tracker.py:18) |
| G6 | MAX_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回に | 新規(既存パターン踏襲) |
_cleanup_stale で一斉 STALE → 一斉リトライ → その最中にまた再起動… という連鎖が実例(15:01〜15:57)。G5/G6 の上限に加え、スレッド単位の指数バックオフ(同一スレッドの復旧間隔を 60s→120s→240s と広げる)を新規に入れて連鎖を減衰させる。
bot再起動は「必ず孤児を生む」構造的トリガ。ここを丁寧に扱わないと復旧が連鎖を悪化させる。
_cleanup_stale(ACTIVE→STALE)が起動時に走る。ここを復旧の権威点とし、起動時と稼働中で同じ STALE→リトライ経路を共有する(経路の二重化を避ける)。_do_start の pgrep+SIGKILL が「起動直後の同 session_id 現役プロセス」を巻き込む余地が残る(researcher 仮説#3)。復旧リトライで立てたプロセスが即killされると復旧ループになる。→ 本件の攻めた自動リトライは、#662 の自己killレース修正を先行または同時に行うことを前提条件とする(§15 リスク)。_cleanup_stale が保険になるため、必須ではなく上積み。無言で止めないことが要件。復旧が上限に達したら必ず一言返す。
:warning: 処理が中断しました。お手数ですが、もう一度送ってください。 をスレッドへ投稿。現状 _cleanup_stale の ERROR 化はサイレント(Slack通知なし)なので、ここに通知を追加するのが主変更点。settings.alert_channel に投稿し、CEO/運用が気づける状態に(#691 observability と整合)。_build_retry_blocks)を復旧失敗時にも提示し、CEOが手動再実行できる導線を残す。発散→収束の結果。評価軸: 復旧の確実性 / 実装リスク / 遅延 / 保守性。
| 案 | 概要 | Pros | Cons | 採否 |
|---|---|---|---|---|
| 案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 は方針・リスク面で却下。
_registry_writer)。ERROR ではなく STALE にし、起動時 _cleanup_stale と同一の意味に揃える。_retry_stale_batch を「起動時だけ」から「定期(例: 60秒間隔の専用ループ or 既存5秒ループに間引き)」でも呼ぶ。Monitor コマンドもこの駆動へ集約(G7)。_retry_thread を駆動(§7)。全長prompt永続化はまず不要とする。| ファイル | 関数 | 変更内容 | 種別 |
|---|---|---|---|
| bot/app.py | _registry_writer(3767-3786) |
孤児検出の遷移先を ERROR → STALE に変更。構造化ログ(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) と既存リトライ部品の「配線し直し+一本化」が主。
| 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破損時の保険 | 中〜高 |
| リスク | 影響 | 対処 |
|---|---|---|
| #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 経路に委ね、二重化しない。 |
_do_start 自己killレース)の修正が Phase1 の攻めた自動リトライより先行 or 同時であること(要 platform 判断)。stats() は error に STALE を含むため表示は要確認)。| 案 | 内容 | 採否 | 理由 |
|---|---|---|---|
| 案A | STALE統一+単一定期駆動 | 採用 | — |
| 案B | 失敗箇所インライン復旧 | 採用(Phase2) | 高速だが bot 全死は捕捉不能・単独では不十分 |
| 案C | 外部Monitor駆動のみ | 却下 | プロセス外依存・in-process ギャップが残る |
| 案D | watchdog再導入 | 却下 | #655/#660 方針に逆行・#662 レース増幅 |