孤児検知の監視マトリクス(「中断しました」誤報ゼロ設計)

何を・いつ・どう見て・どう判定し・どう動くか — 監視対象ごとの網羅整理
Issue #552 起票元: platform M層 DELEGATE 作成: W層 architect 日付: 2026-07-04 フェーズ: 見立てる/仕立てる(設計のみ・実装なし) 関連: #545 orphan recovery(後段)

CEO向け 3行サマリー

目次

1. 誤報の正体(コードで確認)

すべて実コードで裏取り済み。ドキュメントではなくソースを一次情報とした。

#事実根拠(file:line)
書き間違い(根本原因): M層の常駐セッションは from bot.process_registry import process_registry を import しようとするが、モジュールが公開する名前は registry だけ。存在しない名前なので ImportError → except で process_registry = None になる。以降 if process_registry and ... は常に偽 → M層のPIDは一度もレジストリに登録されない persistent_session.py:526-528, 536
process_registry.py:183(公開名は registry
他の呼び出し元は正しく from bot.process_registry import registry と書けている(=書き間違いはM層登録経路の1箇所だけ)。 app.py:40 / claude_runner.py:18
代理登録: M層がW層に委譲すると、W層 subprocess は親M層と同じ task_id でレジストリに登録される(_run_workers(delegations, task_id, ...)run_claude(task_id=task_id, route=d.worker)registry.register(pid, task_id=task_id))。W層は正常終了時に registry.unregister(pid) で姿を消す。 router.py:674, 1158-1164
claude_runner.py:469, 503
誤判定エンジン: _registry_writer が5秒ごとに active_task_ids()(=今レジストリに載っているtask_id)を取り、get_orphaned_active_tasks で「EXECUTING/FORMATTING かつ task_idが名簿に無い かつ 更新から60秒超 かつ 未通知 かつ channel有」を孤児と判定 → ERROR化+「中断しました」通知 app.py:3779, 3813-3833
task_tracker.py:288-308(閾値=60秒は :296)
時計が止まる: M層タスクは実行開始時に一度だけ EXECUTING に更新され updated_at が記録される。その後、複数ラウンドの send_prompt・W層委譲が何分続いても updated_at更新されないsend_prompt は TaskTracker を触らない)。→ 60秒の物差しは正常な長時間処理でも即超える app.py:1661(EXECUTINGは1回)
router.py:635,707,842(以降 update 呼ばず)
物差しの不整合: M層の1回の応答は最大 default_query_timeout=900秒/無出力許容 default_heartbeat_timeout=900秒まで正常。だが孤児判定は60秒で発火。900秒かかる正当な処理を60秒で「中断」と断じうる。 persistent_session.py:232, 236 vs task_tracker.py:296
M層の生死は is_alive()proc is not None and proc.returncode is None)で直接確認できる。だが孤児判定はこれを使っていない(名簿の突合だけ)。 persistent_session.py:406-407(未接続)
誤報の連鎖(1文): ①でM層本体が名簿に載らない → M層のtask_idを名簿に載せているのは③の「下請けW層」だけ → W層が正常終了して名簿から消える → M層はまだEXECUTINGなのに⑤⑥で60秒判定に引っかかる → ④で「中断しました」と誤報W層が正常に終わるほど誤報が出るという倒錯。
補足: W層に一度も委譲しない「純粋なM層応答」でも、M本体が名簿に載らないため、60秒を超えた瞬間に同じ誤報対象になる。委譲の有無にかかわらず、M層の長時間処理は構造的に誤報リスクを抱えている。

2. 監視対象の一覧(コード上に実在する対象)

ID監視対象実体(コード)誰が生死を持つか登録状況
T1M層セッションプロセス PersistentSession.proc(常駐 claude CLI)persistent_session.py:381,406 proc.returncode 名簿未登録(バグ①)
T2W層 worker subprocess run_claude の subprocess(one-shot)claude_runner.py:456-477 proc終了 / watchdog 親M task_idで登録(代理)
T3IF層 subprocess run_claude(route="direct")(直接回答)app.py:1609-1616 proc終了 / watchdog 自身のtask_idで登録
T4botプロセス本体 launchd常駐の event loopapp.py:4187-4206, 4338 OS / launchd —(監視する側)
T5process_registry エントリ pid → ProcessInfo の辞書process_registry.py:61,64 register / unregister で増減
T6タスク状態(TaskTracker) JSON永続 Task.status / updated_attask_tracker.py:21-47, 338 状態遷移で更新
T7レート制限使用率 get_rate_limit_usage()app.py:3837 閾値通知(副次監視)
T83線 Monitor コマンド経路 orphan_retry / orphan_error / waiting_staleapp.py:3987-4088 —(外部投入) 手動確定の権威

3. 網羅マトリクス(5列)

判定の凡例: 正常終了 実行中 本物の中断 復旧中

「対象 × 監視契機」ごとに1行。現状の問題行には 誤報 を付す。修正提案は §5。

#監視対象いつ監視するか(契機・周期)どんな監視をするか(何を見るか)どう判定するか(判定に使うシグナル)その判定でどう動くか
M-1T1 M層プロセス(孤児判定) _registry_writer 5秒ループapp.py:3790 タスクの task_idactive_task_ids() に有るか+now-updated_at>60s 現状: 名簿に無い=本物の中断と即断(M本体が未登録なので生存中でも「無い」) 現状: ERROR化+「中断しました」通知app.py:3824-3831誤報の発火点
M-2T1 M層プロセス(直接生死) send_prompt 入口/restartpersistent_session.py:531 is_alive()proc.returncode is None 生存でなければ要再起動 start()で起動し直して継続。※孤児判定には未接続(§5 材料1で接続)
M-3T1 M層プロセス(無出力) send_prompt 実行中(heartbeat 900s)persistent_session.py:552 直近出力からの経過秒(_read_until_result 900s無出力→応答停止(プロセスは生存し得る) 例外 raise → EXECUTING残留 → M-1へ落ちる(現状は結局誤報/沈黙に収束)
M-4T1 M層プロセス(絶対timeout) send_prompt 実行中(query 900s)persistent_session.py:551 送信からの総経過秒 900s超→本物の中断 restart後 raise。EXECUTING残留 → M-1へ
M-5T1 M層プロセス(stdout EOF / stdin broken) send_prompt 実行中persistent_session.py:530-638 stdout EOF / BrokenPipe 例外 1回目→自己修復可 / 再発→中断 restart再送(1回)。失敗で raise → EXECUTING残留 → M-1へ
W-1T2 W層 subprocess(生死・登録) 起動時 register / 終了時 unregisterclaude_runner.py:469,503 subprocess の起動・終了(親M task_idで代理登録) 正常終了→正常終了(名簿から消える) 結果をM層へ返す。副作用: 名簿からM task_idが消え、M-1の誤報を誘発誤報の起点
W-2T2 W層 subprocess(無活動) 実行中 _watchdog(heartbeat/absolute)claude_runner.py:494 stdout/stderr の last_activity 無活動→停止(kill対象) SIGKILL。途中結果を「⚠タイムアウト」注記でM層へ返す
W-3T2 W層 subprocess(即死/例外) 起動直後・実行中claude_runner.py:456-500 returncode即座 / RateLimitError / 例外 即死→失敗 エラー文字列でM層へ返す。全W層失敗ならM起動せず打ち切りrouter.py:693
I-1T3 IF層 subprocess 実行中(register/watchdog/os.kill) 自身のtask_idで登録・生死監視 正常終了→正常終了 / 停止→中断 直接回答を返し即COMPLETED。登録が生きているためM層のような誤報は起きにくい(非対称)
R-1T5 registry エントリ(PID実在) _registry_writer 5秒ループapp.py:3795-3805 os.kill(pid, 0) ProcessLookupError→消失 / PermissionError→生存 消失なら unregister(pid)。名簿を実態に同期
R-2T5 registry エントリ(明示解除) 送信完了時 finally W層: registry.unregister(有効)claude_runner.py:503 / M層: process_registry.unregisterNone で無効persistent_session.py:630-633 W層→解除成功 / M層→no-op W層は正しく解除。M層は登録も解除も無効(バグ①の対)
R-3T5 registry(ダッシュボード) 5秒ループapp.py:3809 スナップショットを JSON へ persist —(可視化) AGENTS_STATUS_FILE 出力(判定には使わない)
K-1T6 タスク状態(孤児抽出) 5秒ループ経由app.py:3814 get_orphaned_active_tasks(EXECUTING/FORMATTING・未登録・60s超・未通知・channel有) 該当→本物の中断(現状は誤報混入) ERROR化+通知(M-1と同一経路)
K-2T6 タスク状態(起動時掃除) bot起動時 _cleanup_staletask_tracker.py:113 ACTIVEのまま残ったタスク STALE(retry_count≥3なら ERROR) STALE化して再実行の対象にする
K-3T6 タスク状態(STALE再実行) 起動時1回(+5分後1回)app.py:3863-3873 get_unnotified_stale_threads(min_age) STALE→復旧中 「自動リトライ中」投稿+_retry_thread稼働中の定期駆動は無し(#545の論点)
K-4T6 タスク状態(返信待ち) Monitor waiting_stale TTL WAITING_FOR_RESPONSE の滞留 プロセス非保持→孤児対象外(正しい) 孤児判定に入れない。滞留はMonitor TTLに委譲(二重化しない)
B-1T4 botプロセス(シグナル) SIGTERM/SIGHUP/SIGINTapp.py:4187-4206 シグナル受信 再起動(in-flight退避なし) event loop停止。in-flightはK-2で起動後STALE化
B-2T7 レート制限 5秒ループapp.py:3835-3860 get_rate_limit_usage() と閾値 閾値超→警告 alert通知(1回/閾値)。判定はタスク中断とは独立
B-3T8 Monitor手動確定 3線Monitorコマンド投入時app.py:3987-4088 orphan_retryorphan_errorwaiting_stale 人/Monitorの意思→復旧確定中断 STALE化+リトライ、またはERROR確定(権威的上書き)

4. 終了/消滅パターン網羅表

researcher が洗い出した全パターン(W層8・M層11・bot3・registry2=計24)を、上のマトリクスのどの行で捌くかに対応付ける。「現状」列に取りこぼし(正常終了を誤報 or 本物の中断を見逃し)があるものを赤で明示し、「修正後」(§5)で解消することを示す。

W層 worker(8種)

#パターン捌く行W層自身の判定現状の取りこぼし(親M層への影響)修正後
W1正常 exit0W-1 / R-2正常親M層に誤報を誘発(最悪ケース)M本体登録+is_aliveで誤報なし
W2非ゼロ exitW-3失敗を返す同上(task_id消失で親誤報リスク)解消
W3heartbeat killW-2kill+注記同上解消
W4絶対 timeoutW-2kill+注記同上解消
W5即死W-3 / R-1失敗を返す同上解消
W6レート制限W-3RateLimitError伝播同上解消
W7認証失敗W-3refresh/失敗を返す同上解消
W8セッション無効W-3one-shotで無関係同上解消
W1〜W8 は W層としてはすべて正しく捌けている。取りこぼしは常に「親M層の孤児判定への波及」で、W層が正常/異常どちらで終わっても親M task_idが名簿から消えるため、現状は全パターンが親の誤報リスクを持つ。W1(正常終了)が最も頻繁に誤報を生むのが問題の核心。

M層 session(11種)

#パターン捌く行現状の判定取りこぼし種別修正後
M1正常完了M-160s超で誤報しうる正常終了を中断誤報誤報なし(材料2+3)
M2query timeout 900sM-4→M-160sで先に誤報→その後ERROR沈黙誤報+沈黙is_alive死判定→正しく復旧(材料1)
M3heartbeat無出力M-3→M-1生存中でも中断扱い正常/停滞を中断誤報is_alive生存→待機(材料1)
M4stdout EOFM-5restart再送→失敗でM-1再発時ERROR沈黙死判定→STALE復旧(#545後段)
M5stdin brokenM-5restart再送→失敗でM-1再発時ERROR沈黙同上
M6レート制限アカウント切替M-5相当切替1回で継続切替中の一時未登録is_alive生存→待機
M7即死(rc即座)M-2start時RuntimeError本物の中断(要復旧)死判定→STALE復旧
M8重複プロセスSIGKILL(#662自己killレース)M-2→M-1復旧プロセス即kill→ループ/沈黙本物の中断(要復旧)死判定→STALE復旧。#662修正が前提
M9rotate(新session起動)M-2_busyロック中で送信排他rotate中の一時未登録生存→待機(材料2で新pid再登録)
M10account変更M-2切替後継続一時未登録生存→待機
M11明示stop(意図的)M-2 / B-1shutdown手続き—(意図的)B-1経由で正しく停止

bot レベル(3種)

#パターン捌く行現状の判定取りこぼし修正後
bot1シグナル終了B-1→K-2再起動後STALE化連鎖再起動時に誤報is_alive+バックオフで緩和
bot2起動時 stale 化K-2ACTIVE→STALE 正常維持
bot3Monitor手動確定B-3権威的確定 正常維持

registry 除去(2種)

#パターン捌く行現状の判定取りこぼし修正後
reg1os.kill(pid,0) 消失除去R-1実態同期 正常M層は登録が無く適用されないM層pidも監視対象になる
reg2send_prompt finally unregisterR-2M層は None で no-opM層の登録/解除が両方無効register/unregisterとも有効化
網羅性の結論: 現状で「取りこぼし」があるのは — W1〜W8 全部(親M層への誤報波及)M1・M2・M3・M7・M8(誤報 or 本物の中断の沈黙)bot1(連鎖時の誤報)reg2(M層 no-op)。いずれも根は「M層本体が名簿に載らない(バグ①)」1点に集約される。§5 の材料2でこの1点を、材料1・3で安全網と物差しを直せば、24パターンすべてが「正常終了を中断誤報しない/本物の中断を見逃さない」を満たす。

5. 提案する変更点(材料1/2/3)

実装はしない。設計方針として3つの材料を「変更ファイル・概要・リスク・#545整合」で整理する。多層防御: 材料2が根治、材料1が安全網、材料3が物差し補正。

材料2(根治・最優先): import の書き間違いを直す

項目内容
変更ファイルbot/persistent_session.py:526
変更概要from bot.process_registry import process_registryfrom bot.process_registry import registry as process_registry(app.py:40 と同型に統一)。これでM層のPIDが送信中ずっと名簿に載り、active_task_ids() に task_id が常在する。rotate(M9)で新pidに変わる際は register を撃ち直す点も設計に含める(persistent_session.py:466-469 の start 後)。
リスク登録が生き返ると、既存の「名簿にM task_idが有る=孤児でない」判定が初めて意図通り機能する。副作用は「今まで出ていた誤報が止まる」方向のため低リスク。ただし unregister(R-2)も有効化されるので、送信完了〜次状態遷移の数ミリ秒の隙間で瞬間的に未登録になりうる → 材料1/3の物差しで吸収する。
#545整合#545 は「孤児と判定されたの復旧」を設計。材料2は「そもそも誤って孤児と判定しない」検知側の根治で、#545の前提。誤検知を残したまま#545の攻めた自動リトライを有効化すると健全タスクを再実行してしまうため、材料2は#545 Phase1より先行または同時が必須

材料1(安全網): 通知の前に is_alive() で実行主体の生死を直接見る

項目内容
変更ファイルbot/app.py_registry_writer の孤児処理 3817-3831)/M層セッション参照ヘルパ
変更概要孤児候補と判定した後、ERROR化+通知の直前task.route から該当M層 PersistentSession を引き、is_alive()persistent_session.py:406)で生死を確認する。生存=実行中→何もしない(通知しない)死亡=本物の中断→STALE化して#545の復旧へ。名簿突合(間接シグナル)に、生死の直接確認を重ねる二段判定。
リスクM層セッションはグローバル管理(route→session)を参照する必要がある。参照先が無いrouteは従来通り名簿判定にフォールバック。is_alive は proc の returncode を見るだけで副作用なし・低コスト。「無出力だが生存」(M3)を中断と誤らないのが最大の利得。
#545整合#545 の「孤児→STALE統一」の入口に、is_aliveゲートを1枚噛ませる形。#545の復旧経路はそのまま活かし、入口の判定精度だけ上げる。競合しない上積み。

材料3(物差し補正): 60秒閾値と実作業時間の整合+updated_at更新

項目内容
変更ファイルbot/task_tracker.py:296(閾値)/bot/persistent_session.py:send_prompt(心拍で updated_at 更新)
変更概要(a) send_prompt のストリーム受信中に、TaskTracker の updated_at を定期的に叩いて「時計を動かす」(現状は EXECUTING 設定時の1回だけで凍結)。(b) 孤児閾値60秒を、default_query_timeout=900sheartbeat=900s と矛盾しない値へ引き上げる(例: heartbeatと同等 or 名簿突合が生きるので短くても可)。材料2でM本体が登録されれば閾値超過だけでは孤児にならないため、(b)は保険。
リスクupdated_at更新の頻度が高すぎるとJSON書き込みが増える → ストリーム全行ではなく「Nラウンドごと/M秒ごと」に間引く。閾値引き上げは「本物の中断の検出が遅れる」トレードオフだが、材料1のis_aliveが即時に死を捉えるため遅延は実質吸収される。
#545整合#545 は孤児由来STALEに min_age の短縮(60〜120s)を提案。材料3の閾値はこれと意味が逆(検知を緩め、誤報を減らす)なので、「検知=材料1/2/3で正確化」→「復旧=#545のmin_ageで機敏化」と役割分担を明文化して衝突を避ける。
導入順序の推奨: ① 材料2(import修正・最小・最低リスク・独立PR)→ 観測で誤報消失を確認 → ② 材料1(is_alive安全網)→ ③ 材料3(物差し)→ その後に #545 の復旧強化。検知の正確化を先に固めてから、復旧の自動化を攻める

6. 現状 vs 修正後 フロー対比

flowchart TB A["M層が処理中
(EXECUTING)"] --> B["W層に委譲
(親M task_idで代理登録)"] B --> C["W層が正常終了
unregister で名簿から消える"] C --> D{"5秒ループ:
task_id は名簿に有る?"} D -->|"無い(M本体は元々未登録)"| E{"updated_at から
60秒 超過?"} E -->|"超過(時計が凍結)"| F["本物の中断と即断"] F --> G["ERROR化+
『中断しました』通知"] G --> H["誤報 — CEO混乱"]
図1: 現状。M本体が名簿に無いため、W層の正常終了だけで「中断」と誤判定して誤報に至る。
flowchart TB A2["M層が処理中
(EXECUTING・材料3で updated_at 更新)"] --> B2["M本体を名簿に登録
(材料2: import修正)"] B2 --> C2["W層委譲・正常終了しても
M task_id は名簿に残る"] C2 --> D2{"5秒ループ:
task_id は名簿に有る?"} D2 -->|"有る"| I2["実行中と判定 → 何もしない"] D2 -->|"無い(稀な隙間)"| J2{"材料1: is_alive() で
M層プロセスの生死を直接確認"} J2 -->|"生存"| I2 J2 -->|"死亡"| K2["本物の中断 → STALE化"] K2 --> L2["#545 の復旧駆動
--resume で返信を出し直し"]
図2: 修正後。名簿にM本体が載り、隙間は is_alive の直接確認で救済。生存は静観、死亡のみ復旧へ。

7. #545 設計との差分・整合

観点#545(orphan recovery)本設計 #552(orphan monitoring)関係
担当領域孤児と判定された後の復旧(何をするか)孤児かどうかの検知精度(誤判定しないか)相補(検知→復旧)
核心の変更孤児→STALE統一+稼働中の定期リカバリ駆動import修正(材料2)+is_aliveゲート(材料1)+物差し補正(材料3)本設計は#545の前提
import バグ①言及なし根本原因として特定・修正提案本設計で新規
is_alive の活用言及なし誤報防止の安全網として接続本設計で新規
閾値/min_age孤児由来STALEの min_age を短縮(機敏な復旧)孤児閾値を実作業時間へ整合(誤報削減)役割分担で衝突回避(検知は正確に/復旧は機敏に)
#662依存Phase1の前提(自己killレース)M8の正しい復旧に同じく#662修正が前提一致
導入順序Phase0観測→Phase1配線材料2→1→3 を#545 Phase1より先行本設計を先に
最重要の整合点: 検知の誤報を残したまま #545 の自動リトライを有効化すると、健全に動いているタスクを勝手に再実行するという新たな害が生まれる。したがって本設計(検知の正確化)は #545(復旧の自動化)の厳格な前提条件である。

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

前提条件

副作用・影響範囲

代替案(却下理由)

内容採否理由
案A材料2+1+3 の多層防御(根治+安全網+物差し)採用
案B閾値を60s→900sに上げるだけ却下本物の中断の検出が900s遅れる。M本体未登録の根本は残る
案C孤児検出自体を無効化却下誤報は消えるが本物の中断を全て見逃す(要件違反)
案Dwatchdog監視スレッド再導入で能動監視却下#655/#660削除方針に逆行・#662レース増幅(#545と同判断)

リスク・懸念


M層への報告要点