ある夜、Conol のエージェントは約9時間にわたって動かなくなった。クラッシュしたわけではない。キューに入った仕事がただ消費されずに残っていた。きっかけはありふれていた。RDS再起動中に一時的なDB障害がワーカーのポーリングループに現れ、ループはキューをドレインしなくなった。しかしプロセスはハンドルを開いたままだったため生存し続け、スーパーバイザーも置き換えなかった。プロセスは呼吸しているだけで何もしない。彼らを救ったのはループではなく、その下にあったもの。エージェントのすべての状態と生成済みエフェクトがPostgresに永続化されていた。ワーカーを再起動すると、キューは停止した場所から再開した。ユーザーセッションを再構築したり、ログから会話をリプレイしたりする必要はなかった。この夜がこの記事の原点。「モデルが賢い」と「システムが信頼できる」の間のギャップは、より良いプロンプトでは埋まらない。エージェントのターンをコールスタックではなく永続データとして扱うことで埋まるのだ。
多くのエージェントランタイムは最初、async 関数のループで LLM を呼び、ツール結果を messages に追加する。ノートPCでは動くが、本番では全ての重要な状態がスタックとローカル変数に載る。プロセスが死ぬかハングすれば、そのターンも失われる。モデルがどんなに賢くても、ランタイムはスタックを保持する最も弱いプロセスと同じだけの信頼性しかない。
最も役立ったのは代数効果というプログラミング言語理論の視点。計算効果とは関数が値を返す以外にすること。代数効果では、コードは要求を「operation」として名前で示し、ハンドラがその意味を決める。perform する側はどう処理されるか知らない。ハンドラは継続 k を受け取り、再開するか、例外のように放棄するか、複数回再開するかを選べる。直感的には「再開可能な例外」。OCaml 5 は one-shot 継続、monads は context 付きの値、effect handler は operation と継続を持つ。Moggi の monads、Plotkin/Power の代数的効果、Plotkin/Pretnar の effect handlers という系譜がある。OCaml 5 の Eio や Koka、Eff、Effekt などの研究言語が実装している。React Suspense や durable workflow engine も似た形を持つが、同じ機構ではない。
Conol は継続をキャプチャせず、エフェクト型システムも持たない。代数効果から借りたのは分離だけ。operation は Postgres に書かれる永続的な effect の値、handler は effect を読み解くワーカー、resumption は永続状態を再構築して続行すること。{ type: "llm", sessionId } のような値が、セッションが LLM ターンを必要としていることを表す。キューは検査可能で、ワーカーは effect を出したプロセスである必要はない。テストでは実ハンドラを決定的なものに置き換えられる。visibleAt がエフェクトをいつ要求できるかを示し、安定 id で同じタイマーを再登録するとキュー行が重複せず置き換わる。
状態は純粋なミューテーションで進む。mutate は CAS ループを使い、version が一致すれば state と ops をコミット。別の書き手が先に進めていたら現在の状態で再実行する。ミューテーションが複数回評価されても最終的に勝った結果だけがコミットされるため、外部I/Oはミューテーション内で実行できない。状態と結果のエフェクトは同時に永続化されなければならない。Conol はトランザクショナルアウトボックスとして、1つの Postgres トランザクションで agent_sessions を CAS 更新し、effect_queue に INSERT する。コミットすれば両方が永続化され、ロールバックすればどちらも起きない。ワーカーはリースでキューから要求を取得し、claim_token と visible_at の延長で配信を管理する。これは少なくとも1回の配信を意味する。クラッシュ後に再配信される可能性があるため、結果ミューテーションは冪等に設計する:tool result は toolCallId で一度だけ書き、LLMストリームイベントは llmCall.id で古いものを捨て、同じ effect ID は行を置き換える。外部ツールの副作用、特に決済APIなどは独自の冪等キーが必要。
ツールを使う1ターンは、5つの永続コミットになる。ユーザー effect が届き、メッセージをコミットして llm effect を発行。llm ハンドラがモデルを実行し、assistant メッセージと tool_call effect をコミット。tool_call ハンドラがツールを実行し、結果を一度だけコミットして後続 llm effect を発行。2回目の llm ハンドラがツール結果で最終回答をコミット。残りの effect がなければセッションを完了として確定。どのステップの間でも全ワーカーが消えても、新しいワーカーは最後にコミットされた状態と残りのキューから再開できる。クラッシュはリプレイになる。ワーカーが effect を要求し、処理を始める。もし処理結果がコミットされる前に死ねば、effect は再度配信される。この設計が「エージェントが自分の実行を生き延びる」ことを支えている。