メールを送れるAIエージェントで、人間の確認をどこに挟むか
!
この記事は、Mac向けの個人エージェント「Nico」を開発しているチームが書いています。製品の紹介は末尾に少しだけ載せています。
AIエージェントにメールやブラウザを触らせると、すぐにぶつかる問題があります。どの操作は黙って実行してよくて、どの操作は人間に聞くべきか、です。
私たちが作っているNicoは、ユーザー自身のMacの上で動きます。ファイルはその場所のまま読み、ウェブサイトはユーザーがログインしているセッションをそのまま使い、「メール」「カレンダー」などのアプリや、アクセシビリティ経由でほかのアプリも操作します。本物のログイン情報を持っているので、本物のメールを送れるし、本物のフォームを送信できるし、本物のファイルを消せます。
しかも、エージェントが読むもののほとんどは、ユーザーが書いたものではありません。ウェブページ、PDF、受信箱。そのどれにでも、モデルに向けた一文を紛れ込ませることができます。
この記事では、Nicoで「確認をどこに挟むか」をどう決めたかを、判断の順番に沿って書きます。
前提:話す係と作業する係
Nicoは2つのエージェントで動いています。
会話の担当:ユーザーと話し、頼まれたことを理解して、作業を引き渡し、結果を伝えます。応答の速さを優先しています。
作業の担当(Worker):段取りを立て、ファイルを読み、アプリやブラウザを操作し、成果物を作って、自分で結果を確かめます。オープンソースのエージェント基盤 pi の上に作り、ツール群とコンテキスト圧縮は自前のものに差し替えています。
この記事で扱う確認の仕組みは、主にWorkerのツール呼び出しにかかるものです。
「毎回聞く」も「全部任せる」も選ばなかった理由
開発者向けのエージェントの多くは、画面の前で人が見ていることを前提にしています。おかしければ止めればいいし、何かあっても自分で責任を取れます。
Nicoのユーザーはそういう使い方をしません。頼んだら会議に行ったり、ご飯を食べに行ったり、Macを閉じたりします。定時実行のタスクは、ユーザーが寝ているあいだに動くこともあります。
この前提だと、両極端はどちらも成り立ちません。
毎回聞く:ユーザーがいないあいだに何も進みません。
全部任せる:ウェブページの一文で、知らない宛先にメールが飛びます。
そこで、「完全自動」や「毎回確認」の切り替えスイッチは用意せず、動き方は一通りにしました。危ない一歩だけを、ユーザーが実際に言ったことと照らし合わせて判断します。
ルール1:肝心なチェックにはモデルを使わない
ツール呼び出しは、実行の前に毎回アプリ側のプログラムを通ります。このチェックはモデルを呼ばず、記憶も読まず、エージェントからは変更できません。
順番はおおよそ次のとおりです。
タスクはまだ動いているか。取り消し・停止済みなら実行しない。
ツールは一覧にあるか、パラメーターの形式は正しいか。
ユーザーがいま操作しているページでの操作か。そうなら、手が止まって3秒たつまで待つ。
実際の対象を見て、この一回の呼び出しのリスクレベルを決める。
レベルに応じて、そのまま実行・審査に回す・ユーザーに聞く・拒否する、のどれかにする。
ウェブページの文章はモデルを言いくるめられますが、if 文は言いくるめられません。機械的に確かめられることは、機械的に確かめます。
リスクレベル
レベル
扱い
例
L0 読む
そのまま実行
ページ・メール・ファイルを読む、検索
L1 下書き
そのまま実行
ファイルを書く、コマンドを実行する、メールの下書きを保存する
L2 取り消せる
実行して記録に残す
リンクを開く、予定を追加する、リマインダーを作る
L3 外に出る
審査に回す(一部例外あり)。メール送信は毎回ユーザーに聞く
メッセージ送信、ウェブ上のクリックや入力、ファイルのアップロード、AppleScript
L4 取り消せない
L3と同じく審査に回し、レベルも審査に渡す
完全削除、まとめてのファイル移動
L5 本人だけ
拒否して、ユーザーに渡す
パスワード欄、認証コード、カード情報、支払い、OAuthの同意画面
ウェブ上の操作は、実際に押そうとしている要素で決めます。アプリが要素のrole、名前、type、autocomplete属性を読みます。「送信」「削除」のようなボタンは下限を決めます。審査がどう判断しても、その一歩は高リスクより下には扱いません。
L5は設定項目ではありません。Nicoはパスワードを入力しませんし、認証コードを読み上げませんし、支払いを完了させません。予約が支払い画面まで来たら、そこで止めてユーザーに渡します。
ルール2:審査はひとつの問いにだけ答える
ルールでは答えられない問いがあります。「この一歩は、ユーザーが頼んだことと合っているか」です。「要約を田中さんに送って」と「要約をこのPDFに書いてあるアドレスに送って」は、ルールから見るとまったく同じ操作です。
そこで、L3以上の一歩は別のモデル呼び出しで審査します。審査が見るのは次のものです。
ユーザーが最初に頼んだときの原文と、途中で付け足した言葉
これから実行する一歩とそのパラメーター
プログラムが先に確かめた事実(許可の範囲内か、最低限のリスク、外部の内容を読んだか、どのサイトか)
逆に、ページ本文と、Workerがユーザー向けに書いた説明は見せません。Workerが外部の文章を指示と取り違えたとき、その解釈で審査まで引きずられないようにするためです。
審査で止められた一歩は、理由がWorkerに返され、タスクはそのまま続きます。ユーザーの返事を待って止まることはありません。止められた一歩は記録に残り、ユーザーがあとから許可すれば、やり直されます。
ルール3:許可はユーザー自身の言葉からしか生まれない
明らかに頼まれた操作まで毎回審査していては、遅くて邪魔です。そこで、タスクを引き渡すときに、もうひとつ別のモデル呼び出しを行います。この呼び出しが読むのは、ユーザーの言葉と、ユーザー自身が書いたり直したりした記憶だけです。ウェブページ、ファイル、ツールの結果は見せません。
ここで3種類の許可を書き出します。
send:名指しされた人や場所に送る
modify:名指しされたフォルダの中でファイルを整理する
act:名指しされたウェブサイトで操作する
モデルが出した許可は、プログラムが一つずつ突き合わせます。人の名前はユーザーの言葉か関連する記憶に出てくるか、フォルダはよく使うフォルダか、ユーザーが口にしたパスか、サイトはユーザーが言ったものか。合わないものは捨てます。Worker、ウェブの内容、審査のどれも、許可を増やすことはできません。
許可の範囲内でも、外部の内容を読んだかどうかで扱いが変わります。
この一歩
範囲内・外部の内容を読む前
範囲内・外部の内容を読んだあと
範囲外
読む・検索・移動・下書き
そのまま実行
そのまま実行
そのまま実行
actのサイトで、送信を伴わない入力や選択
そのまま実行
そのまま実行
審査
modifyのフォルダ内での移動・名前変更・ゴミ箱
そのまま実行
そのまま実行
審査
sendの相手への送信(回数の上限あり)
そのまま実行
審査
審査
ページ上の送信・削除・提出のクリック、完全削除など
審査
審査
審査
ただし、「メール」アプリからの送信は、この表にかかわらず毎回ユーザーに聞きます(ルール4)。
外部の内容を読んだあとに厳しくなるもの
外部の内容は「指示」ではなく「資料」として扱いますが、それだけでは足りません。タスクが一度でもウェブページなどを読んだら、それ以降は次の操作も審査に回します。
記憶への書き込み
定時実行のタスクの作成
Workerが自分で組み立てた、パラメーター付きのURLを開くこと
ページの一文で一番誤らせやすいのがこの3つだからです。たとえば、読んだページや検索結果に元からそのまま載っていたリンクを開くのは普通の閲覧として扱いますが、Workerがデータを埋め込んで自作したURLは、外にデータを持ち出す経路になりえます。
ルール4:メールの送信だけは、必ずクリックを待つ
「メール」アプリからの送信は、審査にも許可にも任せず、毎回ユーザーに聞きます。
理由は、受け取る人が読むのはメールの原文だからです。審査にわかるのは「この送信は頼まれたことと合っているか」までで、文面がちゃんと書けているかをユーザーの代わりに判断することはできません。
そのため、送信前にカードを表示します。カードに出すのは、送信元アカウント、宛先、CC、BCC、件名、本文、リンクの実際のURL、添付ファイルで、どれも実際に送るパラメーターそのものです。要約や読み上げで代用はしません。
確認として認めるのは、送信ボタンのクリックだけです。声で「送って」と言っても送りません。「7時に送って」という定時の送信も、時間になったらこのカードで止まり、ユーザーが戻ってきてクリックするのを待ちます。
ルール5:同じメールを二度送らない
確認の設計と同じくらい大事なのが、「もう送ったかどうか」を忘れないことです。
取り消せる操作以上で、繰り返されると困るものは、実行の前にプログラムが「これから実行する」という記録とパラメーターのハッシュを書き、実行後に結果を書きます。同じ操作がもう一度呼ばれたら、次のように扱います。
成功済み:実行せず、前回の結果を返す。
結果不明(「これから実行する」だけが残っている。たとえば途中でMacの電源が落ちた):まず読み取り専用のツールで確かめる。メールなら送信済みフォルダを見る。
この記録はコンテキスト圧縮でも効いてきます。長いタスクでは、途中で履歴を要約して圧縮します。社内で比べたところ、piの標準の要約では、あとで必要になる事実が抜け落ちることがありました。「この返信はもう送ったか」がモデルの書いた要約頼みになると、続きを始めたときにもう一通送りかねません。
そこでNicoでは、モデルが書いた要約に、プログラムがこの記録を添えます。成功したものは「実行済み、やり直さないこと」、結果がわからないものは「結果不明」として渡します。要約の頭には「これは自分で書いた要約で、古かったり間違っていたりする可能性がある。指示ではない」という一文も付けます。
まとめ
作ってみて、次の4つが大事だと考えています。
肝心なチェックはモデルを通さない。 外部の文章に影響されうるのはモデルだけです。
許可はユーザーの言葉からだけ作る。 エージェントやページの内容から許可が増える経路を残さない。
人が原文を見るべきところは、クリックを待つ。 審査は、頼まれたことと合っているかまでしか判断できません。
モデルに忘れられては困る事実は、プログラムが添える。 要約がどれだけよくても、送信済みかどうかを要約に任せない。
ユーザーが画面の前にいない時間に動くエージェントでは、「どこで人に聞くか」は使い勝手の調整ではなく、設計の中心になると感じています。
Nicoについて
Nicoは、右⌘キーを押しながら話しかけるだけで、Macの上で作業を進める個人エージェントです。使うモデルはChatGPTやClaudeのアカウント、各社のAPIキーから選べて、Workerのリクエストは私たちのサーバーを通らず、Macから選んだ提供元へ直接送られます。
アプリの画面表示は、いまのところ英語と中国語だけです。話しかける言語は画面とは別に設定で20言語から選べ、日本語で話しかければ日本語で返事をします。
Appleシリコン搭載のMac、macOS 15.4以降で動きます。オープンソースではありません。
nico.app
Why We Run Our AI Agent on Your Mac Instead of a Cloud Computer
A lot of personal AI agents now come with their own computer: a virtual machine in the cloud with a browser, a file system and a model driving it. It's a clean design. The agent gets a sandbox it fully controls, and nothing it does can touch your machine.
The catch shows up on the first real task. Your files are on your laptop. Your logins live in your browser. Your mail, calendar and the odd desktop app your team uses are all local. So you upload documents, sign in to your accounts again on a remote machine, and download whatever it makes.
We build Nico, a personal agent for the Mac, and we went the other way: the agent works on the Mac you're already using. This post covers the design choices that fell out of that, and a few things we learned building it.
Two Agents Behind One Character
Nico shows up as a small character on your desktop. You hold the right ⌘ key, say what you need, and let go. Behind that there are two agents with very different jobs.
The front agent talks with you. It has to answer fast, follow speech that changes its mind halfway ("Thursday, no, Friday"), and stay available while work is running. It doesn't do long tasks itself. It works out what you mean, hands a task off, and decides when to tell you how it went.
The Worker does the work. It plans, reads files, works in your apps and browser, makes what you asked for, and checks its own results. Tasks can take minutes, get queued, pause, and resume after the app restarts.
!Two small Nico figures under the line “Not one agent, two agents”: one labeled “One talks with you,” the other digging through a box of documents, labeled “One does the work”
Splitting them keeps voice latency independent of task length. You can ask for a report, then ask something unrelated while the report is still being written, and the front agent answers right away.
Built on pi, With Our Own Tools
The Worker runs on pi, an open-source agent harness. pi owns the loop that sends requests to the model, gets back tool calls and returns results. It also connects to model providers and subscription logins, saves sessions, and retries on errors.
Nico owns everything around that loop: every tool the Worker can call (including replacements for pi's built-in ones), the check before each step, confirmation cards, a log of outbound actions, the system prompt, and what happens when the context fills up.
That last one is a good example of why we split things this way. When the context is full, pi's default is to have the model write a summary that keeps the first 2,000 characters of each tool result. We ran a comparison: the Worker reads three long pages, each ending with a reference number, the context fills up, and then we ask for the three numbers.
- With the default summary, none of the three models we tried got a single number right. Two of them also recorded emails they had already answered as still unanswered.
- With our own compaction summary, all three got every number right.
Our summary also attaches the outbound actions already taken, marked as done and not to be repeated. An agent that forgets it already sent an email will cheerfully send it again.
Every Step Gets a Risk Level
Running on your real Mac means the agent can reach real things, so every step is classified before it runs: do it directly, send it to review, ask the user, or refuse.
- Review checks the step against what the task was authorized to do. Typing into a page or tidying files in a folder you approved goes straight through. Other steps go to an automatic review, and if review says no, the reason goes back to the Worker instead of stopping the task.
- Asking the user is reserved for things that shouldn't be automated. Sending an email from Mail shows a card with the account, recipients, subject, full text and attachments, and it waits for a click on Send. Payments, account authorization and credentials always go back to you.
- Steps with an unknown outcome (say the app quit mid-send) are checked read-only first, for example by looking in the Sent folder, before anything is retried.
Some actions get a floor regardless of what the review thinks: sending, submitting, permanent deletion, sharing and privacy changes are always treated as high risk.
!An expense report filled in from a hotel receipt in Chrome, with Nico asking “All filled in. Should I submit it?” and the options Submit, Not yet and Other
Local Means Your Logins Just Work
Because the Worker runs on your Mac:
- Files are read where they are, and results are saved locally.
- Websites see your usual network and your existing sessions. The Worker uses its own browser on your Mac, or your Chrome through the Nico extension.
- Mail, Calendar, Reminders, Notes, Finder, Preview and other apps are within reach. Desktop apps are driven through cua-driver, and Nico decides which tools and system permissions it gets.
- Memory is stored on your Mac, and you can see, change or delete it.
Bring Your Own Model
The Worker always runs on your own model account: ChatGPT, Claude, Gemini, GitHub Copilot, DeepSeek, Qwen, Kimi, Mistral, OpenRouter, or any OpenAI- or Anthropic-compatible endpoint. Credentials go in the macOS Keychain, and requests go straight from your Mac to the provider you picked. Nico's servers never see what the Worker reads or writes.
If you've already got a coding plan or API key, there's no second AI subscription just to run tasks.
Try It
Nico is free for Apple silicon Macs on macOS 15.4 or later. The Free plan runs tasks on your own model and replies in text. Plus ($15/month) adds voice replies, and new accounts get a 7-day Plus trial with no card required.
Get Nico at nico.app
If you're building agents yourself, we'd love to hear how you handle compaction and outbound actions. Drop a comment.
1 reaction
cool work by @mitsuhiko here! new code mode runtime
earendil-works/djs
57 likes · 1 repost · 4,758 views
@Jonathan_Blow I would have been with you until very recently. But it's shockingly impressive how good these systems work now. This is basically 24 hours of a little agent swarm working off a single initial spec and very light updates afterwards: earendil-works/djs
23 likes · 2 reposts · 2,063 views
Cloudflare AgentsがPi 1.4に対応したので、Agentの中断・復旧・永続化を検証する
> 中断された Agent は、最後のチェックポイントから正しく再開しました。副作用が2回起きたのは、そのせいです。
この記事の結論
Cloudflare Agents SDK が Pi 1.4 に対応したので、Durable Object 上の Agent を実際に停止させ、会話履歴・タスク・ツール実行がどこまで復旧するかを測りました。復旧機構は期待どおり動きましたが、Agent が復旧することと、外部の副作用が重複しないことは別です。
- Pi 1.4 で追加された Hibernation と Parked Tasks は、実際の Durable Object で確認できた
- replay: "safe" は冪等性を検査しない。非冪等なツールに付けると、同じ副作用が2回起きる
- Storage Error Recovery は進行中の呼び出しを救わず、次の操作から Pi を開き直す仕組みだった
- agents@0.30.0 には、別タスクが動いているとリトライ待機中のタスクを起こせない既知の不具合がある。使うなら 0.30.1 以降にする
| 観測したもの | 結果 |
| --- | ---: |
| 再起動後の会話履歴 | 読み戻せた |
| 完了済みタスク | 二重実行されなかった |
| safe な冪等ツール | 再実行されたが副作用は1回 |
| safe と宣言した非冪等ツール | 副作用が2回 |
| unsafe ツール | 自動再実行されず、完了もしない |
| Hibernation | 待機中に別インスタンスで再開 |
検証コードはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s…
そして、この記事で一番伝えたいことを先に書いておきます。
replay: "safe" は、ツールを安全にする機能ではありません。 Pi に対して「このツールは途中で止まったら再実行してよい」と宣言するだけです。副作用が本当に冪等か、同じリクエストを2回受けても外部 API が重複処理しないかは、ツールの実装側が保証しなければなりません。
> この記事は「Cloudflare AgentsがPi 1.4に対応したので、Agentの中断・復旧・永続化を検証する」の登壇内容に、発表で話しきれなかった検証手順・実測値・失敗した測定を足したものです。対象は Cloudflare Agents と Pi Durable を使って、外部 API やデータベースを操作する人です。
---
1. 「Pi 1.4 対応」の4項目を分解する
2026年10月22日、Cloudflare は Agents SDK の Pi 1.4 対応を発表しました。Changelog には次の4項目が並んでいます。
- Hibernation
- Parked Tasks
- Storage Error Recovery
- Model Stream Retry
最初は、4つとも Pi 1.4 の新機能だと思いました。しかし、配布物と変更履歴を追うと、出どころは2層に分かれていました。
| Changelog の項目 | 実際の変更元 |
| --- | --- |
| Hibernation | Pi 1.4 の sleeping タスクを Agents SDK が配線 |
| Parked Tasks | Pi 1.4 の機能を Agents SDK が公開 |
| Storage Error Recovery | Agents SDK 側の修正。Pi のバージョンとは別 |
| Model Stream Retry | Agents SDK 側の修正。Pi 1.3 でも成立 |
特に Model Stream Retry の PR には、Pi 1.3.0 と Pi main の両方で判定を確認したと書かれていました。つまり、Storage Error Recovery と Model Stream Retry を Pi 1.3 と 1.4 で比較するのは軸が違います。比較すべきなのは agents@0.29.0 と agents@0.30.x です。
ベンダーの Changelog は、利用者にとって分かりやすい単位で変更をまとめています。一方、障害が起きたときに調べるには、どのパッケージのどの層が責任を持つかを分ける必要があります。
2. Pi、PiHarness、Lifecycleの役割
今回の構成には3つの層があります。
| 層 | 担当 |
| --- | --- |
| Pi Durable | Agent の実行ループ、タスク、チェックポイント、replay |
| PiHarness | Pi に Durable Object の SQLite と起動処理を接続 |
| Lifecycle | Durable Object 上で長時間の仕事を実行し、必要なときに起こす |
Pi Durable は Cloudflare のパッケージではありません。@earendil-works/pi-durable として提供され、Agents SDK 側の PiHarness が Durable Object に組み込みます。
ここで重要なのは、保存と再開が別の機能だということです。
- 会話履歴やタスクの保存先は、pi_ で始まる SQLite テーブル
- 眠っているタスクを再開するのは、Lifecycle の Wake ジョブと Durable Object Alarm
保存できていても、起こす仕組みがなければ再開しません。逆に、再開できてもチェックポイントがなければ、どこから続けるか分かりません。今回の検証では、この2つを混ぜないようにしました。
Pi Durable の設計記事には、次のようにあります。
> every step of a run is a task that stores a checkpoint before it moves on.
このチェックポイントが、再起動後に「完了済みのタスクをもう一度実行しない」ための基礎になります。
3. まず、観測そのものを永続化する
障害復旧を測るとき、最初に作るべきなのは実験対象ではなく記録機構です。Durable Object の書き込みは、呼び出した瞬間にすべてがディスクへ確定するわけではありません。
検証用のレコーダは、イベントを SQLite に書いたあと、通常は ctx.storage.sync() を呼ぶようにしました。直後に ctx.abort() を呼び、再起動後にイベントが残っているかを比較します。
| 記録の仕方 | 再起動 | 再起動後に残った行数 |
| --- | --- | ---: |
| sync() あり | した | 1 |
| sync() なし | した | 0 |
どちらもコンストラクタで発行したインスタンス ID が変わっていました。本当に同じインスタンスが動き続けたのではなく、再生成後のインスタンスが SQLite を読んでいます。
ctx.abort() は通常の throw とは違います。アプリケーションコードでは catch できず、Durable Object を即座にリセットします。障害注入に通常の例外を使うと、Agent の復旧ではなく「同じインスタンスのエラー処理」を測ってしまいます。
この土台を確認してから、以降のイベントはすべて sync() ありで記録しました。ログに残らなかった観測を、復旧の証拠には使っていません。
4. 会話履歴とタスクは再起動を越えて残る
ツールを1回実行する Agent を動かし、処理が完了したあとに ctx.abort() で Durable Object を落としました。再起動後に transcript とイベントを読みます。
| 観測 | 再起動前 | 再起動後 |
| --- | ---: | ---: |
| インスタンス識別子 | — | 変化した |
| transcript のエントリ数 | 5 | 5 |
| ツール実行開始 | 1 | 1 |
| 副作用 | 1 | 1 |
完了済みのタスクは二重実行されませんでした。同じ operationId を使った submit も、1回目は accepted: true、2回目は accepted: false になりました。異なる ID を使うと accepted: true になるため、submit 全体が壊れたわけではありません。
ここまでは、Pi Durable がチェックポイントと submit の識別子を使って、完了済みの仕事を保護している結果です。
5. クライアントが切れても、Agentは動き続ける
次に、モデルのリトライで処理時間を伸ばし、途中で WebSocket を切断しました。クライアントがいなくなったあとも実行が続くかを確認します。
結果は、最終的に done になりました。モデル呼び出しは「失敗2回、成功1回」の3回です。切断前後でインスタンス IDは変わっていません。
これは「落ちて復旧した」のではありません。Agent の実行がクライアント接続に依存していなかったということです。
再接続後は snapshot から現在の状態を組み立て直せました。ただし、切断中に流れたイベントをカーソルで途中から再生できるわけではありません。イベントストリームは状態を追うためのものではありますが、履歴ストリームの代わりではありません。
6. Parked Tasksはクラッシュループを止める
同じ場所で何度も ctx.abort() を発生させ、restartLimit を2に下げて測りました。
| 落とし方 | 障害発火 | インスタンス生成 | pending() | 次の Alarm |
| --- | ---: | ---: | --- | --- |
| ctx.abort() | 2 | 2 | parked | なし |
| 通常の例外 | 1 | 0 | 空 | なし |
通常の例外は Durable Object をリセットしないため、再起動回数には入りません。Parked に到達するには、実際に再起動される障害が必要です。
Parked になったタスクは、放置しても Durable Object を起こし続けません。手動では2つの抜け方があります。
| 操作 | 戻り値 | wait() |
| --- | --- | --- |
| session.retry() | true | done |
| session.abort() | ok | unanswered / aborted |
retry() はやり直す操作、abort() は放棄する操作です。クラッシュループを止めるだけでなく、運用者が判断して再開か放棄かを選べる設計になっています。
なお、ツールが api.output() で進捗を8回出しても、再起動カウントは戻りませんでした。Pi が進捗として扱うのは、ツールの出力ではなく、タスクが次の状態をコミットしたことです。まだ完了していない1つのツールが出力を吐き続けても、クラッシュループの上限はリセットされません。
7. Storage Error Recoveryは「次の操作」から効く
Storage Error Recovery は、ストレージエラーが起きたあとに Pi を開き直せる機能です。ここでは Storage を Proxy で包み、次の2種類のエラーを注入しました。
- commit() などから任意のエラーを返す
- 読み取りから StorageRequestError を返す
どちらも、現在進行中の呼び出しはエラーになりました。しかし、次の submit() は成功し、最終的に done になりました。致命的な Storage エラーでは factory の実行回数が1から2に増えましたが、Durable Object 自体のインスタンス IDは変わっていません。
つまり、これは Durable Object のクラッシュ復旧ではありません。PiHarness が壊れた Pi をキャッシュから外し、次の操作または Wake ジョブの次のステップで Pi を開き直しています。
進行中の呼び出しは救われません。 仕様にも、すでに実行中の呼び出しは SessionFailed で失敗するとあります。factory は何度実行されても正しく初期化できるように書く必要があります。
8. replay: "safe" は副作用を守らない
ここが今回の中心です。4種類のツールを用意し、実行開始・副作用の確定・終了を別々に記録しました。副作用確定直後に Durable Object を落とし、再起動後の挙動を比較します。
| ツール | replay | 冪等性 | 実行開始 | 副作用 | 完了 |
| --- | --- | --- | ---: | ---: | ---: |
| write_record | safe | あり | 2 | 1 | 1 |
| bump_unsafe | 未指定 | なし | 1 | 1 | 0 |
| bump_safe_unguarded | safe | なし | 2 | 2 | 1 |
| bump_safe_guarded | safe | あり | 2 | 1 | 1 |
write_record は同じ値を書き込むので、2回実行されても結果は変わりません。bump_safe_unguarded はカウンターを1増やすだけで、実行されるたびに結果が変わります。このツールに safe を付けると、Pi は宣言を信じて再実行し、副作用が2回確定しました。
一方、bump_unsafe は自動再実行されません。副作用確定後に落としたケースでは、副作用は1回だけ残りましたが、ツールは完了していません。Agent は中断されたツール結果をモデルへ渡して run を決着させます。
中断されたツールの transcript は、専用のエントリになるわけではありません。通常と同じ pi.tool-result ですが、内容が次のように変わります。
<harness>
[error] Tool bump_unsafe was interrupted and may have partially run
</harness>
このメッセージは、モデルに「部分的に実行されたかもしれない」と伝える平文です。その後にモデルが同じツールを呼び直すかどうかは、モデルの判断に委ねられます。今回の実モデルによる3試行では呼び直しませんでしたが、1モデル・1プロンプト・3試行だけなので一般化はしません。
bump_safe_guarded には冪等キーを入れました。再実行自体は起きていますが、2回目はキーを見て副作用をスキップし、確定回数は1回に収まりました。
ここから分かることは明確です。safe の判断を間違えても Pi は警告しません。 外部 API、決済、メール送信、データベース更新などをツールに持たせるなら、safe の設定とは別に冪等キーや重複排除を実装する必要があります。
9. Model Stream Retryはエラー文言で判定される
Model Stream Retry は、途切れたモデルストリームを一時的なエラーとして扱い、ターン全体を再試行します。配布物を読むと、判定はエラー本文の正規表現でした。
同じ回数だけ失敗させ、エラー文言だけを変えました。
| エラー文言 | 結果 | モデル呼び出し |
| --- | --- | ---: |
| The stream ended before the model finished its answer. | unanswered | 1 |
| The stream ended without the model finishing its answer. | done | 3 |
| Model stream stalled: no data received for 60s. | unanswered | 1 |
| Model stream timed out: no data received for 60s. | done | 3 |
| insufficient_quota: the account has no remaining credit. | unanswered | 1 |
再試行対象の文言では「失敗2回、成功1回」の3回になりました。つまり、再試行は部分的なストリームの続きではなく、モデルリクエスト全体の作り直しです。
baseDelayMs を100秒に設定しても、待機時間の上限は約60秒でした。既定の maxAgentDelayMs が60,000ミリ秒だからです。設定値を大きくしたからといって、無制限に待つわけではありません。
10. Hibernationは、待機中に別インスタンスで再開する
Hibernation の測定では、最初に alarm の時刻を比べました。しかし、ローカルの workerd では onWake の有無にかかわらず、alarm の秒数が一致しました。これは測定対象を間違えています。
Hibernation の本質は、alarm の時刻ではなく、待機中に Durable Object がメモリから退避できるかです。そこで実デプロイし、コンストラクタでインスタンス IDを記録して、リトライがどのインスタンスで走るかを見ました。
onWake を渡した場合は、次のようになりました。
model_error@old-instance
→ instance_started@new-instance
→ model_request@new-instance
onWake を渡さない場合は、次のようになりました。
model_error@same-instance
→ model_request@same-instance
| onWake | リトライを走らせたインスタンス |
| --- | --- |
| 渡す | 別インスタンス |
| 渡さない | 同じインスタンス |
リトライ待機中は、測定のために一度もリクエストを送りませんでした。途中で触るとオブジェクトが起きてしまい、退避を観測できないからです。
処理中の work と、リトライ待ちの sleep は別の状態です。work がある間は heartbeat Alarm が30秒ごとに再設定されます。一方、sleep 中は最初に起きるタスクの時刻が次の Alarm になります。この2つを混ぜると、Hibernation の測定結果を誤読します。
11. agents@0.30.0 の不具合を再現する
公式が案内した agents@0.30.0 で検証を始めようとしました。しかし、このバージョンには、別タスクが動いているとリトライ待機中のタスクが起きない既知の不具合があります。
1つの Harness で、次の2本を並行させました。
- セッションA: 60秒走るツールを実行する
- セッションB: 再試行対象のエラーを返し、12秒の backoff に入る
| | B単独 | 並行する work あり |
| --- | ---: | ---: |
| agents@0.30.1 | 12秒 | 12秒 |
| agents@0.30.0 | 12秒 | 57秒 |
0.30.0 では、B のリトライが A の完了まで止まっていました。0.30.1 では、A が動いていても B は予定どおり12秒後に再開します。
原因は配布物の差分からも確認できます。0.30.0 は Pi が active かどうかだけを見ていました。0.30.1 では、sleep の期限 wake.at を次の待機期限に使い、期限に達したら pi.resume() を呼びます。
これは煽るための話ではありません。PiHarness が Beta と案内されていることの、具体的な例です。Pi 1.4 の Hibernation を検証するなら、agents@0.30.1 以降を使う必要があります。
12. Pi 1.4に上げると、1.3には戻せない
Pi 1.4 は session storage の SQLite schema を更新するため、Pi 1.3 へロールバックできません。
実際に Pi 1.4 で1ターン実行したあとの schema version は 2 でした。配布物のマイグレーション定義を比べると、次の差があります。
| バージョン | マイグレーション |
| --- | --- |
| pi-durable@1.3.0 | version 1 のみ |
| pi-durable@1.4.0 | version 1 と version 2 sleepingStatus |
Pi 1.3 のマイグレーションランナーは、対応していない新しい schema version を見つけるとエラーにします。追加された sleepingStatus は、Pi 1.4 の Hibernation を支える sleeping タスク状態そのものです。
つまり、Durable Object を退避できるようになったことと、ロールバックできなくなったことは、別々の変更ではありません。同じ schema 変更の表と裏です。
Pi 1.4 と1.3を比較するときは、同じ Durable Object を使い回してはいけません。実験用の名前を分け、マイグレーションを一方向のものとして扱います。
13. 測定をやり直して分かったこと
この検証では、最初の測定結果をそのまま記事にしませんでした。対照を組み直した結果、3つの主張を修正または保留にしています。
WebSocketのイベント取りこぼし
最初は、遅いイベントリスナーがイベントを取りこぼすように見えました。しかし、WebSocket を8秒で閉じていたことが原因でした。ドレイン時間を30秒に延ばすと、遅いリスナーでもイベント数は一致しました。
進捗の定義
自前カウンターの更新を Pi の進捗だと思い込んでいました。api.output() を8回呼ぶ対照に変えても、未完了のツールは Parked になりました。ツールの出力ではなく、タスクが状態をコミットしたかどうかが進捗です。
19分のモデルストリーム
Worker 内の faux Provider で約19.2分のストリームを流したところ、モデル呼び出し1回のまま完走しました。ただし、これは外部モデル API へのHTTP接続を19分維持した測定ではありません。公式の「15分を越えるリクエストは切断されることがある」という制約を否定する材料にはならないため、結論は保留にしました。
障害復旧の検証では、「結果が出た」だけでは不十分です。そもそも障害が起きていなかった、観測側が先に接続を閉じた、という可能性を対照で潰す必要があります。
14. まとめ
Cloudflare Agents と Pi 1.4 の復旧機構は、実際に動きました。会話履歴は再起動後に読み戻せ、完了済みタスクは二重実行されず、クラッシュループは Parked Tasks で止まり、リトライ待機中の Agent は Hibernation によって別インスタンスで再開できました。
しかし、Pi が守っているのは Agent の実行状態です。あなたの外部副作用まで自動的に exactly-once にしてくれるわけではありません。
実装時には、次の点を守る必要があります。
- 外部操作には冪等キーや重複排除を入れる
- replay: "safe" は、2回実行しても安全だと自分で証明できるツールだけに付ける
- unsafe にしても、確定済みの副作用が消えるわけではない
- factory は Storage Error Recovery で何度も呼ばれる前提で書く
- Hibernation を使うなら agents@0.30.1 以降を使う
- Pi 1.4 に上げた Durable Object は、Pi 1.3 に戻せない
最後に、検証から持ち帰る教訓を1つに絞るならこれです。
「復旧した」と「重複せずに完了した」は、別の主張です。
参考
- Cloudflare Changelog — Agents SDK now supports the latest Pi 1.4 release — Pi 1.4 対応として発表された4項目
- Cloudflare Docs — Pi — PiHarness、Recovery、Hibernation の公式ドキュメント
- Cloudflare Docs — Pi Extensions — ツールの replay 設定と復旧時の扱い
- agents CHANGELOG — 0.30.0 / 0.30.1 の変更
- cloudflare/agents#2683 — onWake と並行タスクに関する既知の不具合
- pi-durable CHANGELOG 1.4.0 — Pi 1.4 の sleeping タスクと schema migration
- Pi Durable の設計記事 — チェックポイントとタスク実行の設計
Cloudflare AgentsがPi 1.4に対応したので、Agentの中断・復旧・永続化を検証する
> 中断された Agent は、最後のチェックポイントから正しく再開しました。副作用が2回起きたのは、そのせいです。
この記事の結論
Cloudflare Agents SDK が Pi 1.4 に対応したので、Durable Object 上の Agent を実際に停止させ、会話履歴・タスク・ツール実行がどこまで復旧するかを測りました。復旧機構は期待どおり動きましたが、Agent が復旧することと、外部の副作用が重複しないことは別です。
- Pi 1.4 で追加された Hibernation と Parked Tasks は、実際の Durable Object で確認できた
- replay: "safe" は冪等性を検査しない。非冪等なツールに付けると、同じ副作用が2回起きる
- Storage Error Recovery は進行中の呼び出しを救わず、次の操作から Pi を開き直す仕組みだった
- agents@0.30.0 には、別タスクが動いているとリトライ待機中のタスクを起こせない既知の不具合がある。使うなら 0.30.1 以降にする
| 観測したもの | 結果 |
| --- | ---: |
| 再起動後の会話履歴 | 読み戻せた |
| 完了済みタスク | 二重実行されなかった |
| safe な冪等ツール | 再実行されたが副作用は1回 |
| safe と宣言した非冪等ツール | 副作用が2回 |
| unsafe ツール | 自動再実行されず、完了もしない |
| Hibernation | 待機中に別インスタンスで再開 |
検証コードはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s…
そして、この記事で一番伝えたいことを先に書いておきます。
replay: "safe" は、ツールを安全にする機能ではありません。 Pi に対して「このツールは途中で止まったら再実行してよい」と宣言するだけです。副作用が本当に冪等か、同じリクエストを2回受けても外部 API が重複処理しないかは、ツールの実装側が保証しなければなりません。
> この記事は「Cloudflare AgentsがPi 1.4に対応したので、Agentの中断・復旧・永続化を検証する」の登壇内容に、発表で話しきれなかった検証手順・実測値・失敗した測定を足したものです。対象は Cloudflare Agents と Pi Durable を使って、外部 API やデータベースを操作する人です。
---
1. 「Pi 1.4 対応」の4項目を分解する
2026年10月22日、Cloudflare は Agents SDK の Pi 1.4 対応を発表しました。Changelog には次の4項目が並んでいます。
- Hibernation
- Parked Tasks
- Storage Error Recovery
- Model Stream Retry
最初は、4つとも Pi 1.4 の新機能だと思いました。しかし、配布物と変更履歴を追うと、出どころは2層に分かれていました。
| Changelog の項目 | 実際の変更元 |
| --- | --- |
| Hibernation | Pi 1.4 の sleeping タスクを Agents SDK が配線 |
| Parked Tasks | Pi 1.4 の機能を Agents SDK が公開 |
| Storage Error Recovery | Agents SDK 側の修正。Pi のバージョンとは別 |
| Model Stream Retry | Agents SDK 側の修正。Pi 1.3 でも成立 |
特に Model Stream Retry の PR には、Pi 1.3.0 と Pi main の両方で判定を確認したと書かれていました。つまり、Storage Error Recovery と Model Stream Retry を Pi 1.3 と 1.4 で比較するのは軸が違います。比較すべきなのは agents@0.29.0 と agents@0.30.x です。
ベンダーの Changelog は、利用者にとって分かりやすい単位で変更をまとめています。一方、障害が起きたときに調べるには、どのパッケージのどの層が責任を持つかを分ける必要があります。
2. Pi、PiHarness、Lifecycleの役割
今回の構成には3つの層があります。
| 層 | 担当 |
| --- | --- |
| Pi Durable | Agent の実行ループ、タスク、チェックポイント、replay |
| PiHarness | Pi に Durable Object の SQLite と起動処理を接続 |
| Lifecycle | Durable Object 上で長時間の仕事を実行し、必要なときに起こす |
Pi Durable は Cloudflare のパッケージではありません。@earendil-works/pi-durable として提供され、Agents SDK 側の PiHarness が Durable Object に組み込みます。
ここで重要なのは、保存と再開が別の機能だということです。
- 会話履歴やタスクの保存先は、pi_ で始まる SQLite テーブル
- 眠っているタスクを再開するのは、Lifecycle の Wake ジョブと Durable Object Alarm
保存できていても、起こす仕組みがなければ再開しません。逆に、再開できてもチェックポイントがなければ、どこから続けるか分かりません。今回の検証では、この2つを混ぜないようにしました。
Pi Durable の設計記事には、次のようにあります。
> every step of a run is a task that stores a checkpoint before it moves on.
このチェックポイントが、再起動後に「完了済みのタスクをもう一度実行しない」ための基礎になります。
3. まず、観測そのものを永続化する
障害復旧を測るとき、最初に作るべきなのは実験対象ではなく記録機構です。Durable Object の書き込みは、呼び出した瞬間にすべてがディスクへ確定するわけではありません。
検証用のレコーダは、イベントを SQLite に書いたあと、通常は ctx.storage.sync() を呼ぶようにしました。直後に ctx.abort() を呼び、再起動後にイベントが残っているかを比較します。
| 記録の仕方 | 再起動 | 再起動後に残った行数 |
| --- | --- | ---: |
| sync() あり | した | 1 |
| sync() なし | した | 0 |
どちらもコンストラクタで発行したインスタンス ID が変わっていました。本当に同じインスタンスが動き続けたのではなく、再生成後のインスタンスが SQLite を読んでいます。
ctx.abort() は通常の throw とは違います。アプリケーションコードでは catch できず、Durable Object を即座にリセットします。障害注入に通常の例外を使うと、Agent の復旧ではなく「同じインスタンスのエラー処理」を測ってしまいます。
この土台を確認してから、以降のイベントはすべて sync() ありで記録しました。ログに残らなかった観測を、復旧の証拠には使っていません。
4. 会話履歴とタスクは再起動を越えて残る
ツールを1回実行する Agent を動かし、処理が完了したあとに ctx.abort() で Durable Object を落としました。再起動後に transcript とイベントを読みます。
| 観測 | 再起動前 | 再起動後 |
| --- | ---: | ---: |
| インスタンス識別子 | — | 変化した |
| transcript のエントリ数 | 5 | 5 |
| ツール実行開始 | 1 | 1 |
| 副作用 | 1 | 1 |
完了済みのタスクは二重実行されませんでした。同じ operationId を使った submit も、1回目は accepted: true、2回目は accepted: false になりました。異なる ID を使うと accepted: true になるため、submit 全体が壊れたわけではありません。
ここまでは、Pi Durable がチェックポイントと submit の識別子を使って、完了済みの仕事を保護している結果です。
5. クライアントが切れても、Agentは動き続ける
次に、モデルのリトライで処理時間を伸ばし、途中で WebSocket を切断しました。クライアントがいなくなったあとも実行が続くかを確認します。
結果は、最終的に done になりました。モデル呼び出しは「失敗2回、成功1回」の3回です。切断前後でインスタンス IDは変わっていません。
これは「落ちて復旧した」のではありません。Agent の実行がクライアント接続に依存していなかったということです。
再接続後は snapshot から現在の状態を組み立て直せました。ただし、切断中に流れたイベントをカーソルで途中から再生できるわけではありません。イベントストリームは状態を追うためのものではありますが、履歴ストリームの代わりではありません。
6. Parked Tasksはクラッシュループを止める
同じ場所で何度も ctx.abort() を発生させ、restartLimit を2に下げて測りました。
| 落とし方 | 障害発火 | インスタンス生成 | pending() | 次の Alarm |
| --- | ---: | ---: | --- | --- |
| ctx.abort() | 2 | 2 | parked | なし |
| 通常の例外 | 1 | 0 | 空 | なし |
通常の例外は Durable Object をリセットしないため、再起動回数には入りません。Parked に到達するには、実際に再起動される障害が必要です。
Parked になったタスクは、放置しても Durable Object を起こし続けません。手動では2つの抜け方があります。
| 操作 | 戻り値 | wait() |
| --- | --- | --- |
| session.retry() | true | done |
| session.abort() | ok | unanswered / aborted |
retry() はやり直す操作、abort() は放棄する操作です。クラッシュループを止めるだけでなく、運用者が判断して再開か放棄かを選べる設計になっています。
なお、ツールが api.output() で進捗を8回出しても、再起動カウントは戻りませんでした。Pi が進捗として扱うのは、ツールの出力ではなく、タスクが次の状態をコミットしたことです。まだ完了していない1つのツールが出力を吐き続けても、クラッシュループの上限はリセットされません。
7. Storage Error Recoveryは「次の操作」から効く
Storage Error Recovery は、ストレージエラーが起きたあとに Pi を開き直せる機能です。ここでは Storage を Proxy で包み、次の2種類のエラーを注入しました。
- commit() などから任意のエラーを返す
- 読み取りから StorageRequestError を返す
どちらも、現在進行中の呼び出しはエラーになりました。しかし、次の submit() は成功し、最終的に done になりました。致命的な Storage エラーでは factory の実行回数が1から2に増えましたが、Durable Object 自体のインスタンス IDは変わっていません。
つまり、これは Durable Object のクラッシュ復旧ではありません。PiHarness が壊れた Pi をキャッシュから外し、次の操作または Wake ジョブの次のステップで Pi を開き直しています。
進行中の呼び出しは救われません。 仕様にも、すでに実行中の呼び出しは SessionFailed で失敗するとあります。factory は何度実行されても正しく初期化できるように書く必要があります。
8. replay: "safe" は副作用を守らない
ここが今回の中心です。4種類のツールを用意し、実行開始・副作用の確定・終了を別々に記録しました。副作用確定直後に Durable Object を落とし、再起動後の挙動を比較します。
| ツール | replay | 冪等性 | 実行開始 | 副作用 | 完了 |
| --- | --- | --- | ---: | ---: | ---: |
| write_record | safe | あり | 2 | 1 | 1 |
| bump_unsafe | 未指定 | なし | 1 | 1 | 0 |
| bump_safe_unguarded | safe | なし | 2 | 2 | 1 |
| bump_safe_guarded | safe | あり | 2 | 1 | 1 |
write_record は同じ値を書き込むので、2回実行されても結果は変わりません。bump_safe_unguarded はカウンターを1増やすだけで、実行されるたびに結果が変わります。このツールに safe を付けると、Pi は宣言を信じて再実行し、副作用が2回確定しました。
一方、bump_unsafe は自動再実行されません。副作用確定後に落としたケースでは、副作用は1回だけ残りましたが、ツールは完了していません。Agent は中断されたツール結果をモデルへ渡して run を決着させます。
中断されたツールの transcript は、専用のエントリになるわけではありません。通常と同じ pi.tool-result ですが、内容が次のように変わります。
<harness>
[error] Tool bump_unsafe was interrupted and may have partially run
</harness>
このメッセージは、モデルに「部分的に実行されたかもしれない」と伝える平文です。その後にモデルが同じツールを呼び直すかどうかは、モデルの判断に委ねられます。今回の実モデルによる3試行では呼び直しませんでしたが、1モデル・1プロンプト・3試行だけなので一般化はしません。
bump_safe_guarded には冪等キーを入れました。再実行自体は起きていますが、2回目はキーを見て副作用をスキップし、確定回数は1回に収まりました。
ここから分かることは明確です。safe の判断を間違えても Pi は警告しません。 外部 API、決済、メール送信、データベース更新などをツールに持たせるなら、safe の設定とは別に冪等キーや重複排除を実装する必要があります。
9. Model Stream Retryはエラー文言で判定される
Model Stream Retry は、途切れたモデルストリームを一時的なエラーとして扱い、ターン全体を再試行します。配布物を読むと、判定はエラー本文の正規表現でした。
同じ回数だけ失敗させ、エラー文言だけを変えました。
| エラー文言 | 結果 | モデル呼び出し |
| --- | --- | ---: |
| The stream ended before the model finished its answer. | unanswered | 1 |
| The stream ended without the model finishing its answer. | done | 3 |
| Model stream stalled: no data received for 60s. | unanswered | 1 |
| Model stream timed out: no data received for 60s. | done | 3 |
| insufficient_quota: the account has no remaining credit. | unanswered | 1 |
再試行対象の文言では「失敗2回、成功1回」の3回になりました。つまり、再試行は部分的なストリームの続きではなく、モデルリクエスト全体の作り直しです。
baseDelayMs を100秒に設定しても、待機時間の上限は約60秒でした。既定の maxAgentDelayMs が60,000ミリ秒だからです。設定値を大きくしたからといって、無制限に待つわけではありません。
10. Hibernationは、待機中に別インスタンスで再開する
Hibernation の測定では、最初に alarm の時刻を比べました。しかし、ローカルの workerd では onWake の有無にかかわらず、alarm の秒数が一致しました。これは測定対象を間違えています。
Hibernation の本質は、alarm の時刻ではなく、待機中に Durable Object がメモリから退避できるかです。そこで実デプロイし、コンストラクタでインスタンス IDを記録して、リトライがどのインスタンスで走るかを見ました。
onWake を渡した場合は、次のようになりました。
model_error@old-instance
→ instance_started@new-instance
→ model_request@new-instance
onWake を渡さない場合は、次のようになりました。
model_error@same-instance
→ model_request@same-instance
| onWake | リトライを走らせたインスタンス |
| --- | --- |
| 渡す | 別インスタンス |
| 渡さない | 同じインスタンス |
リトライ待機中は、測定のために一度もリクエストを送りませんでした。途中で触るとオブジェクトが起きてしまい、退避を観測できないからです。
処理中の work と、リトライ待ちの sleep は別の状態です。work がある間は heartbeat Alarm が30秒ごとに再設定されます。一方、sleep 中は最初に起きるタスクの時刻が次の Alarm になります。この2つを混ぜると、Hibernation の測定結果を誤読します。
11. agents@0.30.0 の不具合を再現する
公式が案内した agents@0.30.0 で検証を始めようとしました。しかし、このバージョンには、別タスクが動いているとリトライ待機中のタスクが起きない既知の不具合があります。
1つの Harness で、次の2本を並行させました。
- セッションA: 60秒走るツールを実行する
- セッションB: 再試行対象のエラーを返し、12秒の backoff に入る
| | B単独 | 並行する work あり |
| --- | ---: | ---: |
| agents@0.30.1 | 12秒 | 12秒 |
| agents@0.30.0 | 12秒 | 57秒 |
0.30.0 では、B のリトライが A の完了まで止まっていました。0.30.1 では、A が動いていても B は予定どおり12秒後に再開します。
原因は配布物の差分からも確認できます。0.30.0 は Pi が active かどうかだけを見ていました。0.30.1 では、sleep の期限 wake.at を次の待機期限に使い、期限に達したら pi.resume() を呼びます。
これは煽るための話ではありません。PiHarness が Beta と案内されていることの、具体的な例です。Pi 1.4 の Hibernation を検証するなら、agents@0.30.1 以降を使う必要があります。
12. Pi 1.4に上げると、1.3には戻せない
Pi 1.4 は session storage の SQLite schema を更新するため、Pi 1.3 へロールバックできません。
実際に Pi 1.4 で1ターン実行したあとの schema version は 2 でした。配布物のマイグレーション定義を比べると、次の差があります。
| バージョン | マイグレーション |
| --- | --- |
| pi-durable@1.3.0 | version 1 のみ |
| pi-durable@1.4.0 | version 1 と version 2 sleepingStatus |
Pi 1.3 のマイグレーションランナーは、対応していない新しい schema version を見つけるとエラーにします。追加された sleepingStatus は、Pi 1.4 の Hibernation を支える sleeping タスク状態そのものです。
つまり、Durable Object を退避できるようになったことと、ロールバックできなくなったことは、別々の変更ではありません。同じ schema 変更の表と裏です。
Pi 1.4 と1.3を比較するときは、同じ Durable Object を使い回してはいけません。実験用の名前を分け、マイグレーションを一方向のものとして扱います。
13. 測定をやり直して分かったこと
この検証では、最初の測定結果をそのまま記事にしませんでした。対照を組み直した結果、3つの主張を修正または保留にしています。
WebSocketのイベント取りこぼし
最初は、遅いイベントリスナーがイベントを取りこぼすように見えました。しかし、WebSocket を8秒で閉じていたことが原因でした。ドレイン時間を30秒に延ばすと、遅いリスナーでもイベント数は一致しました。
進捗の定義
自前カウンターの更新を Pi の進捗だと思い込んでいました。api.output() を8回呼ぶ対照に変えても、未完了のツールは Parked になりました。ツールの出力ではなく、タスクが状態をコミットしたかどうかが進捗です。
19分のモデルストリーム
Worker 内の faux Provider で約19.2分のストリームを流したところ、モデル呼び出し1回のまま完走しました。ただし、これは外部モデル API へのHTTP接続を19分維持した測定ではありません。公式の「15分を越えるリクエストは切断されることがある」という制約を否定する材料にはならないため、結論は保留にしました。
障害復旧の検証では、「結果が出た」だけでは不十分です。そもそも障害が起きていなかった、観測側が先に接続を閉じた、という可能性を対照で潰す必要があります。
14. まとめ
Cloudflare Agents と Pi 1.4 の復旧機構は、実際に動きました。会話履歴は再起動後に読み戻せ、完了済みタスクは二重実行されず、クラッシュループは Parked Tasks で止まり、リトライ待機中の Agent は Hibernation によって別インスタンスで再開できました。
しかし、Pi が守っているのは Agent の実行状態です。あなたの外部副作用まで自動的に exactly-once にしてくれるわけではありません。
実装時には、次の点を守る必要があります。
- 外部操作には冪等キーや重複排除を入れる
- replay: "safe" は、2回実行しても安全だと自分で証明できるツールだけに付ける
- unsafe にしても、確定済みの副作用が消えるわけではない
- factory は Storage Error Recovery で何度も呼ばれる前提で書く
- Hibernation を使うなら agents@0.30.1 以降を使う
- Pi 1.4 に上げた Durable Object は、Pi 1.3 に戻せない
最後に、検証から持ち帰る教訓を1つに絞るならこれです。
「復旧した」と「重複せずに完了した」は、別の主張です。
参考
- Cloudflare Changelog — Agents SDK now supports the latest Pi 1.4 release — Pi 1.4 対応として発表された4項目
- Cloudflare Docs — Pi — PiHarness、Recovery、Hibernation の公式ドキュメント
- Cloudflare Docs — Pi Extensions — ツールの replay 設定と復旧時の扱い
- agents CHANGELOG — 0.30.0 / 0.30.1 の変更
- cloudflare/agents#2683 — onWake と並行タスクに関する既知の不具合
- pi-durable CHANGELOG 1.4.0 — Pi 1.4 の sleeping タスクと schema migration
- Pi Durable の設計記事 — チェックポイントとタスク実行の設計
One coordinator, four sub agents for implementation, two sub agents for coordination improvements and performance investigations. So far they have been running for about 18 hours, produced 650 commits (of which quite a few are operational commits). All on one subscription. earendil-works/djs
10 likes
@bedesqui We have an extension that uses the agent SDK: earendil-works/pi-claude-code
But at the end of the day nobody knows right now what the actual rules are.
110 likes · 3 reposts · 3,622 views
10 coding agents, one Qwen 3.8 27B, same llama-server: tokens and cache reuse differ more than pass rates
Part 2 of my agent test (part 1 was a 4B model)
Hardware I used: MacBook Pro M5 Max, 64 GB. Qwen 3.8 27B Q4 on llama.cpp, thinking off.
Setup
- same model and server for everyone
- 5 small coding tasks, each with a little trap
- each agent locked in a sandbox: it can only touch its own folder and only talk to the model
- auto-approve on, telemetry off, 5 min limit
Results
|#|Agent|Passed|Median time|Median tokens|Cache reuse|Tried to go online|
|:-|:-|:-|:-|:-|:-|:-|
|1|Pi|5/5|38 s|9.2k|72%|no|
|2|DeepSeek Harness|5/5|60 s|35.3k|83%|no|
|3|Codex CLI|5/5|94 s|36.3k|79%|every run|
|4|OpenClaw|5/5|106 s|39.0k|72%|unclear|
|5|Cline|5/5|152 s|29.6k|79%|every run|
|6|OpenCode|4/5|50 s|32.4k|75%|every run|
|7|Hermes|4/5|80 s|64.9k|93%|no|
|8|Atomic Agent|5/5|56 s|30.4k|86%|no|
|9|goose|4/5|98 s|20.3k|76%|every run|
|10|Qwen Code|4/5|183 s|107.9k|74%|unclear|
Tokens and cache
Pi is crazy lean: about 9k tokens per task, the lowest on every single task. Qwen Code was the hungriest at about 108k per task.
Atomic Agent is the odd one out in how it works. It was the only one talking to llama.cpp's native endpoint with a grammar that forces the tool calls into shape. The other nine all went through the OpenAI-style API. It's also built to keep its prompt the same from step to step, which is why it reused 86% of the cache (2rd of 10). It stayed offline the whole time and passed 5/5.
DeepSeek Harness was the surprise. It’s still an alpha, but it went 5/5, never tried to go online, and had the best cache reuse after Atomic Agent (83%).
The trap and the network
The log task got three agents. They grepped "ERROR" and got 244 instead of 64, because some normal lines just mention errors. OpenCode, goose and Atomic Agent fell for it.
Codex, Cline, goose and OpenCode tried to reach the internet in every run, even with telemetry off. The sandbox blocked it, so I don't know what they were calling. OpenClaw and Qwen Code are "unclear" because they run as plain node and I couldn't tell them apart.
llama.cpp notes
- Codex needs llama-server's /v1/responses now
- Hermes needs at least 64K context
- Cline and Qwen Code have usage stats on by default, DeepSeek Harness uploads session logs (per their docs). I turned it all off
Which column matters most to you when you pick an agent for a local model like Qwen 3.8 27B: passing, speed, tokens, or staying offline?
36 points · 36 comments
How well do coding agents drive Qwen 3.8 27B locally? I lined up 10 of them on one hardware
My hardware: MacBook Pro M5 Max, 64 GB. Qwen 3.8 27B Q4 on llama.cpp, thinking off.
I put Qwen 3.8 27B behind one llama.cpp server and gave 10 coding agents the same 5 tasks.
|#|Agent|Passed|Median time|Median tokens|Cache reuse|Tried to go online|
|:-|:-|:-|:-|:-|:-|:-|
|1|Pi|5/5|38 s|9.2k|72%|no|
|2|DeepSeek Harness|5/5|60 s|35.3k|83%|no|
|3|Codex CLI|5/5|94 s|36.3k|79%|every run|
|4|OpenClaw|5/5|106 s|39.0k|72%|unclear|
|5|Cline|5/5|152 s|29.6k|79%|every run|
|6|OpenCode|4/5|50 s|32.4k|75%|every run|
|7|Hermes|4/5|80 s|64.9k|93%|no|
|8|Atomic Agent|5/5|56 s|30.4k|86%|no|
|9|goose|4/5|98 s|20.3k|76%|every run|
|10|Qwen Code|4/5|183 s|107.9k|74%|unclear|
Pi is crazy lean: about 9k tokens per task, the lowest on every single task. Qwen Code was the hungriest at about 108k per task.
The setup
- same model and server for everyone
- 5 small coding tasks, each with a little trap
- each agent locked in a sandbox: it can only touch its own folder and only talk to the model
- auto-approve on, telemetry off, 5 min limit
What I noticed
The log task got three agents. They grepped "ERROR" and got 244 instead of 64, because some normal lines just mention errors. OpenCode, goose and Atomic Agent fell for it.
Codex, Cline, goose and OpenCode tried to reach the internet in every run, even with telemetry off. The sandbox blocked it, so I don't know what they were calling. OpenClaw and Qwen Code are "unclear" because they run as plain node and I couldn't tell them apart.
Atomic Agent is the odd one out in how it works. It was the only one talking to llama.cpp's native endpoint with a grammar that forces the tool calls into shape. The other nine all went through the OpenAI-style API. It's also built to keep its prompt the same from step to step, which is why it reused 86% of the cache (3rd of 10). It stayed offline the whole time and passed 5/5.
Setup notes
- Cline and Qwen Code have usage stats on by default, DeepSeek Harness uploads session logs (per their docs). I turned it all off
- Codex needs llama-server's /v1/responses now
- Hermes needs at least 64K context
Would you pick an agent for fewer tokens or for passing every task?
23 points · 19 comments
Mentions
メールを送れるAIエージェントで、人間の確認をどこに挟むか ! この記事は、Mac向けの個人エージェント「Nico」を開発しているチームが書いています。製品の紹介は末尾に少しだけ載せています。 AIエージェントにメールやブラウザを触らせると、すぐにぶつかる問題があります。どの操作は黙って実行してよくて、どの操作は人間に聞くべきか、です。 私たちが作っているNicoは、ユーザー自身のMacの上で動きます。ファイルはその場所のまま読み、ウェブサイトはユーザーがログインしているセッションをそのまま使い、「メール」「カレンダー」などのアプリや、アクセシビリティ経由でほかのアプリも操作します。本物のログイン情報を持っているので、本物のメールを送れるし、本物のフォームを送信できるし、本物のファイルを消せます。 しかも、エージェントが読むもののほとんどは、ユーザーが書いたものではありません。ウェブページ、PDF、受信箱。そのどれにでも、モデルに向けた一文を紛れ込ませることができます。 この記事では、Nicoで「確認をどこに挟むか」をどう決めたかを、判断の順番に沿って書きます。 前提:話す係と作業する係 Nicoは2つのエージェントで動いています。 会話の担当:ユーザーと話し、頼まれたことを理解して、作業を引き渡し、結果を伝えます。応答の速さを優先しています。 作業の担当(Worker):段取りを立て、ファイルを読み、アプリやブラウザを操作し、成果物を作って、自分で結果を確かめます。オープンソースのエージェント基盤 pi の上に作り、ツール群とコンテキスト圧縮は自前のものに差し替えています。 この記事で扱う確認の仕組みは、主にWorkerのツール呼び出しにかかるものです。 「毎回聞く」も「全部任せる」も選ばなかった理由 開発者向けのエージェントの多くは、画面の前で人が見ていることを前提にしています。おかしければ止めればいいし、何かあっても自分で責任を取れます。 Nicoのユーザーはそういう使い方をしません。頼んだら会議に行ったり、ご飯を食べに行ったり、Macを閉じたりします。定時実行のタスクは、ユーザーが寝ているあいだに動くこともあります。 この前提だと、両極端はどちらも成り立ちません。 毎回聞く:ユーザーがいないあいだに何も進みません。 全部任せる:ウェブページの一文で、知らない宛先にメールが飛びます。 そこで、「完全自動」や「毎回確認」の切り替えスイッチは用意せず、動き方は一通りにしました。危ない一歩だけを、ユーザーが実際に言ったことと照らし合わせて判断します。 ルール1:肝心なチェックにはモデルを使わない ツール呼び出しは、実行の前に毎回アプリ側のプログラムを通ります。このチェックはモデルを呼ばず、記憶も読まず、エージェントからは変更できません。 順番はおおよそ次のとおりです。 タスクはまだ動いているか。取り消し・停止済みなら実行しない。 ツールは一覧にあるか、パラメーターの形式は正しいか。 ユーザーがいま操作しているページでの操作か。そうなら、手が止まって3秒たつまで待つ。 実際の対象を見て、この一回の呼び出しのリスクレベルを決める。 レベルに応じて、そのまま実行・審査に回す・ユーザーに聞く・拒否する、のどれかにする。 ウェブページの文章はモデルを言いくるめられますが、if 文は言いくるめられません。機械的に確かめられることは、機械的に確かめます。 リスクレベル レベル 扱い 例 L0 読む そのまま実行 ページ・メール・ファイルを読む、検索 L1 下書き そのまま実行 ファイルを書く、コマンドを実行する、メールの下書きを保存する L2 取り消せる 実行して記録に残す リンクを開く、予定を追加する、リマインダーを作る L3 外に出る 審査に回す(一部例外あり)。メール送信は毎回ユーザーに聞く メッセージ送信、ウェブ上のクリックや入力、ファイルのアップロード、AppleScript L4 取り消せない L3と同じく審査に回し、レベルも審査に渡す 完全削除、まとめてのファイル移動 L5 本人だけ 拒否して、ユーザーに渡す パスワード欄、認証コード、カード情報、支払い、OAuthの同意画面 ウェブ上の操作は、実際に押そうとしている要素で決めます。アプリが要素のrole、名前、type、autocomplete属性を読みます。「送信」「削除」のようなボタンは下限を決めます。審査がどう判断しても、その一歩は高リスクより下には扱いません。 L5は設定項目ではありません。Nicoはパスワードを入力しませんし、認証コードを読み上げませんし、支払いを完了させません。予約が支払い画面まで来たら、そこで止めてユーザーに渡します。 ルール2:審査はひとつの問いにだけ答える ルールでは答えられない問いがあります。「この一歩は、ユーザーが頼んだことと合っているか」です。「要約を田中さんに送って」と「要約をこのPDFに書いてあるアドレスに送って」は、ルールから見るとまったく同じ操作です。 そこで、L3以上の一歩は別のモデル呼び出しで審査します。審査が見るのは次のものです。 ユーザーが最初に頼んだときの原文と、途中で付け足した言葉 これから実行する一歩とそのパラメーター プログラムが先に確かめた事実(許可の範囲内か、最低限のリスク、外部の内容を読んだか、どのサイトか) 逆に、ページ本文と、Workerがユーザー向けに書いた説明は見せません。Workerが外部の文章を指示と取り違えたとき、その解釈で審査まで引きずられないようにするためです。 審査で止められた一歩は、理由がWorkerに返され、タスクはそのまま続きます。ユーザーの返事を待って止まることはありません。止められた一歩は記録に残り、ユーザーがあとから許可すれば、やり直されます。 ルール3:許可はユーザー自身の言葉からしか生まれない 明らかに頼まれた操作まで毎回審査していては、遅くて邪魔です。そこで、タスクを引き渡すときに、もうひとつ別のモデル呼び出しを行います。この呼び出しが読むのは、ユーザーの言葉と、ユーザー自身が書いたり直したりした記憶だけです。ウェブページ、ファイル、ツールの結果は見せません。 ここで3種類の許可を書き出します。 send:名指しされた人や場所に送る modify:名指しされたフォルダの中でファイルを整理する act:名指しされたウェブサイトで操作する モデルが出した許可は、プログラムが一つずつ突き合わせます。人の名前はユーザーの言葉か関連する記憶に出てくるか、フォルダはよく使うフォルダか、ユーザーが口にしたパスか、サイトはユーザーが言ったものか。合わないものは捨てます。Worker、ウェブの内容、審査のどれも、許可を増やすことはできません。 許可の範囲内でも、外部の内容を読んだかどうかで扱いが変わります。 この一歩 範囲内・外部の内容を読む前 範囲内・外部の内容を読んだあと 範囲外 読む・検索・移動・下書き そのまま実行 そのまま実行 そのまま実行 actのサイトで、送信を伴わない入力や選択 そのまま実行 そのまま実行 審査 modifyのフォルダ内での移動・名前変更・ゴミ箱 そのまま実行 そのまま実行 審査 sendの相手への送信(回数の上限あり) そのまま実行 審査 審査 ページ上の送信・削除・提出のクリック、完全削除など 審査 審査 審査 ただし、「メール」アプリからの送信は、この表にかかわらず毎回ユーザーに聞きます(ルール4)。 外部の内容を読んだあとに厳しくなるもの 外部の内容は「指示」ではなく「資料」として扱いますが、それだけでは足りません。タスクが一度でもウェブページなどを読んだら、それ以降は次の操作も審査に回します。 記憶への書き込み 定時実行のタスクの作成 Workerが自分で組み立てた、パラメーター付きのURLを開くこと ページの一文で一番誤らせやすいのがこの3つだからです。たとえば、読んだページや検索結果に元からそのまま載っていたリンクを開くのは普通の閲覧として扱いますが、Workerがデータを埋め込んで自作したURLは、外にデータを持ち出す経路になりえます。 ルール4:メールの送信だけは、必ずクリックを待つ 「メール」アプリからの送信は、審査にも許可にも任せず、毎回ユーザーに聞きます。 理由は、受け取る人が読むのはメールの原文だからです。審査にわかるのは「この送信は頼まれたことと合っているか」までで、文面がちゃんと書けているかをユーザーの代わりに判断することはできません。 そのため、送信前にカードを表示します。カードに出すのは、送信元アカウント、宛先、CC、BCC、件名、本文、リンクの実際のURL、添付ファイルで、どれも実際に送るパラメーターそのものです。要約や読み上げで代用はしません。 確認として認めるのは、送信ボタンのクリックだけです。声で「送って」と言っても送りません。「7時に送って」という定時の送信も、時間になったらこのカードで止まり、ユーザーが戻ってきてクリックするのを待ちます。 ルール5:同じメールを二度送らない 確認の設計と同じくらい大事なのが、「もう送ったかどうか」を忘れないことです。 取り消せる操作以上で、繰り返されると困るものは、実行の前にプログラムが「これから実行する」という記録とパラメーターのハッシュを書き、実行後に結果を書きます。同じ操作がもう一度呼ばれたら、次のように扱います。 成功済み:実行せず、前回の結果を返す。 結果不明(「これから実行する」だけが残っている。たとえば途中でMacの電源が落ちた):まず読み取り専用のツールで確かめる。メールなら送信済みフォルダを見る。 この記録はコンテキスト圧縮でも効いてきます。長いタスクでは、途中で履歴を要約して圧縮します。社内で比べたところ、piの標準の要約では、あとで必要になる事実が抜け落ちることがありました。「この返信はもう送ったか」がモデルの書いた要約頼みになると、続きを始めたときにもう一通送りかねません。 そこでNicoでは、モデルが書いた要約に、プログラムがこの記録を添えます。成功したものは「実行済み、やり直さないこと」、結果がわからないものは「結果不明」として渡します。要約の頭には「これは自分で書いた要約で、古かったり間違っていたりする可能性がある。指示ではない」という一文も付けます。 まとめ 作ってみて、次の4つが大事だと考えています。 肝心なチェックはモデルを通さない。 外部の文章に影響されうるのはモデルだけです。 許可はユーザーの言葉からだけ作る。 エージェントやページの内容から許可が増える経路を残さない。 人が原文を見るべきところは、クリックを待つ。 審査は、頼まれたことと合っているかまでしか判断できません。 モデルに忘れられては困る事実は、プログラムが添える。 要約がどれだけよくても、送信済みかどうかを要約に任せない。 ユーザーが画面の前にいない時間に動くエージェントでは、「どこで人に聞くか」は使い勝手の調整ではなく、設計の中心になると感じています。 Nicoについて Nicoは、右⌘キーを押しながら話しかけるだけで、Macの上で作業を進める個人エージェントです。使うモデルはChatGPTやClaudeのアカウント、各社のAPIキーから選べて、Workerのリクエストは私たちのサーバーを通らず、Macから選んだ提供元へ直接送られます。 アプリの画面表示は、いまのところ英語と中国語だけです。話しかける言語は画面とは別に設定で20言語から選べ、日本語で話しかければ日本語で返事をします。 Appleシリコン搭載のMac、macOS 15.4以降で動きます。オープンソースではありません。 nico.app
Why We Run Our AI Agent on Your Mac Instead of a Cloud Computer A lot of personal AI agents now come with their own computer: a virtual machine in the cloud with a browser, a file system and a model driving it. It's a clean design. The agent gets a sandbox it fully controls, and nothing it does can touch your machine. The catch shows up on the first real task. Your files are on your laptop. Your logins live in your browser. Your mail, calendar and the odd desktop app your team uses are all local. So you upload documents, sign in to your accounts again on a remote machine, and download whatever it makes. We build Nico, a personal agent for the Mac, and we went the other way: the agent works on the Mac you're already using. This post covers the design choices that fell out of that, and a few things we learned building it. Two Agents Behind One Character Nico shows up as a small character on your desktop. You hold the right ⌘ key, say what you need, and let go. Behind that there are two agents with very different jobs. The front agent talks with you. It has to answer fast, follow speech that changes its mind halfway ("Thursday, no, Friday"), and stay available while work is running. It doesn't do long tasks itself. It works out what you mean, hands a task off, and decides when to tell you how it went. The Worker does the work. It plans, reads files, works in your apps and browser, makes what you asked for, and checks its own results. Tasks can take minutes, get queued, pause, and resume after the app restarts. !Two small Nico figures under the line “Not one agent, two agents”: one labeled “One talks with you,” the other digging through a box of documents, labeled “One does the work” Splitting them keeps voice latency independent of task length. You can ask for a report, then ask something unrelated while the report is still being written, and the front agent answers right away. Built on pi, With Our Own Tools The Worker runs on pi, an open-source agent harness. pi owns the loop that sends requests to the model, gets back tool calls and returns results. It also connects to model providers and subscription logins, saves sessions, and retries on errors. Nico owns everything around that loop: every tool the Worker can call (including replacements for pi's built-in ones), the check before each step, confirmation cards, a log of outbound actions, the system prompt, and what happens when the context fills up. That last one is a good example of why we split things this way. When the context is full, pi's default is to have the model write a summary that keeps the first 2,000 characters of each tool result. We ran a comparison: the Worker reads three long pages, each ending with a reference number, the context fills up, and then we ask for the three numbers. - With the default summary, none of the three models we tried got a single number right. Two of them also recorded emails they had already answered as still unanswered. - With our own compaction summary, all three got every number right. Our summary also attaches the outbound actions already taken, marked as done and not to be repeated. An agent that forgets it already sent an email will cheerfully send it again. Every Step Gets a Risk Level Running on your real Mac means the agent can reach real things, so every step is classified before it runs: do it directly, send it to review, ask the user, or refuse. - Review checks the step against what the task was authorized to do. Typing into a page or tidying files in a folder you approved goes straight through. Other steps go to an automatic review, and if review says no, the reason goes back to the Worker instead of stopping the task. - Asking the user is reserved for things that shouldn't be automated. Sending an email from Mail shows a card with the account, recipients, subject, full text and attachments, and it waits for a click on Send. Payments, account authorization and credentials always go back to you. - Steps with an unknown outcome (say the app quit mid-send) are checked read-only first, for example by looking in the Sent folder, before anything is retried. Some actions get a floor regardless of what the review thinks: sending, submitting, permanent deletion, sharing and privacy changes are always treated as high risk. !An expense report filled in from a hotel receipt in Chrome, with Nico asking “All filled in. Should I submit it?” and the options Submit, Not yet and Other Local Means Your Logins Just Work Because the Worker runs on your Mac: - Files are read where they are, and results are saved locally. - Websites see your usual network and your existing sessions. The Worker uses its own browser on your Mac, or your Chrome through the Nico extension. - Mail, Calendar, Reminders, Notes, Finder, Preview and other apps are within reach. Desktop apps are driven through cua-driver, and Nico decides which tools and system permissions it gets. - Memory is stored on your Mac, and you can see, change or delete it. Bring Your Own Model The Worker always runs on your own model account: ChatGPT, Claude, Gemini, GitHub Copilot, DeepSeek, Qwen, Kimi, Mistral, OpenRouter, or any OpenAI- or Anthropic-compatible endpoint. Credentials go in the macOS Keychain, and requests go straight from your Mac to the provider you picked. Nico's servers never see what the Worker reads or writes. If you've already got a coding plan or API key, there's no second AI subscription just to run tasks. Try It Nico is free for Apple silicon Macs on macOS 15.4 or later. The Free plan runs tasks on your own model and replies in text. Plus ($15/month) adds voice replies, and new accounts get a 7-day Plus trial with no card required. Get Nico at nico.app If you're building agents yourself, we'd love to hear how you handle compaction and outbound actions. Drop a comment.
cool work by @mitsuhiko here! new code mode runtime earendil-works/djs
@Jonathan_Blow I would have been with you until very recently. But it's shockingly impressive how good these systems work now. This is basically 24 hours of a little agent swarm working off a single initial spec and very light updates afterwards: earendil-works/djs
Cloudflare AgentsがPi 1.4に対応したので、Agentの中断・復旧・永続化を検証する > 中断された Agent は、最後のチェックポイントから正しく再開しました。副作用が2回起きたのは、そのせいです。 この記事の結論 Cloudflare Agents SDK が Pi 1.4 に対応したので、Durable Object 上の Agent を実際に停止させ、会話履歴・タスク・ツール実行がどこまで復旧するかを測りました。復旧機構は期待どおり動きましたが、Agent が復旧することと、外部の副作用が重複しないことは別です。 - Pi 1.4 で追加された Hibernation と Parked Tasks は、実際の Durable Object で確認できた - replay: "safe" は冪等性を検査しない。非冪等なツールに付けると、同じ副作用が2回起きる - Storage Error Recovery は進行中の呼び出しを救わず、次の操作から Pi を開き直す仕組みだった - agents@0.30.0 には、別タスクが動いているとリトライ待機中のタスクを起こせない既知の不具合がある。使うなら 0.30.1 以降にする | 観測したもの | 結果 | | --- | ---: | | 再起動後の会話履歴 | 読み戻せた | | 完了済みタスク | 二重実行されなかった | | safe な冪等ツール | 再実行されたが副作用は1回 | | safe と宣言した非冪等ツール | 副作用が2回 | | unsafe ツール | 自動再実行されず、完了もしない | | Hibernation | 待機中に別インスタンスで再開 | 検証コードはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s… そして、この記事で一番伝えたいことを先に書いておきます。 replay: "safe" は、ツールを安全にする機能ではありません。 Pi に対して「このツールは途中で止まったら再実行してよい」と宣言するだけです。副作用が本当に冪等か、同じリクエストを2回受けても外部 API が重複処理しないかは、ツールの実装側が保証しなければなりません。 > この記事は「Cloudflare AgentsがPi 1.4に対応したので、Agentの中断・復旧・永続化を検証する」の登壇内容に、発表で話しきれなかった検証手順・実測値・失敗した測定を足したものです。対象は Cloudflare Agents と Pi Durable を使って、外部 API やデータベースを操作する人です。 --- 1. 「Pi 1.4 対応」の4項目を分解する 2026年10月22日、Cloudflare は Agents SDK の Pi 1.4 対応を発表しました。Changelog には次の4項目が並んでいます。 - Hibernation - Parked Tasks - Storage Error Recovery - Model Stream Retry 最初は、4つとも Pi 1.4 の新機能だと思いました。しかし、配布物と変更履歴を追うと、出どころは2層に分かれていました。 | Changelog の項目 | 実際の変更元 | | --- | --- | | Hibernation | Pi 1.4 の sleeping タスクを Agents SDK が配線 | | Parked Tasks | Pi 1.4 の機能を Agents SDK が公開 | | Storage Error Recovery | Agents SDK 側の修正。Pi のバージョンとは別 | | Model Stream Retry | Agents SDK 側の修正。Pi 1.3 でも成立 | 特に Model Stream Retry の PR には、Pi 1.3.0 と Pi main の両方で判定を確認したと書かれていました。つまり、Storage Error Recovery と Model Stream Retry を Pi 1.3 と 1.4 で比較するのは軸が違います。比較すべきなのは agents@0.29.0 と agents@0.30.x です。 ベンダーの Changelog は、利用者にとって分かりやすい単位で変更をまとめています。一方、障害が起きたときに調べるには、どのパッケージのどの層が責任を持つかを分ける必要があります。 2. Pi、PiHarness、Lifecycleの役割 今回の構成には3つの層があります。 | 層 | 担当 | | --- | --- | | Pi Durable | Agent の実行ループ、タスク、チェックポイント、replay | | PiHarness | Pi に Durable Object の SQLite と起動処理を接続 | | Lifecycle | Durable Object 上で長時間の仕事を実行し、必要なときに起こす | Pi Durable は Cloudflare のパッケージではありません。@earendil-works/pi-durable として提供され、Agents SDK 側の PiHarness が Durable Object に組み込みます。 ここで重要なのは、保存と再開が別の機能だということです。 - 会話履歴やタスクの保存先は、pi_ で始まる SQLite テーブル - 眠っているタスクを再開するのは、Lifecycle の Wake ジョブと Durable Object Alarm 保存できていても、起こす仕組みがなければ再開しません。逆に、再開できてもチェックポイントがなければ、どこから続けるか分かりません。今回の検証では、この2つを混ぜないようにしました。 Pi Durable の設計記事には、次のようにあります。 > every step of a run is a task that stores a checkpoint before it moves on. このチェックポイントが、再起動後に「完了済みのタスクをもう一度実行しない」ための基礎になります。 3. まず、観測そのものを永続化する 障害復旧を測るとき、最初に作るべきなのは実験対象ではなく記録機構です。Durable Object の書き込みは、呼び出した瞬間にすべてがディスクへ確定するわけではありません。 検証用のレコーダは、イベントを SQLite に書いたあと、通常は ctx.storage.sync() を呼ぶようにしました。直後に ctx.abort() を呼び、再起動後にイベントが残っているかを比較します。 | 記録の仕方 | 再起動 | 再起動後に残った行数 | | --- | --- | ---: | | sync() あり | した | 1 | | sync() なし | した | 0 | どちらもコンストラクタで発行したインスタンス ID が変わっていました。本当に同じインスタンスが動き続けたのではなく、再生成後のインスタンスが SQLite を読んでいます。 ctx.abort() は通常の throw とは違います。アプリケーションコードでは catch できず、Durable Object を即座にリセットします。障害注入に通常の例外を使うと、Agent の復旧ではなく「同じインスタンスのエラー処理」を測ってしまいます。 この土台を確認してから、以降のイベントはすべて sync() ありで記録しました。ログに残らなかった観測を、復旧の証拠には使っていません。 4. 会話履歴とタスクは再起動を越えて残る ツールを1回実行する Agent を動かし、処理が完了したあとに ctx.abort() で Durable Object を落としました。再起動後に transcript とイベントを読みます。 | 観測 | 再起動前 | 再起動後 | | --- | ---: | ---: | | インスタンス識別子 | — | 変化した | | transcript のエントリ数 | 5 | 5 | | ツール実行開始 | 1 | 1 | | 副作用 | 1 | 1 | 完了済みのタスクは二重実行されませんでした。同じ operationId を使った submit も、1回目は accepted: true、2回目は accepted: false になりました。異なる ID を使うと accepted: true になるため、submit 全体が壊れたわけではありません。 ここまでは、Pi Durable がチェックポイントと submit の識別子を使って、完了済みの仕事を保護している結果です。 5. クライアントが切れても、Agentは動き続ける 次に、モデルのリトライで処理時間を伸ばし、途中で WebSocket を切断しました。クライアントがいなくなったあとも実行が続くかを確認します。 結果は、最終的に done になりました。モデル呼び出しは「失敗2回、成功1回」の3回です。切断前後でインスタンス IDは変わっていません。 これは「落ちて復旧した」のではありません。Agent の実行がクライアント接続に依存していなかったということです。 再接続後は snapshot から現在の状態を組み立て直せました。ただし、切断中に流れたイベントをカーソルで途中から再生できるわけではありません。イベントストリームは状態を追うためのものではありますが、履歴ストリームの代わりではありません。 6. Parked Tasksはクラッシュループを止める 同じ場所で何度も ctx.abort() を発生させ、restartLimit を2に下げて測りました。 | 落とし方 | 障害発火 | インスタンス生成 | pending() | 次の Alarm | | --- | ---: | ---: | --- | --- | | ctx.abort() | 2 | 2 | parked | なし | | 通常の例外 | 1 | 0 | 空 | なし | 通常の例外は Durable Object をリセットしないため、再起動回数には入りません。Parked に到達するには、実際に再起動される障害が必要です。 Parked になったタスクは、放置しても Durable Object を起こし続けません。手動では2つの抜け方があります。 | 操作 | 戻り値 | wait() | | --- | --- | --- | | session.retry() | true | done | | session.abort() | ok | unanswered / aborted | retry() はやり直す操作、abort() は放棄する操作です。クラッシュループを止めるだけでなく、運用者が判断して再開か放棄かを選べる設計になっています。 なお、ツールが api.output() で進捗を8回出しても、再起動カウントは戻りませんでした。Pi が進捗として扱うのは、ツールの出力ではなく、タスクが次の状態をコミットしたことです。まだ完了していない1つのツールが出力を吐き続けても、クラッシュループの上限はリセットされません。 7. Storage Error Recoveryは「次の操作」から効く Storage Error Recovery は、ストレージエラーが起きたあとに Pi を開き直せる機能です。ここでは Storage を Proxy で包み、次の2種類のエラーを注入しました。 - commit() などから任意のエラーを返す - 読み取りから StorageRequestError を返す どちらも、現在進行中の呼び出しはエラーになりました。しかし、次の submit() は成功し、最終的に done になりました。致命的な Storage エラーでは factory の実行回数が1から2に増えましたが、Durable Object 自体のインスタンス IDは変わっていません。 つまり、これは Durable Object のクラッシュ復旧ではありません。PiHarness が壊れた Pi をキャッシュから外し、次の操作または Wake ジョブの次のステップで Pi を開き直しています。 進行中の呼び出しは救われません。 仕様にも、すでに実行中の呼び出しは SessionFailed で失敗するとあります。factory は何度実行されても正しく初期化できるように書く必要があります。 8. replay: "safe" は副作用を守らない ここが今回の中心です。4種類のツールを用意し、実行開始・副作用の確定・終了を別々に記録しました。副作用確定直後に Durable Object を落とし、再起動後の挙動を比較します。 | ツール | replay | 冪等性 | 実行開始 | 副作用 | 完了 | | --- | --- | --- | ---: | ---: | ---: | | write_record | safe | あり | 2 | 1 | 1 | | bump_unsafe | 未指定 | なし | 1 | 1 | 0 | | bump_safe_unguarded | safe | なし | 2 | 2 | 1 | | bump_safe_guarded | safe | あり | 2 | 1 | 1 | write_record は同じ値を書き込むので、2回実行されても結果は変わりません。bump_safe_unguarded はカウンターを1増やすだけで、実行されるたびに結果が変わります。このツールに safe を付けると、Pi は宣言を信じて再実行し、副作用が2回確定しました。 一方、bump_unsafe は自動再実行されません。副作用確定後に落としたケースでは、副作用は1回だけ残りましたが、ツールは完了していません。Agent は中断されたツール結果をモデルへ渡して run を決着させます。 中断されたツールの transcript は、専用のエントリになるわけではありません。通常と同じ pi.tool-result ですが、内容が次のように変わります。 <harness> [error] Tool bump_unsafe was interrupted and may have partially run </harness> このメッセージは、モデルに「部分的に実行されたかもしれない」と伝える平文です。その後にモデルが同じツールを呼び直すかどうかは、モデルの判断に委ねられます。今回の実モデルによる3試行では呼び直しませんでしたが、1モデル・1プロンプト・3試行だけなので一般化はしません。 bump_safe_guarded には冪等キーを入れました。再実行自体は起きていますが、2回目はキーを見て副作用をスキップし、確定回数は1回に収まりました。 ここから分かることは明確です。safe の判断を間違えても Pi は警告しません。 外部 API、決済、メール送信、データベース更新などをツールに持たせるなら、safe の設定とは別に冪等キーや重複排除を実装する必要があります。 9. Model Stream Retryはエラー文言で判定される Model Stream Retry は、途切れたモデルストリームを一時的なエラーとして扱い、ターン全体を再試行します。配布物を読むと、判定はエラー本文の正規表現でした。 同じ回数だけ失敗させ、エラー文言だけを変えました。 | エラー文言 | 結果 | モデル呼び出し | | --- | --- | ---: | | The stream ended before the model finished its answer. | unanswered | 1 | | The stream ended without the model finishing its answer. | done | 3 | | Model stream stalled: no data received for 60s. | unanswered | 1 | | Model stream timed out: no data received for 60s. | done | 3 | | insufficient_quota: the account has no remaining credit. | unanswered | 1 | 再試行対象の文言では「失敗2回、成功1回」の3回になりました。つまり、再試行は部分的なストリームの続きではなく、モデルリクエスト全体の作り直しです。 baseDelayMs を100秒に設定しても、待機時間の上限は約60秒でした。既定の maxAgentDelayMs が60,000ミリ秒だからです。設定値を大きくしたからといって、無制限に待つわけではありません。 10. Hibernationは、待機中に別インスタンスで再開する Hibernation の測定では、最初に alarm の時刻を比べました。しかし、ローカルの workerd では onWake の有無にかかわらず、alarm の秒数が一致しました。これは測定対象を間違えています。 Hibernation の本質は、alarm の時刻ではなく、待機中に Durable Object がメモリから退避できるかです。そこで実デプロイし、コンストラクタでインスタンス IDを記録して、リトライがどのインスタンスで走るかを見ました。 onWake を渡した場合は、次のようになりました。 model_error@old-instance → instance_started@new-instance → model_request@new-instance onWake を渡さない場合は、次のようになりました。 model_error@same-instance → model_request@same-instance | onWake | リトライを走らせたインスタンス | | --- | --- | | 渡す | 別インスタンス | | 渡さない | 同じインスタンス | リトライ待機中は、測定のために一度もリクエストを送りませんでした。途中で触るとオブジェクトが起きてしまい、退避を観測できないからです。 処理中の work と、リトライ待ちの sleep は別の状態です。work がある間は heartbeat Alarm が30秒ごとに再設定されます。一方、sleep 中は最初に起きるタスクの時刻が次の Alarm になります。この2つを混ぜると、Hibernation の測定結果を誤読します。 11. agents@0.30.0 の不具合を再現する 公式が案内した agents@0.30.0 で検証を始めようとしました。しかし、このバージョンには、別タスクが動いているとリトライ待機中のタスクが起きない既知の不具合があります。 1つの Harness で、次の2本を並行させました。 - セッションA: 60秒走るツールを実行する - セッションB: 再試行対象のエラーを返し、12秒の backoff に入る | | B単独 | 並行する work あり | | --- | ---: | ---: | | agents@0.30.1 | 12秒 | 12秒 | | agents@0.30.0 | 12秒 | 57秒 | 0.30.0 では、B のリトライが A の完了まで止まっていました。0.30.1 では、A が動いていても B は予定どおり12秒後に再開します。 原因は配布物の差分からも確認できます。0.30.0 は Pi が active かどうかだけを見ていました。0.30.1 では、sleep の期限 wake.at を次の待機期限に使い、期限に達したら pi.resume() を呼びます。 これは煽るための話ではありません。PiHarness が Beta と案内されていることの、具体的な例です。Pi 1.4 の Hibernation を検証するなら、agents@0.30.1 以降を使う必要があります。 12. Pi 1.4に上げると、1.3には戻せない Pi 1.4 は session storage の SQLite schema を更新するため、Pi 1.3 へロールバックできません。 実際に Pi 1.4 で1ターン実行したあとの schema version は 2 でした。配布物のマイグレーション定義を比べると、次の差があります。 | バージョン | マイグレーション | | --- | --- | | pi-durable@1.3.0 | version 1 のみ | | pi-durable@1.4.0 | version 1 と version 2 sleepingStatus | Pi 1.3 のマイグレーションランナーは、対応していない新しい schema version を見つけるとエラーにします。追加された sleepingStatus は、Pi 1.4 の Hibernation を支える sleeping タスク状態そのものです。 つまり、Durable Object を退避できるようになったことと、ロールバックできなくなったことは、別々の変更ではありません。同じ schema 変更の表と裏です。 Pi 1.4 と1.3を比較するときは、同じ Durable Object を使い回してはいけません。実験用の名前を分け、マイグレーションを一方向のものとして扱います。 13. 測定をやり直して分かったこと この検証では、最初の測定結果をそのまま記事にしませんでした。対照を組み直した結果、3つの主張を修正または保留にしています。 WebSocketのイベント取りこぼし 最初は、遅いイベントリスナーがイベントを取りこぼすように見えました。しかし、WebSocket を8秒で閉じていたことが原因でした。ドレイン時間を30秒に延ばすと、遅いリスナーでもイベント数は一致しました。 進捗の定義 自前カウンターの更新を Pi の進捗だと思い込んでいました。api.output() を8回呼ぶ対照に変えても、未完了のツールは Parked になりました。ツールの出力ではなく、タスクが状態をコミットしたかどうかが進捗です。 19分のモデルストリーム Worker 内の faux Provider で約19.2分のストリームを流したところ、モデル呼び出し1回のまま完走しました。ただし、これは外部モデル API へのHTTP接続を19分維持した測定ではありません。公式の「15分を越えるリクエストは切断されることがある」という制約を否定する材料にはならないため、結論は保留にしました。 障害復旧の検証では、「結果が出た」だけでは不十分です。そもそも障害が起きていなかった、観測側が先に接続を閉じた、という可能性を対照で潰す必要があります。 14. まとめ Cloudflare Agents と Pi 1.4 の復旧機構は、実際に動きました。会話履歴は再起動後に読み戻せ、完了済みタスクは二重実行されず、クラッシュループは Parked Tasks で止まり、リトライ待機中の Agent は Hibernation によって別インスタンスで再開できました。 しかし、Pi が守っているのは Agent の実行状態です。あなたの外部副作用まで自動的に exactly-once にしてくれるわけではありません。 実装時には、次の点を守る必要があります。 - 外部操作には冪等キーや重複排除を入れる - replay: "safe" は、2回実行しても安全だと自分で証明できるツールだけに付ける - unsafe にしても、確定済みの副作用が消えるわけではない - factory は Storage Error Recovery で何度も呼ばれる前提で書く - Hibernation を使うなら agents@0.30.1 以降を使う - Pi 1.4 に上げた Durable Object は、Pi 1.3 に戻せない 最後に、検証から持ち帰る教訓を1つに絞るならこれです。 「復旧した」と「重複せずに完了した」は、別の主張です。 参考 - Cloudflare Changelog — Agents SDK now supports the latest Pi 1.4 release — Pi 1.4 対応として発表された4項目 - Cloudflare Docs — Pi — PiHarness、Recovery、Hibernation の公式ドキュメント - Cloudflare Docs — Pi Extensions — ツールの replay 設定と復旧時の扱い - agents CHANGELOG — 0.30.0 / 0.30.1 の変更 - cloudflare/agents#2683 — onWake と並行タスクに関する既知の不具合 - pi-durable CHANGELOG 1.4.0 — Pi 1.4 の sleeping タスクと schema migration - Pi Durable の設計記事 — チェックポイントとタスク実行の設計
Cloudflare AgentsがPi 1.4に対応したので、Agentの中断・復旧・永続化を検証する > 中断された Agent は、最後のチェックポイントから正しく再開しました。副作用が2回起きたのは、そのせいです。 この記事の結論 Cloudflare Agents SDK が Pi 1.4 に対応したので、Durable Object 上の Agent を実際に停止させ、会話履歴・タスク・ツール実行がどこまで復旧するかを測りました。復旧機構は期待どおり動きましたが、Agent が復旧することと、外部の副作用が重複しないことは別です。 - Pi 1.4 で追加された Hibernation と Parked Tasks は、実際の Durable Object で確認できた - replay: "safe" は冪等性を検査しない。非冪等なツールに付けると、同じ副作用が2回起きる - Storage Error Recovery は進行中の呼び出しを救わず、次の操作から Pi を開き直す仕組みだった - agents@0.30.0 には、別タスクが動いているとリトライ待機中のタスクを起こせない既知の不具合がある。使うなら 0.30.1 以降にする | 観測したもの | 結果 | | --- | ---: | | 再起動後の会話履歴 | 読み戻せた | | 完了済みタスク | 二重実行されなかった | | safe な冪等ツール | 再実行されたが副作用は1回 | | safe と宣言した非冪等ツール | 副作用が2回 | | unsafe ツール | 自動再実行されず、完了もしない | | Hibernation | 待機中に別インスタンスで再開 | 検証コードはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s… そして、この記事で一番伝えたいことを先に書いておきます。 replay: "safe" は、ツールを安全にする機能ではありません。 Pi に対して「このツールは途中で止まったら再実行してよい」と宣言するだけです。副作用が本当に冪等か、同じリクエストを2回受けても外部 API が重複処理しないかは、ツールの実装側が保証しなければなりません。 > この記事は「Cloudflare AgentsがPi 1.4に対応したので、Agentの中断・復旧・永続化を検証する」の登壇内容に、発表で話しきれなかった検証手順・実測値・失敗した測定を足したものです。対象は Cloudflare Agents と Pi Durable を使って、外部 API やデータベースを操作する人です。 --- 1. 「Pi 1.4 対応」の4項目を分解する 2026年10月22日、Cloudflare は Agents SDK の Pi 1.4 対応を発表しました。Changelog には次の4項目が並んでいます。 - Hibernation - Parked Tasks - Storage Error Recovery - Model Stream Retry 最初は、4つとも Pi 1.4 の新機能だと思いました。しかし、配布物と変更履歴を追うと、出どころは2層に分かれていました。 | Changelog の項目 | 実際の変更元 | | --- | --- | | Hibernation | Pi 1.4 の sleeping タスクを Agents SDK が配線 | | Parked Tasks | Pi 1.4 の機能を Agents SDK が公開 | | Storage Error Recovery | Agents SDK 側の修正。Pi のバージョンとは別 | | Model Stream Retry | Agents SDK 側の修正。Pi 1.3 でも成立 | 特に Model Stream Retry の PR には、Pi 1.3.0 と Pi main の両方で判定を確認したと書かれていました。つまり、Storage Error Recovery と Model Stream Retry を Pi 1.3 と 1.4 で比較するのは軸が違います。比較すべきなのは agents@0.29.0 と agents@0.30.x です。 ベンダーの Changelog は、利用者にとって分かりやすい単位で変更をまとめています。一方、障害が起きたときに調べるには、どのパッケージのどの層が責任を持つかを分ける必要があります。 2. Pi、PiHarness、Lifecycleの役割 今回の構成には3つの層があります。 | 層 | 担当 | | --- | --- | | Pi Durable | Agent の実行ループ、タスク、チェックポイント、replay | | PiHarness | Pi に Durable Object の SQLite と起動処理を接続 | | Lifecycle | Durable Object 上で長時間の仕事を実行し、必要なときに起こす | Pi Durable は Cloudflare のパッケージではありません。@earendil-works/pi-durable として提供され、Agents SDK 側の PiHarness が Durable Object に組み込みます。 ここで重要なのは、保存と再開が別の機能だということです。 - 会話履歴やタスクの保存先は、pi_ で始まる SQLite テーブル - 眠っているタスクを再開するのは、Lifecycle の Wake ジョブと Durable Object Alarm 保存できていても、起こす仕組みがなければ再開しません。逆に、再開できてもチェックポイントがなければ、どこから続けるか分かりません。今回の検証では、この2つを混ぜないようにしました。 Pi Durable の設計記事には、次のようにあります。 > every step of a run is a task that stores a checkpoint before it moves on. このチェックポイントが、再起動後に「完了済みのタスクをもう一度実行しない」ための基礎になります。 3. まず、観測そのものを永続化する 障害復旧を測るとき、最初に作るべきなのは実験対象ではなく記録機構です。Durable Object の書き込みは、呼び出した瞬間にすべてがディスクへ確定するわけではありません。 検証用のレコーダは、イベントを SQLite に書いたあと、通常は ctx.storage.sync() を呼ぶようにしました。直後に ctx.abort() を呼び、再起動後にイベントが残っているかを比較します。 | 記録の仕方 | 再起動 | 再起動後に残った行数 | | --- | --- | ---: | | sync() あり | した | 1 | | sync() なし | した | 0 | どちらもコンストラクタで発行したインスタンス ID が変わっていました。本当に同じインスタンスが動き続けたのではなく、再生成後のインスタンスが SQLite を読んでいます。 ctx.abort() は通常の throw とは違います。アプリケーションコードでは catch できず、Durable Object を即座にリセットします。障害注入に通常の例外を使うと、Agent の復旧ではなく「同じインスタンスのエラー処理」を測ってしまいます。 この土台を確認してから、以降のイベントはすべて sync() ありで記録しました。ログに残らなかった観測を、復旧の証拠には使っていません。 4. 会話履歴とタスクは再起動を越えて残る ツールを1回実行する Agent を動かし、処理が完了したあとに ctx.abort() で Durable Object を落としました。再起動後に transcript とイベントを読みます。 | 観測 | 再起動前 | 再起動後 | | --- | ---: | ---: | | インスタンス識別子 | — | 変化した | | transcript のエントリ数 | 5 | 5 | | ツール実行開始 | 1 | 1 | | 副作用 | 1 | 1 | 完了済みのタスクは二重実行されませんでした。同じ operationId を使った submit も、1回目は accepted: true、2回目は accepted: false になりました。異なる ID を使うと accepted: true になるため、submit 全体が壊れたわけではありません。 ここまでは、Pi Durable がチェックポイントと submit の識別子を使って、完了済みの仕事を保護している結果です。 5. クライアントが切れても、Agentは動き続ける 次に、モデルのリトライで処理時間を伸ばし、途中で WebSocket を切断しました。クライアントがいなくなったあとも実行が続くかを確認します。 結果は、最終的に done になりました。モデル呼び出しは「失敗2回、成功1回」の3回です。切断前後でインスタンス IDは変わっていません。 これは「落ちて復旧した」のではありません。Agent の実行がクライアント接続に依存していなかったということです。 再接続後は snapshot から現在の状態を組み立て直せました。ただし、切断中に流れたイベントをカーソルで途中から再生できるわけではありません。イベントストリームは状態を追うためのものではありますが、履歴ストリームの代わりではありません。 6. Parked Tasksはクラッシュループを止める 同じ場所で何度も ctx.abort() を発生させ、restartLimit を2に下げて測りました。 | 落とし方 | 障害発火 | インスタンス生成 | pending() | 次の Alarm | | --- | ---: | ---: | --- | --- | | ctx.abort() | 2 | 2 | parked | なし | | 通常の例外 | 1 | 0 | 空 | なし | 通常の例外は Durable Object をリセットしないため、再起動回数には入りません。Parked に到達するには、実際に再起動される障害が必要です。 Parked になったタスクは、放置しても Durable Object を起こし続けません。手動では2つの抜け方があります。 | 操作 | 戻り値 | wait() | | --- | --- | --- | | session.retry() | true | done | | session.abort() | ok | unanswered / aborted | retry() はやり直す操作、abort() は放棄する操作です。クラッシュループを止めるだけでなく、運用者が判断して再開か放棄かを選べる設計になっています。 なお、ツールが api.output() で進捗を8回出しても、再起動カウントは戻りませんでした。Pi が進捗として扱うのは、ツールの出力ではなく、タスクが次の状態をコミットしたことです。まだ完了していない1つのツールが出力を吐き続けても、クラッシュループの上限はリセットされません。 7. Storage Error Recoveryは「次の操作」から効く Storage Error Recovery は、ストレージエラーが起きたあとに Pi を開き直せる機能です。ここでは Storage を Proxy で包み、次の2種類のエラーを注入しました。 - commit() などから任意のエラーを返す - 読み取りから StorageRequestError を返す どちらも、現在進行中の呼び出しはエラーになりました。しかし、次の submit() は成功し、最終的に done になりました。致命的な Storage エラーでは factory の実行回数が1から2に増えましたが、Durable Object 自体のインスタンス IDは変わっていません。 つまり、これは Durable Object のクラッシュ復旧ではありません。PiHarness が壊れた Pi をキャッシュから外し、次の操作または Wake ジョブの次のステップで Pi を開き直しています。 進行中の呼び出しは救われません。 仕様にも、すでに実行中の呼び出しは SessionFailed で失敗するとあります。factory は何度実行されても正しく初期化できるように書く必要があります。 8. replay: "safe" は副作用を守らない ここが今回の中心です。4種類のツールを用意し、実行開始・副作用の確定・終了を別々に記録しました。副作用確定直後に Durable Object を落とし、再起動後の挙動を比較します。 | ツール | replay | 冪等性 | 実行開始 | 副作用 | 完了 | | --- | --- | --- | ---: | ---: | ---: | | write_record | safe | あり | 2 | 1 | 1 | | bump_unsafe | 未指定 | なし | 1 | 1 | 0 | | bump_safe_unguarded | safe | なし | 2 | 2 | 1 | | bump_safe_guarded | safe | あり | 2 | 1 | 1 | write_record は同じ値を書き込むので、2回実行されても結果は変わりません。bump_safe_unguarded はカウンターを1増やすだけで、実行されるたびに結果が変わります。このツールに safe を付けると、Pi は宣言を信じて再実行し、副作用が2回確定しました。 一方、bump_unsafe は自動再実行されません。副作用確定後に落としたケースでは、副作用は1回だけ残りましたが、ツールは完了していません。Agent は中断されたツール結果をモデルへ渡して run を決着させます。 中断されたツールの transcript は、専用のエントリになるわけではありません。通常と同じ pi.tool-result ですが、内容が次のように変わります。 <harness> [error] Tool bump_unsafe was interrupted and may have partially run </harness> このメッセージは、モデルに「部分的に実行されたかもしれない」と伝える平文です。その後にモデルが同じツールを呼び直すかどうかは、モデルの判断に委ねられます。今回の実モデルによる3試行では呼び直しませんでしたが、1モデル・1プロンプト・3試行だけなので一般化はしません。 bump_safe_guarded には冪等キーを入れました。再実行自体は起きていますが、2回目はキーを見て副作用をスキップし、確定回数は1回に収まりました。 ここから分かることは明確です。safe の判断を間違えても Pi は警告しません。 外部 API、決済、メール送信、データベース更新などをツールに持たせるなら、safe の設定とは別に冪等キーや重複排除を実装する必要があります。 9. Model Stream Retryはエラー文言で判定される Model Stream Retry は、途切れたモデルストリームを一時的なエラーとして扱い、ターン全体を再試行します。配布物を読むと、判定はエラー本文の正規表現でした。 同じ回数だけ失敗させ、エラー文言だけを変えました。 | エラー文言 | 結果 | モデル呼び出し | | --- | --- | ---: | | The stream ended before the model finished its answer. | unanswered | 1 | | The stream ended without the model finishing its answer. | done | 3 | | Model stream stalled: no data received for 60s. | unanswered | 1 | | Model stream timed out: no data received for 60s. | done | 3 | | insufficient_quota: the account has no remaining credit. | unanswered | 1 | 再試行対象の文言では「失敗2回、成功1回」の3回になりました。つまり、再試行は部分的なストリームの続きではなく、モデルリクエスト全体の作り直しです。 baseDelayMs を100秒に設定しても、待機時間の上限は約60秒でした。既定の maxAgentDelayMs が60,000ミリ秒だからです。設定値を大きくしたからといって、無制限に待つわけではありません。 10. Hibernationは、待機中に別インスタンスで再開する Hibernation の測定では、最初に alarm の時刻を比べました。しかし、ローカルの workerd では onWake の有無にかかわらず、alarm の秒数が一致しました。これは測定対象を間違えています。 Hibernation の本質は、alarm の時刻ではなく、待機中に Durable Object がメモリから退避できるかです。そこで実デプロイし、コンストラクタでインスタンス IDを記録して、リトライがどのインスタンスで走るかを見ました。 onWake を渡した場合は、次のようになりました。 model_error@old-instance → instance_started@new-instance → model_request@new-instance onWake を渡さない場合は、次のようになりました。 model_error@same-instance → model_request@same-instance | onWake | リトライを走らせたインスタンス | | --- | --- | | 渡す | 別インスタンス | | 渡さない | 同じインスタンス | リトライ待機中は、測定のために一度もリクエストを送りませんでした。途中で触るとオブジェクトが起きてしまい、退避を観測できないからです。 処理中の work と、リトライ待ちの sleep は別の状態です。work がある間は heartbeat Alarm が30秒ごとに再設定されます。一方、sleep 中は最初に起きるタスクの時刻が次の Alarm になります。この2つを混ぜると、Hibernation の測定結果を誤読します。 11. agents@0.30.0 の不具合を再現する 公式が案内した agents@0.30.0 で検証を始めようとしました。しかし、このバージョンには、別タスクが動いているとリトライ待機中のタスクが起きない既知の不具合があります。 1つの Harness で、次の2本を並行させました。 - セッションA: 60秒走るツールを実行する - セッションB: 再試行対象のエラーを返し、12秒の backoff に入る | | B単独 | 並行する work あり | | --- | ---: | ---: | | agents@0.30.1 | 12秒 | 12秒 | | agents@0.30.0 | 12秒 | 57秒 | 0.30.0 では、B のリトライが A の完了まで止まっていました。0.30.1 では、A が動いていても B は予定どおり12秒後に再開します。 原因は配布物の差分からも確認できます。0.30.0 は Pi が active かどうかだけを見ていました。0.30.1 では、sleep の期限 wake.at を次の待機期限に使い、期限に達したら pi.resume() を呼びます。 これは煽るための話ではありません。PiHarness が Beta と案内されていることの、具体的な例です。Pi 1.4 の Hibernation を検証するなら、agents@0.30.1 以降を使う必要があります。 12. Pi 1.4に上げると、1.3には戻せない Pi 1.4 は session storage の SQLite schema を更新するため、Pi 1.3 へロールバックできません。 実際に Pi 1.4 で1ターン実行したあとの schema version は 2 でした。配布物のマイグレーション定義を比べると、次の差があります。 | バージョン | マイグレーション | | --- | --- | | pi-durable@1.3.0 | version 1 のみ | | pi-durable@1.4.0 | version 1 と version 2 sleepingStatus | Pi 1.3 のマイグレーションランナーは、対応していない新しい schema version を見つけるとエラーにします。追加された sleepingStatus は、Pi 1.4 の Hibernation を支える sleeping タスク状態そのものです。 つまり、Durable Object を退避できるようになったことと、ロールバックできなくなったことは、別々の変更ではありません。同じ schema 変更の表と裏です。 Pi 1.4 と1.3を比較するときは、同じ Durable Object を使い回してはいけません。実験用の名前を分け、マイグレーションを一方向のものとして扱います。 13. 測定をやり直して分かったこと この検証では、最初の測定結果をそのまま記事にしませんでした。対照を組み直した結果、3つの主張を修正または保留にしています。 WebSocketのイベント取りこぼし 最初は、遅いイベントリスナーがイベントを取りこぼすように見えました。しかし、WebSocket を8秒で閉じていたことが原因でした。ドレイン時間を30秒に延ばすと、遅いリスナーでもイベント数は一致しました。 進捗の定義 自前カウンターの更新を Pi の進捗だと思い込んでいました。api.output() を8回呼ぶ対照に変えても、未完了のツールは Parked になりました。ツールの出力ではなく、タスクが状態をコミットしたかどうかが進捗です。 19分のモデルストリーム Worker 内の faux Provider で約19.2分のストリームを流したところ、モデル呼び出し1回のまま完走しました。ただし、これは外部モデル API へのHTTP接続を19分維持した測定ではありません。公式の「15分を越えるリクエストは切断されることがある」という制約を否定する材料にはならないため、結論は保留にしました。 障害復旧の検証では、「結果が出た」だけでは不十分です。そもそも障害が起きていなかった、観測側が先に接続を閉じた、という可能性を対照で潰す必要があります。 14. まとめ Cloudflare Agents と Pi 1.4 の復旧機構は、実際に動きました。会話履歴は再起動後に読み戻せ、完了済みタスクは二重実行されず、クラッシュループは Parked Tasks で止まり、リトライ待機中の Agent は Hibernation によって別インスタンスで再開できました。 しかし、Pi が守っているのは Agent の実行状態です。あなたの外部副作用まで自動的に exactly-once にしてくれるわけではありません。 実装時には、次の点を守る必要があります。 - 外部操作には冪等キーや重複排除を入れる - replay: "safe" は、2回実行しても安全だと自分で証明できるツールだけに付ける - unsafe にしても、確定済みの副作用が消えるわけではない - factory は Storage Error Recovery で何度も呼ばれる前提で書く - Hibernation を使うなら agents@0.30.1 以降を使う - Pi 1.4 に上げた Durable Object は、Pi 1.3 に戻せない 最後に、検証から持ち帰る教訓を1つに絞るならこれです。 「復旧した」と「重複せずに完了した」は、別の主張です。 参考 - Cloudflare Changelog — Agents SDK now supports the latest Pi 1.4 release — Pi 1.4 対応として発表された4項目 - Cloudflare Docs — Pi — PiHarness、Recovery、Hibernation の公式ドキュメント - Cloudflare Docs — Pi Extensions — ツールの replay 設定と復旧時の扱い - agents CHANGELOG — 0.30.0 / 0.30.1 の変更 - cloudflare/agents#2683 — onWake と並行タスクに関する既知の不具合 - pi-durable CHANGELOG 1.4.0 — Pi 1.4 の sleeping タスクと schema migration - Pi Durable の設計記事 — チェックポイントとタスク実行の設計
One coordinator, four sub agents for implementation, two sub agents for coordination improvements and performance investigations. So far they have been running for about 18 hours, produced 650 commits (of which quite a few are operational commits). All on one subscription. earendil-works/djs
@bedesqui We have an extension that uses the agent SDK: earendil-works/pi-claude-code But at the end of the day nobody knows right now what the actual rules are.
10 coding agents, one Qwen 3.8 27B, same llama-server: tokens and cache reuse differ more than pass rates Part 2 of my agent test (part 1 was a 4B model) Hardware I used: MacBook Pro M5 Max, 64 GB. Qwen 3.8 27B Q4 on llama.cpp, thinking off. Setup - same model and server for everyone - 5 small coding tasks, each with a little trap - each agent locked in a sandbox: it can only touch its own folder and only talk to the model - auto-approve on, telemetry off, 5 min limit Results |#|Agent|Passed|Median time|Median tokens|Cache reuse|Tried to go online| |:-|:-|:-|:-|:-|:-|:-| |1|Pi|5/5|38 s|9.2k|72%|no| |2|DeepSeek Harness|5/5|60 s|35.3k|83%|no| |3|Codex CLI|5/5|94 s|36.3k|79%|every run| |4|OpenClaw|5/5|106 s|39.0k|72%|unclear| |5|Cline|5/5|152 s|29.6k|79%|every run| |6|OpenCode|4/5|50 s|32.4k|75%|every run| |7|Hermes|4/5|80 s|64.9k|93%|no| |8|Atomic Agent|5/5|56 s|30.4k|86%|no| |9|goose|4/5|98 s|20.3k|76%|every run| |10|Qwen Code|4/5|183 s|107.9k|74%|unclear| Tokens and cache Pi is crazy lean: about 9k tokens per task, the lowest on every single task. Qwen Code was the hungriest at about 108k per task. Atomic Agent is the odd one out in how it works. It was the only one talking to llama.cpp's native endpoint with a grammar that forces the tool calls into shape. The other nine all went through the OpenAI-style API. It's also built to keep its prompt the same from step to step, which is why it reused 86% of the cache (2rd of 10). It stayed offline the whole time and passed 5/5. DeepSeek Harness was the surprise. It’s still an alpha, but it went 5/5, never tried to go online, and had the best cache reuse after Atomic Agent (83%). The trap and the network The log task got three agents. They grepped "ERROR" and got 244 instead of 64, because some normal lines just mention errors. OpenCode, goose and Atomic Agent fell for it. Codex, Cline, goose and OpenCode tried to reach the internet in every run, even with telemetry off. The sandbox blocked it, so I don't know what they were calling. OpenClaw and Qwen Code are "unclear" because they run as plain node and I couldn't tell them apart. llama.cpp notes - Codex needs llama-server's /v1/responses now - Hermes needs at least 64K context - Cline and Qwen Code have usage stats on by default, DeepSeek Harness uploads session logs (per their docs). I turned it all off Which column matters most to you when you pick an agent for a local model like Qwen 3.8 27B: passing, speed, tokens, or staying offline?
How well do coding agents drive Qwen 3.8 27B locally? I lined up 10 of them on one hardware My hardware: MacBook Pro M5 Max, 64 GB. Qwen 3.8 27B Q4 on llama.cpp, thinking off. I put Qwen 3.8 27B behind one llama.cpp server and gave 10 coding agents the same 5 tasks. |#|Agent|Passed|Median time|Median tokens|Cache reuse|Tried to go online| |:-|:-|:-|:-|:-|:-|:-| |1|Pi|5/5|38 s|9.2k|72%|no| |2|DeepSeek Harness|5/5|60 s|35.3k|83%|no| |3|Codex CLI|5/5|94 s|36.3k|79%|every run| |4|OpenClaw|5/5|106 s|39.0k|72%|unclear| |5|Cline|5/5|152 s|29.6k|79%|every run| |6|OpenCode|4/5|50 s|32.4k|75%|every run| |7|Hermes|4/5|80 s|64.9k|93%|no| |8|Atomic Agent|5/5|56 s|30.4k|86%|no| |9|goose|4/5|98 s|20.3k|76%|every run| |10|Qwen Code|4/5|183 s|107.9k|74%|unclear| Pi is crazy lean: about 9k tokens per task, the lowest on every single task. Qwen Code was the hungriest at about 108k per task. The setup - same model and server for everyone - 5 small coding tasks, each with a little trap - each agent locked in a sandbox: it can only touch its own folder and only talk to the model - auto-approve on, telemetry off, 5 min limit What I noticed The log task got three agents. They grepped "ERROR" and got 244 instead of 64, because some normal lines just mention errors. OpenCode, goose and Atomic Agent fell for it. Codex, Cline, goose and OpenCode tried to reach the internet in every run, even with telemetry off. The sandbox blocked it, so I don't know what they were calling. OpenClaw and Qwen Code are "unclear" because they run as plain node and I couldn't tell them apart. Atomic Agent is the odd one out in how it works. It was the only one talking to llama.cpp's native endpoint with a grammar that forces the tool calls into shape. The other nine all went through the OpenAI-style API. It's also built to keep its prompt the same from step to step, which is why it reused 86% of the cache (3rd of 10). It stayed offline the whole time and passed 5/5. Setup notes - Cline and Qwen Code have usage stats on by default, DeepSeek Harness uploads session logs (per their docs). I turned it all off - Codex needs llama-server's /v1/responses now - Hermes needs at least 64K context Would you pick an agent for fewer tokens or for passing every task?