Agent Mentor Learn
状態管理と永続化: 長いタスクを中断から生き延びさせる · 第 6 回 / 全 6 回

レッスン6: ハンズオン: ハーネスにチェックポイントと再開を配線する

学習目標:

  • 「地点Aで宙ぶらりんな呼び出しを保存し、地点Bでクリアする」というチェックポイント方式を、概念図のままにせず、このシリーズの第7コースのrunAgentループへ実際に溶接する
  • runToolUsesに副作用台帳を取り付け、ツールが成功したその瞬間にレコードを1件ディスクへ書き、再開が「このツールは実際に走ったのか否か」を判別できるようにする
  • reconcileの3分岐を書き、制御された「疑似キル+--resume」の実行で、復旧が本来あるべき振る舞いをすることを自分の目で確かめる

前提: レッスン1〜5を読み、このシリーズの第7コース「Agent Harness Fundamentals: Loops and Control」のハーネスループを動かせること | 前: レッスン5 <<

まず動かして見る

最初の5レッスンでは、チェックポイント、再開、冪等性、巻き戻しとフォークを一つずつ分解して説明してきました。このレッスンでは、それらを実際に動くハーネスへ溶接します。ループはいつもの見慣れたもの——messagesでモデルを呼び、stop_reason === "tool_use"ならツールを実行してもう一度呼ぶ——ですが、今回は毎ターン2つのチェックポイントをディスクに書き、さらにツール実行結果を記録する台帳を持ちます。タスクは「営業メモをレポートにする」で、read_notescount_wordswrite_reportの3つのツールを順に呼びます。3ターン目まで正常に走り、そこで一気に落とされたときの様子はこうです。

text
$ CRASH_AFTER=after-effect-write:3 node agent.js
[turn 1][save A] pending=read_notes[turn 1][save B][turn 2][save A] pending=count_words[turn 2][save B][turn 3][save A] pending=write_report[kill] after-effect-write:3 で疑似キルEXIT=137

1ターン目と2ターン目はsave A→実行→save Bの3ステップをどちらも完走しており、すべて正常です。3ターン目はsave Aを保存し(宙ぶらりんな呼び出しがwrite_reportであることを記録し)、ツールも実際に実行を終え、その結果はすでに台帳へ書かれていました——しかし次のステップのsave Bが保存される前にプロセスが落とされました。これこそ、このレッスンが仕留めにいく窓です。この瞬間、checkpoint.jsonにはまだ宙ぶらりんなpendingToolUseが残っています。その場面を抱えたまま、--resumeで拾い直します。

text
$ node agent.js --resume
[resume] turn=3 pending=write_report を読み込み[resume][reconcile] tool_use_id=toolu_03 name=write_report 台帳ヒット、結果を再利用、再実行しない[turn 3][save B] 再開後にこのターンのツール結果を補完[done] report.txt にレポートを書き出しました。タスク完了。

再開の流れはturn=3 pending=write_reportを読み、台帳を確認します——するとこの呼び出しはキルの前に実際に完了して記録されていたとわかるので、そのレコードをそのまま再利用し、write_reportを再実行しません。そしてこのターンに欠けていたsave Bを補い、いつもどおりモデルの締めくくりへ進みます。タスク全体が最初からやり直されることはなく、レポートが2回書かれることもありませんでした。

この2つのターミナル出力は手書きの例ではありません。後述の「検証ハーネス」セクションで固定のレスポンスキューによって駆動される Node スクリプトの実出力を、1行ずつそのまま写したものです。

ブロックごとに組み立てる

チェックポイントの読み書き: saveCheckpoint / loadCheckpoint

チェックポイントとは、この場面——{version, task, turns, tokensUsed, messages, pendingToolUse}——をディスクへシリアライズしたものにすぎません。唯一気をつけるべきはファイルを壊さないことです。まず一時ファイルへ書き、次にfs.renameSyncでアトミックに入れ替えます。renameは同一ファイルシステム内では不可分の操作なので、「半分だけ書かれた」中間状態が存在しません。

読み込みは2つのことに耐えなければなりません。ファイルが存在しないこと(一度も実行していない、または最初から始めるつもりである)と、ファイルのパースに失敗することです。2番目のケースは特に慎重に扱う価値があります。JSON.parseの失敗はたいてい、前回の書き込み自体が中断されたことを意味します(saveCheckpointは理屈の上ではアトミックですが、.tmpファイルすら書き終わる前にプロセスが落とされたり、ディスク自体に問題があったりすれば、renameより前の中途半端なファイルが誤って読まれることはありえます)。その時点で、状態をこっそり空にリセットして何事もなかったふりをすることは絶対にやってはいけません。タスクが失われるのはまさにそこです。正しい振る舞いは、エラーを素直に投げ出し、このチェックポイントはもう信頼できないので削除して最初からやり直すべきだとユーザーに伝えることです。プログラムに推測で全体像を組み立てさせてはいけません。

ついでにversionフィールドも確認しておきます。後でチェックポイントの構造が変わったとき、古いファイルを新しいフォーマットとして無理やりパースすべきではありません。半分正しく半分間違った状態を読み出すくらいなら、読み込みを拒否するほうがましです。この2つの関数は実際に切り詰められた JSON でテスト済みです。半分だけ書かれた{"version":1,"turns":3,"pendingTを食わせると、loadCheckpointは上記の「削除して最初からやり直せ」というエラーをきっちり投げ、もっともらしい既定値を返すことはありません。

地点Aと地点B: runAgentループへの配線

このシリーズの第7コースのループの骨格は変わっていません——while (response.stop_reason === "tool_use")push assistant→ツール実行→push tool_result→モデルへ再リクエスト。このレッスンではループ本体に2つのチェックポイントを差し込みます。その置き場所こそがこのレッスンの核心です。

地点Aはresponseが届いた後、messages.push({ role: "assistant", ... })の前に置かれます。モデルが「ツールを名指ししたが、まだ実際には実行していない」瞬間であり、pendingToolUseはその名指しをそのまま記録します。地点BはrunToolUsesが終わり、tool_resultmessagesへ push された後に置かれます。その時点でこのターンは完全に締めくくられており、pendingToolUsenullにクリアされます。2つの保存に挟まれているのは、ツールが本当に実行されるコード区間そのものです。もしその区間の最中、あるいは直後にプロセスが死ねば、ディスクに残るのは「地点Aは保存済み、地点Bは未保存」という場面——pendingToolUseが空でない状態であり、これこそ復旧ロジックが処理するために作られた信号です。

この「宙ぶらりんな呼び出しは1つ」というプロトコル(pendingToolUseは配列ではなく単一のオブジェクト)が破綻しないよう、このレッスンではモデルが1ターンにちょうど1つのツールを名指しするようタスクを設計しています。これは意図的な単純化であり、その境界は「程度の問題」のセクションで明示します。

副作用台帳: runToolUsesへの配線

台帳が解決する問題はこうです。「ツールが実際に実行を終えた」と「結果が messages に着地した」のちょうど間にクラッシュが落ちたとき、この呼び出しがすでに走っていて再度走らせてはならないことを、再開はどうやって知るのか。やり方は、ツールが成功したその瞬間に、その結果をtool_use_idをキーとする台帳へ別途書き込むことです(ここでも一時ファイル+renameのアトミックな書き込みを使います)。

この順序は入れ替えられません。まずtoolImpls[block.name](block.input)の本物の結果を得て、そのうえで初めてsaveEffectがそれを書き留められる——実行が先、記録が後です。台帳が記録するのは「これは本当に起きた、そしてこれがその結果だ」ということです。逆にして実行前に記録したなら、台帳に着地しうるのはプレースホルダだけになり、台帳は「すでに完了した」という約束の意味を丸ごと失います(レベル2の演習では、このアンチパターンを自分の手で再現してもらいます)。

通常の単発実行では、runToolUsesは「実行→記録」の2ステップを歩みます。各tool_use_idは初めて現れるので、引くべきものが何もないからです。再開が処理しなければならない唯一の宙ぶらりんな呼び出しは、より完全な「台帳を確認→(必要なら)実行→(実行したなら)記録」の3ステップを歩みます。次に見るreconcileがその3ステップの実装であり、どちらも同じ規律に従います。本物の結果を手にする前に「すでに完了した」を台帳へ書いてはならない、という規律です。

reconcile: クラッシュ後の宙ぶらりんな呼び出しに対する3分岐

再開が処理しなければならないのは、チェックポイントの中にある(あれば)その1つのpendingToolUseです。それは3つの可能性に対応します。

3つの分岐は、いずれも実際にテストされた3つのシナリオに対応します。

  • 台帳ヒット — これはレッスン冒頭のクラッシュのデモです。write_reportは実際に実行を終えて記録されており、save Bだけが間に合いませんでした。再開時は台帳の結果をそのまま再利用し、再実行せず、レポートを2回書くことを避けます。
  • 台帳ミス+読み取り専用ツールread_notesのような、副作用のないツールです。記録が着地する前にクラッシュしても問題にならないので、もう一度走らせて結果を得て、ついでにこの実行を台帳へ記録します。
    text
    [resume][reconcile] tool_use_id=toolu_ro name=read_notes 台帳ミス、読み取り専用ツール、再実行する
  • 台帳ミス+副作用ありwrite_reportのような、外部の状態を変えるツールで、記録が着地する前にクラッシュした場合です。それが実際に走ったかどうかはわかりません(実際のファイルシステム上では、write_reportの副作用はすでに起きていて、ただ台帳に記録されなかっただけ、ということも十分にありえます)。ここでは推測するのではなく、is_error: truetool_resultでモデルに「この呼び出しの状態は不明だ」と正直に伝え、判断を差し戻します。
    text
    [resume][reconcile] tool_use_id=toolu_side name=write_report 台帳ミスかつ副作用あり、判断不能、is_error を追加

3つのログ行はすべて実出力であり、でっち上げではありません——reconcile自体はタスクが何であるかを知る必要がなく、pendingToolUseと対応する台帳の状態を与えれば、3つの分岐はそれぞれ独立にテストできます。

エントリポイント: main()--resume

最後はエントリポイントです。main()が下す決定はちょうど1つ、コマンドラインに--resumeがあるかどうかです。あればloadCheckpoint()を通して復旧し、なければ前回の残りのチェックポイントファイルと台帳ファイルを片付けて新規に始めます。この片付けによって、「--resumeなしでやり直す」が常にきれいな出発点になり、前回の中途半端な場面に汚染されないことが保証されます。

runAgentの内部には対応する2つの経路があります。opts.resumeが真のときはloadCheckpoint()を呼び、reconcileを走らせ、調整結果(あれば)をmessagesへ push して地点Bのチェックポイントを1つ保存し、そのうえでいつもどおりモデルへリクエストを送ります。偽のときは古いチェックポイントと台帳をfs.rmSyncし、空のmessagesから始めます。本物のagent.jsでは、モデルクライアントが@anthropic-ai/sdkclient.messages.create({ model, max_tokens, tools, messages })に差し替わるだけで、構造はほかに何も変わりません。

プロトコルを典拠として引く

このレッスンの2つの設計判断は、どちらも恣意的に決めたものではありません。

台帳が見つからずreconcileが状態を確信できないとき、黙ってスキップするのではなくis_error: truetool_resultを追加することを選ぶのは、コンテンツブロックのペアリングに関するプロトコルの厳格な要求に依拠しています。すべてのtool_useは対になるtool_resultを伴って返らなければならず、それらはまとめて返され、それぞれがtool_use_idで紐づけられます1。地点Aの保存を省けば、再開の流れはその呼び出しが起きたことすら知らないままになるので、そのペアリング規則を満たしようがありません。reconcileが存在する目的はまさに、台帳がヒットしようがミスしようが、宙ぶらりんな呼び出しが最終的に対になるtool_resultを得ることを保証する点にあります。

「エラーで落として最初からやり直す」ではなく「再開して続ける」を選ぶことは、Anthropic のエンジニアリングチームが自社のリサーチシステムの振り返りで述べたことと響き合っています。エラーが起きたとき、単にやり直すことはできません。なぜなら "restarts are expensive and frustrating for users,"(やり直しは高くつき、ユーザーにとって苛立たしい)からであり、だから彼らは代わりに "built systems that can resume from where the agent was when the errors occurred"(エラーが起きた時点のエージェントの位置から再開できるシステムを構築した)のです2。同じ振り返りは、エージェントの適応力が決定論的なセーフガードと対立するものではなく、組み合わせられるものであることも述べています——"the adaptability of AI agents built on Claude with deterministic safeguards like retry logic and regular checkpoints"(Claude 上に構築された AI エージェントの適応力を、リトライロジックや定期的なチェックポイントのような決定論的なセーフガードと組み合わせる)2。チェックポイントは「プロセスが死んだ」という決定論的な失敗を受け止め、モデルの適応力は「台帳では判断不能」のような、コードでは決め打ちできない種類のケースを扱います。reconcileis_error分岐は、その二つが出会う場所です。不明な状態について真実をモデルへ伝え、検証するか再試行するかをモデルに決めさせる——そして "letting the agent know when a tool is failing and letting it adapt works surprisingly well"(ツールが失敗していることをエージェントに知らせ、適応させるやり方は驚くほどうまくいく)のです2

検証ハーネス

このレッスンの2つのターミナルデモは、実際にプロセスをキルして何が起きるかを見る、というやり方には依存していません。それではクラッシュのタイミングが毎回変わってしまい、「N回目のツール呼び出しの後にクラッシュし、復旧の振る舞いが正しい」といった狙いを定めたアサーションが立てられないからです。やり方は、モデルクライアントを、決まった順にカードを出すスタブへ差し替えることです。messages.createが呼ばれるたびに、あらかじめ書いておいたレスポンスを順に手渡すレスポンスキューを用意し、キューが尽きた後も呼び続けたら即座に投げるようにします。こうすれば、タスクがどのターンにどのツールを呼ぶか、モデルがいつ締めくくるかは、1回の実際の呼び出しによって動くことのない、ハードコードされた定数になります。

「プロセスをキルする」のは、環境変数で制御されるcrashPoint(label)です。runToolUsesが台帳への書き込みを終えるたびに「これが何回目の書き込みか」を文字列ラベルへ縫い込み、CRASH_AFTER環境変数と突き合わせ、一致したら専用のSimulatedCrash例外を投げます。これによって「N回目のツール呼び出しの後にクラッシュ」が、タイミング任せの偶発事象ではなく、正確に指定できる整数になります。main()は最外層でこの例外だけを捕まえ、[kill]のログを1行だけ出して137SIGKILLで落とされた場合の慣例的な終了コード)で終了するので、デモは醜いスタックトレースではなく本物のプロセスキルのように読めます。

この「レスポンスキューで内容を固定し、ラベルでクラッシュ回数を固定する」手法は、このシリーズの第8コース「Context Engineering: Spending Finite Attention Where It Counts」のレッスン6がコンテキストエンジニアリングを検証するのに使ったのと同じ発想です。ほうっておけば非決定的なもの(今回モデルが何を言うか、今回プロセスがどこで死ぬか)を先に固定量へ落とし込み、そのうえで初めて、毎回違う結果になるのではなく復旧の振る舞いを1行ずつアサートできるようになります。このレッスンが3つの分岐——「台帳ヒット、再実行しない」「読み取り専用ツールの台帳ミス、そのまま再実行」「副作用ありツールの台帳ミス、is_errorを追加」——に加えてloadCheckpointの切り詰めファイル耐性まで検証できたのは、この方法によるものです。いずれも机上で推論しただけでなく、実際のnode実行で一つずつ確認しました。

程度の問題: すべてのタスクにこれが必要なわけではない

このレッスンで溶接した機構——2つのチェックポイント、1つの台帳、3分岐のreconcile——は、何ターンも続けて走り、その途中に副作用を持つ長いタスクのためのものです。数秒で終わり、失敗したら再実行すればよい小さなタスクは、このディスクI/Oと状態機械の一式を担ぐ価値がないかもしれません。ここではこのシリーズの第7コース「Agent Harness Fundamentals: Loops and Control」が引いたのと同じ程度の感覚を借りられます。検討に値するのは、"you should consider adding complexity only when it demonstrably improves outcomes"3(複雑さは、成果を明らかに改善する場合にのみ追加を検討すべき)という一点です。これは「必ずこうしなければならない」という厳格な規則ではなく、始める前に自分に問うべき質問に近いものです。このタスクは、チェックポイントを維持する価値があるほど本当に長く、本当に重要でしょうか。

このレッスンの実装は、明示的な境界も2つ引いています。「学んだらそのまま本番へ落とし込める」と受け取らないよう、口に出して言っておく価値があります。

  • 各ターンが扱う宙ぶらりんなpendingToolUseはちょうど1つで、モデルが1ターンに1つのツールを名指しするデモタスクに合わせてあります。実際の現場では、1つのモデルレスポンスが複数のtool_useブロックを同時に運ぶことは十分にありえます(このシリーズの第7コースのrunToolUsesPromise.allでそれらを並行実行します)。このレッスンの「宙ぶらりんな呼び出しは1つ」というプロトコルを宙ぶらりんな呼び出しの集合へ拡張するとは、pendingToolUseをオブジェクトから配列へ変え、それぞれに対してreconcileを走らせるということです。このレッスンはその一層の複雑さを意図的に外しました。まずは単一の宙ぶらりんな呼び出しに対する調整ロジックをはっきり伝えきるためです。
  • このレッスンのチェックポイントと台帳が管轄するのは「1プロセスが1タスクを走らせる」という一事だけです。複数のセッションが状態をどう共有するか、複数のプロセスが同じチェックポイントに同時に触れたら衝突するのか、マシンをまたいだ一貫性はどう保証されるのか——これらはマルチセッションの並行性と分散一貫性に属する話であり、このレッスンにも、このコースの範囲にも含まれません。

まとめ

  • チェックポイントは1ターンに2回保存します。地点Aはモデルのレスポンス到着後に宙ぶらりんなpendingToolUseを記録し、地点Bはツール結果がmessagesに着地した後にそれを null へクリアします。地点Bだけを保存すると、「モデルがツールを名指しする」から「結果が記録される」までの窓がチェックポイント上で完全に不可視になります。すべてのtool_useは対になるtool_resultを伴って返らなければならない以上1、その窓の中の宙ぶらりんな呼び出しを追跡可能にするものこそが地点Aです
  • 副作用台帳はtool_use_idで記録し、その規律は「実行が先、記録が後」です。記録は本物の結果をすでに手にしていることを前提とします。逆にすれば「まだ走っていない」を「すでに完了した」と誤記録することになります
  • reconcileの3分岐が再開時の宙ぶらりんな呼び出しを処理します。台帳ヒットなら再利用して再実行しない。台帳ミスだが読み取り専用ならそのまま再実行する。台帳ミスかつ副作用ありなら推測せず、is_errortool_resultを追加して状態を正直にモデルへ返す。これは「エラー時に最初からやり直すことはできず、当たった場所から再開しなければならない」と「ツールが失敗したことをモデルに知らせ、適応は任せると、驚くほどうまくいく」という2つのエンジニアリング上の教訓2と響き合い、「決定論的なセーフガードとモデルの適応力を組み合わせる」という考え方2とも一致します
  • チェックポイントと台帳の機構はタダではありません。その複雑さが成果を明らかに改善するときにのみ追加してください3。このレッスンの実装が管轄するのは「1プロセスが1タスクを走らせる」ことだけであり、マルチセッションの並行性と分散一貫性はその関心事にも、このコースの範囲にも含まれません

これでこのコースは修了です。「エージェントはステートフルであり、エラーは積み重なる」という見立てから出発して、チェックポイントは何を保存すべきか、いつディスクへ書くべきか、再開時に宙ぶらりんな呼び出しをどう扱うか、冪等性がどう復旧を裏打ちするか、そしてチェックポイントがさらに巻き戻しとフォークにどう役立つかを、通しで歩いてきました。そしてこのレッスンで、それらを自分の手で、本当に動き、本当に落とされ、本当に拾い直して完走するハーネスへ溶接しました。いま手元にあるのは概念の集合だけではなく、実際のnode実行で検証されたひとまとまりのコードです。これを自分のハーネスへ配線しておけば、次に本当に落とされたとき、それは中断したところからそのまま拾い直してくれます。

Footnotes

  1. Handle tool calls — Claude API — https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls 2

  2. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3 4 5

  3. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2

練習

01

1ターンの中で、このレッスンのチェックポイントが捕まえうる「クラッシュの瞬間」は最大3つです。① 地点Aを保存した直後、ツールはまだ実行を開始していない。② ツールの実装関数はすでに終わっているが、台帳がまだ書かれていない。③ 地点Bを保存した直後。このレッスンが実装したsaveCheckpoint / saveEffect / reconcileに対して、それぞれの瞬間を明確に書き出してください。クラッシュ後、checkpoint.jsonとeffects.jsonはそれぞれどんな状態か。--resumeしたときreconcileはどの分岐に入るか。そして中断されたのが読み取り専用ではない副作用ありのツール(たとえばwrite_report)だった場合、①と②のディスク上の観測可能な状態は同じか、復旧の振る舞いは同じか——同じだとしたら、それは何を意味するか。

レベル1: クラッシュ瞬間の訓練マニュアル
完了基準 · ローカルでチェック
02

インシデントレポートにはこうあります。「ユーザーがタスクを中断し、--resumeした後にツールが1つスキップされた。ログには『完了済み』と出ていたが、このツールは実際には一度も実行されておらず、書き出されるはずのファイルがそもそも存在しない」。当時本番で走っていたrunToolUsesを掘り起こすと、このレッスンのバージョンとの違いが1つ見つかります。

レベル2: 台帳を逆順に書いてしまった順序の誤りを見つける

この順序の誤りを見つけ、なぜそれが「明らかに一度も走っていないツールが完了扱いされる」を引き起こすのかを明確に説明し、順序を修正してください。そのうえで、このレッスンの「検証ハーネス」セクションの方法に倣って、再現用の小さなスクリプトを書いてください。saveEffecttoolImpls[block.name](...)のあいだに環境変数で制御する疑似クラッシュ地点を挿入し、nodeで実際に走らせます——誤った順序ではクラッシュ前にすでにresult: nullのレコードが台帳に入っており、順序を修正すれば同じクラッシュ地点でこのtool_use_idのエントリは台帳にまったく存在しません。

完了基準 · ローカルでチェック