第 40 章:永続的な Agent Loop
「落ちたら続きから再開すればいい」——ただし再開するのが 3 か月前の破壊的操作で、しかも誰も見ていないなら、素直に失敗してくれた方がよかったと思うことになる。
5 分で要点掴む
- Checkpoint はフェーズ境界でデバウンスして打つ。毎反復でも、最後だけでもない
- 永続マーカー索引で、全 Session を走査せずに落ちた Turn を発見できるようにする
- 復旧には 3 つの関門が要る:有効化、陳腐化ウィンドウ、試行回数上限
- 復旧された Turn は元の出自に関係なく常に無人として扱う
- 試行回数はモデル呼び出しの前に永続化する。後だとクラッシュループで永久にゼロに戻る
10 分パス:40.1-40.3 → 40.5 → Kocoro OSS Lab
公開ソースのスナップショット:実装例は 2026-07-27 に確認した Kocoro 公開
origin/mainのコミット4ec6772に基づきます。学ぶべき対象は不変条件であり、定数は最新ソースで再確認してください。
40.1 3 か月遅れで目を覚ました Turn の話
Agent が破壊的な操作の途中にいます。ユーザーが依頼し、承認し、その場に座っています。そこで Daemon が落ちます。アップグレード、OOM、ノート PC を閉じた。
その Turn は Checkpoint されました。よいことです。永続化の意義そのものです。
3 か月後、ユーザーがアップグレードします。Daemon が起動し、永続 Checkpoint を見つけ、設計どおりのことをします。再開する。破壊的操作が走る。誰も見ていません。その意図は四半期前に失効していて、正しさを支えていた文脈——参照していたファイル、対象のブランチ、それが良い案だった理由——も一緒に消えています。
これは実在する失敗モードで、直し方は「Checkpoint を減らす」ではありません。有効期限のない再開可能性は、永続的な導火線のついた時限爆弾です。
40.2 どこで Checkpoint を打つか
素朴な選択肢は両方とも誤りです。毎反復で打てば、すぐまた変わる状態のためにホットパスでシリアライズ代を払います。最後だけ打てば、クラッシュで全部失う——それこそ直そうとしていた事態です。
役に立つ答えはフェーズ境界です。「残す価値のあるものを産んだ直後で、これから戻ってこないかもしれないことをする直前」の地点。モデル呼び出しの完了。ツールバッチの終了。圧縮要約の書き込み——とりわけ最後の 1 つは、その要約が高価で、失えば二度買うことになるからです。
デバウンスも入れます。密な境界の連続が、密な書き込みの連続にならないように。2 秒程度で十分です。Checkpoint の目的はクラッシュを生き延びることであって、ミリ秒単位で最新であることではありません。
そして Session は危険なことの前に書きます。後ではありません。第 34 章が逆側から扱ったとおり、Session が loop.Run の前に永続化されるのは、実行中のクラッシュが穴ではなく記録を残すためです。永続性は前払いで買うもので、そうでなければ手に入りません。
40.3 落ちた Turn をどう見つけ直すか
誰にも発見されない Checkpoint は、ディスク使用量にすぎません。
素朴な方法——起動時に全 Session を走査して未完了を探す——は使い込むほど遅くなります。これは逆です。定常状態の起動は安くあるべきです。
そこで永続的なマーカー索引を持ちます。中断した Turn ごとに小さなレコードを 1 つ、Session の隣に書く。起動時に読むのは索引であって、全体ではありません。守るべき不変条件は双条件です。
ディスク上の
InProgress= true ⟺ マーカーが存在する
すべての永続化パスがこれを維持しなければなりません。フルセーブも、すべての read-modify-write パッチも。1 つ漏らせば、完了済みの Turn を指す孤児マーカーか、完了に見えて実は中断された Turn が生まれます。どちらも「復旧機構がない」より悪い。復旧機構そのものを信用できなくするからです。
40.4 3 つの関門と、いちばん効くもの
発見は簡単な方の半分です。実行するかどうかの判断に設計が宿ります。
有効化。 既定オンのブール値で、マーカーは残したまま自動続行だけを止められます。運用側には「見つけるが走らせるな」と言う手段が要ります。とくに障害対応中には。
陳腐化ウィンドウ。 40.1 の関門です。公開の既定は 4 時間で、理由は設定に書かれています。
Default 4 covers crash/upgrade restarts within a work session; anything older executes a stale user intent with nobody present (a months-old interrupted destructive turn must never fire on upgrade). When it binds, the checkpoint is abandoned with an
interrupted_turn_abandonedevent instead of resumed.
この論証の形に注目してください。4 時間は座りのいい丸い数字ではなく、「1 つの作業セッション」——ユーザーの意図がまだ成立していそうな幅です。それを超えると、Checkpoint が表しているのはユーザーが今も望むものではなく、もう存在しない文脈の中でかつて望んだものです。
放棄がイベントであって黙って捨てるのではない点にも注目を。期限切れの Checkpoint は可視であるべきです。黙って放棄することと黙って再開することは、どちらも「意外なことを誰にも告げずにやる」ことです。
試行回数上限。 既定 3。そしてこれを機能させる細部は、試行回数がモデル呼び出しの前に永続化されることです。後で増やすと、呼び出し中に Daemon を落とす Turn は自分の試行を記録できません。だから永遠にリトライし、起動のたびに落ち、あなたの復旧機構は起動ループになります。
40.5 復旧された Turn は常に無人
ここが最も微妙で、しかも正確性ではなくセキュリティの性質です。
対話的なデスクトップ Session から始まった Turn は有人でした。人がいて、承認は実在の人へ往復でき、権限システムは同意の要る操作を正しく許可していました。
同じ Turn が Daemon 起動時に再開すると、誰もいません。しかし Session に記録された出自は依然として desktop です。出自で分類すれば、復旧された Turn は有人の特権を継承します。行使する人間はいないのに。公開 Runtime はここを明示的に固定しています。
IsUnattendedRunis always true: a recovered turn executes at daemon start with no human present, regardless of the session's original source. Without this marker a desktop/kocoro-source checkpoint would classify as attended and bypass the unattended auto-approval deny-list.
一般則はこうです。「人がいるか」は実行の瞬間の性質であって、Session の出自の性質ではありません。 現在の現実ではなく保存された出自から権限を導くシステムは、いずれ無人の部屋に特権を与えます。
同じ論理から、再開リクエストは元の RouteKey を取る必要があります。そうすれば並行する受信トラフィックと同じロックを争います。通常のルーティングを迂回する復旧は、同じ Session 上の生きた Turn と並行して走りえます。書き手が 2 つ、Session が 1 つ、あいだにロックなし。
40.6 スナップショットの根拠
| 観測 | 4ec6772 のソース位置 |
|---|---|
| 永続マーカー索引が発見を駆動 | interrupted_recovery.go L34 |
| 復旧された Turn は常に無人 | interrupted_recovery.go L116 |
| 4 時間の陳腐化ウィンドウとその根拠 | config.go L186 |
| マーカーを残したまま復旧を無効化できる | config.go L194 |
| 試行回数の既定は 3 | config.go L446 |
| Session は実行前に永続化 | runner.go L2576 |
これらは特定時点の実装例であり、普遍的な契約ではありません。
40.7 永続化が与えてくれないもの
Turn を再開することは、それを冪等にすることではありません。 この 2 つを混同することが、復旧機構が防ぐはずだった障害を自ら起こす経路です。
中断された Turn がすでにメールを送り、カードに課金し、ブランチを push していたなら、Checkpoint が記録しているのはツールが呼ばれたことだけです。副作用が着地したかは記録できません。何も考えず再開すれば、二度送るかもしれません。防御はツール層にあり、復旧層ではありません。外部効果を持つものには冪等キーを付け、繰り返しが重複ではなく no-op になるようにします。
永続化は壊れたデプロイにも耐えません。あるバージョンが書いた Checkpoint を別のバージョンが再開するのは、メッセージ形状に互換があるときだけ安全です。永続状態にバージョンを付け、読めない Checkpoint は「起動時クラッシュ」ではなく「イベント付きで放棄」として扱ってください。
キャンセルの代わりにもなりません。キャンセルしたユーザーが望むのはその Turn が消えることであって、Checkpoint されて次の Daemon 起動で親切に蘇ることではありません。キャンセルはマーカーを消さなければなりません。
40.8 よくある落とし穴
試行回数を呼び出しの後に増やす。 クラッシュのバグに見える起動ループを産みます。
陳腐化ウィンドウがない。 40.1 の事故です。永続再開があって期限のないシステムには、今この瞬間このバグが潜んでいます。
Session の出自で有人性を分類する。 誰もいない相手に権限一式を与えます。
Save ではマーカーを維持し、パッチパスでは維持しない。 不変条件は、維持していない 1 本のコードパスが走るまで成立し、その後は黙って偽になります。
通常のルートロックの外で再開する。 1 Session に書き手 2 つ。あとから不可解に交錯した履歴として発見されます。
「ツールが呼ばれた」を「効果が起きた」と扱う。 Checkpoint が記録するのは意図であって結果ではありません。
Kocoro OSS Lab(10 分)
InterruptedResumeMaxAgeHoursのコメントを読みます。防いでいる失敗を 1 つの節で名指ししていること、しかもその値を変える人が必ず見る設定構造体の中に書かれていることに注目。IsUnattendedRunを見つけ、そのtrueが具体的にどのツールを無人実行から締め出しているか書き出します。- 自分のシステムで外部副作用を持つツールを列挙します。それぞれについて「それを呼んだ Turn が再開したらどうなるか」を問うてください。答えられないなら、持っているのは永続復旧ではなく永続重複です。
ここだけは押さえる
- Checkpoint はフェーズ境界でデバウンスして打つ。 毎反復でも最後だけでもない。
- マーカー索引が発見を安くする。 そして「ディスク上のフラグ ⟺ マーカー」の双条件は、すべての永続化パスで成立させる。
- 3 つの関門:有効化、陳腐化、試行回数。 多くのシステムに欠けているのは陳腐化ウィンドウです。
- 有人性は実行の瞬間に属する。 復旧された Turn は出自を問わず無人です。
- 再開は冪等ではない。 外部副作用にはツール層で冪等キーを。Checkpoint はメールが出たかどうかを教えてくれません。
次章:第 41 章は逆方向——止まった Turn の復旧ではなく、まだ走っている Turn を変える話を扱います。