第 42 章:Agent のタイムアウトと Watchdog
「90 秒間何も起きなかった」だけでは、ループが何を待っていたかを言えない限り、障害の説明にはなりません。
クイックトラック(5 分で核心をつかむ)
- 待機フェーズを明示的にモデル化する。経過時間だけでは停止と正当な作業を区別できない
- 最終的な強制停止の要約を含め、遠隔 LLM 応答を待つ間だけ空き時間を数える
- Soft Idle はフェーズの各インスタンスにつき一度だけ報告し、Hard Idle は型付きの原因と後処理の余裕を持たせて取り消す
- ストリーム間隔の Watchdog は別の時計であり、伝送層内のデータ片の間隔を見る
- フェーズの所有権が信用できなくなったら計時による操作を止め、構造上のバグを表面化する
公開ソースのスナップショット:実装例は 2026-07-27 に確認した Kocoro 公開
origin/mainのコミット4ec6772に基づきます。学ぶべき対象は不変条件であり、定数は最新ソースで再確認してください。
42.1 正当な作業を止めてしまう一本のタイマー
Agent が画像サービスに大きな素材の描画を依頼します。八分は遅いものの正当です。ツール自身のタイムアウトは十分で、処理もまだ進んでいます。
別の実行では、LLM ストリームが文の途中まで送った後、TCP 接続が沈黙します。終了イベントもエラーも新しいバイトもありません。ここで八分待つのは忍耐ではなく、停止状態です。
両方を一つの「ターン全体のタイムアウト」で囲むと、どちらを壊すか選ぶことになります。90 秒なら正常なツールを止め、10 分なら死んだストリームがワーカーを占有します。何らかの動きがあるたびにタイマーをリセットすると、無意味な作業を交互に繰り返すループが永遠に生き残れます。
問題は正しい時間を一つ選ぶことではありません。異なる構成要素が、それぞれ異なる待ち時間を所有していることです。
42.2 Watchdog にはフェーズモデルが要る
ループは、最後に触ったタイマーから状態を推測する代わりに、明示的な TurnPhase を公開します。
| フェーズ | LLM の空き時間に数える? | 待ちの所有者 |
|---|---|---|
| init | いいえ | ループの構築 |
| setup | いいえ | ローカル初期化 |
| awaiting LLM | はい | ターンの Watchdog |
| retrying LLM | いいえ | 再試行ポリシー |
| compacting | いいえ | 圧縮コーディネーター |
| awaiting approval | いいえ | 承認画面とポリシー |
| executing tools | いいえ | ツール固有のタイムアウト |
| injecting message | いいえ | 追加入力の注入経路 |
| force stop | はい | ターンの Watchdog |
| done | いいえ | なし |
遠隔モデルの応答を厳密に待つフェーズだけを空き時間として数えます。Bash コマンド、承認ダイアログ、ローカルでの履歴書き換えもループからは静かに見えますが、それぞれ別の所有者と正当な所要時間があります。
これにより、各タイムアウトは一つの問いだけを持ちます。
このフェーズの進行を約束した構成要素はどれで、どの観測が進行を証明するか。
所有者がいないなら、タイマーの追加は設計上の穴を隠すだけです。同じ待ちに所有者が二人いるなら、どのエラーをユーザーへ見せるかで、いつか衝突します。
42.3 Soft Idle と Hard Idle は二つの判断
公開スナップショットの既定値は Soft Idle が 90 秒、Hard Idle が 540 秒です。これは特定の伝送スタックの値であり、普遍的な忍耐の基準ではありません。
Soft Idle は「このフェーズで、これだけの時間 LLM の動きがない」という状態を出します。取り消しはしません。単調増加する連番で識別されたフェーズ遷移ごとに、一度だけ発火します。awaiting_llm を離れ、再試行後にもう一度 awaiting_llm へ入れば、新しいフェーズのインスタンスとして再び報告できます。
Hard Idle は終端状態を出し、ErrHardIdleTimeout で実行を取り消します。540 秒という既定値は 600 秒の伝送上限より 60 秒短く、外側の HTTP 層が諦める前に取り消し信号を伝播させ、後処理を行う余裕を残します。
運用上、この分離は重要です。Soft は可視性、Hard はポリシーです。片方だけを変えても、もう片方まで変えたふりをせずに済みます。
型付きの取り消し原因も重要です。ユーザーによる取り消しと Watchdog による取り消しは、どちらもコンテキストを閉じますが、同じ結果ではありません。呼び出し側は「あなたが止めた」と「Provider の進行が止まった」を別に表示し、復旧ロジックは再試行がユーザーの意図に反しないか判断する必要があります。
42.4 ストリームの空き時間は別の時計
ターンの Watchdog は、ループが awaiting_llm にいる時間を知っていますが、読み込みの止まった SSE 応答本体の内部機構は所有していません。
そのためゲートウェイは、CompleteStream 内にデータ片ごとのタイマーを持ちます。一行受け取るたびにリセットし、設定間隔の半分で警告、間隔いっぱいでスキャナーを取り消して応答本体を閉じます。スナップショットの既定値は 90 秒です。
これは外側のターン用タイマーが苦手な障害を捕捉します。途中まで内容を届けた接続が、エラーを返さないまま新しいバイトを生まなくなる状態です。同じ停止した上流を非ストリーミング方式で再試行しても、伝送待ちをもう一度買うだけかもしれません。そこでループはストリーム間隔エラーを部分結果として扱い、受信済みの文章を保ち、一文字もなければ明示的なプレースホルダーを挿入し、期限超過の状態を記録して終了します。
時計ごとに問いが違います。
- ターンの空き時間: この LLM フェーズは長すぎないか。
- ストリームの空き時間: この伝送は最近データ片を出したか。
- ツールのタイムアウト: このツールは自身の実行契約を超えたか。
別の時計のイベントでリセットしてはいけません。スクリーンショットツールの完了で Provider ストリームの停止を許さず、Provider の token でツールの期限を延ばしません。
42.5 入れ子の呼び出しにも所有権が要る
圧縮はローカルフェーズなので、履歴を整形する CPU 処理を Watchdog で数えるべきではありません。しかし圧縮は、学習内容の永続化や要約生成のために LLM を呼びます。その入れ子になった遠隔呼び出しには監視が必要です。
フェーズ追跡器は EnterTransient(PhaseAwaitingLLM) を使い、何度呼んでも安全な復元クロージャを返します。
restore := tracker.EnterTransient(PhaseAwaitingLLM)
summary, err := client.Complete(ctx, request)
restore()
外側のフェーズは compacting のままで、遠隔呼び出しを待つ区間だけを一時的に LLM Watchdog が所有します。復元を忘れると、ループは誤ったフェーズに残ります。一時フェーズを使わなければ、停止した要約処理が見えません。
これは、業務ロジックの各所に「Watchdog を一時停止 / 再開」を手作業で置く設計が脆い理由です。フェーズが計時ポリシーを所有し、入れ子の作業はフェーズを借りて返します。
42.6 フェーズ追跡器が嘘をついたら
追跡器を「異常時は閉じる」と決め、状態が矛盾したら必ず取り消したくなるかもしれません。
しかし矛盾した追跡器には、ループがモデルを待っているのか、正当にツールを実行中なのか分かりません。それに基づく操作は正常な作業を殺しかねません。スナップショットは逆の本番上の取捨を選びます。構造違反が一つでもあれば追跡器を無効とし、警告を記録し、残りの実行では Watchdog の観測器を無効にします。テストまたは SHANNON_PHASE_STRICT=1 では同じ違反を panic にして、開発時に常態化させません。
挙動は次の通りです。
development / strict run: structural bug → panic
ordinary production run: structural bug → warning + watchdog disabled
本物の停止が生き残る可能性があります。反対の選択では、信用できない証拠によって、正常でありながら破壊的または高価な操作を取り消すかもしれません。どちらにも代償があります。明示的に選び、失った監視範囲を見えるようにします。
復元クロージャの冪等性は、一種類の所有権バグを消します。終了時の表明は、戻し忘れた一時フェーズを捕捉します。一時フェーズ中の最上位 Enter も違反です。これらの検査により、「タイマーの感じがおかしい」が具体的なフェーズ階層のエラーになります。
42.7 タイムアウトはエラーだけでなく結果
Hard Idle が発火したら、取り消し信号は四つの境界へ届く必要があります。
- 実行中の Provider 呼び出しまたはストリーム
- ストリームの差分から始めた推測的なツール作業
- ターンを誤って完了扱いにしないためのチェックポイントと永続層
- フェーズと部分出力の状態を受け取る呼び出し側
context canceled だけでは足りません。型付きの原因がなければ、画面は Watchdog とユーザーの意図を区別できません。部分的な文章がなければ、九分かけて届いた応答が最後の十秒の停止ですべて消えます。終端の実行状態がなければ、第 40 章の永続層と呼び出し側は、タイムアウトで切れたターンと正常完了を区別できません。
Soft イベントにも、現在のフェーズと実測した空き時間を含めます。「Agent slow」では運用に役立ちません。「No LLM activity for 90s, phase=force_stop」なら、最終要約の呼び出しが所有者だと分かります。
Watchdog は沈黙を隠すのではなく、その長さを制限すべきです。
42.8 スナップショットの根拠
| 観測 | 4ec6772 のソース |
|---|---|
| 一回の Agent 実行に対する明示的なフェーズ一覧 | phase.go L12 |
| awaiting LLM と force stop だけを空き時間に数える | phase.go L58 |
| 入れ子の待機呼び出しは一時フェーズを借りる | phase.go L138 |
| 構造違反は観測器を無効にし、警告または panic を出す | phase.go L117 |
| Watchdog の Soft/Hard の意味と追跡器無効時の動作 | watchdog.go L19 |
| 既定値:Soft 90 秒、Hard 540 秒、ストリーム間隔 90 秒 | config.go L162 |
| ストリーム本体は独自のデータ片間隔 Watchdog を持つ | gateway.go L1144 |
| ストリーム間隔による終了は部分出力を保持する | loop.go L3679 |
これは特定時点の実装と伝送スタックを述べたもので、普遍的なタイムアウト値ではありません。
42.9 よくある失敗
ターン全体に一つのタイムアウト。 正常なツールを止めるか、死んだ Provider を待ちすぎます。
どんな動きでもリセットする。 無関係な進展が、本当に詰まった所有者を永遠に生かします。
圧縮全体を除外する。 ローカルの外枠は空き時間に数えませんが、入れ子の要約呼び出しには LLM の監視が必要です。
承認待ちを Agent の空き時間と数える。 人間が所有する停止をモデルのタイマーで取り消すのは、分類の誤りです。
遷移の識別子なしでフェーズ名だけを使う。 awaiting_llm へ再び入っても、Soft 警告が再設定されません。
ストリーム間隔のタイムアウト後に自動で代替経路へ移る。 同じ上流が再び停止し、遅延と費用が二倍になります。
無効なフェーズ情報を基に取り消す。 所有権が信用できなければ、タイマーは何を止めるのか分かりません。
一般的な取り消しエラーだけを返す。 呼び出し側は原因、フェーズ、部分出力、復旧方法を選ぶ手掛かりを失います。
要点
- 時間には所有者があります。 所要時間は、既知の待機フェーズ内でだけ意味を持ちます。
- Soft は可視性、Hard はポリシーです。 独立して設定し、観測します。
- ストリーム間隔には伝送層の時計が必要です。 ターン全体の経過時間でデータ片の間隔を代用できません。
- 入れ子の遠隔作業は遠隔フェーズを借ります。 フェーズの所有権は、散在する一時停止と再開より強い設計です。
- 信用できない計時情報から、確信に満ちた操作を起こしません。 構造上のバグを表面化し、本番での取捨を意識して選びます。
次章の第 43 章では、別種の非進展を扱います。ループは動きとイベントを生み続けながら、どこにも進んでいません。