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 の設計記事 — チェックポイントとタスク実行の設計
@makisuo @Cloudflare @foldkit using PiHarness + sandbox escalation when needed would be cool github.com/cloudflare/agents/tree/main/examples/n…
9 views
Cloudflare AgentsのAI Providerが統合されたので検証してみる
> 落ちたら切り替わる、と思っていました。切り替わるのは、本文が 1 文字も出ていないうちだけでした。
この記事の結論
Cloudflare Agents の createAI が持つ fallback を、偽の AI binding で 86 ケース、実機で 1 回測りました。ストリームが壊れたときの守りは 3 層あり、一番外側の手当てだけが抜けていました。
- fallback は出力が始まるまで。 ツール名が届いた段階ならまだ切り替わり、本文が 1 文字出た時点で確定します
- その先は resume が受け持つ。 ただしカタログモデルと dynamic route だけで、Workers AI には run id が無いので誰も守りません
- resume が諦めたときのエラーは、ドキュメント通りに書くと拾えません。 公開されている CloudflareAIError とは別のクラスが飛びます
| 壊れた場所 | 守る層 |
| --- | --- |
| 出力が始まる前 | createAI の fallback |
| ツール名だけ届いた | 同上。まだ間に合う |
| 本文が 1 文字出た後 | fallback は手を引く |
| 出力後・run id あり | AI Gateway の resume |
| 出力後・Workers AI | 誰も守らない |
| resume が尽きた | 誰も。しかもエラーが拾えない |
検証コードはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s…
そして、この記事で一番伝えたいことを先に書いておきます。
fallback を 1 行書いた時点で、守られていると思ってはいけません。 守備範囲は「モデルが口を開くまで」です。開いた後に壊れた場合は別の仕組みが引き継ぎ、その仕組みにも上限があり、上限に達したときのエラーは型が違っていて catch で拾えませんでした。最後の 1 件は Cloudflare に報告し、別の方が独立に再現して同じ修正に辿り着いています。
> この記事は LT「Cloudflare AgentsのAI Providerが統合されたので検証してみる」の内容に、発表で話しきれなかった計測と実装の読み解きを足したものです。対象は、Cloudflare Workers で LLM を呼んでいて fallback を書いている開発者です。
---
1. 何が統合されたのか
2026 年 10 月 21 日、Cloudflare が Agents SDK の AI Provider を createAI に一本化したと発表しました。旧 2 つは非推奨です。
これはレジストリから確認できます。
curl -s https://registry.npmjs.org/workers-ai-provider \
| python3 -c "import json,sys; d=json.load(sys.stdin); lt=d['dist-tags']['latest']; \
print(lt, d['time'][lt][:10], repr(d['versions'][lt].get('deprecated')))"
4.0.1 2026-10-20 'Deprecated. Use createAI from agents/models/ai-sdk...'
createAI は 4 通りのモデル指定を受け付けます。@cf/ で始まる Workers AI のモデル、<author>/<model> の形をした AI Gateway カタログのモデル、dynamic/ で始まる dynamic route、ベンダー製パッケージが作るモデルオブジェクトです。
そして fallback が 1 行で書けるようになりました。
const result = await generateText({
model: ai("@cf/zai-org/glm-4.7-flash", {
fallback: ["anthropic/claude-opus-5.5"],
}),
prompt: "Hello",
});
便利です。ただ、ここで引っかかりました。「落ちたら」って、どこからでしょうか。
ストリーミングだと、途中まで文章が出てから切れます。そのとき切り替わるのか。切り替わらないのか。ドキュメントにはこうあります。
> A request that the vendor rejects falls back. So does a stream that fails before its first output part.
「first output part」がどこを指すのかは書いてありません。
脇道:「統合」には移植されなかった機能がある
本筋から外れますが、1 つだけ。Issue #2569 が移行チェックリストになっていて、2 項目に取り消し線が引かれています。サーバーサイドの fallback と safePrompt です。
前者の理由は「Universal Endpoint の機能だったから」でした。旧 createAiGateway([a, b]) は 1 リクエストを投げて Gateway 側がモデルを選んでいましたが、createAI の fallback は Worker 内でモデルごとに 1 リクエストずつ投げます。投げる数が変わります。
脇道はここまでです。
2. 偽の binding で測る
境界を測るなら、狙った位置でストリームを切れないと話になりません。本物のモデルではできません。
そこで createAI が binding に何を要求しているのかを読みました。models/core/transport.ts を見ると、必要なのは 3 つだけです。run(model, input, options) が Response を返すこと、fetch(url, init) が Response を返すこと、aiGatewayLogId プロパティを持つこと。
つまり偽物を渡せば動きます。
const binding = {
aiGatewayLogId: "01LOGID",
async fetch() { return new Response("gone", { status: 404 }); },
async run(model, input, options) {
return new Response(sseBody, {
headers: { "content-type": "text/event-stream" },
});
},
};
const ai = createAI({ binding: binding as never });
これで fallback の状態機械がローカルで動きます。アカウントも課金も要りません。
肝心なのは本文の作り方です。チャンクを 1 つずつ pull で出し、尽きたところでエラーにします。
function dropAfter(chunks: Chunk[]): ReadableStream<Uint8Array> {
const enc = new TextEncoder();
let i = 0;
return new ReadableStream({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(enc.encode(`data: ${JSON.stringify(chunks[i++])}\n\n`));
return;
}
controller.error(new Error("connection dropped"));
},
});
}
chunks に何を渡すかで、切れる位置が決まります。{ choices: [{ delta: { content: "h" } }] } を入れれば本文が 1 文字出てから切れ、{ choices: [{ delta: { tool_calls: [{ function: { name: "x", arguments: "" } }] } }] } を入れればツール名だけ出てから切れます。この差が、そのまま次の節の表になります。
実装を読むのに使った手も書いておきます。agents は dist に sourcemap を同梱していて、sourcesContent に元の TypeScript が丸ごと入っています。
curl -sL https://registry.npmjs.org/agents/-/agents-0.30.0.tgz -o agents.tgz
tar -xzf agents.tgz -C pkg
python3 -c "
import json
m = json.load(open('pkg/package/dist/ai-sdk-DRPh2rRa.js.map'))
for i, s in enumerate(m['sources']):
open(s.split('/')[-1], 'w').write(m['sourcesContent'][i])
"
GitHub を読むより確実です。実際に npm から配られている版のソースが出てきます。
なお、この偽 binding という手口は後から Cloudflare の公式テストでも使われていると分かりました。packages/agents/src/tests/models/ai-sdk/fallback-and-errors.test.ts が同じことをしています。
3. fallback の境界
計測:切る位置だけを変える
primary を「指定位置まで流して接続断」、fallback を「正常に答える」に固定し、切断する位置だけを動かしました。
| 切断した位置 | fallback |
| --- | --- |
| 何も出さずに切断 | する |
| response-metadata のみ | する |
| ツール名だけ(tool-input-start) | する |
| ツールの引数が出た | しない |
| reasoning-delta まで | しない |
| 本文 1 文字(text-delta) | しない |
ツール名までなら間に合い、本文が 1 文字出たら手遅れでした。
fallback が起きたとき、落ちたモデルのパートは利用者に 1 つも届きません。答えたモデルのプリアンブルに丸ごと差し替わります。途中まで出た文章が残って、その後ろに別のモデルの答えが続く、ということにはなりません。
この結果は 2 つの失敗モードで確かめました。接続が切れる場合と、SSE の中に error が載る場合です。実装上は別の分岐を通ります。モデル ID も本文も変えました。線は同じでした。
考察:なぜそこが境界なのか
実装に PREAMBLE_PARTS という集合があります。
const PREAMBLE_PARTS = new Set([
"stream-start",
"response-metadata",
"text-start",
"reasoning-start",
"tool-input-start",
"raw"
]);
「ここまでなら、まだ何も出力していない」という判定です。6 種類あります。
ところが chat-completions の wire を読むと、6 種類のうち単独で出せるのは 3 つだけでした。
const content = text(delta.content);
if (content !== undefined && content !== "") {
if (textId === undefined) {
controller.enqueue({ id: textId, type: "text-start" }); // ← 続けて
}
controller.enqueue({ delta: content, id: textId, type: "text-delta" });
}
空文字が弾かれるので、text-start の直後には必ず text-delta が続きます。reasoning-start も reasoning-delta と対です。
一方、ツール呼び出しは引数のガードが別になっています。
if (input !== undefined && input !== "") {
controller.enqueue({ delta: input, id: state.id, type: "tool-input-delta" });
}
名前だけ届いて引数が空なら、tool-input-start が単独で出ます。だからここが、切り替われる最後の地点になります。
並べるとこうなります。
| preamble パート | 単独で出せるか | 理由 |
| --- | --- | --- |
| stream-start | 出せる | 先頭に 1 つ出る |
| response-metadata | 出せる | 最初のチャンクの id と model から出る |
| text-start | 出せない | text-delta と同じチャンクから続けて出る |
| reasoning-start | 出せない | reasoning-delta と対で出る |
| tool-input-start | 出せる | 引数が空なら tool-input-delta が出ない |
| raw | 出せる | includeRawChunks を立てたときだけ |
6 つのうち 2 つは、この wire では到達できません。集合の定義を読むだけだと「text-start の後もまだ間に合う」と読めてしまいますが、実際には試せません。
text-start が集合に入っているのは、ベンダーのモデルオブジェクト向けと読めます。Anthropic の provider は content_block_start と content_block_delta を別のイベントで出すので、そちらなら単独で到達できるはずです。ただしベンダーモデルでは測っていないので、ここは実装の読みに留まります。
公式テストは「出力前」と「出力後」の 2 つしか試していません。ツール名だけの状態は扱われていません。
4. 出力が始まった後は resume が受け持つ
fallback が手を引いた後、何も無いわけではありません。
AAA まで流して切断し、再接続して BBB を受け取ると、利用者が手にするのは AAABBB です。取りこぼしも重複もありません。そしてこのとき fallback は 1 度も呼ばれません。
重複しない理由は、再接続の単位が SSE イベントだからです。実装のコメントにこうあります。
> The one subtlety is alignment. from counts complete SSE events, so the wrapper only ever passes complete events on and holds back a trailing partial one. After a drop the partial is discarded and the replay starts at the count of events already delivered: no byte is duplicated or lost.
完全なイベントだけを下流に渡し、途中まで届いたイベントは握っておく。切れたらその半端な分を捨てて、すでに渡した個数から再開する、という作りです。
実際に偽 binding で fetch の URL を記録すると、渡した個数がそのまま from= に乗っているのが見えます。イベントを 2 つ流して切れば ?from=2、3 つなら ?from=3 でした。
役割が分かれています。fallback は「開けなかったとき」。resume は「開いた後に壊れたとき」です。
ただし resume は cf-aig-run-id ヘッダが無いと働きません。実機で確かめたところ、Workers AI のストリームには run id が付きませんでした。守備範囲はカタログモデルと dynamic route だけです。
実機の値も出しておきます。wrangler dev --remote で @cf/meta/llama-3.2-1b-instruct を 1 回叩いた結果です。
| 項目 | 値 |
| --- | --- |
| 最初のパートまで | 328 ms |
| パート数 | 32 |
| providerMetadata.cloudflare のキー | 8 個 |
| runId | 載らない |
| provider | 載らない |
載っていたキーは cacheStatus / eventId / gateway / logId / model / requestId / step / traceId です。1 回しか測っていないので、328 ms は分布ではなく 1 本ぶんの値として読んでください。
ハマりポイント①:対照が通らない回の観測は捨てる
実機の 1 回目は、先頭 2 件が 500 で落ちました。provider-error、isRetryable: true です。後続のリクエストは通っていたので、立ち上がりの一時的な失敗だと思います。
このとき、後続の結果だけ拾いたくなります。やめました。先頭の正常系が通っていない回は、その後の観測が何に対する値なのか言えません。Worker を直して測り直し、先頭と末尾の両方で正常系が通った回の結果だけを採用しています。
5. resume の限界と、拾えないエラー
resume 側も毎回落とすようにして数えたところ、5 回で諦めました。
実装の定数は resumableBody の options.maxReconnects ?? 5 です。ソースのコメントには「Reconnects allowed before giving up. Defaults to 5.」とあります。公開オプションには無いので、呼び出し側から変えることはできません。
諦めたときのメッセージはこうなります。
The stream dropped 6 times; giving up at event 7.
ドキュメントは「after five drops」と書いていますが、実際はドロップ 5 回を生き延びて 6 回目で落ちます。
そして、ここからが本題です。ドキュメントはこう約束しています。
> The provider throws CloudflareAIError, which extends the AI SDK's APICallError, for Workers AI failures, AI Gateway failures, failed stream resumes, and a fallback chain where every model failed.
同じ import、同じ streamText で、失敗のさせ方だけを変えて比べました。
| | 通常の失敗 | resume の失敗 |
| --- | --- | --- |
| e.name | CloudflareAIError | CloudflareAIError |
| instanceof CloudflareAIError | true | false |
| APICallError.isInstance() | true | false |
| code / status | 正しい | 正しい(provider-error / 404) |
名前が同じで、型だけが違います。ログでは気づけません。
原因は、同じ名前のクラスが 2 つあることでした。models/core/errors.ts の方は extends Error で、公開されていません。models/ai-sdk/errors.ts の方は extends APICallError で、こちらが公開されています。そして core/resume.ts は前者を import しています。
import { CloudflareAIError } from "./errors";
しかもそのエラーを投げるのは、run() が返った後、利用者がストリームを読んでいる最中です。run() の catch に置かれた変換は、その時点ではもう通り過ぎています。
async run(request) {
try {
return await inner.run(request); // レスポンスが来た時点で解決する
} catch (error) {
throw toAISDKError(error); // body 読み出し中の失敗は見えない
}
},
この関数の doc コメントには「with every failure lifted into the AI SDK error type」と書かれています。その約束から、ストリームを読んでいる最中の失敗だけが漏れていました。
困るのは 2 つです。ドキュメントのサンプル通りに instanceof で分岐すると、resume の失敗だけ素通りします。APICallError でもないので、AI SDK の retry ループが isRetryable を読めません。
セキュリティの問題でも可用性の問題でもありません。エラーの分類が取れないだけです。
6. なぜ誰も気づかなかったか
公式テストが内部クラスを import しているからです。
import { CloudflareAIError } from "../../../models/core/errors";
投げられているのはまさにそのクラスです。toBeInstanceOf は常に通ります。公開 API としての契約を見ているテストが、1 つもありません。
これは Cloudflare に限った話ではないはずです。内部実装を import したテストは、公開 API の約束を守りません。 自分のコードでも起こります。
ついでに書くと、設計 RFC(リポジトリ内の design/rfc-workers-ai-gateway-merge.md)と出荷版にも差がありました。RFC は layer / transport / index / startedAt を持つ attempt tree を設計していましたが、出荷されたのは { model, error } の 2 フィールドだけです。「デバッグで一番聞かれるのはどの層が再試行したかだ」と書いて設計された構造が、2 フィールドの配列になって出ています。
7. streamRetries を足すと
AI SDK v7 に streamRetries があります。既定は 0 で、省略すると stream retry そのものが無効になります。「出力が始まった後のプロバイダーエラーを再試行する」オプションです。
createAI のドキュメントと AI SDK のドキュメントは、互いにこのオプションに触れていません。重なり方は測るしかありませんでした。
primary を「本文を出してから error」に固定して測りました。
| streamRetries | primary 呼び出し | 利用者が見たテキスト | res.text |
| --- | ---: | --- | --- |
| 省略 | 1 | PARTIAL- | PARTIAL- |
| 1 | 2 | PARTIAL-PARTIAL- | PARTIAL- |
| 2 | 3 | PARTIAL-PARTIAL-PARTIAL- | PARTIAL- |
部分出力が、別の id を持つ新しいテキストブロックとして流れます。
text-start id=03b4d183… → text-delta "PARTIAL-" → text-end
text-start id=a8c81696… → text-delta "PARTIAL-" → text-end
text-start id=101d4351… → text-delta "PARTIAL-" → text-end
res.text は 1 つ分しか返しません。つまり res.text を読む実装と fullStream を UI に流す実装で、見えるものが違います。 UI に流していると、同じ文章が繰り返し画面に出ます。
なお再試行しても fallback には届きません。primary がストリームを開けてしまうので、毎回 primary に確定するからです。「出力後の答えは最終」というドキュメントの記述は、上のレイヤーを足しても破れませんでした。
8. 実運用で効く数値
Retry は fallback チェーンの外側にあります。
| 構成 | 総リクエスト数 |
| --- | ---: |
| 2 本チェーン / maxRetries: 0 | 2 |
| 2 本チェーン / maxRetries: 2(既定) | 6 |
| 3 段の入れ子 / maxRetries: 2 | 9 |
チェーンの長さ ×(1 + maxRetries)です。maxRetries の既定は 2 なので、何も指定しないと 3 倍になります。
入れ子の fallback は展開されます。ここは予想が外れました。最初は「leg の fallback は無視されるはず」と書いていたのですが、測ったら P → F → F2 と 3 段とも試されていました。leg が自分で持つ fallback は保持され、呼び出し時に渡したものだけが剥がされます。
404 は fallback しますが retry はしません。判定の基準が別だからです。fallback は abort 以外のすべてで次に進み、retry は isRetryable だけを見ます。
その isRetryable の境目も測りました。
| ステータス | isRetryable |
| ---: | --- |
| 400 / 401 / 403 / 404 / 422 | false |
| 408 / 409 / 429 / 500 / 503 | true |
ステータスが付かない失敗(接続断など)も true です。実装では status === undefined を transport レベルの障害とみなしています。
abort だけは別扱いです。 AbortSignal で中断すると、fallback は次のモデルに進まずそのまま throw します。利用者が画面を閉じたのに裏で別のモデルに課金される、という事故にはなりません。対照として、abort ではない例外を投げると fallback は普通に進みました。
もう 1 つ、設定の合流で踏みやすいところがあります。fallback をモデル側と呼び出し側の両方に書くと、呼び出し側が置き換えます。連結ではありません。
const model = ai(P, { fallback: [F] }); // モデル側は F
await generateText({
model,
providerOptions: { cloudflare: { fallback: [F2] } }, // 呼び出し側は F2
});
// 実際に試されたのは P → F2。F は 1 度も呼ばれない
空配列を渡した場合は未指定と同じ扱いで、attempts も入りませんでした。
attempts は fallback を設定していないと undefined です。ドキュメントのサンプルには条件なしで「lists every fallback model that failed」と書かれていますが、Cloudflare 自身のテストが expect(error.attempts).toBeUndefined() を期待しています。
実機では「存在しないモデル」の code が 2 つに割れました。
| 入力 | status | code |
| --- | ---: | --- |
| @cf/nope/does-not-exist | 400 | bad-request |
| nope/not-a-real-model | 404 | not-found |
code === "not-found" でモデル ID の打ち間違いを拾おうとすると、@cf/ の打ち間違いだけがすり抜けます。
ハマりポイント②:ドキュメントを疑って、外した
peerDependencies は "ai": "^6.0.0 || ^7.0.0" ですが、ドキュメントは「The provider needs ai@^7」と書いています。食い違っているので、最初はドキュメントが古いのだと思いました。
直す前に ai@6 を入れて動かしました。
ai: 6.0.312 | @ai-sdk/provider: 4.0.31 | agents: 0.30.0
UnsupportedModelVersionError: Unsupported model version v4 for provider "cloudflare"
逆でした。ai@6 は @ai-sdk/provider@3 を直接依存で抱えているので、トップレベルに v4 を置いても関係ありません。ドキュメントが正しく、package.json の範囲が広すぎたということです。
入れても peer の警告は出ません。最初の generateText で落ちます。
9. 報告して、どうなったか
見つけたものは報告しました。
| | 内容 |
| --- | --- |
| agents#2687 | resume のエラー型 |
| agents#2690 | ai の peer range |
| cloudflare-docs#34546 | 再接続回数と attempts の記述 |
agents は pull request をコラボレーターに限定しています。Issue に修正ブランチのリンクを添える形にしました。
翌日、#2687 に別の方から再現報告が付きました。原因の特定も修正方針も、こちらが出したものと一致しています。createTransport().run() でレスポンスの body を包み、読み出し中のエラーを toAISDKError に通す、という形です。
> the AI SDK boundary only converts synchronous failures: createTransport's try/catch in models/ai-sdk/transport.ts has already returned by the time the stream errors on a later read.
別経路で同じ場所に辿り着いているので、読み違いではなさそうです。
この方は core 側の設計意図も引いていました。models/core/errors.ts には「framework modules wrap it into their own error type at their boundary」と書かれています。つまり core が framework 中立のエラーを投げるのは設計通りで、抜けているのは境界側の変換だけ、という整理です。直す場所が core ではなく models/ai-sdk/transport.ts だという判断も、ここから出ています。
ついでに観測した話を 1 つ。このリポジトリには報告を確認して PR を出すボットがいます。別件で出した Issue では、確認コメントが付いた数時間後に修正 PR が立ちました。すべての報告がそうなるとは限りませんが、ファイルと行が特定できている報告は動きが早いようです。
10. まとめ
fallback を 1 行書いて安心していました。測ってみると、守備範囲は「モデルが口を開くまで」でした。
flowchart TD
A[ストリーム開始] --> B{出力が始まったか}
B -- まだ --> C[fallback: 次のモデルへ]
B -- 始まった --> D{run id があるか}
D -- ある --> E[resume: 同じ run に再接続]
D -- ない --> F[そのまま失敗]
E --> G{6 回目のドロップか}
G -- そう --> H[CloudflareAIError<br/>ただし公開型ではない]
G -- まだ --> E
- ツール名が届いた段階なら、まだ別のモデルに切り替わります
- 本文が 1 文字出たら、もう切り替わりません
- その先は resume が引き継ぎますが、Workers AI では働きません
- resume は 5 回で諦めます。その回数は変えられません
- 諦めたときのエラーは、ドキュメント通りに instanceof で書くと拾えません
測れなかったものも書いておきます。429 と 401 は無料枠では起こせず、分類は偽 binding でしか確認していません。Gateway 側の retries が実際に何回投げたかは、RFC によると設計上クライアントからは観測できません。カタログモデルでの metadata.model と response.modelId のズレは、Unified Billing を使わなかったので未確認です。
偽の binding を 1 つ書くだけで、ここまで測れます。50 行ほどのオブジェクトです。自分が使っている SDK で同じことをすれば、ドキュメントに書いていない境界が出てくるはずです。
参考
- Agents — Models — createAI が受け付ける 4 種類のモデル指定
- Agents — AI SDK provider — fallback / resume / エラー型の記述。この記事が突き合わせた相手
- createAI replaces workers-ai-provider and ai-gateway-provider — 移行表と非推奨の告知
- cloudflare/agents#2569 — 移行チェックリスト。移植しなかった機能に取り消し線が引いてある
- cloudflare/agents#2576 — Universal Endpoint をコアから削除した PR
- AI Gateway — Fallbacks — Gateway 側の fallback。createAI からは使えない
Cloudflare Agents 0.29.0 (Oct 20) lets createAI run outside a Worker. We're testing createAI to pull source-backed fact cards. Lesson so far: structured output can look right while facts are wrong, so a human reviews each card. github.com/cloudflare/agents/releases/tag/agents%…
17 views
I Migrated a Cloudflare Agents Workspace and Measured Everything: What Changes When the Storage Format Changes?
> The difference was not the table names.
TL;DR
On October 20, 2026, Cloudflare Agents switched its Workspace implementation from @cloudflare/shell to @cloudflare/computer. The change is not just a new API name. It changes how files are stored. The old implementation stored one row per path, with the file contents base64-encoded in that same row. The new one separates inodes and chunks, storing content-addressed blobs in 512 KiB pieces.
I ran the old and new implementations in the same Durable Object and compared the data before and after migration.
- The tables are different, so old files are invisible after switching without migration. They are still there, though.
- Storing a whole file in one row changes the size limit and performance. The old implementation hit a limit at about 6 MiB, and its 1 MB read/write cycle was 13.5 times slower.
- The schema is versioned, so it migrates forward automatically but cannot go backward.
| Aspect | Old @cloudflare/shell | New @cloudflare/computer |
| --- | --- | --- |
| Tables | cf_workspace_<namespace> | vfs_* / _vfs_* |
| Representation of one file | One row (path TEXT PRIMARY KEY) | Inode + 512 KiB chunks |
| Where content lives | Base64 in the same row's content column | Blob addressed by SHA-256 |
| Observed file-size limit | About 6 MiB without R2 | No single-row limit; content is chunked |
| 1 MB write + read | 87.25 ms | 6.45 ms |
| Same content at two paths | Stored twice | Both paths share one blob row |
| Reopen with old implementation | Works | Fails with EIO |
Test code: GitHub repository
Here is the main point upfront.
The old and new implementations differ in storage format, not API. Once you understand that, the reason files disappear after switching without a migration, why the file-size limit changes, why the new implementation is faster, and why you cannot switch back all follow from the same underlying change. The migration guide says to copy files manually because the tables differ. The deeper difference is their structure, not their names.
> This article expands on my talk about migrating a Cloudflare Agents Workspace with measurements I could not fit into the presentation. It is for people using Cloudflare Agents who are preparing to move to @cloudflare/computer.
---
1. The announcement
On October 20, 2026, Cloudflare posted this changelog:
> Think and Agent Skills now use the workspace from @cloudflare/computer as well as the filesystem tools.
> @cloudflare/shell is deprecated, and so is Think's createExecuteTool. These will no longer be developed nor supported from today onwards.
On the same day, Cloudflare published @cloudflare/think@0.21.0, @cloudflare/computer@0.6.0, and agents@0.29.0. @cloudflare/shell also received version 0.5.0 that day to announce its deprecation. npm's deprecated flag applies retroactively to earlier versions too, so 0.4.3 shows the same warning.
The migration guide itself had landed earlier, in PR #2564 on October 14. It did not arrive at the same time as the announcement.
The Changelog also says:
> This is a breaking change. @cloudflare/computer stores files in different tables from @cloudflare/shell, and there is no automatic data migration between workspace formats.
“Different tables.” That is where I started. Let's see how different they are.
2. The old Workspace stored one file per row
A Workspace is where an agent stores its files. A model interacts with them through read, write, edit, and ls; the files themselves live in Durable Object SQLite.
The old @cloudflare/shell schema is included in the migration guide:
CREATE TABLE cf_workspace_default (
path TEXT PRIMARY KEY,
parent_path TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('file','directory','symlink')),
mime_type TEXT NOT NULL DEFAULT 'text/plain',
size INTEGER NOT NULL DEFAULT 0,
storage_backend TEXT NOT NULL DEFAULT 'inline' CHECK(storage_backend IN ('inline','r2')),
r2_key TEXT,
target TEXT,
content_encoding TEXT NOT NULL DEFAULT 'utf8',
content TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
modified_at INTEGER NOT NULL DEFAULT (unixepoch())
);
It is straightforward: the path is the primary key, and the metadata and content live in the same row. The content column holds file contents, base64-encoded for binary files.
Large files are handled separately. The default threshold is 1,500,000 bytes. Files above that threshold are moved to R2, leaving only an r2_key in the row. If no R2 bucket is provided, the implementation logs a warning and keeps the file inline:
if (size >= this.threshold && !r2)
console.warn(`[Workspace] File ${path} is ${size} bytes but no R2 bucket was provided. Storing inline.`);
This “put everything in one row” design matters later.
3. The new Workspace separates inodes and chunks
The new @cloudflare/computer uses several tables:
| Table | Stores |
| --- | --- |
| vfs_dirents | Parent inode + name → child inode |
| vfs_nodes | Inode metadata (type / mode / mtime / size / link_target) |
| vfs_chunks | Inode + chunk index → blob SHA-256 |
| vfs_blobs | SHA-256 → size and last-reference time |
| vfs_blob_bytes | SHA-256 → actual bytes |
| vfs_meta | schema_version and revision counter |
Path resolution walks vfs_dirents from the root (inode = 1). File contents are split into 512 KiB chunks, each stored in vfs_blob_bytes as a blob keyed by SHA-256:
const CHUNK_SIZE = 524288;
The tables start with vfs_ or _vfs_, so they do not collide with cf_workspace_*. Both formats can coexist during migration.
From here, I measured what follows from the structural difference.
4. Consequence #1: switching does not make old files visible
I put both implementations on the same Durable Object. Splitting them across two DO namespaces would not test migration on the same storage. I switched between them inside one class.
I wrote 13 items with the old implementation, initialized the new one, and tried reading them:
Read through old Workspace before switch → {found: true, length: 23}
Initialize new Workspace
Read /workspace/notes.md through new → ENOENT no such file
Read /notes.md through new → ENOENT no such file
New-side tree → [{path: "/workspace", type: "dir"}]
The new implementation could not see the files.
If I stopped here, the article would say “the data is gone.” That would be quick—and wrong.
Add two controls
“Not visible through the new implementation” does not tell us whether the original write failed. In the same run, I verified that the old Workspace could still read the file both before and after switching:
Read through old before switch → {found: true, length: 23}
Read through old after switch → {found: true, length: 23}
Rows in cf_workspace_default → 14 (same as before)
The old implementation could still read the file after the switch. The row count had not changed either.
The data was not gone. The new implementation was looking somewhere else. A table listing showed both formats coexisting:
cf_workspace_default
computer_execution_runtime
sqlite_sequence
vfs_blob_bytes vfs_blobs vfs_changes vfs_chunks
vfs_dirents vfs_manifests vfs_meta vfs_nodes
_vfs_fetch_cursor _vfs_mounts _vfs_push_cursor
_vfs_sync_operations _vfs_sync_skips _vfs_watermark
I also learned that all vfs_* tables are created as soon as you call new Workspace(). They do not wait for the first filesystem operation. The schema_version was 10.
5. Consequence #2: the size limit changes when everything fits in one row
The old implementation base64-encodes file contents and puts them in one cell. A 4 MB file becomes 5,592,408 base64 characters.
Meanwhile, the Durable Objects Limits page says:
| Item | Documented limit |
| --- | --- |
| Maximum string, BLOB, or table row size | 2 MB |
| KV key size | Key and value combined cannot exceed 2 MB |
A 4 MB file should not fit in the old Shell. I predicted that while designing the benchmark and prepared files of 1 KB, 100 KB, 1 MB, and 4 MB.
They all worked.
My prediction was wrong.
4 MB → storage_backend=inline, content_encoding=base64,
content_len=5,592,408 → read-back SHA-256 matches
8 MB → Error: string or blob too big: SQLITE_TOOBIG
I got the same result in local Miniflare and a deployed production Durable Object.
Measurement: where is the actual limit?
Stepping through the library would only reveal its implementation constant, not the underlying limit, so I called ctx.storage.sql and ctx.storage.kv directly to find the boundary. Each probe used a new Durable Object.
Largest accepted 8,388,627 bytes
Smallest rejected 8,388,628 bytes
8 MiB is 8,388,608 bytes. TEXT, BLOB, and a KV value all failed at the same boundary.
That is four times the documented 2 MB. I repeated probes around the boundary on a new Durable Object and got the same result. I reported it in cloudflare-docs#34502. Since I could not rule out 2 MB being the guaranteed limit and 8 MiB merely an implementation allowance, I did not open a PR asserting a new limit; I attached a reference branch instead.
Why does the old implementation stop at 6 MiB?
The old Shell hit its own boundary below the raw SQL limit:
Largest file accepted by shell.writeFileBytes 6,291,408 bytes (8,388,544 base64 chars)
Smallest rejected 6,291,409 bytes (8,388,548 base64 chars)
Base64 expands the content by about 4/3, so that works out to roughly 6 MiB of file data.
I could not explain why the Shell fails earlier than raw SQL at 8,388,627 bytes. I reproduced its DDL, indexes, INSERT statement, ON CONFLICT DO UPDATE clause, and base64 string, then ran them against the same Durable Object. Raw SQL accepted the value. I also suspected that UPSERT might duplicate the value through excluded, but an UPSERT against an existing row accepted the same size.
The stack trace points to the Shell's INSERT:
Error: string or blob too big: SQLITE_TOOBIG
at Object.run (index.js:367:17)
at Workspace.writeFileBytes (index.js:652:22)
I stopped there. For practical purposes, remember that “without R2, the old Shell's file-size limit is about 6 MiB.”
The new Computer splits content into 512 KiB chunks, so this particular wall is gone. Files of 8 MB, 16 MB, and 32 MB all worked. Chunking may look like a performance design, but it also changes the size limit.
6. Consequence #3: base64 conversion on every operation is slower
I hit an oddity here: Date.now() does not advance inside a Worker. In production, even a 4 MB write was recorded as 0ms. It advanced in local Miniflare, so at first I thought I was misreading the production logs.
I measured from outside instead. I sent one request that repeated the operation 60 times and a control request that did nothing, then divided the difference by 60. The table shows the median of seven runs:
| Size | Old Shell | New Computer |
| --- | ---: | ---: |
| 1 KB | 1.58 ms | -0.75 ms |
| 100 KB | 4.92 ms | 0.80 ms |
| 1 MB | 87.25 ms | 6.45 ms |
I cannot draw conclusions from the 1 KB and 100 KB results. As the negative value shows, the differences are buried in HTTP round-trip variance.
At 1 MB, the gap was clear: 13.5×. The old Shell base64-encodes a 1 MB file into one row on every write and decodes it on every read, adding overhead. My measurement includes the write and the read together, so I cannot say which one is slower on its own.
7. Consequence #4: content addressing enables deduplication
The new implementation uses the blob's SHA-256 as its primary key. The same bytes can be shared by files anywhere in the tree.
I wrote the same 512 KiB of content to two paths and counted rows in vfs_blobs:
Initial blob count → 0
Write identical content to path 1 → 1
Write identical content to path 2 → 1 (no increase)
Control: write different content → 2 (increases)
Without the control, I could not distinguish deduplication from an implementation that never added a blob at all. The different byte sequence confirmed that a second row is created when the content differs.
The old implementation stores content per path, so putting the same file in two places uses storage twice.
8. Consequence #5: there is nowhere to put some metadata
The old FileInfo has mimeType, createdAt, and updatedAt. The new WorkspaceStatResult has only these fields:
{ name, inode, mode, mtime, size, isFile, isDirectory, isSymbolicLink }
There is no API in workspace.fs to read or write MIME types. There is only one timestamp, mtime.
The migration guide also says this metadata is not retained:
> Things the copy does not keep:
> - created_at and modified_at. Computer stamps the time of the copy.
> - mime_type. Computer does not store a MIME type per file.
In my measurements, every migrated file's mtime was the time the copy ran. It is not just that the data is “not retained”; the new schema has nowhere to store it.
9. Consequence #6: you can move forward, but not backward
The new implementation versions its schema. Its initialization code looks like this:
const onDiskVersion = db.one("SELECT v FROM vfs_meta WHERE k = ?", "schema_version")?.v ?? 0;
if (onDiskVersion > 10) throw createWorkspaceError("EIO", `Unsupported workspace filesystem schema version ${onDiskVersion}`);
if (onDiskVersion > 0 && onDiskVersion < 10) runMigrations(db, onDiskVersion, 10);
Migrations run forward automatically. There are nine registered migrations, from v1 to v10.
There is no migration backward.
Rather than deploying an old version, I changed the on-disk schema_version to 11 to simulate a database newer than the binary, then ran the same initialization path:
Current schema_version → 10
Control: reconstruct with same version → {read: "hello"}
Set schema_version to 11
Open again with older binary → EIO Unsupported workspace filesystem schema version 11
Set schema_version back to 10
Can it open now? → {read: "hello"}
What I measured is that the binary cannot open a database whose on-disk schema_version is newer than the version it supports. This means “you cannot roll back” only if a new release raises the schema version. It has already been raised nine times, so it is reasonable to expect that it could happen again.
Neither the migration guide nor the Changelog mentions this. There is one sentence in the implementation document 03_filesystem_schema.md:
> Open() refuses to run if the binary is older than the on-disk schema_version.
The old data remains, so if migration fails you can still read from the old tables. The inability to roll back is the less recoverable part.
10. Running the migration guide's script
Now that I understood the structural difference, I ran the migration.
The guide provides a copy function named copyShellWorkspace(). It walks the old Workspace with glob("/**") and writes the files through the new fs API. The destination is under /workspace, which Think also uses as its working directory.
I ran the script without changing it. My test data included UTF-8 Japanese text, TypeScript in a nested directory, JSON for exact-content comparison, 64 KB of binary data generated with a deterministic pseudorandom sequence, an empty directory, and four symlinks covering relative, absolute, and root-escaping targets.
comparison → {
total: 13, missing: [], typeChanged: [],
contentMismatch: [], extra: []
}
All 13 items existed after migration, their types had not changed, and the 64 KB binary's SHA-256 matched. The empty directory remained too. I got the same result in local wrangler dev and in a deployed production environment.
I also verified that the hash comparison could actually detect a mismatch. I changed one bit in a byte sequence and confirmed that it produced a different hash: af2ba40f… versus 73734ea1…. A comparison that cannot detect a mismatch does not prove that two files match.
I checked one assumption the script relies on as well. A comment says, “glob returns rows ordered by path, so parents come before children.” If that were false, writeFile could run before mkdir. In my tests, results were sorted lexicographically, so each parent appeared before its children.
Symlinks need one extra check
When I followed each link individually, one of them failed:
| Link | Target in Shell | Target after migration | Can follow it? |
| --- | --- | --- | --- |
| /links/rel.md | ../notes.md | /workspace/notes.md | Yes |
| /links/abs.md | /notes.md | /workspace/notes.md | Yes |
| /links/dotdot.md | /src/../notes.md | /workspace/src/../notes.md | Yes |
| /links/esc.md | /../notes.md | /workspace/../notes.md | ENOENT |
/workspace/../notes.md resolves to /notes.md, which is outside the migration destination /workspace.
The cause is in the script's resolveTarget():
// Shell resolves a relative target against the link's directory and never
// goes above `/`.
function resolveTarget(link: string, target: string): string {
if (target.startsWith("/")) return target; // Absolute paths pass through
const parts = link.split("/").slice(1, -1);
for (const part of target.split("/")) {
if (part === "..") parts.pop();
else if (part !== "" && part !== ".") parts.push(part);
}
return `/${parts.join("/")}`;
}
The @cloudflare/shell implementation (resolveSymlink) also sends absolute paths through normalizePath. normalizePath stops .. at the root, so /../notes.md becomes /notes.md in Shell. The migration script passes the path through unchanged. When it prefixes /workspace, the target escapes that directory.
The conditions are narrow. Relative paths all migrate correctly. Absolute paths containing ., //, or .. that does not go above the root also work. The only failure is an absolute path containing .. that goes above the root.
The script does not report an error. symlink() saves the target without validating it, and following it stops at the root rather than throwing. This is like a legal dangling link on POSIX. The copy completes successfully.
A path/type/hash comparison will not catch this. The link exists, so it is not missing. Its type is still symlink, so the type matches. It has no file content, so it is not included in the hash comparison. My own comparison script reported missing: [], contentMismatch: []. To catch this, you have to follow and read the link:
await to.fs.readFile(linkPath, "utf8"); // ENOENT if the target cannot be followed
A second issue is that the migration is not safe to rerun:
First migration → ok
Second migration → EEXIST path exists: /workspace/links/abs.md
mkdir({ recursive: true }) silently succeeds for an existing directory. writeFile overwrites unless exclusive is specified. symlink(target, path) has no equivalent of a force option; if the path already exists, it always throws EEXIST. That matches Node's fs.symlink behavior and is not a library bug.
The guide's onStart example sets a flag after copying completes. If the copy fails partway through, the flag is never set and the next startup fails again on the same link. Remove the link before recreating it:
const link = at(entry.path);
if (await to.fs.readlink(link).then(() => true, () => false)) {
await to.fs.rm(link);
}
await to.fs.symlink(at(target), link);
I reported these two issues in cloudflare/agents#2645. Before filing, I checked for duplicates and found that both had been raised in earlier reviews. One did not survive a PR replacement; the other had been marked resolved based on the incorrect assumption that absolute paths were already rebased. Checking first meant I did not present either as a new discovery.
If your Workspace has no symlinks, you will not hit this part.
11. Persistence survives hibernation
The storage format changed, but the files still live in Durable Object SQLite. I checked whether they survive restarts.
“Readable from another request” is not enough evidence. The same instance might still be in memory and return the file from there. I generated a crypto.randomUUID() in the constructor and included it in the response. If that value changes, the Durable Object is a new incarnation.
bootId Read
1. Write 1cadfbdd-7595-4879-94c1-43abca4688c0 ok
2. Read immediately 1cadfbdd-7595-4879-94c1-43abca4688c0 ok (same)
3. After 15 seconds e4f5f2ab-3d7d-4389-8e2d-ee32fdd97b02 ok (changed)
4. After 150 seconds c602f7f1-c9f6-465e-9b36-0c3259706d64 ok (changed again)
Reads 3 and 4 came from different incarnations. The file content included the old bootId from when it was written, showing it was the same data.
I decided which fields to record before running the test: a case number for writing back the verdict, bootId to show whether a restart happened, env to distinguish local from production, sha256 and size to identify the file, the operation's elapsed time, and ok and error fields that hold results instead of throwing exceptions.
This control proved useful. After redeploying, my first immediate read succeeded, but the bootId was unchanged. The DO was still in memory and running the old code; it had not restarted. Without the control, I might have concluded that the file persisted across a restart. I waited 15 seconds and tried again, then confirmed that the bootId changed and the file was still readable.
How long until hibernation?
I varied the wait time and checked bootId. The zero-second wait was the control:
Wait (s) Restarted Read
0 No ok (control)
3 No ok
6 No ok
9 No ok
11 Yes ok
13 Yes ok
15 Yes ok
The boundary is between 9 and 11 seconds, matching the documentation's “hibernate after 10 seconds of inactivity.”
12. Before you migrate
- Count your symlinks. Run glob("/**") and count entries with type === "symlink". If there are none, the official script should work as-is.
- If you have links, follow and read them after migration. Path/type/hash comparisons will not catch the issue.
- Check for files larger than 6 MiB. If the old implementation stored them in R2, pass the same r2 bucket and name during migration.
- Deploy assuming you cannot roll back. Once the new schema has been written, an older @cloudflare/computer cannot open that Durable Object.
- Do not delete the old data immediately. The old tables remain, so wait to DROP TABLE until your comparison is done.
The dependency arrangement changes too. In @cloudflare/think@0.20.2, @cloudflare/shell was a direct dependency; you did not install it yourself. In 0.21.0, @cloudflare/computer is a peer dependency. The mechanism behind “I upgraded and my Workspace is empty” is right there.
13. Conclusion
The storage format changed, and the rest followed from it:
- Different tables → files are invisible after switching without a migration, but the old tables remain and the old Workspace can still read them.
- One row versus chunks → the old implementation hits a limit at about 6 MiB; the new one accepts files of 32 MB.
- Base64 conversion on every operation → a 13.5× difference in the 1 MB read/write test.
- Content-addressed blobs → the same content at two paths takes one blob row.
- Fewer metadata columns → there is nowhere to store mime_type or created_at.
- A versioned schema → it migrates forward automatically and cannot be opened by an older implementation after the version changes.
The migration guide says to copy files manually because the tables differ. The difference is not just the table names; it is the structure. Understanding that structure tells you what to verify before you migrate.
I also found two mismatches with the documentation. The Durable Object SQLite value limit I tested was 8 MiB, not 2 MB, and the user-facing docs do not mention that an older implementation cannot open a Durable Object after its schema has been upgraded.
The most surprising result was that a 4 MB file worked. I had predicted the result from the official limit and got it wrong. Because I had trusted the documented number, I no longer trusted my other predictions once it failed. I ended up measuring both the size limit and performance myself. That took more time than the migration itself.
References
- Think agents now use a @cloudflare/computer workspace — The announcement, the lack of automatic migration, and deprecation of createExecuteTool.
- Migrating from @cloudflare/shell to @cloudflare/computer — Both schemas and the copy script discussed in §10.
- cloudflare/computer docs/03_filesystem_schema.md — All vfs_* table definitions, chunk size, and schema version behavior.
- Durable Objects — Limits — The documented 2 MB row-size limit tested in §5.
- Durable Objects — Lifecycle — Sources for the 10-second hibernation and 70–140-second eviction behavior.
- cloudflare/computer#217 / #219 — Acknowledgment that writing to R2 and namespaces are not supported yet.
- cloudflare/agents#2645 — Report about the symlink migration script in §10.
6 reactions · 2 comments
Cloudflare AgentsのWorkspaceを移行して測ってみる
> 違うのは、テーブル名ではありませんでした
この記事の結論
2026年10月20日、Cloudflare Agents の Workspace が @cloudflare/shell から @cloudflare/computer に切り替わりました。変わったのは API の名前ではなく、ファイルの持ち方です。旧はパス1本につき1行で、中身を同じ行に base64 で持っていました。新は inode とチャンクに分けて、512 KiB ごとの content-addressed な blob に置きます。
実際の Durable Object に新旧を同居させて、移行の前後を1件ずつ突き合わせました。
- テーブルが別なので、移行せずに切り替えると旧ファイルは見えない。 ただし消えてはいない
- 1行に全部入れるかどうかで、上限も速度も変わる。 旧は約 6 MiB で頭打ち、1MB の読み書きは 13.5 倍遅い
- スキーマにバージョンがあるので、前には自動で進むが後ろには戻れない
| 観点 | 旧 @cloudflare/shell | 新 @cloudflare/computer |
| --- | --- | --- |
| テーブル | cf_workspace_<namespace> | vfs_* / _vfs_* |
| 1ファイルの表現 | 1行(path TEXT PRIMARY KEY) | inode + 512 KiB のチャンク |
| 中身の置き場 | 同じ行の content に base64 | sha256 をキーにした blob |
| 実測の上限 | 約 6 MiB(R2 を渡さない場合) | チャンクに割れるので頭打ちなし |
| 1MB の書き込み+読み込み | 87.25ms | 6.45ms |
| 同じ内容を2箇所に置く | 2行ぶん | blob は1行を共有 |
| 古い実装に戻す | できる | EIO で開けない |
検証コードはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s…
そして、この記事で一番伝えたいことを先に書いておきます。
新旧の差は、API ではなく保存形式です。 そこさえ押さえれば、移行しないと見えなくなる理由も、ファイルサイズの上限が変わる理由も、速くなる理由も、古い実装に戻せない理由も、すべて同じ1つの変更から導けます。移行ガイドは「テーブルが違うので手でコピーしてください」としか書いていませんが、違うのはテーブル名ではなく構造のほうです。
> この記事は「Cloudflare AgentsのWorkspaceを移行してみる」の登壇内容に、発表で話しきれなかった計測値を足したものです。対象は Cloudflare Agents を使っていて、これから @cloudflare/computer に移る人です。
---
1. 発端
2026年10月20日、Changelog が出ました。
> Think and Agent Skills now use the workspace from @cloudflare/computer as well as the filesystem tools.
> @cloudflare/shell is deprecated, and so is Think's createExecuteTool. These will no longer be developed nor supported from today onwards.
同じ日に @cloudflare/think@0.21.0、@cloudflare/computer@0.6.0、agents@0.29.0 が公開されています。@cloudflare/shell も同日に 0.5.0 が出ていて、これが非推奨を告知するリリースです。npm の deprecated フラグは過去のバージョンにも遡って付くので、0.4.3 を見ても同じ警告が出ます。
移行ガイド自体はもう少し前、10月14日の PR #2564 で入っていました。発表と同時にガイドが出たわけではありません。
そして Changelog にはこう書いてあります。
> This is a breaking change. @cloudflare/computer stores files in different tables from @cloudflare/shell, and there is no automatic data migration between workspace formats.
「テーブルが違う」。ここが出発点です。どう違うのかを見ていきます。
2. 旧 Workspace は、1ファイル1行だった
Workspace はエージェントのファイル置き場です。モデルが read write edit ls で触り、実体は Durable Object の SQLite に載っています。
旧 @cloudflare/shell の定義は移行ガイドに載っています。
CREATE TABLE cf_workspace_default (
path TEXT PRIMARY KEY,
parent_path TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('file','directory','symlink')),
mime_type TEXT NOT NULL DEFAULT 'text/plain',
size INTEGER NOT NULL DEFAULT 0,
storage_backend TEXT NOT NULL DEFAULT 'inline' CHECK(storage_backend IN ('inline','r2')),
r2_key TEXT,
target TEXT,
content_encoding TEXT NOT NULL DEFAULT 'utf8',
content TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
modified_at INTEGER NOT NULL DEFAULT (unixepoch())
);
素直です。パスが主キーで、メタデータも中身も同じ行に乗っています。content にはファイルの中身が入り、バイナリなら base64 です。
大きいファイルだけ別扱いになります。既定の閾値は 1,500,000 バイトで、これを超えると R2 に逃がし、行には r2_key だけ残ります。R2 を渡していない場合は警告を出して、そのまま inline に入れます。
if (size >= this.threshold && !r2)
console.warn(`[Workspace] File ${path} is ${size} bytes but no R2 bucket was provided. Storing inline.`);
この「1行に全部入れる」という性質が、後で響いてきます。
3. 新 Workspace は、inode とチャンクに分かれている
新 @cloudflare/computer はテーブルを分けます。
| テーブル | 持つもの |
| --- | --- |
| vfs_dirents | 親 inode + 名前 → 子 inode |
| vfs_nodes | inode のメタデータ(type / mode / mtime / size / link_target) |
| vfs_chunks | inode + 何番目のチャンクか → blob の sha256 |
| vfs_blobs | sha256 → サイズと最終参照時刻 |
| vfs_blob_bytes | sha256 → 実バイト列 |
| vfs_meta | schema_version とリビジョンカウンタ |
パスの解決は vfs_dirents をルート(inode = 1)から辿る形です。ファイルの中身は 512 KiB ごとに切られ、それぞれが sha256 をキーにした blob として vfs_blob_bytes に入ります。
const CHUNK_SIZE = 524288;
名前が vfs_ と _vfs_ で始まるので、cf_workspace_* とは衝突しません。移行の最中は両方が並んで存在できます。
ここから先は、この構造の違いから何が導けるかを1つずつ測りました。
4. 帰結①:切り替えても、旧ファイルは見えない
同じ Durable Object の上に新旧を並べました。別の DO namespace に分けると「同じストレージを使った移行」になりません。1つのクラスの中で切り替えています。
旧で13個書いてから、新を構築して読みます。
対照: 旧から読む → {found: true, length: 23}
新 Workspace を構築
新から読む /workspace/notes.md → ENOENT no such file
新から読む /notes.md → ENOENT no such file
新側のツリー → [{path: "/workspace", type: "dir"}]
新からは見えません。
ここで止めると「データが消えた」という記事になります。早い。そして違います。
対照を2本置く
「新から見えない」だけでは、そもそも書き込みが失敗していた可能性と区別できません。同じ run の中で、旧 Workspace から読めることを切り替えの前後で確認しました。
切り替え前に旧から読む → {found: true, length: 23}
切り替え後に旧から読む → {found: true, length: 23}
cf_workspace_default の行数 → 14(切り替え前と同じ)
切り替えた後も旧からは読めます。行数も減っていません。
消えてはいない。読みに行く先が変わっただけです。テーブル一覧を見ると、両方が同居しています。
cf_workspace_default
computer_execution_runtime
sqlite_sequence
vfs_blob_bytes vfs_blobs vfs_changes vfs_chunks
vfs_dirents vfs_manifests vfs_meta vfs_nodes
_vfs_fetch_cursor _vfs_mounts _vfs_push_cursor
_vfs_sync_operations _vfs_sync_skips _vfs_watermark
ついでに1つ分かりました。vfs_* は new Workspace() を呼んだ時点で全部作られます。最初の fs 操作を待ちません。schema_version は 10 でした。
5. 帰結②:1行に入れるかどうかで、上限が変わる
旧はファイルの中身を base64 にして1つのセルに入れます。4MB のファイルなら base64 で 5,592,408 文字です。
一方、Durable Objects の Limits ページにはこう書いてあります。
| 項目 | 記載値 |
| --- | --- |
| Maximum string, BLOB or table row size | 2 MB |
| Key size(KV) | Key and value combined cannot exceed 2 MB |
2 MB を超えるので、4MB のファイルは旧 Shell に保存できないはずでした。ベンチマークの設計でそう予測して、1KB・100KB・1MB・4MB を並べました。
通りました。
予測が外れました。
4MB → storage_backend=inline, content_encoding=base64,
content_len=5,592,408 → 読み戻しの SHA-256 も一致
8MB → Error: string or blob too big: SQLITE_TOOBIG
ローカルの miniflare でも、デプロイした本番の Durable Object でも同じです。
計測:本当の上限はどこか
段階的なプローブで止めると実装の定数が見えないので、ライブラリを通さず ctx.storage.sql と ctx.storage.kv を直接叩いて境界を詰めました。プローブごとに新しい Durable Object を使っています。
通る最大 8,388,627 バイト
落ちる最小 8,388,628 バイト
8 MiB は 8,388,608 です。TEXT も BLOB も KV の値も、同じところで落ちます。
ドキュメントの 2 MB とは4倍ちがいます。新しい Durable Object で境界の前後だけを叩き直して、同じ結果になることも確認しました。この件は cloudflare-docs#34502 に出しています。2 MB が保証値で 8 MiB が実装の緩さ、という可能性を潰せていないので、数値を断定する PR にはせず、参考ブランチを添えるだけにしました。
考察:なぜ旧だけ 6 MiB で止まるのか
旧 Shell 自身の境界は、素の SQL より低いところにありました。
shell.writeFileBytes が通る最大 6,291,408 バイト(base64 8,388,544 文字)
落ちる最小 6,291,409 バイト(base64 8,388,548 文字)
base64 が 4/3 に膨らむので、ファイルとしては約 6 MiB が上限です。
ただし、なぜ素の SQL(8,388,627)より手前で落ちるのかは分かりませんでした。Shell の DDL、インデックス、INSERT 文、ON CONFLICT DO UPDATE 句、そして Shell が実際に作る base64 文字列まで揃えて、同じ Durable Object で流しても、素の SQL は通ります。 UPSERT が excluded で値を二重に持つのではないかという仮説も立てましたが、行が既にある状態の UPSERT も同じサイズで通りました。
スタックトレースは Shell の INSERT を指しています。
Error: string or blob too big: SQLITE_TOOBIG
at Object.run (index.js:367:17)
at Workspace.writeFileBytes (index.js:652:22)
ここは諦めました。実用上は「旧 Shell に R2 を渡さない場合、ファイルは約 6 MiB が上限」と覚えておけば足ります。
新 Computer は 512 KiB のチャンクに割るので、この壁がありません。8MB も 16MB も 32MB も通りました。チャンク化は速度の工夫に見えますが、変わったのは上限のほうです。
6. 帰結③:毎回 base64 に変換するので、遅い
ここで1つ引っかかりました。Worker の中では Date.now() が進みません。 本番では 4MB の書き込みすら 0ms と記録されます。ローカルの miniflare では進むので、最初は本番のログを見て目を疑いました。
なので外から測り直しました。同じ操作を60回繰り返すリクエストと、0回で素通しするリクエストを投げて、その差を60で割ります。7回の中央値です。
| サイズ | 旧 Shell | 新 Computer |
| --- | ---: | ---: |
| 1KB | 1.58ms | -0.75ms |
| 100KB | 4.92ms | 0.80ms |
| 1MB | 87.25ms | 6.45ms |
1KB と 100KB は結論を出せません。負の値が出ている通り、HTTP の往復のばらつきに埋もれています。
1MB では差が出ました。13.5倍です。旧 Shell は毎回 1MB を base64 に変換して1行に入れ、読むときに戻しているので、その分が乗ります。書きと読みは分離していません。どちらが遅いかまでは言えません。
7. 帰結④:content-addressed なので、重複排除が効く
新は blob の主キーが sha256 です。同じバイト列はツリーのどこにあっても1行を共有します。
512 KiB の同じ内容を2つのパスに書いて、vfs_blobs の行数を数えました。
blobs 初期 → 0
同じ内容を1パス目 → 1
同じ内容を2パス目 → 1 ← 増えない
対照: 違う内容 → 2 ← 増える
対照を置かないと「そもそも blob が増えない実装」と区別できないので、違うバイト列で2行になることも確認しています。
旧はパスごとに content を持つので、同じファイルを2箇所に置けば2回分の容量を使います。
8. 帰結⑤:メタデータの置き場が無くなった
旧の FileInfo は mimeType createdAt updatedAt を持っています。新の WorkspaceStatResult はこれだけです。
{ name, inode, mode, mtime, size, isFile, isDirectory, isSymbolicLink }
MIME を読み書きする API が、workspace.fs のどこにもありません。時刻も mtime の1本だけです。
移行ガイドも「保持されない」と書いています。
> Things the copy does not keep:
> - created_at and modified_at. Computer stamps the time of the copy.
> - mime_type. Computer does not store a MIME type per file.
実測でも、移行後の mtime は全件コピー実行時刻になっていました。「保持されない」というより、新スキーマに置く場所がありません。
9. 帰結⑥:前には進めるが、後ろには戻れない
新はスキーマにバージョンを持ちます。初期化のコードはこうです。
const onDiskVersion = db.one("SELECT v FROM vfs_meta WHERE k = ?", "schema_version")?.v ?? 0;
if (onDiskVersion > 10) throw createWorkspaceError("EIO", `Unsupported workspace filesystem schema version ${onDiskVersion}`);
if (onDiskVersion > 0 && onDiskVersion < 10) runMigrations(db, onDiskVersion, 10);
前方への移行は自動です。v1 から v10 まで、マイグレータが9本登録されています。
後方はありません。
実際に古い版をデプロイする代わりに、on-disk の schema_version を 11 に書き換えて「このバイナリより新しい DB」を作り、同じ構築経路を通しました。
schema_version(現在) → 10
対照: 同じ版で再構築 → {read: "hello"}
schema_version を 11 に偽装
古いバイナリで開き直す → EIO Unsupported workspace filesystem schema version 11
schema_version を 10 に戻す
戻したら開けるか → {read: "hello"}
測ったのは「on-disk の schema_version がバイナリの対応版より新しいと EIO で開けない」ことです。これが「古い版にロールバックできない」を意味するのは、新しい版がスキーマを上げた場合に限ります。過去に9回上げているので、次も上がると見るのが自然です。
移行ガイドにも Changelog にも、この記述はありません。実装ドキュメントの 03_filesystem_schema.md に1行あるだけです。
> Open() refuses to run if the binary is older than the on-disk schema_version.
旧データは残るので、移行に失敗しても最悪は旧テーブルから読み直せます。戻れないほうは取り返しがつきません。
10. 移行ガイドのスクリプトを流す
構造の違いは分かりました。実際に移します。
移行ガイドに copyShellWorkspace() というコピー用のコードが載っています。旧を glob("/**") で歩いて、新の fs に書き直すだけのものです。移行先は /workspace 配下で、Think もそこを作業ディレクトリとして使います。
手を入れずにそのまま流しました。題材は、UTF-8 の日本語を含むテキスト、ネストしたディレクトリに置いた TypeScript、内容の完全一致を見るための JSON、決定的な疑似乱数で生成した 64KB のバイナリ、中身を持たない空ディレクトリ、そして相対・絶対・ルート超えを撃ち分けたシンボリックリンク4本です。
comparison → {
total: 13, missing: [], typeChanged: [],
contentMismatch: [], extra: []
}
13件すべて存在し、型も変わらず、64KB のバイナリも SHA-256 が一致しています。空ディレクトリも残りました。ローカルの wrangler dev でも、デプロイした本番でも同じ結果です。
ハッシュ比較が機能していることも確認しました。1ビットだけ変えたバイト列を用意して、ハッシュが別物になることを見ています。af2ba40f… と 73734ea1… です。比較が不一致を検出できないまま「一致した」と言っても、何も示したことになりません。
スクリプトが暗黙に頼っている前提も確かめました。コードのコメントに「glob returns rows ordered by path, so parents come before children」とあります。これが崩れると mkdir の前に writeFile が走ります。実測では辞書順でソートされていて、親は必ず子より先に来ていました。
ハマりポイント:シンボリックリンクだけ、別の扱いが要る
リンクを個別に辿ってみると、1本だけ落ちました。
| リンク | Shell でのリンク先 | 移行後のリンク先 | 辿れるか |
| --- | --- | --- | --- |
| /links/rel.md | ../notes.md | /workspace/notes.md | 辿れる |
| /links/abs.md | /notes.md | /workspace/notes.md | 辿れる |
| /links/dotdot.md | /src/../notes.md | /workspace/src/../notes.md | 辿れる |
| /links/esc.md | /../notes.md | /workspace/../notes.md | ENOENT |
/workspace/../notes.md は /notes.md に解決されます。移行先の /workspace の外です。
原因はスクリプトの resolveTarget() にあります。
// Shell resolves a relative target against the link's directory and never
// goes above `/`.
function resolveTarget(link: string, target: string): string {
if (target.startsWith("/")) return target; // 絶対パスは素通し
const parts = link.split("/").slice(1, -1);
for (const part of target.split("/")) {
if (part === "..") parts.pop();
else if (part !== "" && part !== ".") parts.push(part);
}
return `/${parts.join("/")}`;
}
@cloudflare/shell の本体(resolveSymlink)は、絶対パスも normalizePath に通します。normalizePath は .. をルートで止めるので、Shell にとって /../notes.md は /notes.md です。スクリプトはそれを素通しするので、/workspace を前置したときに外へ出ます。
条件は狭いです。相対パスは全件正しく、. や // を含む絶対パスも、ルートを超えない .. を含む絶対パスも正しく移ります。崩れるのは「絶対パスで、かつルートより上に登る .. を含む」場合だけです。
そして、これはエラーになりません。symlink() はリンク先を検証せずに保存し、辿るときも例外を投げずルートで止まります。POSIX のダングリングリンクが合法なのと同じです。コピーは正常終了します。
パス・型・ハッシュの照合では捕まりません。 リンクは存在するので欠落にならず、型は symlink のままなので型違いにもならず、中身を持たないのでハッシュ比較の対象にすら入りません。実際、私の照合スクリプトも missing: [], contentMismatch: [] を返しました。確かめるなら、辿って読むしかありません。
await to.fs.readFile(linkPath, "utf8"); // 辿れなければ ENOENT
もう1つ、再実行も止まります。
1回目の移行 → ok
2回目の移行 → EEXIST path exists: /workspace/links/abs.md
mkdir({ recursive: true }) は既存ディレクトリなら黙って返ります。writeFile は exclusive を指定しない限り上書きします。symlink(target, path) には force に相当するオプションがなく、パスが既にあれば必ず EEXIST を投げます。Node の fs.symlink と同じ挙動で、ライブラリ側のバグではありません。
ガイドの onStart の例はコピー完了後にフラグを立てるので、途中で落ちるとフラグが立たず、起動のたびに同じリンクで失敗します。張る前に消せば済みます。
const link = at(entry.path);
if (await to.fs.readlink(link).then(() => true, () => false)) {
await to.fs.rm(link);
}
await to.fs.symlink(at(target), link);
この2点は cloudflare/agents#2645 に出しました。出す前に重複を調べたら、どちらも過去のレビューで指摘されていました。片方は PR の差し替えで引き継がれず、もう片方は「絶対パスは既に rebase されている」という誤った前提のまま解決済みになっていました。新発見として書かずに済んだのは、調べたからです。
シンボリックリンクを含まない Workspace なら、ここは踏みません。
11. 永続化は保たれている
保存形式が変わっても、Durable Object の SQLite に載っている点は同じです。再起動をまたいで残るかを確かめました。
「別のリクエストから読めた」だけでは証拠になりません。同じインスタンスがメモリに残っていて、そこから返しただけかもしれないからです。コンストラクタで crypto.randomUUID() を1つ生成して、レスポンスに載せました。値が変わっていれば、新しい incarnation です。
bootId 読めたか
① 書く 1cadfbdd-7595-4879-94c1-43abca4688c0 ok
② 直後に読む 1cadfbdd-7595-4879-94c1-43abca4688c0 ok ← 同じ
③ 15秒後 e4f5f2ab-3d7d-4389-8e2d-ee32fdd97b02 ok ← 変わった
④ 150秒後 c602f7f1-c9f6-465e-9b36-0c3259706d64 ok ← また変わった
③と④は別の incarnation から読めています。④の中身には書いたときの古い bootId が入っていたので、同じデータだと示せます。
記録するフィールドは最初から決めておきました。判定を書き戻すための C 番号、再起動の有無を示す bootId、ローカルか本番かを示す env、ファイルの同一性を示す sha256 と size、操作そのものにかかった時間、そして例外を投げずに結果として持つ ok と error です。
この対照は実際に仕事をしました。再デプロイ後に読めるかを試したとき、1回目は待たずに読んで成功したのですが、bootId が同じでした。DO は旧コードのままメモリに居座っていて、再起動していません。対照が無ければ「再デプロイしても残る」と書いて終わっていました。15秒待ってから取り直して、bootId が変わったうえで読めることを確認しています。
計測:hibernate は何秒か
待ち時間を変えて bootId を見ました。対照は待ち0秒です。
待ち(s) 再起動 読めた
0 しない ok ← 対照
3 しない ok
6 しない ok
9 しない ok
11 する ok
13 する ok
15 する ok
境界は9秒と11秒の間にあります。ドキュメントの「無活動10秒で hibernate」と一致しました。
12. 移行する前にやること
- シンボリックリンクがあるか数える。 glob("/**") して type === "symlink" を数えるだけです。0本なら公式スクリプトをそのまま使って構いません
- リンクがあるなら、移行後に辿って読む。 パス・型・ハッシュの照合では捕まりません
- 6 MiB を超えるファイルがないか確認する。 旧で R2 に逃がしていたなら、移行時も同じ r2 と name を渡します
- 古い実装に戻せない前提でデプロイする。 新スキーマを一度書いたら、その Durable Object は古い @cloudflare/computer では開けません
- 旧データはすぐ消さない。 旧テーブルは残るので、照合が終わるまで DROP TABLE を待ちます
依存の形も変わっています。旧 @cloudflare/think@0.20.2 では @cloudflare/shell が直接依存で、自分でインストールするものではありませんでした。0.21.0 では @cloudflare/computer が peer dependency になります。「上げたら Workspace が空になった」が起きる構造が、ここにそのまま出ています。
13. まとめ
変わったのは保存形式です。そこから全部が導けました。
- テーブルが別 → 移行せずに切り替えると見えない。ただし旧テーブルは残り、旧 Workspace からは読める
- 1行に全部入れる / チャンクに割る → 旧は約 6 MiB で頭打ち。新は 32MB でも通る
- 毎回 base64 に変換する → 1MB の読み書きで 13.5 倍の差
- blob が content-addressed → 同じ内容を2箇所に置いても1行で済む
- メタデータの列が減った → mime_type と created_at は置く場所がない
- スキーマにバージョンがある → 前には自動で進み、後ろには戻れない
移行ガイドは「テーブルが違うので手でコピーしてください」としか書いていません。違うのはテーブル名ではなく構造のほうで、そこを見ておくと、移行のときに何を確かめればいいかが先に分かります。
ドキュメントとずれていた点も2つ出ました。Durable Object SQLite の値の上限は 2 MB ではなく 8 MiB でしたし、新スキーマを書いた Durable Object が古い実装で開けないことは、ユーザー向けのどこにも書かれていません。
一番こたえたのは、4MB が通ったところです。公式の数字から予測を立てて、外しました。前提を公式に置いていたぶん、崩れたときに他の予測まで信用できなくなって、上限も速度も結局は自分で測り直しています。移行そのものより、そっちに時間を使いました。
参考
- Think agents now use a @cloudflare/computer workspace — 切り替えの告知。自動移行が無いことと createExecuteTool の非推奨
- Migrating from @cloudflare/shell to @cloudflare/computer — 新旧両方の DDL と、10章で流したコピースクリプト
- cloudflare/computer docs/03_filesystem_schema.md — vfs_* の全テーブル定義。チャンクサイズとスキーマバージョンの扱い
- Durable Objects — Limits — 行サイズ 2 MB の記載元。5章で突き合わせた相手
- Durable Objects — Lifecycle — hibernate 10秒、eviction 70〜140秒の出典
- cloudflare/computer#217 / #219 — R2 への書き出しと namespace が未対応であることの自己申告
- cloudflare/agents#2645 — 10章のシンボリックリンクの件を報告したもの
@mattzcarey agents#2209 could you take a look at that, would be awesome
46 views
Why probabilistic AI review bots fail at catching AI agent bugs—and how we enforce deterministic AST invariants & 12 CWE security shields in GitHub Actions
>TL;DR: AI coding agents increased raw PR volume by 300%, yet overall merge velocity has dropped by 40% because Senior Engineers spend 15–20 hrs/week untangling 200-line hallucinated wrappers, deleted tests, and subtle CWE security flaws. After shipping patches into tier-1 upstream systems repos (LLVM #215076, Meta Velox #18529, DuckDB #26443, Apache DataFusion #24394, Microsoft Pyright #11605, Tokio #8595, ClickHouse #114954, OpenAI Triton #12221), we codified those exact deterministic CI gates into an open GitHub Actions workflow (ZeroSlop · Architecture & Simulator) that strips 94% of AI bloat, enforces 12 CWE security shields, and gets PRs merged 100% green in minutes.
1. The 2026 Engineering Bottleneck: Why AI PRs Stall in Review
When shipping patches into upstream systems repos like LLVM, Meta Velox, DuckDB, or Tokio, a real production fix is 4 to 15 lines of surgical invariant changes paired with a deterministic regression test that passes CI on the first push.
Inside commercial teams using Cursor, Copilot, or Claude Code, the opposite happens:
- 94% Diff Bloat: An agent generates a 240-line wrapper class instead of fixing a 4-line boundary condition (interactive diff breakdown).
- Weakened Tests: When a test fails, the agent silently deletes or loosens the assertion to manufacture a green check.
- Noisy LLM Review Bots: Conversational AI review bots run another LLM to dump 25 chatty comments on top of a bloated PR—without shrinking the diff or getting the PR merged.
2. Conversational LLM Review Bots vs. Deterministic PR Airlock
|Engineering Dimension|Conversational AI Review Bots|Deterministic PR Airlock (ZeroSlop)|
|:-|:-|:-|
|Primary Objective|Leave 25+ chatty LLM comments on a bloated diff|Get PRs merged 100% green in minutes (not 4 days)|
|Diff Minimization|Reviews 200 lines of hallucinated wrappers as-is|Strips up to 94% of AI bloat down to the core AST invariant|
|CI Failure Triage|Ignores red CI checks or hallucinates fixes|Separates contributor code defects from runner flakes|
|Test Integrity|Misses deleted or weakened test assertions|Hard-blocks unauthorized regression test deletions|
|PR Comment Hygiene|Spams new threads on every git push|Strictly 1 self-updating PR Verdict Card + Mermaid Graph|
|Security & Privacy|Cloud LLM retention & external dashboards|12-Shield CWE Airlock + Zero Code Retention|
3. The 12-Shield Deterministic CWE Security Airlock (ZS-SEC-001 – ZS-SEC-012)
Every Pull Request is audited in milliseconds across 8 merge-readiness signals (0–100) and 12 deterministic CWE security shields before a human reviewer is pinged:
|Shield ID|Security Vector & CWE / CVE Mapping|What ZeroSlop Security Deterministically Intercepts|
|:-|:-|:-|
|ZS-SEC-001|SQL & Vector Injection (CWE-89 / CWE-943)|Unparameterized SQL/NoSQL/Vector template literals & raw string concatenation|
|ZS-SEC-002|Hardcoded & Seeded Secrets (CWE-798 / CWE-256)|API keys, JWT secrets, seeded credentials & plaintext password comparisons|
|ZS-SEC-003|Unbound Identity / IDOR (CWE-639 / CWE-862)|Trusting req.body.userId instead of verified session/JWT caller identity|
|ZS-SEC-004|Command & Eval Injection (CWE-78 / CWE-94)|Unsanitized child_process.exec, os.system, subprocess(shell=True), eval()|
|ZS-SEC-005|Silent Exception Swallowing (CWE-390)|Empty catch () {} blocks and swallowed promise rejections that hide crashes|
|ZS-SEC-006|XSS & Unsanitized DOM (CWE-79)|dangerouslySetInnerHTML, .innerHTML =, and unescaped template rendering|
|ZS-SEC-007|Unsafe Deserialization (CWE-502 / CWE-22)|pickle.loads, yaml.load without SafeLoader, and ../ path traversal|
|ZS-SEC-008|SSRF & Disabled TLS (CWE-918 / CWE-295)|Unvalidated fetch(req.body.url) & NODE_TLS_REJECT_UNAUTHORIZED = '0'|
|ZS-SEC-009|CI/CD "Pwn Requests" (CWE-426 / CWE-250)|pull_request_target fork checkouts & ${{ github.event.pull_request.title }} injection|
|ZS-SEC-010|AI Package "Slopsquatting" (CWE-829 / CWE-494)|Hallucinated npm/PyPI/Cargo packages, unpinned Git deps & `curl|
|ZS-SEC-011|Resource Exhaustion / DoS (CWE-400 / CWE-770)|while(true) without exit bounds, leaked setInterval, unbounded Promise.all|
|ZS-SEC-012|Trojan Source & Jailbreaks (CVE-2021-42574)|Invisible Unicode Bidi characters & hidden IGNORE PREVIOUS INSTRUCTIONS comments|
4. Upstream Systems Proof (Click Any PR to Inspect the Diff)
These deterministic CI invariants come directly from shipping merged & 100% green Pull Requests across upstream systems repositories:
|Upstream Repository|Live Pull Request|Status|Core Systems Invariant & Regression Coverage|
|:-|:-|:-|:-|
|LLVM Compiler Infrastructure|llvm/llvm-project#215076|MERGED|Fixed Waitcnt brackets in GFX12+ AMDGPUAsmPrinter with MC regression tests|
|Meta Velox Execution Engine|facebookincubator/velox#18529|MERGED|Aligned DecimalUtil::toByteArray overflow bounds & restored kMaxDecimalBytes|
|DuckDB Analytical Engine|duckdb/duckdb#26443|MERGED|Fixed pragma_storage_info internal compressed segment type alignment|
|Apache DataFusion Query Engine|apache/datafusion#24394|MERGED|Fixed lead()/lag() window frame boundaries with non-NULL default values|
|Microsoft Pyright Type Checker|microsoft/pyright#11605|MERGED|Resolved type narrowing across nested loop closures & pattern guards|
|Tokio Async Rust Runtime|tokio-rs/tokio#8595|100% GREEN|Preserved Coop budget accounting across JoinSet & FuturesUnordered|
|ClickHouse OLAP DBMS|ClickHouse/ClickHouse#114954|100% GREEN|Fixed StorageURL distributed cluster header forwarding & serialization|
|OpenAI Triton GPU Compiler|triton-lang/triton#12221|100% GREEN|Enforced strict tensor descriptor stride & block shape invariants|
|Cloudflare Agents SDK|cloudflare/agents#1248|MERGED|Hardened state synchronization & WebSocket hibernation lifecycle invariants|
5. Architecture Documentation & GitHub Action Source
- Interactive PR Simulator & Architecture: ribbsaetersystems.com
- 12-Shield CWE & Zero-Retention Security Matrix: ribbsaetersystems.com
- GitHub Action Workflow: github.com/marketplace/actions/zeroslop-by-ribbsa…
- GitHub Action Repository: patrickswedish/zeroslop-action
- Technical Deep-Dive Writeup: ribbsaetersystems.com
What deterministic CI gates or pre-review checks are your teams enforcing in 2026 to get PRs merged cleanly without burning out your staff engineers?
2 comments
We packaged our upstream LLVM, Meta Velox & DuckDB workflow into a GitHub Action (ZeroSlop) that strips 94% of AI diff bloat and blocks 12 CWE security flaws
>TL;DR: AI coding agents increased raw PR volume by 300%, yet overall merge velocity has dropped by 40% because Senior Engineers spend 15–20 hrs/week untangling 200-line hallucinated wrappers, deleted tests, and subtle CWE security flaws. At Ribbsaeter Systems, we packaged our upstream systems workflow (LLVM #215076, Meta Velox #18529, DuckDB #26443, Apache DataFusion #24394, Microsoft Pyright #11605, Tokio #8595, ClickHouse #114954, OpenAI Triton #12221) into a deterministic GitHub Actions PR Airlock called ZeroSlop (GitHub Marketplace · Security Brief) that strips 94% of AI bloat, enforces 12 CWE security shields, and gets PRs merged 100% green in minutes.
1. Why We Built ZeroSlop: The 2026 PR Review Bottleneck
When shipping patches into upstream systems repos like LLVM, Meta Velox, DuckDB, or Tokio, a real production fix is 4 to 15 lines of surgical invariant changes paired with a deterministic regression test that passes CI on the first push.
Inside commercial teams using Cursor, Copilot, or Claude Code, the opposite happens:
- 94% Diff Bloat: An agent generates a 240-line wrapper class instead of fixing a 4-line boundary condition (try the live interactive diff simulator here).
- Weakened Tests: When a test fails, the agent silently deletes or loosens the assertion to manufacture a green check.
- Noisy LLM Review Bots: First-gen AI review bots run another LLM to dump 25 conversational comments on top of a bloated PR—without shrinking the diff or getting the PR merged.
2. Conversational AI Review Bots vs. ZeroSlop End-to-End PR Airlock
|Engineering Dimension|Conversational AI Review Bots|ZeroSlop End-to-End PR Airlock|
|:-|:-|:-|
|Primary Objective|Leave 25+ chatty LLM comments on a bloated diff|Get PRs merged 100% green in minutes (not 4 days)|
|Diff Minimization|Reviews 200 lines of hallucinated wrappers as-is|Strips up to 94% of AI bloat down to the core AST invariant|
|CI Failure Triage|Ignores red CI checks or hallucinates fixes|Separates contributor code defects from runner flakes|
|Test Integrity|Misses deleted or weakened test assertions|Hard-blocks unauthorized regression test deletions|
|PR Comment Hygiene|Spams new threads on every git push|Strictly 1 self-updating PR Verdict Card + Mermaid Graph|
|Security & Privacy|Cloud LLM retention & external SaaS portals|12-Shield CWE Airlock + Zero Code Retention|
3. The 12-Shield Deterministic CWE Security Airlock (ZS-SEC-001 – ZS-SEC-012)
Every Pull Request is audited in milliseconds across 8 merge-readiness signals (0–100) and 12 deterministic CWE security shields before a human reviewer is ever pinged:
|Shield ID|Security Vector & CWE / CVE Mapping|What ZeroSlop Security Deterministically Intercepts|
|:-|:-|:-|
|ZS-SEC-001|SQL & Vector Injection (CWE-89 / CWE-943)|Unparameterized SQL/NoSQL/Vector template literals & raw string concatenation|
|ZS-SEC-002|Hardcoded & Seeded Secrets (CWE-798 / CWE-256)|API keys, JWT secrets, seeded credentials & plaintext password comparisons|
|ZS-SEC-003|Unbound Identity / IDOR (CWE-639 / CWE-862)|Trusting req.body.userId instead of verified session/JWT caller identity|
|ZS-SEC-004|Command & Eval Injection (CWE-78 / CWE-94)|Unsanitized child_process.exec, os.system, subprocess(shell=True), eval()|
|ZS-SEC-005|Silent Exception Swallowing (CWE-390)|Empty catch () {} blocks and swallowed promise rejections that hide crashes|
|ZS-SEC-006|XSS & Unsanitized DOM (CWE-79)|dangerouslySetInnerHTML, .innerHTML =, and unescaped template rendering|
|ZS-SEC-007|Unsafe Deserialization (CWE-502 / CWE-22)|pickle.loads, yaml.load without SafeLoader, and ../ path traversal|
|ZS-SEC-008|SSRF & Disabled TLS (CWE-918 / CWE-295)|Unvalidated fetch(req.body.url) & NODE_TLS_REJECT_UNAUTHORIZED = '0'|
|ZS-SEC-009|CI/CD "Pwn Requests" (CWE-426 / CWE-250)|pull_request_target fork checkouts & ${{ github.event.pull_request.title }} injection|
|ZS-SEC-010|AI Package "Slopsquatting" (CWE-829 / CWE-494)|Hallucinated npm/PyPI/Cargo packages, unpinned Git deps & curl | bash|
|ZS-SEC-011|Resource Exhaustion / DoS (CWE-400 / CWE-770)|while(true) without exit bounds, leaked setInterval, unbounded Promise.all|
|ZS-SEC-012|Trojan Source & Jailbreaks (CVE-2021-42574)|Invisible Unicode Bidi characters & hidden IGNORE PREVIOUS INSTRUCTIONS comments|
4. Battle-Tested Upstream Proof (Click Any PR to Inspect the Diff)
ZeroSlop codifies the exact zero-slop invariant discipline used to ship merged & 100% green Pull Requests across tier-1 systems repositories:
|Upstream Repository|Live Pull Request|Status|Core Systems Invariant & Regression Coverage|
|:-|:-|:-|:-|
|LLVM Compiler Infrastructure|llvm/llvm-project#215076|MERGED|Fixed Waitcnt brackets in GFX12+ AMDGPUAsmPrinter with MC regression tests|
|Meta Velox Execution Engine|facebookincubator/velox#18529|MERGED|Aligned DecimalUtil::toByteArray overflow bounds & restored kMaxDecimalBytes|
|DuckDB Analytical Engine|duckdb/duckdb#26443|MERGED|Fixed pragma_storage_info internal compressed segment type alignment|
|Apache DataFusion Query Engine|apache/datafusion#24394|MERGED|Fixed lead()/lag() window frame boundaries with non-NULL default values|
|Microsoft Pyright Type Checker|microsoft/pyright#11605|MERGED|Resolved type narrowing across nested loop closures & pattern guards|
|Tokio Async Rust Runtime|tokio-rs/tokio#8595|100% GREEN|Preserved Coop budget accounting across JoinSet & FuturesUnordered|
|ClickHouse OLAP DBMS|ClickHouse/ClickHouse#114954|100% GREEN|Fixed StorageURL distributed cluster header forwarding & serialization|
|OpenAI Triton GPU Compiler|triton-lang/triton#12221|100% GREEN|Enforced strict tensor descriptor stride & block shape invariants|
|Cloudflare Agents SDK|cloudflare/agents#1248|MERGED|Hardened state synchronization & WebSocket hibernation lifecycle invariants|
5. Live Interactive PR Simulator & GitHub Action Links
- Interactive PR Simulator & Product Hub: ribbsaetersystems.com
- 12-Shield CWE & Zero-Retention Security Matrix: ribbsaetersystems.com
- GitHub Marketplace: github.com/marketplace/actions/zeroslop-by-ribbsa…
1 comment
Mentions
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 の設計記事 — チェックポイントとタスク実行の設計
@makisuo @Cloudflare @foldkit using PiHarness + sandbox escalation when needed would be cool github.com/cloudflare/agents/tree/main/examples/n…
Cloudflare AgentsのAI Providerが統合されたので検証してみる > 落ちたら切り替わる、と思っていました。切り替わるのは、本文が 1 文字も出ていないうちだけでした。 この記事の結論 Cloudflare Agents の createAI が持つ fallback を、偽の AI binding で 86 ケース、実機で 1 回測りました。ストリームが壊れたときの守りは 3 層あり、一番外側の手当てだけが抜けていました。 - fallback は出力が始まるまで。 ツール名が届いた段階ならまだ切り替わり、本文が 1 文字出た時点で確定します - その先は resume が受け持つ。 ただしカタログモデルと dynamic route だけで、Workers AI には run id が無いので誰も守りません - resume が諦めたときのエラーは、ドキュメント通りに書くと拾えません。 公開されている CloudflareAIError とは別のクラスが飛びます | 壊れた場所 | 守る層 | | --- | --- | | 出力が始まる前 | createAI の fallback | | ツール名だけ届いた | 同上。まだ間に合う | | 本文が 1 文字出た後 | fallback は手を引く | | 出力後・run id あり | AI Gateway の resume | | 出力後・Workers AI | 誰も守らない | | resume が尽きた | 誰も。しかもエラーが拾えない | 検証コードはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s… そして、この記事で一番伝えたいことを先に書いておきます。 fallback を 1 行書いた時点で、守られていると思ってはいけません。 守備範囲は「モデルが口を開くまで」です。開いた後に壊れた場合は別の仕組みが引き継ぎ、その仕組みにも上限があり、上限に達したときのエラーは型が違っていて catch で拾えませんでした。最後の 1 件は Cloudflare に報告し、別の方が独立に再現して同じ修正に辿り着いています。 > この記事は LT「Cloudflare AgentsのAI Providerが統合されたので検証してみる」の内容に、発表で話しきれなかった計測と実装の読み解きを足したものです。対象は、Cloudflare Workers で LLM を呼んでいて fallback を書いている開発者です。 --- 1. 何が統合されたのか 2026 年 10 月 21 日、Cloudflare が Agents SDK の AI Provider を createAI に一本化したと発表しました。旧 2 つは非推奨です。 これはレジストリから確認できます。 curl -s https://registry.npmjs.org/workers-ai-provider \ | python3 -c "import json,sys; d=json.load(sys.stdin); lt=d['dist-tags']['latest']; \ print(lt, d['time'][lt][:10], repr(d['versions'][lt].get('deprecated')))" 4.0.1 2026-10-20 'Deprecated. Use createAI from agents/models/ai-sdk...' createAI は 4 通りのモデル指定を受け付けます。@cf/ で始まる Workers AI のモデル、<author>/<model> の形をした AI Gateway カタログのモデル、dynamic/ で始まる dynamic route、ベンダー製パッケージが作るモデルオブジェクトです。 そして fallback が 1 行で書けるようになりました。 const result = await generateText({ model: ai("@cf/zai-org/glm-4.7-flash", { fallback: ["anthropic/claude-opus-5.5"], }), prompt: "Hello", }); 便利です。ただ、ここで引っかかりました。「落ちたら」って、どこからでしょうか。 ストリーミングだと、途中まで文章が出てから切れます。そのとき切り替わるのか。切り替わらないのか。ドキュメントにはこうあります。 > A request that the vendor rejects falls back. So does a stream that fails before its first output part. 「first output part」がどこを指すのかは書いてありません。 脇道:「統合」には移植されなかった機能がある 本筋から外れますが、1 つだけ。Issue #2569 が移行チェックリストになっていて、2 項目に取り消し線が引かれています。サーバーサイドの fallback と safePrompt です。 前者の理由は「Universal Endpoint の機能だったから」でした。旧 createAiGateway([a, b]) は 1 リクエストを投げて Gateway 側がモデルを選んでいましたが、createAI の fallback は Worker 内でモデルごとに 1 リクエストずつ投げます。投げる数が変わります。 脇道はここまでです。 2. 偽の binding で測る 境界を測るなら、狙った位置でストリームを切れないと話になりません。本物のモデルではできません。 そこで createAI が binding に何を要求しているのかを読みました。models/core/transport.ts を見ると、必要なのは 3 つだけです。run(model, input, options) が Response を返すこと、fetch(url, init) が Response を返すこと、aiGatewayLogId プロパティを持つこと。 つまり偽物を渡せば動きます。 const binding = { aiGatewayLogId: "01LOGID", async fetch() { return new Response("gone", { status: 404 }); }, async run(model, input, options) { return new Response(sseBody, { headers: { "content-type": "text/event-stream" }, }); }, }; const ai = createAI({ binding: binding as never }); これで fallback の状態機械がローカルで動きます。アカウントも課金も要りません。 肝心なのは本文の作り方です。チャンクを 1 つずつ pull で出し、尽きたところでエラーにします。 function dropAfter(chunks: Chunk[]): ReadableStream<Uint8Array> { const enc = new TextEncoder(); let i = 0; return new ReadableStream({ pull(controller) { if (i < chunks.length) { controller.enqueue(enc.encode(`data: ${JSON.stringify(chunks[i++])}\n\n`)); return; } controller.error(new Error("connection dropped")); }, }); } chunks に何を渡すかで、切れる位置が決まります。{ choices: [{ delta: { content: "h" } }] } を入れれば本文が 1 文字出てから切れ、{ choices: [{ delta: { tool_calls: [{ function: { name: "x", arguments: "" } }] } }] } を入れればツール名だけ出てから切れます。この差が、そのまま次の節の表になります。 実装を読むのに使った手も書いておきます。agents は dist に sourcemap を同梱していて、sourcesContent に元の TypeScript が丸ごと入っています。 curl -sL https://registry.npmjs.org/agents/-/agents-0.30.0.tgz -o agents.tgz tar -xzf agents.tgz -C pkg python3 -c " import json m = json.load(open('pkg/package/dist/ai-sdk-DRPh2rRa.js.map')) for i, s in enumerate(m['sources']): open(s.split('/')[-1], 'w').write(m['sourcesContent'][i]) " GitHub を読むより確実です。実際に npm から配られている版のソースが出てきます。 なお、この偽 binding という手口は後から Cloudflare の公式テストでも使われていると分かりました。packages/agents/src/tests/models/ai-sdk/fallback-and-errors.test.ts が同じことをしています。 3. fallback の境界 計測:切る位置だけを変える primary を「指定位置まで流して接続断」、fallback を「正常に答える」に固定し、切断する位置だけを動かしました。 | 切断した位置 | fallback | | --- | --- | | 何も出さずに切断 | する | | response-metadata のみ | する | | ツール名だけ(tool-input-start) | する | | ツールの引数が出た | しない | | reasoning-delta まで | しない | | 本文 1 文字(text-delta) | しない | ツール名までなら間に合い、本文が 1 文字出たら手遅れでした。 fallback が起きたとき、落ちたモデルのパートは利用者に 1 つも届きません。答えたモデルのプリアンブルに丸ごと差し替わります。途中まで出た文章が残って、その後ろに別のモデルの答えが続く、ということにはなりません。 この結果は 2 つの失敗モードで確かめました。接続が切れる場合と、SSE の中に error が載る場合です。実装上は別の分岐を通ります。モデル ID も本文も変えました。線は同じでした。 考察:なぜそこが境界なのか 実装に PREAMBLE_PARTS という集合があります。 const PREAMBLE_PARTS = new Set([ "stream-start", "response-metadata", "text-start", "reasoning-start", "tool-input-start", "raw" ]); 「ここまでなら、まだ何も出力していない」という判定です。6 種類あります。 ところが chat-completions の wire を読むと、6 種類のうち単独で出せるのは 3 つだけでした。 const content = text(delta.content); if (content !== undefined && content !== "") { if (textId === undefined) { controller.enqueue({ id: textId, type: "text-start" }); // ← 続けて } controller.enqueue({ delta: content, id: textId, type: "text-delta" }); } 空文字が弾かれるので、text-start の直後には必ず text-delta が続きます。reasoning-start も reasoning-delta と対です。 一方、ツール呼び出しは引数のガードが別になっています。 if (input !== undefined && input !== "") { controller.enqueue({ delta: input, id: state.id, type: "tool-input-delta" }); } 名前だけ届いて引数が空なら、tool-input-start が単独で出ます。だからここが、切り替われる最後の地点になります。 並べるとこうなります。 | preamble パート | 単独で出せるか | 理由 | | --- | --- | --- | | stream-start | 出せる | 先頭に 1 つ出る | | response-metadata | 出せる | 最初のチャンクの id と model から出る | | text-start | 出せない | text-delta と同じチャンクから続けて出る | | reasoning-start | 出せない | reasoning-delta と対で出る | | tool-input-start | 出せる | 引数が空なら tool-input-delta が出ない | | raw | 出せる | includeRawChunks を立てたときだけ | 6 つのうち 2 つは、この wire では到達できません。集合の定義を読むだけだと「text-start の後もまだ間に合う」と読めてしまいますが、実際には試せません。 text-start が集合に入っているのは、ベンダーのモデルオブジェクト向けと読めます。Anthropic の provider は content_block_start と content_block_delta を別のイベントで出すので、そちらなら単独で到達できるはずです。ただしベンダーモデルでは測っていないので、ここは実装の読みに留まります。 公式テストは「出力前」と「出力後」の 2 つしか試していません。ツール名だけの状態は扱われていません。 4. 出力が始まった後は resume が受け持つ fallback が手を引いた後、何も無いわけではありません。 AAA まで流して切断し、再接続して BBB を受け取ると、利用者が手にするのは AAABBB です。取りこぼしも重複もありません。そしてこのとき fallback は 1 度も呼ばれません。 重複しない理由は、再接続の単位が SSE イベントだからです。実装のコメントにこうあります。 > The one subtlety is alignment. from counts complete SSE events, so the wrapper only ever passes complete events on and holds back a trailing partial one. After a drop the partial is discarded and the replay starts at the count of events already delivered: no byte is duplicated or lost. 完全なイベントだけを下流に渡し、途中まで届いたイベントは握っておく。切れたらその半端な分を捨てて、すでに渡した個数から再開する、という作りです。 実際に偽 binding で fetch の URL を記録すると、渡した個数がそのまま from= に乗っているのが見えます。イベントを 2 つ流して切れば ?from=2、3 つなら ?from=3 でした。 役割が分かれています。fallback は「開けなかったとき」。resume は「開いた後に壊れたとき」です。 ただし resume は cf-aig-run-id ヘッダが無いと働きません。実機で確かめたところ、Workers AI のストリームには run id が付きませんでした。守備範囲はカタログモデルと dynamic route だけです。 実機の値も出しておきます。wrangler dev --remote で @cf/meta/llama-3.2-1b-instruct を 1 回叩いた結果です。 | 項目 | 値 | | --- | --- | | 最初のパートまで | 328 ms | | パート数 | 32 | | providerMetadata.cloudflare のキー | 8 個 | | runId | 載らない | | provider | 載らない | 載っていたキーは cacheStatus / eventId / gateway / logId / model / requestId / step / traceId です。1 回しか測っていないので、328 ms は分布ではなく 1 本ぶんの値として読んでください。 ハマりポイント①:対照が通らない回の観測は捨てる 実機の 1 回目は、先頭 2 件が 500 で落ちました。provider-error、isRetryable: true です。後続のリクエストは通っていたので、立ち上がりの一時的な失敗だと思います。 このとき、後続の結果だけ拾いたくなります。やめました。先頭の正常系が通っていない回は、その後の観測が何に対する値なのか言えません。Worker を直して測り直し、先頭と末尾の両方で正常系が通った回の結果だけを採用しています。 5. resume の限界と、拾えないエラー resume 側も毎回落とすようにして数えたところ、5 回で諦めました。 実装の定数は resumableBody の options.maxReconnects ?? 5 です。ソースのコメントには「Reconnects allowed before giving up. Defaults to 5.」とあります。公開オプションには無いので、呼び出し側から変えることはできません。 諦めたときのメッセージはこうなります。 The stream dropped 6 times; giving up at event 7. ドキュメントは「after five drops」と書いていますが、実際はドロップ 5 回を生き延びて 6 回目で落ちます。 そして、ここからが本題です。ドキュメントはこう約束しています。 > The provider throws CloudflareAIError, which extends the AI SDK's APICallError, for Workers AI failures, AI Gateway failures, failed stream resumes, and a fallback chain where every model failed. 同じ import、同じ streamText で、失敗のさせ方だけを変えて比べました。 | | 通常の失敗 | resume の失敗 | | --- | --- | --- | | e.name | CloudflareAIError | CloudflareAIError | | instanceof CloudflareAIError | true | false | | APICallError.isInstance() | true | false | | code / status | 正しい | 正しい(provider-error / 404) | 名前が同じで、型だけが違います。ログでは気づけません。 原因は、同じ名前のクラスが 2 つあることでした。models/core/errors.ts の方は extends Error で、公開されていません。models/ai-sdk/errors.ts の方は extends APICallError で、こちらが公開されています。そして core/resume.ts は前者を import しています。 import { CloudflareAIError } from "./errors"; しかもそのエラーを投げるのは、run() が返った後、利用者がストリームを読んでいる最中です。run() の catch に置かれた変換は、その時点ではもう通り過ぎています。 async run(request) { try { return await inner.run(request); // レスポンスが来た時点で解決する } catch (error) { throw toAISDKError(error); // body 読み出し中の失敗は見えない } }, この関数の doc コメントには「with every failure lifted into the AI SDK error type」と書かれています。その約束から、ストリームを読んでいる最中の失敗だけが漏れていました。 困るのは 2 つです。ドキュメントのサンプル通りに instanceof で分岐すると、resume の失敗だけ素通りします。APICallError でもないので、AI SDK の retry ループが isRetryable を読めません。 セキュリティの問題でも可用性の問題でもありません。エラーの分類が取れないだけです。 6. なぜ誰も気づかなかったか 公式テストが内部クラスを import しているからです。 import { CloudflareAIError } from "../../../models/core/errors"; 投げられているのはまさにそのクラスです。toBeInstanceOf は常に通ります。公開 API としての契約を見ているテストが、1 つもありません。 これは Cloudflare に限った話ではないはずです。内部実装を import したテストは、公開 API の約束を守りません。 自分のコードでも起こります。 ついでに書くと、設計 RFC(リポジトリ内の design/rfc-workers-ai-gateway-merge.md)と出荷版にも差がありました。RFC は layer / transport / index / startedAt を持つ attempt tree を設計していましたが、出荷されたのは { model, error } の 2 フィールドだけです。「デバッグで一番聞かれるのはどの層が再試行したかだ」と書いて設計された構造が、2 フィールドの配列になって出ています。 7. streamRetries を足すと AI SDK v7 に streamRetries があります。既定は 0 で、省略すると stream retry そのものが無効になります。「出力が始まった後のプロバイダーエラーを再試行する」オプションです。 createAI のドキュメントと AI SDK のドキュメントは、互いにこのオプションに触れていません。重なり方は測るしかありませんでした。 primary を「本文を出してから error」に固定して測りました。 | streamRetries | primary 呼び出し | 利用者が見たテキスト | res.text | | --- | ---: | --- | --- | | 省略 | 1 | PARTIAL- | PARTIAL- | | 1 | 2 | PARTIAL-PARTIAL- | PARTIAL- | | 2 | 3 | PARTIAL-PARTIAL-PARTIAL- | PARTIAL- | 部分出力が、別の id を持つ新しいテキストブロックとして流れます。 text-start id=03b4d183… → text-delta "PARTIAL-" → text-end text-start id=a8c81696… → text-delta "PARTIAL-" → text-end text-start id=101d4351… → text-delta "PARTIAL-" → text-end res.text は 1 つ分しか返しません。つまり res.text を読む実装と fullStream を UI に流す実装で、見えるものが違います。 UI に流していると、同じ文章が繰り返し画面に出ます。 なお再試行しても fallback には届きません。primary がストリームを開けてしまうので、毎回 primary に確定するからです。「出力後の答えは最終」というドキュメントの記述は、上のレイヤーを足しても破れませんでした。 8. 実運用で効く数値 Retry は fallback チェーンの外側にあります。 | 構成 | 総リクエスト数 | | --- | ---: | | 2 本チェーン / maxRetries: 0 | 2 | | 2 本チェーン / maxRetries: 2(既定) | 6 | | 3 段の入れ子 / maxRetries: 2 | 9 | チェーンの長さ ×(1 + maxRetries)です。maxRetries の既定は 2 なので、何も指定しないと 3 倍になります。 入れ子の fallback は展開されます。ここは予想が外れました。最初は「leg の fallback は無視されるはず」と書いていたのですが、測ったら P → F → F2 と 3 段とも試されていました。leg が自分で持つ fallback は保持され、呼び出し時に渡したものだけが剥がされます。 404 は fallback しますが retry はしません。判定の基準が別だからです。fallback は abort 以外のすべてで次に進み、retry は isRetryable だけを見ます。 その isRetryable の境目も測りました。 | ステータス | isRetryable | | ---: | --- | | 400 / 401 / 403 / 404 / 422 | false | | 408 / 409 / 429 / 500 / 503 | true | ステータスが付かない失敗(接続断など)も true です。実装では status === undefined を transport レベルの障害とみなしています。 abort だけは別扱いです。 AbortSignal で中断すると、fallback は次のモデルに進まずそのまま throw します。利用者が画面を閉じたのに裏で別のモデルに課金される、という事故にはなりません。対照として、abort ではない例外を投げると fallback は普通に進みました。 もう 1 つ、設定の合流で踏みやすいところがあります。fallback をモデル側と呼び出し側の両方に書くと、呼び出し側が置き換えます。連結ではありません。 const model = ai(P, { fallback: [F] }); // モデル側は F await generateText({ model, providerOptions: { cloudflare: { fallback: [F2] } }, // 呼び出し側は F2 }); // 実際に試されたのは P → F2。F は 1 度も呼ばれない 空配列を渡した場合は未指定と同じ扱いで、attempts も入りませんでした。 attempts は fallback を設定していないと undefined です。ドキュメントのサンプルには条件なしで「lists every fallback model that failed」と書かれていますが、Cloudflare 自身のテストが expect(error.attempts).toBeUndefined() を期待しています。 実機では「存在しないモデル」の code が 2 つに割れました。 | 入力 | status | code | | --- | ---: | --- | | @cf/nope/does-not-exist | 400 | bad-request | | nope/not-a-real-model | 404 | not-found | code === "not-found" でモデル ID の打ち間違いを拾おうとすると、@cf/ の打ち間違いだけがすり抜けます。 ハマりポイント②:ドキュメントを疑って、外した peerDependencies は "ai": "^6.0.0 || ^7.0.0" ですが、ドキュメントは「The provider needs ai@^7」と書いています。食い違っているので、最初はドキュメントが古いのだと思いました。 直す前に ai@6 を入れて動かしました。 ai: 6.0.312 | @ai-sdk/provider: 4.0.31 | agents: 0.30.0 UnsupportedModelVersionError: Unsupported model version v4 for provider "cloudflare" 逆でした。ai@6 は @ai-sdk/provider@3 を直接依存で抱えているので、トップレベルに v4 を置いても関係ありません。ドキュメントが正しく、package.json の範囲が広すぎたということです。 入れても peer の警告は出ません。最初の generateText で落ちます。 9. 報告して、どうなったか 見つけたものは報告しました。 | | 内容 | | --- | --- | | agents#2687 | resume のエラー型 | | agents#2690 | ai の peer range | | cloudflare-docs#34546 | 再接続回数と attempts の記述 | agents は pull request をコラボレーターに限定しています。Issue に修正ブランチのリンクを添える形にしました。 翌日、#2687 に別の方から再現報告が付きました。原因の特定も修正方針も、こちらが出したものと一致しています。createTransport().run() でレスポンスの body を包み、読み出し中のエラーを toAISDKError に通す、という形です。 > the AI SDK boundary only converts synchronous failures: createTransport's try/catch in models/ai-sdk/transport.ts has already returned by the time the stream errors on a later read. 別経路で同じ場所に辿り着いているので、読み違いではなさそうです。 この方は core 側の設計意図も引いていました。models/core/errors.ts には「framework modules wrap it into their own error type at their boundary」と書かれています。つまり core が framework 中立のエラーを投げるのは設計通りで、抜けているのは境界側の変換だけ、という整理です。直す場所が core ではなく models/ai-sdk/transport.ts だという判断も、ここから出ています。 ついでに観測した話を 1 つ。このリポジトリには報告を確認して PR を出すボットがいます。別件で出した Issue では、確認コメントが付いた数時間後に修正 PR が立ちました。すべての報告がそうなるとは限りませんが、ファイルと行が特定できている報告は動きが早いようです。 10. まとめ fallback を 1 行書いて安心していました。測ってみると、守備範囲は「モデルが口を開くまで」でした。 flowchart TD A[ストリーム開始] --> B{出力が始まったか} B -- まだ --> C[fallback: 次のモデルへ] B -- 始まった --> D{run id があるか} D -- ある --> E[resume: 同じ run に再接続] D -- ない --> F[そのまま失敗] E --> G{6 回目のドロップか} G -- そう --> H[CloudflareAIError<br/>ただし公開型ではない] G -- まだ --> E - ツール名が届いた段階なら、まだ別のモデルに切り替わります - 本文が 1 文字出たら、もう切り替わりません - その先は resume が引き継ぎますが、Workers AI では働きません - resume は 5 回で諦めます。その回数は変えられません - 諦めたときのエラーは、ドキュメント通りに instanceof で書くと拾えません 測れなかったものも書いておきます。429 と 401 は無料枠では起こせず、分類は偽 binding でしか確認していません。Gateway 側の retries が実際に何回投げたかは、RFC によると設計上クライアントからは観測できません。カタログモデルでの metadata.model と response.modelId のズレは、Unified Billing を使わなかったので未確認です。 偽の binding を 1 つ書くだけで、ここまで測れます。50 行ほどのオブジェクトです。自分が使っている SDK で同じことをすれば、ドキュメントに書いていない境界が出てくるはずです。 参考 - Agents — Models — createAI が受け付ける 4 種類のモデル指定 - Agents — AI SDK provider — fallback / resume / エラー型の記述。この記事が突き合わせた相手 - createAI replaces workers-ai-provider and ai-gateway-provider — 移行表と非推奨の告知 - cloudflare/agents#2569 — 移行チェックリスト。移植しなかった機能に取り消し線が引いてある - cloudflare/agents#2576 — Universal Endpoint をコアから削除した PR - AI Gateway — Fallbacks — Gateway 側の fallback。createAI からは使えない
Cloudflare Agents 0.29.0 (Oct 20) lets createAI run outside a Worker. We're testing createAI to pull source-backed fact cards. Lesson so far: structured output can look right while facts are wrong, so a human reviews each card. github.com/cloudflare/agents/releases/tag/agents%…
I Migrated a Cloudflare Agents Workspace and Measured Everything: What Changes When the Storage Format Changes? > The difference was not the table names. TL;DR On October 20, 2026, Cloudflare Agents switched its Workspace implementation from @cloudflare/shell to @cloudflare/computer. The change is not just a new API name. It changes how files are stored. The old implementation stored one row per path, with the file contents base64-encoded in that same row. The new one separates inodes and chunks, storing content-addressed blobs in 512 KiB pieces. I ran the old and new implementations in the same Durable Object and compared the data before and after migration. - The tables are different, so old files are invisible after switching without migration. They are still there, though. - Storing a whole file in one row changes the size limit and performance. The old implementation hit a limit at about 6 MiB, and its 1 MB read/write cycle was 13.5 times slower. - The schema is versioned, so it migrates forward automatically but cannot go backward. | Aspect | Old @cloudflare/shell | New @cloudflare/computer | | --- | --- | --- | | Tables | cf_workspace_<namespace> | vfs_* / _vfs_* | | Representation of one file | One row (path TEXT PRIMARY KEY) | Inode + 512 KiB chunks | | Where content lives | Base64 in the same row's content column | Blob addressed by SHA-256 | | Observed file-size limit | About 6 MiB without R2 | No single-row limit; content is chunked | | 1 MB write + read | 87.25 ms | 6.45 ms | | Same content at two paths | Stored twice | Both paths share one blob row | | Reopen with old implementation | Works | Fails with EIO | Test code: GitHub repository Here is the main point upfront. The old and new implementations differ in storage format, not API. Once you understand that, the reason files disappear after switching without a migration, why the file-size limit changes, why the new implementation is faster, and why you cannot switch back all follow from the same underlying change. The migration guide says to copy files manually because the tables differ. The deeper difference is their structure, not their names. > This article expands on my talk about migrating a Cloudflare Agents Workspace with measurements I could not fit into the presentation. It is for people using Cloudflare Agents who are preparing to move to @cloudflare/computer. --- 1. The announcement On October 20, 2026, Cloudflare posted this changelog: > Think and Agent Skills now use the workspace from @cloudflare/computer as well as the filesystem tools. > @cloudflare/shell is deprecated, and so is Think's createExecuteTool. These will no longer be developed nor supported from today onwards. On the same day, Cloudflare published @cloudflare/think@0.21.0, @cloudflare/computer@0.6.0, and agents@0.29.0. @cloudflare/shell also received version 0.5.0 that day to announce its deprecation. npm's deprecated flag applies retroactively to earlier versions too, so 0.4.3 shows the same warning. The migration guide itself had landed earlier, in PR #2564 on October 14. It did not arrive at the same time as the announcement. The Changelog also says: > This is a breaking change. @cloudflare/computer stores files in different tables from @cloudflare/shell, and there is no automatic data migration between workspace formats. “Different tables.” That is where I started. Let's see how different they are. 2. The old Workspace stored one file per row A Workspace is where an agent stores its files. A model interacts with them through read, write, edit, and ls; the files themselves live in Durable Object SQLite. The old @cloudflare/shell schema is included in the migration guide: CREATE TABLE cf_workspace_default ( path TEXT PRIMARY KEY, parent_path TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('file','directory','symlink')), mime_type TEXT NOT NULL DEFAULT 'text/plain', size INTEGER NOT NULL DEFAULT 0, storage_backend TEXT NOT NULL DEFAULT 'inline' CHECK(storage_backend IN ('inline','r2')), r2_key TEXT, target TEXT, content_encoding TEXT NOT NULL DEFAULT 'utf8', content TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), modified_at INTEGER NOT NULL DEFAULT (unixepoch()) ); It is straightforward: the path is the primary key, and the metadata and content live in the same row. The content column holds file contents, base64-encoded for binary files. Large files are handled separately. The default threshold is 1,500,000 bytes. Files above that threshold are moved to R2, leaving only an r2_key in the row. If no R2 bucket is provided, the implementation logs a warning and keeps the file inline: if (size >= this.threshold && !r2) console.warn(`[Workspace] File ${path} is ${size} bytes but no R2 bucket was provided. Storing inline.`); This “put everything in one row” design matters later. 3. The new Workspace separates inodes and chunks The new @cloudflare/computer uses several tables: | Table | Stores | | --- | --- | | vfs_dirents | Parent inode + name → child inode | | vfs_nodes | Inode metadata (type / mode / mtime / size / link_target) | | vfs_chunks | Inode + chunk index → blob SHA-256 | | vfs_blobs | SHA-256 → size and last-reference time | | vfs_blob_bytes | SHA-256 → actual bytes | | vfs_meta | schema_version and revision counter | Path resolution walks vfs_dirents from the root (inode = 1). File contents are split into 512 KiB chunks, each stored in vfs_blob_bytes as a blob keyed by SHA-256: const CHUNK_SIZE = 524288; The tables start with vfs_ or _vfs_, so they do not collide with cf_workspace_*. Both formats can coexist during migration. From here, I measured what follows from the structural difference. 4. Consequence #1: switching does not make old files visible I put both implementations on the same Durable Object. Splitting them across two DO namespaces would not test migration on the same storage. I switched between them inside one class. I wrote 13 items with the old implementation, initialized the new one, and tried reading them: Read through old Workspace before switch → {found: true, length: 23} Initialize new Workspace Read /workspace/notes.md through new → ENOENT no such file Read /notes.md through new → ENOENT no such file New-side tree → [{path: "/workspace", type: "dir"}] The new implementation could not see the files. If I stopped here, the article would say “the data is gone.” That would be quick—and wrong. Add two controls “Not visible through the new implementation” does not tell us whether the original write failed. In the same run, I verified that the old Workspace could still read the file both before and after switching: Read through old before switch → {found: true, length: 23} Read through old after switch → {found: true, length: 23} Rows in cf_workspace_default → 14 (same as before) The old implementation could still read the file after the switch. The row count had not changed either. The data was not gone. The new implementation was looking somewhere else. A table listing showed both formats coexisting: cf_workspace_default computer_execution_runtime sqlite_sequence vfs_blob_bytes vfs_blobs vfs_changes vfs_chunks vfs_dirents vfs_manifests vfs_meta vfs_nodes _vfs_fetch_cursor _vfs_mounts _vfs_push_cursor _vfs_sync_operations _vfs_sync_skips _vfs_watermark I also learned that all vfs_* tables are created as soon as you call new Workspace(). They do not wait for the first filesystem operation. The schema_version was 10. 5. Consequence #2: the size limit changes when everything fits in one row The old implementation base64-encodes file contents and puts them in one cell. A 4 MB file becomes 5,592,408 base64 characters. Meanwhile, the Durable Objects Limits page says: | Item | Documented limit | | --- | --- | | Maximum string, BLOB, or table row size | 2 MB | | KV key size | Key and value combined cannot exceed 2 MB | A 4 MB file should not fit in the old Shell. I predicted that while designing the benchmark and prepared files of 1 KB, 100 KB, 1 MB, and 4 MB. They all worked. My prediction was wrong. 4 MB → storage_backend=inline, content_encoding=base64, content_len=5,592,408 → read-back SHA-256 matches 8 MB → Error: string or blob too big: SQLITE_TOOBIG I got the same result in local Miniflare and a deployed production Durable Object. Measurement: where is the actual limit? Stepping through the library would only reveal its implementation constant, not the underlying limit, so I called ctx.storage.sql and ctx.storage.kv directly to find the boundary. Each probe used a new Durable Object. Largest accepted 8,388,627 bytes Smallest rejected 8,388,628 bytes 8 MiB is 8,388,608 bytes. TEXT, BLOB, and a KV value all failed at the same boundary. That is four times the documented 2 MB. I repeated probes around the boundary on a new Durable Object and got the same result. I reported it in cloudflare-docs#34502. Since I could not rule out 2 MB being the guaranteed limit and 8 MiB merely an implementation allowance, I did not open a PR asserting a new limit; I attached a reference branch instead. Why does the old implementation stop at 6 MiB? The old Shell hit its own boundary below the raw SQL limit: Largest file accepted by shell.writeFileBytes 6,291,408 bytes (8,388,544 base64 chars) Smallest rejected 6,291,409 bytes (8,388,548 base64 chars) Base64 expands the content by about 4/3, so that works out to roughly 6 MiB of file data. I could not explain why the Shell fails earlier than raw SQL at 8,388,627 bytes. I reproduced its DDL, indexes, INSERT statement, ON CONFLICT DO UPDATE clause, and base64 string, then ran them against the same Durable Object. Raw SQL accepted the value. I also suspected that UPSERT might duplicate the value through excluded, but an UPSERT against an existing row accepted the same size. The stack trace points to the Shell's INSERT: Error: string or blob too big: SQLITE_TOOBIG at Object.run (index.js:367:17) at Workspace.writeFileBytes (index.js:652:22) I stopped there. For practical purposes, remember that “without R2, the old Shell's file-size limit is about 6 MiB.” The new Computer splits content into 512 KiB chunks, so this particular wall is gone. Files of 8 MB, 16 MB, and 32 MB all worked. Chunking may look like a performance design, but it also changes the size limit. 6. Consequence #3: base64 conversion on every operation is slower I hit an oddity here: Date.now() does not advance inside a Worker. In production, even a 4 MB write was recorded as 0ms. It advanced in local Miniflare, so at first I thought I was misreading the production logs. I measured from outside instead. I sent one request that repeated the operation 60 times and a control request that did nothing, then divided the difference by 60. The table shows the median of seven runs: | Size | Old Shell | New Computer | | --- | ---: | ---: | | 1 KB | 1.58 ms | -0.75 ms | | 100 KB | 4.92 ms | 0.80 ms | | 1 MB | 87.25 ms | 6.45 ms | I cannot draw conclusions from the 1 KB and 100 KB results. As the negative value shows, the differences are buried in HTTP round-trip variance. At 1 MB, the gap was clear: 13.5×. The old Shell base64-encodes a 1 MB file into one row on every write and decodes it on every read, adding overhead. My measurement includes the write and the read together, so I cannot say which one is slower on its own. 7. Consequence #4: content addressing enables deduplication The new implementation uses the blob's SHA-256 as its primary key. The same bytes can be shared by files anywhere in the tree. I wrote the same 512 KiB of content to two paths and counted rows in vfs_blobs: Initial blob count → 0 Write identical content to path 1 → 1 Write identical content to path 2 → 1 (no increase) Control: write different content → 2 (increases) Without the control, I could not distinguish deduplication from an implementation that never added a blob at all. The different byte sequence confirmed that a second row is created when the content differs. The old implementation stores content per path, so putting the same file in two places uses storage twice. 8. Consequence #5: there is nowhere to put some metadata The old FileInfo has mimeType, createdAt, and updatedAt. The new WorkspaceStatResult has only these fields: { name, inode, mode, mtime, size, isFile, isDirectory, isSymbolicLink } There is no API in workspace.fs to read or write MIME types. There is only one timestamp, mtime. The migration guide also says this metadata is not retained: > Things the copy does not keep: > - created_at and modified_at. Computer stamps the time of the copy. > - mime_type. Computer does not store a MIME type per file. In my measurements, every migrated file's mtime was the time the copy ran. It is not just that the data is “not retained”; the new schema has nowhere to store it. 9. Consequence #6: you can move forward, but not backward The new implementation versions its schema. Its initialization code looks like this: const onDiskVersion = db.one("SELECT v FROM vfs_meta WHERE k = ?", "schema_version")?.v ?? 0; if (onDiskVersion > 10) throw createWorkspaceError("EIO", `Unsupported workspace filesystem schema version ${onDiskVersion}`); if (onDiskVersion > 0 && onDiskVersion < 10) runMigrations(db, onDiskVersion, 10); Migrations run forward automatically. There are nine registered migrations, from v1 to v10. There is no migration backward. Rather than deploying an old version, I changed the on-disk schema_version to 11 to simulate a database newer than the binary, then ran the same initialization path: Current schema_version → 10 Control: reconstruct with same version → {read: "hello"} Set schema_version to 11 Open again with older binary → EIO Unsupported workspace filesystem schema version 11 Set schema_version back to 10 Can it open now? → {read: "hello"} What I measured is that the binary cannot open a database whose on-disk schema_version is newer than the version it supports. This means “you cannot roll back” only if a new release raises the schema version. It has already been raised nine times, so it is reasonable to expect that it could happen again. Neither the migration guide nor the Changelog mentions this. There is one sentence in the implementation document 03_filesystem_schema.md: > Open() refuses to run if the binary is older than the on-disk schema_version. The old data remains, so if migration fails you can still read from the old tables. The inability to roll back is the less recoverable part. 10. Running the migration guide's script Now that I understood the structural difference, I ran the migration. The guide provides a copy function named copyShellWorkspace(). It walks the old Workspace with glob("/**") and writes the files through the new fs API. The destination is under /workspace, which Think also uses as its working directory. I ran the script without changing it. My test data included UTF-8 Japanese text, TypeScript in a nested directory, JSON for exact-content comparison, 64 KB of binary data generated with a deterministic pseudorandom sequence, an empty directory, and four symlinks covering relative, absolute, and root-escaping targets. comparison → { total: 13, missing: [], typeChanged: [], contentMismatch: [], extra: [] } All 13 items existed after migration, their types had not changed, and the 64 KB binary's SHA-256 matched. The empty directory remained too. I got the same result in local wrangler dev and in a deployed production environment. I also verified that the hash comparison could actually detect a mismatch. I changed one bit in a byte sequence and confirmed that it produced a different hash: af2ba40f… versus 73734ea1…. A comparison that cannot detect a mismatch does not prove that two files match. I checked one assumption the script relies on as well. A comment says, “glob returns rows ordered by path, so parents come before children.” If that were false, writeFile could run before mkdir. In my tests, results were sorted lexicographically, so each parent appeared before its children. Symlinks need one extra check When I followed each link individually, one of them failed: | Link | Target in Shell | Target after migration | Can follow it? | | --- | --- | --- | --- | | /links/rel.md | ../notes.md | /workspace/notes.md | Yes | | /links/abs.md | /notes.md | /workspace/notes.md | Yes | | /links/dotdot.md | /src/../notes.md | /workspace/src/../notes.md | Yes | | /links/esc.md | /../notes.md | /workspace/../notes.md | ENOENT | /workspace/../notes.md resolves to /notes.md, which is outside the migration destination /workspace. The cause is in the script's resolveTarget(): // Shell resolves a relative target against the link's directory and never // goes above `/`. function resolveTarget(link: string, target: string): string { if (target.startsWith("/")) return target; // Absolute paths pass through const parts = link.split("/").slice(1, -1); for (const part of target.split("/")) { if (part === "..") parts.pop(); else if (part !== "" && part !== ".") parts.push(part); } return `/${parts.join("/")}`; } The @cloudflare/shell implementation (resolveSymlink) also sends absolute paths through normalizePath. normalizePath stops .. at the root, so /../notes.md becomes /notes.md in Shell. The migration script passes the path through unchanged. When it prefixes /workspace, the target escapes that directory. The conditions are narrow. Relative paths all migrate correctly. Absolute paths containing ., //, or .. that does not go above the root also work. The only failure is an absolute path containing .. that goes above the root. The script does not report an error. symlink() saves the target without validating it, and following it stops at the root rather than throwing. This is like a legal dangling link on POSIX. The copy completes successfully. A path/type/hash comparison will not catch this. The link exists, so it is not missing. Its type is still symlink, so the type matches. It has no file content, so it is not included in the hash comparison. My own comparison script reported missing: [], contentMismatch: []. To catch this, you have to follow and read the link: await to.fs.readFile(linkPath, "utf8"); // ENOENT if the target cannot be followed A second issue is that the migration is not safe to rerun: First migration → ok Second migration → EEXIST path exists: /workspace/links/abs.md mkdir({ recursive: true }) silently succeeds for an existing directory. writeFile overwrites unless exclusive is specified. symlink(target, path) has no equivalent of a force option; if the path already exists, it always throws EEXIST. That matches Node's fs.symlink behavior and is not a library bug. The guide's onStart example sets a flag after copying completes. If the copy fails partway through, the flag is never set and the next startup fails again on the same link. Remove the link before recreating it: const link = at(entry.path); if (await to.fs.readlink(link).then(() => true, () => false)) { await to.fs.rm(link); } await to.fs.symlink(at(target), link); I reported these two issues in cloudflare/agents#2645. Before filing, I checked for duplicates and found that both had been raised in earlier reviews. One did not survive a PR replacement; the other had been marked resolved based on the incorrect assumption that absolute paths were already rebased. Checking first meant I did not present either as a new discovery. If your Workspace has no symlinks, you will not hit this part. 11. Persistence survives hibernation The storage format changed, but the files still live in Durable Object SQLite. I checked whether they survive restarts. “Readable from another request” is not enough evidence. The same instance might still be in memory and return the file from there. I generated a crypto.randomUUID() in the constructor and included it in the response. If that value changes, the Durable Object is a new incarnation. bootId Read 1. Write 1cadfbdd-7595-4879-94c1-43abca4688c0 ok 2. Read immediately 1cadfbdd-7595-4879-94c1-43abca4688c0 ok (same) 3. After 15 seconds e4f5f2ab-3d7d-4389-8e2d-ee32fdd97b02 ok (changed) 4. After 150 seconds c602f7f1-c9f6-465e-9b36-0c3259706d64 ok (changed again) Reads 3 and 4 came from different incarnations. The file content included the old bootId from when it was written, showing it was the same data. I decided which fields to record before running the test: a case number for writing back the verdict, bootId to show whether a restart happened, env to distinguish local from production, sha256 and size to identify the file, the operation's elapsed time, and ok and error fields that hold results instead of throwing exceptions. This control proved useful. After redeploying, my first immediate read succeeded, but the bootId was unchanged. The DO was still in memory and running the old code; it had not restarted. Without the control, I might have concluded that the file persisted across a restart. I waited 15 seconds and tried again, then confirmed that the bootId changed and the file was still readable. How long until hibernation? I varied the wait time and checked bootId. The zero-second wait was the control: Wait (s) Restarted Read 0 No ok (control) 3 No ok 6 No ok 9 No ok 11 Yes ok 13 Yes ok 15 Yes ok The boundary is between 9 and 11 seconds, matching the documentation's “hibernate after 10 seconds of inactivity.” 12. Before you migrate - Count your symlinks. Run glob("/**") and count entries with type === "symlink". If there are none, the official script should work as-is. - If you have links, follow and read them after migration. Path/type/hash comparisons will not catch the issue. - Check for files larger than 6 MiB. If the old implementation stored them in R2, pass the same r2 bucket and name during migration. - Deploy assuming you cannot roll back. Once the new schema has been written, an older @cloudflare/computer cannot open that Durable Object. - Do not delete the old data immediately. The old tables remain, so wait to DROP TABLE until your comparison is done. The dependency arrangement changes too. In @cloudflare/think@0.20.2, @cloudflare/shell was a direct dependency; you did not install it yourself. In 0.21.0, @cloudflare/computer is a peer dependency. The mechanism behind “I upgraded and my Workspace is empty” is right there. 13. Conclusion The storage format changed, and the rest followed from it: - Different tables → files are invisible after switching without a migration, but the old tables remain and the old Workspace can still read them. - One row versus chunks → the old implementation hits a limit at about 6 MiB; the new one accepts files of 32 MB. - Base64 conversion on every operation → a 13.5× difference in the 1 MB read/write test. - Content-addressed blobs → the same content at two paths takes one blob row. - Fewer metadata columns → there is nowhere to store mime_type or created_at. - A versioned schema → it migrates forward automatically and cannot be opened by an older implementation after the version changes. The migration guide says to copy files manually because the tables differ. The difference is not just the table names; it is the structure. Understanding that structure tells you what to verify before you migrate. I also found two mismatches with the documentation. The Durable Object SQLite value limit I tested was 8 MiB, not 2 MB, and the user-facing docs do not mention that an older implementation cannot open a Durable Object after its schema has been upgraded. The most surprising result was that a 4 MB file worked. I had predicted the result from the official limit and got it wrong. Because I had trusted the documented number, I no longer trusted my other predictions once it failed. I ended up measuring both the size limit and performance myself. That took more time than the migration itself. References - Think agents now use a @cloudflare/computer workspace — The announcement, the lack of automatic migration, and deprecation of createExecuteTool. - Migrating from @cloudflare/shell to @cloudflare/computer — Both schemas and the copy script discussed in §10. - cloudflare/computer docs/03_filesystem_schema.md — All vfs_* table definitions, chunk size, and schema version behavior. - Durable Objects — Limits — The documented 2 MB row-size limit tested in §5. - Durable Objects — Lifecycle — Sources for the 10-second hibernation and 70–140-second eviction behavior. - cloudflare/computer#217 / #219 — Acknowledgment that writing to R2 and namespaces are not supported yet. - cloudflare/agents#2645 — Report about the symlink migration script in §10.
Cloudflare AgentsのWorkspaceを移行して測ってみる > 違うのは、テーブル名ではありませんでした この記事の結論 2026年10月20日、Cloudflare Agents の Workspace が @cloudflare/shell から @cloudflare/computer に切り替わりました。変わったのは API の名前ではなく、ファイルの持ち方です。旧はパス1本につき1行で、中身を同じ行に base64 で持っていました。新は inode とチャンクに分けて、512 KiB ごとの content-addressed な blob に置きます。 実際の Durable Object に新旧を同居させて、移行の前後を1件ずつ突き合わせました。 - テーブルが別なので、移行せずに切り替えると旧ファイルは見えない。 ただし消えてはいない - 1行に全部入れるかどうかで、上限も速度も変わる。 旧は約 6 MiB で頭打ち、1MB の読み書きは 13.5 倍遅い - スキーマにバージョンがあるので、前には自動で進むが後ろには戻れない | 観点 | 旧 @cloudflare/shell | 新 @cloudflare/computer | | --- | --- | --- | | テーブル | cf_workspace_<namespace> | vfs_* / _vfs_* | | 1ファイルの表現 | 1行(path TEXT PRIMARY KEY) | inode + 512 KiB のチャンク | | 中身の置き場 | 同じ行の content に base64 | sha256 をキーにした blob | | 実測の上限 | 約 6 MiB(R2 を渡さない場合) | チャンクに割れるので頭打ちなし | | 1MB の書き込み+読み込み | 87.25ms | 6.45ms | | 同じ内容を2箇所に置く | 2行ぶん | blob は1行を共有 | | 古い実装に戻す | できる | EIO で開けない | 検証コードはこちらです: github.com/tanahiro2010/slides/tree/main/slides/s… そして、この記事で一番伝えたいことを先に書いておきます。 新旧の差は、API ではなく保存形式です。 そこさえ押さえれば、移行しないと見えなくなる理由も、ファイルサイズの上限が変わる理由も、速くなる理由も、古い実装に戻せない理由も、すべて同じ1つの変更から導けます。移行ガイドは「テーブルが違うので手でコピーしてください」としか書いていませんが、違うのはテーブル名ではなく構造のほうです。 > この記事は「Cloudflare AgentsのWorkspaceを移行してみる」の登壇内容に、発表で話しきれなかった計測値を足したものです。対象は Cloudflare Agents を使っていて、これから @cloudflare/computer に移る人です。 --- 1. 発端 2026年10月20日、Changelog が出ました。 > Think and Agent Skills now use the workspace from @cloudflare/computer as well as the filesystem tools. > @cloudflare/shell is deprecated, and so is Think's createExecuteTool. These will no longer be developed nor supported from today onwards. 同じ日に @cloudflare/think@0.21.0、@cloudflare/computer@0.6.0、agents@0.29.0 が公開されています。@cloudflare/shell も同日に 0.5.0 が出ていて、これが非推奨を告知するリリースです。npm の deprecated フラグは過去のバージョンにも遡って付くので、0.4.3 を見ても同じ警告が出ます。 移行ガイド自体はもう少し前、10月14日の PR #2564 で入っていました。発表と同時にガイドが出たわけではありません。 そして Changelog にはこう書いてあります。 > This is a breaking change. @cloudflare/computer stores files in different tables from @cloudflare/shell, and there is no automatic data migration between workspace formats. 「テーブルが違う」。ここが出発点です。どう違うのかを見ていきます。 2. 旧 Workspace は、1ファイル1行だった Workspace はエージェントのファイル置き場です。モデルが read write edit ls で触り、実体は Durable Object の SQLite に載っています。 旧 @cloudflare/shell の定義は移行ガイドに載っています。 CREATE TABLE cf_workspace_default ( path TEXT PRIMARY KEY, parent_path TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('file','directory','symlink')), mime_type TEXT NOT NULL DEFAULT 'text/plain', size INTEGER NOT NULL DEFAULT 0, storage_backend TEXT NOT NULL DEFAULT 'inline' CHECK(storage_backend IN ('inline','r2')), r2_key TEXT, target TEXT, content_encoding TEXT NOT NULL DEFAULT 'utf8', content TEXT, created_at INTEGER NOT NULL DEFAULT (unixepoch()), modified_at INTEGER NOT NULL DEFAULT (unixepoch()) ); 素直です。パスが主キーで、メタデータも中身も同じ行に乗っています。content にはファイルの中身が入り、バイナリなら base64 です。 大きいファイルだけ別扱いになります。既定の閾値は 1,500,000 バイトで、これを超えると R2 に逃がし、行には r2_key だけ残ります。R2 を渡していない場合は警告を出して、そのまま inline に入れます。 if (size >= this.threshold && !r2) console.warn(`[Workspace] File ${path} is ${size} bytes but no R2 bucket was provided. Storing inline.`); この「1行に全部入れる」という性質が、後で響いてきます。 3. 新 Workspace は、inode とチャンクに分かれている 新 @cloudflare/computer はテーブルを分けます。 | テーブル | 持つもの | | --- | --- | | vfs_dirents | 親 inode + 名前 → 子 inode | | vfs_nodes | inode のメタデータ(type / mode / mtime / size / link_target) | | vfs_chunks | inode + 何番目のチャンクか → blob の sha256 | | vfs_blobs | sha256 → サイズと最終参照時刻 | | vfs_blob_bytes | sha256 → 実バイト列 | | vfs_meta | schema_version とリビジョンカウンタ | パスの解決は vfs_dirents をルート(inode = 1)から辿る形です。ファイルの中身は 512 KiB ごとに切られ、それぞれが sha256 をキーにした blob として vfs_blob_bytes に入ります。 const CHUNK_SIZE = 524288; 名前が vfs_ と _vfs_ で始まるので、cf_workspace_* とは衝突しません。移行の最中は両方が並んで存在できます。 ここから先は、この構造の違いから何が導けるかを1つずつ測りました。 4. 帰結①:切り替えても、旧ファイルは見えない 同じ Durable Object の上に新旧を並べました。別の DO namespace に分けると「同じストレージを使った移行」になりません。1つのクラスの中で切り替えています。 旧で13個書いてから、新を構築して読みます。 対照: 旧から読む → {found: true, length: 23} 新 Workspace を構築 新から読む /workspace/notes.md → ENOENT no such file 新から読む /notes.md → ENOENT no such file 新側のツリー → [{path: "/workspace", type: "dir"}] 新からは見えません。 ここで止めると「データが消えた」という記事になります。早い。そして違います。 対照を2本置く 「新から見えない」だけでは、そもそも書き込みが失敗していた可能性と区別できません。同じ run の中で、旧 Workspace から読めることを切り替えの前後で確認しました。 切り替え前に旧から読む → {found: true, length: 23} 切り替え後に旧から読む → {found: true, length: 23} cf_workspace_default の行数 → 14(切り替え前と同じ) 切り替えた後も旧からは読めます。行数も減っていません。 消えてはいない。読みに行く先が変わっただけです。テーブル一覧を見ると、両方が同居しています。 cf_workspace_default computer_execution_runtime sqlite_sequence vfs_blob_bytes vfs_blobs vfs_changes vfs_chunks vfs_dirents vfs_manifests vfs_meta vfs_nodes _vfs_fetch_cursor _vfs_mounts _vfs_push_cursor _vfs_sync_operations _vfs_sync_skips _vfs_watermark ついでに1つ分かりました。vfs_* は new Workspace() を呼んだ時点で全部作られます。最初の fs 操作を待ちません。schema_version は 10 でした。 5. 帰結②:1行に入れるかどうかで、上限が変わる 旧はファイルの中身を base64 にして1つのセルに入れます。4MB のファイルなら base64 で 5,592,408 文字です。 一方、Durable Objects の Limits ページにはこう書いてあります。 | 項目 | 記載値 | | --- | --- | | Maximum string, BLOB or table row size | 2 MB | | Key size(KV) | Key and value combined cannot exceed 2 MB | 2 MB を超えるので、4MB のファイルは旧 Shell に保存できないはずでした。ベンチマークの設計でそう予測して、1KB・100KB・1MB・4MB を並べました。 通りました。 予測が外れました。 4MB → storage_backend=inline, content_encoding=base64, content_len=5,592,408 → 読み戻しの SHA-256 も一致 8MB → Error: string or blob too big: SQLITE_TOOBIG ローカルの miniflare でも、デプロイした本番の Durable Object でも同じです。 計測:本当の上限はどこか 段階的なプローブで止めると実装の定数が見えないので、ライブラリを通さず ctx.storage.sql と ctx.storage.kv を直接叩いて境界を詰めました。プローブごとに新しい Durable Object を使っています。 通る最大 8,388,627 バイト 落ちる最小 8,388,628 バイト 8 MiB は 8,388,608 です。TEXT も BLOB も KV の値も、同じところで落ちます。 ドキュメントの 2 MB とは4倍ちがいます。新しい Durable Object で境界の前後だけを叩き直して、同じ結果になることも確認しました。この件は cloudflare-docs#34502 に出しています。2 MB が保証値で 8 MiB が実装の緩さ、という可能性を潰せていないので、数値を断定する PR にはせず、参考ブランチを添えるだけにしました。 考察:なぜ旧だけ 6 MiB で止まるのか 旧 Shell 自身の境界は、素の SQL より低いところにありました。 shell.writeFileBytes が通る最大 6,291,408 バイト(base64 8,388,544 文字) 落ちる最小 6,291,409 バイト(base64 8,388,548 文字) base64 が 4/3 に膨らむので、ファイルとしては約 6 MiB が上限です。 ただし、なぜ素の SQL(8,388,627)より手前で落ちるのかは分かりませんでした。Shell の DDL、インデックス、INSERT 文、ON CONFLICT DO UPDATE 句、そして Shell が実際に作る base64 文字列まで揃えて、同じ Durable Object で流しても、素の SQL は通ります。 UPSERT が excluded で値を二重に持つのではないかという仮説も立てましたが、行が既にある状態の UPSERT も同じサイズで通りました。 スタックトレースは Shell の INSERT を指しています。 Error: string or blob too big: SQLITE_TOOBIG at Object.run (index.js:367:17) at Workspace.writeFileBytes (index.js:652:22) ここは諦めました。実用上は「旧 Shell に R2 を渡さない場合、ファイルは約 6 MiB が上限」と覚えておけば足ります。 新 Computer は 512 KiB のチャンクに割るので、この壁がありません。8MB も 16MB も 32MB も通りました。チャンク化は速度の工夫に見えますが、変わったのは上限のほうです。 6. 帰結③:毎回 base64 に変換するので、遅い ここで1つ引っかかりました。Worker の中では Date.now() が進みません。 本番では 4MB の書き込みすら 0ms と記録されます。ローカルの miniflare では進むので、最初は本番のログを見て目を疑いました。 なので外から測り直しました。同じ操作を60回繰り返すリクエストと、0回で素通しするリクエストを投げて、その差を60で割ります。7回の中央値です。 | サイズ | 旧 Shell | 新 Computer | | --- | ---: | ---: | | 1KB | 1.58ms | -0.75ms | | 100KB | 4.92ms | 0.80ms | | 1MB | 87.25ms | 6.45ms | 1KB と 100KB は結論を出せません。負の値が出ている通り、HTTP の往復のばらつきに埋もれています。 1MB では差が出ました。13.5倍です。旧 Shell は毎回 1MB を base64 に変換して1行に入れ、読むときに戻しているので、その分が乗ります。書きと読みは分離していません。どちらが遅いかまでは言えません。 7. 帰結④:content-addressed なので、重複排除が効く 新は blob の主キーが sha256 です。同じバイト列はツリーのどこにあっても1行を共有します。 512 KiB の同じ内容を2つのパスに書いて、vfs_blobs の行数を数えました。 blobs 初期 → 0 同じ内容を1パス目 → 1 同じ内容を2パス目 → 1 ← 増えない 対照: 違う内容 → 2 ← 増える 対照を置かないと「そもそも blob が増えない実装」と区別できないので、違うバイト列で2行になることも確認しています。 旧はパスごとに content を持つので、同じファイルを2箇所に置けば2回分の容量を使います。 8. 帰結⑤:メタデータの置き場が無くなった 旧の FileInfo は mimeType createdAt updatedAt を持っています。新の WorkspaceStatResult はこれだけです。 { name, inode, mode, mtime, size, isFile, isDirectory, isSymbolicLink } MIME を読み書きする API が、workspace.fs のどこにもありません。時刻も mtime の1本だけです。 移行ガイドも「保持されない」と書いています。 > Things the copy does not keep: > - created_at and modified_at. Computer stamps the time of the copy. > - mime_type. Computer does not store a MIME type per file. 実測でも、移行後の mtime は全件コピー実行時刻になっていました。「保持されない」というより、新スキーマに置く場所がありません。 9. 帰結⑥:前には進めるが、後ろには戻れない 新はスキーマにバージョンを持ちます。初期化のコードはこうです。 const onDiskVersion = db.one("SELECT v FROM vfs_meta WHERE k = ?", "schema_version")?.v ?? 0; if (onDiskVersion > 10) throw createWorkspaceError("EIO", `Unsupported workspace filesystem schema version ${onDiskVersion}`); if (onDiskVersion > 0 && onDiskVersion < 10) runMigrations(db, onDiskVersion, 10); 前方への移行は自動です。v1 から v10 まで、マイグレータが9本登録されています。 後方はありません。 実際に古い版をデプロイする代わりに、on-disk の schema_version を 11 に書き換えて「このバイナリより新しい DB」を作り、同じ構築経路を通しました。 schema_version(現在) → 10 対照: 同じ版で再構築 → {read: "hello"} schema_version を 11 に偽装 古いバイナリで開き直す → EIO Unsupported workspace filesystem schema version 11 schema_version を 10 に戻す 戻したら開けるか → {read: "hello"} 測ったのは「on-disk の schema_version がバイナリの対応版より新しいと EIO で開けない」ことです。これが「古い版にロールバックできない」を意味するのは、新しい版がスキーマを上げた場合に限ります。過去に9回上げているので、次も上がると見るのが自然です。 移行ガイドにも Changelog にも、この記述はありません。実装ドキュメントの 03_filesystem_schema.md に1行あるだけです。 > Open() refuses to run if the binary is older than the on-disk schema_version. 旧データは残るので、移行に失敗しても最悪は旧テーブルから読み直せます。戻れないほうは取り返しがつきません。 10. 移行ガイドのスクリプトを流す 構造の違いは分かりました。実際に移します。 移行ガイドに copyShellWorkspace() というコピー用のコードが載っています。旧を glob("/**") で歩いて、新の fs に書き直すだけのものです。移行先は /workspace 配下で、Think もそこを作業ディレクトリとして使います。 手を入れずにそのまま流しました。題材は、UTF-8 の日本語を含むテキスト、ネストしたディレクトリに置いた TypeScript、内容の完全一致を見るための JSON、決定的な疑似乱数で生成した 64KB のバイナリ、中身を持たない空ディレクトリ、そして相対・絶対・ルート超えを撃ち分けたシンボリックリンク4本です。 comparison → { total: 13, missing: [], typeChanged: [], contentMismatch: [], extra: [] } 13件すべて存在し、型も変わらず、64KB のバイナリも SHA-256 が一致しています。空ディレクトリも残りました。ローカルの wrangler dev でも、デプロイした本番でも同じ結果です。 ハッシュ比較が機能していることも確認しました。1ビットだけ変えたバイト列を用意して、ハッシュが別物になることを見ています。af2ba40f… と 73734ea1… です。比較が不一致を検出できないまま「一致した」と言っても、何も示したことになりません。 スクリプトが暗黙に頼っている前提も確かめました。コードのコメントに「glob returns rows ordered by path, so parents come before children」とあります。これが崩れると mkdir の前に writeFile が走ります。実測では辞書順でソートされていて、親は必ず子より先に来ていました。 ハマりポイント:シンボリックリンクだけ、別の扱いが要る リンクを個別に辿ってみると、1本だけ落ちました。 | リンク | Shell でのリンク先 | 移行後のリンク先 | 辿れるか | | --- | --- | --- | --- | | /links/rel.md | ../notes.md | /workspace/notes.md | 辿れる | | /links/abs.md | /notes.md | /workspace/notes.md | 辿れる | | /links/dotdot.md | /src/../notes.md | /workspace/src/../notes.md | 辿れる | | /links/esc.md | /../notes.md | /workspace/../notes.md | ENOENT | /workspace/../notes.md は /notes.md に解決されます。移行先の /workspace の外です。 原因はスクリプトの resolveTarget() にあります。 // Shell resolves a relative target against the link's directory and never // goes above `/`. function resolveTarget(link: string, target: string): string { if (target.startsWith("/")) return target; // 絶対パスは素通し const parts = link.split("/").slice(1, -1); for (const part of target.split("/")) { if (part === "..") parts.pop(); else if (part !== "" && part !== ".") parts.push(part); } return `/${parts.join("/")}`; } @cloudflare/shell の本体(resolveSymlink)は、絶対パスも normalizePath に通します。normalizePath は .. をルートで止めるので、Shell にとって /../notes.md は /notes.md です。スクリプトはそれを素通しするので、/workspace を前置したときに外へ出ます。 条件は狭いです。相対パスは全件正しく、. や // を含む絶対パスも、ルートを超えない .. を含む絶対パスも正しく移ります。崩れるのは「絶対パスで、かつルートより上に登る .. を含む」場合だけです。 そして、これはエラーになりません。symlink() はリンク先を検証せずに保存し、辿るときも例外を投げずルートで止まります。POSIX のダングリングリンクが合法なのと同じです。コピーは正常終了します。 パス・型・ハッシュの照合では捕まりません。 リンクは存在するので欠落にならず、型は symlink のままなので型違いにもならず、中身を持たないのでハッシュ比較の対象にすら入りません。実際、私の照合スクリプトも missing: [], contentMismatch: [] を返しました。確かめるなら、辿って読むしかありません。 await to.fs.readFile(linkPath, "utf8"); // 辿れなければ ENOENT もう1つ、再実行も止まります。 1回目の移行 → ok 2回目の移行 → EEXIST path exists: /workspace/links/abs.md mkdir({ recursive: true }) は既存ディレクトリなら黙って返ります。writeFile は exclusive を指定しない限り上書きします。symlink(target, path) には force に相当するオプションがなく、パスが既にあれば必ず EEXIST を投げます。Node の fs.symlink と同じ挙動で、ライブラリ側のバグではありません。 ガイドの onStart の例はコピー完了後にフラグを立てるので、途中で落ちるとフラグが立たず、起動のたびに同じリンクで失敗します。張る前に消せば済みます。 const link = at(entry.path); if (await to.fs.readlink(link).then(() => true, () => false)) { await to.fs.rm(link); } await to.fs.symlink(at(target), link); この2点は cloudflare/agents#2645 に出しました。出す前に重複を調べたら、どちらも過去のレビューで指摘されていました。片方は PR の差し替えで引き継がれず、もう片方は「絶対パスは既に rebase されている」という誤った前提のまま解決済みになっていました。新発見として書かずに済んだのは、調べたからです。 シンボリックリンクを含まない Workspace なら、ここは踏みません。 11. 永続化は保たれている 保存形式が変わっても、Durable Object の SQLite に載っている点は同じです。再起動をまたいで残るかを確かめました。 「別のリクエストから読めた」だけでは証拠になりません。同じインスタンスがメモリに残っていて、そこから返しただけかもしれないからです。コンストラクタで crypto.randomUUID() を1つ生成して、レスポンスに載せました。値が変わっていれば、新しい incarnation です。 bootId 読めたか ① 書く 1cadfbdd-7595-4879-94c1-43abca4688c0 ok ② 直後に読む 1cadfbdd-7595-4879-94c1-43abca4688c0 ok ← 同じ ③ 15秒後 e4f5f2ab-3d7d-4389-8e2d-ee32fdd97b02 ok ← 変わった ④ 150秒後 c602f7f1-c9f6-465e-9b36-0c3259706d64 ok ← また変わった ③と④は別の incarnation から読めています。④の中身には書いたときの古い bootId が入っていたので、同じデータだと示せます。 記録するフィールドは最初から決めておきました。判定を書き戻すための C 番号、再起動の有無を示す bootId、ローカルか本番かを示す env、ファイルの同一性を示す sha256 と size、操作そのものにかかった時間、そして例外を投げずに結果として持つ ok と error です。 この対照は実際に仕事をしました。再デプロイ後に読めるかを試したとき、1回目は待たずに読んで成功したのですが、bootId が同じでした。DO は旧コードのままメモリに居座っていて、再起動していません。対照が無ければ「再デプロイしても残る」と書いて終わっていました。15秒待ってから取り直して、bootId が変わったうえで読めることを確認しています。 計測:hibernate は何秒か 待ち時間を変えて bootId を見ました。対照は待ち0秒です。 待ち(s) 再起動 読めた 0 しない ok ← 対照 3 しない ok 6 しない ok 9 しない ok 11 する ok 13 する ok 15 する ok 境界は9秒と11秒の間にあります。ドキュメントの「無活動10秒で hibernate」と一致しました。 12. 移行する前にやること - シンボリックリンクがあるか数える。 glob("/**") して type === "symlink" を数えるだけです。0本なら公式スクリプトをそのまま使って構いません - リンクがあるなら、移行後に辿って読む。 パス・型・ハッシュの照合では捕まりません - 6 MiB を超えるファイルがないか確認する。 旧で R2 に逃がしていたなら、移行時も同じ r2 と name を渡します - 古い実装に戻せない前提でデプロイする。 新スキーマを一度書いたら、その Durable Object は古い @cloudflare/computer では開けません - 旧データはすぐ消さない。 旧テーブルは残るので、照合が終わるまで DROP TABLE を待ちます 依存の形も変わっています。旧 @cloudflare/think@0.20.2 では @cloudflare/shell が直接依存で、自分でインストールするものではありませんでした。0.21.0 では @cloudflare/computer が peer dependency になります。「上げたら Workspace が空になった」が起きる構造が、ここにそのまま出ています。 13. まとめ 変わったのは保存形式です。そこから全部が導けました。 - テーブルが別 → 移行せずに切り替えると見えない。ただし旧テーブルは残り、旧 Workspace からは読める - 1行に全部入れる / チャンクに割る → 旧は約 6 MiB で頭打ち。新は 32MB でも通る - 毎回 base64 に変換する → 1MB の読み書きで 13.5 倍の差 - blob が content-addressed → 同じ内容を2箇所に置いても1行で済む - メタデータの列が減った → mime_type と created_at は置く場所がない - スキーマにバージョンがある → 前には自動で進み、後ろには戻れない 移行ガイドは「テーブルが違うので手でコピーしてください」としか書いていません。違うのはテーブル名ではなく構造のほうで、そこを見ておくと、移行のときに何を確かめればいいかが先に分かります。 ドキュメントとずれていた点も2つ出ました。Durable Object SQLite の値の上限は 2 MB ではなく 8 MiB でしたし、新スキーマを書いた Durable Object が古い実装で開けないことは、ユーザー向けのどこにも書かれていません。 一番こたえたのは、4MB が通ったところです。公式の数字から予測を立てて、外しました。前提を公式に置いていたぶん、崩れたときに他の予測まで信用できなくなって、上限も速度も結局は自分で測り直しています。移行そのものより、そっちに時間を使いました。 参考 - Think agents now use a @cloudflare/computer workspace — 切り替えの告知。自動移行が無いことと createExecuteTool の非推奨 - Migrating from @cloudflare/shell to @cloudflare/computer — 新旧両方の DDL と、10章で流したコピースクリプト - cloudflare/computer docs/03_filesystem_schema.md — vfs_* の全テーブル定義。チャンクサイズとスキーマバージョンの扱い - Durable Objects — Limits — 行サイズ 2 MB の記載元。5章で突き合わせた相手 - Durable Objects — Lifecycle — hibernate 10秒、eviction 70〜140秒の出典 - cloudflare/computer#217 / #219 — R2 への書き出しと namespace が未対応であることの自己申告 - cloudflare/agents#2645 — 10章のシンボリックリンクの件を報告したもの
@mattzcarey agents#2209 could you take a look at that, would be awesome
Why probabilistic AI review bots fail at catching AI agent bugs—and how we enforce deterministic AST invariants & 12 CWE security shields in GitHub Actions >TL;DR: AI coding agents increased raw PR volume by 300%, yet overall merge velocity has dropped by 40% because Senior Engineers spend 15–20 hrs/week untangling 200-line hallucinated wrappers, deleted tests, and subtle CWE security flaws. After shipping patches into tier-1 upstream systems repos (LLVM #215076, Meta Velox #18529, DuckDB #26443, Apache DataFusion #24394, Microsoft Pyright #11605, Tokio #8595, ClickHouse #114954, OpenAI Triton #12221), we codified those exact deterministic CI gates into an open GitHub Actions workflow (ZeroSlop · Architecture & Simulator) that strips 94% of AI bloat, enforces 12 CWE security shields, and gets PRs merged 100% green in minutes. 1. The 2026 Engineering Bottleneck: Why AI PRs Stall in Review When shipping patches into upstream systems repos like LLVM, Meta Velox, DuckDB, or Tokio, a real production fix is 4 to 15 lines of surgical invariant changes paired with a deterministic regression test that passes CI on the first push. Inside commercial teams using Cursor, Copilot, or Claude Code, the opposite happens: - 94% Diff Bloat: An agent generates a 240-line wrapper class instead of fixing a 4-line boundary condition (interactive diff breakdown). - Weakened Tests: When a test fails, the agent silently deletes or loosens the assertion to manufacture a green check. - Noisy LLM Review Bots: Conversational AI review bots run another LLM to dump 25 chatty comments on top of a bloated PR—without shrinking the diff or getting the PR merged. 2. Conversational LLM Review Bots vs. Deterministic PR Airlock |Engineering Dimension|Conversational AI Review Bots|Deterministic PR Airlock (ZeroSlop)| |:-|:-|:-| |Primary Objective|Leave 25+ chatty LLM comments on a bloated diff|Get PRs merged 100% green in minutes (not 4 days)| |Diff Minimization|Reviews 200 lines of hallucinated wrappers as-is|Strips up to 94% of AI bloat down to the core AST invariant| |CI Failure Triage|Ignores red CI checks or hallucinates fixes|Separates contributor code defects from runner flakes| |Test Integrity|Misses deleted or weakened test assertions|Hard-blocks unauthorized regression test deletions| |PR Comment Hygiene|Spams new threads on every git push|Strictly 1 self-updating PR Verdict Card + Mermaid Graph| |Security & Privacy|Cloud LLM retention & external dashboards|12-Shield CWE Airlock + Zero Code Retention| 3. The 12-Shield Deterministic CWE Security Airlock (ZS-SEC-001 – ZS-SEC-012) Every Pull Request is audited in milliseconds across 8 merge-readiness signals (0–100) and 12 deterministic CWE security shields before a human reviewer is pinged: |Shield ID|Security Vector & CWE / CVE Mapping|What ZeroSlop Security Deterministically Intercepts| |:-|:-|:-| |ZS-SEC-001|SQL & Vector Injection (CWE-89 / CWE-943)|Unparameterized SQL/NoSQL/Vector template literals & raw string concatenation| |ZS-SEC-002|Hardcoded & Seeded Secrets (CWE-798 / CWE-256)|API keys, JWT secrets, seeded credentials & plaintext password comparisons| |ZS-SEC-003|Unbound Identity / IDOR (CWE-639 / CWE-862)|Trusting req.body.userId instead of verified session/JWT caller identity| |ZS-SEC-004|Command & Eval Injection (CWE-78 / CWE-94)|Unsanitized child_process.exec, os.system, subprocess(shell=True), eval()| |ZS-SEC-005|Silent Exception Swallowing (CWE-390)|Empty catch () {} blocks and swallowed promise rejections that hide crashes| |ZS-SEC-006|XSS & Unsanitized DOM (CWE-79)|dangerouslySetInnerHTML, .innerHTML =, and unescaped template rendering| |ZS-SEC-007|Unsafe Deserialization (CWE-502 / CWE-22)|pickle.loads, yaml.load without SafeLoader, and ../ path traversal| |ZS-SEC-008|SSRF & Disabled TLS (CWE-918 / CWE-295)|Unvalidated fetch(req.body.url) & NODE_TLS_REJECT_UNAUTHORIZED = '0'| |ZS-SEC-009|CI/CD "Pwn Requests" (CWE-426 / CWE-250)|pull_request_target fork checkouts & ${{ github.event.pull_request.title }} injection| |ZS-SEC-010|AI Package "Slopsquatting" (CWE-829 / CWE-494)|Hallucinated npm/PyPI/Cargo packages, unpinned Git deps & `curl| |ZS-SEC-011|Resource Exhaustion / DoS (CWE-400 / CWE-770)|while(true) without exit bounds, leaked setInterval, unbounded Promise.all| |ZS-SEC-012|Trojan Source & Jailbreaks (CVE-2021-42574)|Invisible Unicode Bidi characters & hidden IGNORE PREVIOUS INSTRUCTIONS comments| 4. Upstream Systems Proof (Click Any PR to Inspect the Diff) These deterministic CI invariants come directly from shipping merged & 100% green Pull Requests across upstream systems repositories: |Upstream Repository|Live Pull Request|Status|Core Systems Invariant & Regression Coverage| |:-|:-|:-|:-| |LLVM Compiler Infrastructure|llvm/llvm-project#215076|MERGED|Fixed Waitcnt brackets in GFX12+ AMDGPUAsmPrinter with MC regression tests| |Meta Velox Execution Engine|facebookincubator/velox#18529|MERGED|Aligned DecimalUtil::toByteArray overflow bounds & restored kMaxDecimalBytes| |DuckDB Analytical Engine|duckdb/duckdb#26443|MERGED|Fixed pragma_storage_info internal compressed segment type alignment| |Apache DataFusion Query Engine|apache/datafusion#24394|MERGED|Fixed lead()/lag() window frame boundaries with non-NULL default values| |Microsoft Pyright Type Checker|microsoft/pyright#11605|MERGED|Resolved type narrowing across nested loop closures & pattern guards| |Tokio Async Rust Runtime|tokio-rs/tokio#8595|100% GREEN|Preserved Coop budget accounting across JoinSet & FuturesUnordered| |ClickHouse OLAP DBMS|ClickHouse/ClickHouse#114954|100% GREEN|Fixed StorageURL distributed cluster header forwarding & serialization| |OpenAI Triton GPU Compiler|triton-lang/triton#12221|100% GREEN|Enforced strict tensor descriptor stride & block shape invariants| |Cloudflare Agents SDK|cloudflare/agents#1248|MERGED|Hardened state synchronization & WebSocket hibernation lifecycle invariants| 5. Architecture Documentation & GitHub Action Source - Interactive PR Simulator & Architecture: ribbsaetersystems.com - 12-Shield CWE & Zero-Retention Security Matrix: ribbsaetersystems.com - GitHub Action Workflow: github.com/marketplace/actions/zeroslop-by-ribbsa… - GitHub Action Repository: patrickswedish/zeroslop-action - Technical Deep-Dive Writeup: ribbsaetersystems.com What deterministic CI gates or pre-review checks are your teams enforcing in 2026 to get PRs merged cleanly without burning out your staff engineers?
We packaged our upstream LLVM, Meta Velox & DuckDB workflow into a GitHub Action (ZeroSlop) that strips 94% of AI diff bloat and blocks 12 CWE security flaws >TL;DR: AI coding agents increased raw PR volume by 300%, yet overall merge velocity has dropped by 40% because Senior Engineers spend 15–20 hrs/week untangling 200-line hallucinated wrappers, deleted tests, and subtle CWE security flaws. At Ribbsaeter Systems, we packaged our upstream systems workflow (LLVM #215076, Meta Velox #18529, DuckDB #26443, Apache DataFusion #24394, Microsoft Pyright #11605, Tokio #8595, ClickHouse #114954, OpenAI Triton #12221) into a deterministic GitHub Actions PR Airlock called ZeroSlop (GitHub Marketplace · Security Brief) that strips 94% of AI bloat, enforces 12 CWE security shields, and gets PRs merged 100% green in minutes. 1. Why We Built ZeroSlop: The 2026 PR Review Bottleneck When shipping patches into upstream systems repos like LLVM, Meta Velox, DuckDB, or Tokio, a real production fix is 4 to 15 lines of surgical invariant changes paired with a deterministic regression test that passes CI on the first push. Inside commercial teams using Cursor, Copilot, or Claude Code, the opposite happens: - 94% Diff Bloat: An agent generates a 240-line wrapper class instead of fixing a 4-line boundary condition (try the live interactive diff simulator here). - Weakened Tests: When a test fails, the agent silently deletes or loosens the assertion to manufacture a green check. - Noisy LLM Review Bots: First-gen AI review bots run another LLM to dump 25 conversational comments on top of a bloated PR—without shrinking the diff or getting the PR merged. 2. Conversational AI Review Bots vs. ZeroSlop End-to-End PR Airlock |Engineering Dimension|Conversational AI Review Bots|ZeroSlop End-to-End PR Airlock| |:-|:-|:-| |Primary Objective|Leave 25+ chatty LLM comments on a bloated diff|Get PRs merged 100% green in minutes (not 4 days)| |Diff Minimization|Reviews 200 lines of hallucinated wrappers as-is|Strips up to 94% of AI bloat down to the core AST invariant| |CI Failure Triage|Ignores red CI checks or hallucinates fixes|Separates contributor code defects from runner flakes| |Test Integrity|Misses deleted or weakened test assertions|Hard-blocks unauthorized regression test deletions| |PR Comment Hygiene|Spams new threads on every git push|Strictly 1 self-updating PR Verdict Card + Mermaid Graph| |Security & Privacy|Cloud LLM retention & external SaaS portals|12-Shield CWE Airlock + Zero Code Retention| 3. The 12-Shield Deterministic CWE Security Airlock (ZS-SEC-001 – ZS-SEC-012) Every Pull Request is audited in milliseconds across 8 merge-readiness signals (0–100) and 12 deterministic CWE security shields before a human reviewer is ever pinged: |Shield ID|Security Vector & CWE / CVE Mapping|What ZeroSlop Security Deterministically Intercepts| |:-|:-|:-| |ZS-SEC-001|SQL & Vector Injection (CWE-89 / CWE-943)|Unparameterized SQL/NoSQL/Vector template literals & raw string concatenation| |ZS-SEC-002|Hardcoded & Seeded Secrets (CWE-798 / CWE-256)|API keys, JWT secrets, seeded credentials & plaintext password comparisons| |ZS-SEC-003|Unbound Identity / IDOR (CWE-639 / CWE-862)|Trusting req.body.userId instead of verified session/JWT caller identity| |ZS-SEC-004|Command & Eval Injection (CWE-78 / CWE-94)|Unsanitized child_process.exec, os.system, subprocess(shell=True), eval()| |ZS-SEC-005|Silent Exception Swallowing (CWE-390)|Empty catch () {} blocks and swallowed promise rejections that hide crashes| |ZS-SEC-006|XSS & Unsanitized DOM (CWE-79)|dangerouslySetInnerHTML, .innerHTML =, and unescaped template rendering| |ZS-SEC-007|Unsafe Deserialization (CWE-502 / CWE-22)|pickle.loads, yaml.load without SafeLoader, and ../ path traversal| |ZS-SEC-008|SSRF & Disabled TLS (CWE-918 / CWE-295)|Unvalidated fetch(req.body.url) & NODE_TLS_REJECT_UNAUTHORIZED = '0'| |ZS-SEC-009|CI/CD "Pwn Requests" (CWE-426 / CWE-250)|pull_request_target fork checkouts & ${{ github.event.pull_request.title }} injection| |ZS-SEC-010|AI Package "Slopsquatting" (CWE-829 / CWE-494)|Hallucinated npm/PyPI/Cargo packages, unpinned Git deps & curl | bash| |ZS-SEC-011|Resource Exhaustion / DoS (CWE-400 / CWE-770)|while(true) without exit bounds, leaked setInterval, unbounded Promise.all| |ZS-SEC-012|Trojan Source & Jailbreaks (CVE-2021-42574)|Invisible Unicode Bidi characters & hidden IGNORE PREVIOUS INSTRUCTIONS comments| 4. Battle-Tested Upstream Proof (Click Any PR to Inspect the Diff) ZeroSlop codifies the exact zero-slop invariant discipline used to ship merged & 100% green Pull Requests across tier-1 systems repositories: |Upstream Repository|Live Pull Request|Status|Core Systems Invariant & Regression Coverage| |:-|:-|:-|:-| |LLVM Compiler Infrastructure|llvm/llvm-project#215076|MERGED|Fixed Waitcnt brackets in GFX12+ AMDGPUAsmPrinter with MC regression tests| |Meta Velox Execution Engine|facebookincubator/velox#18529|MERGED|Aligned DecimalUtil::toByteArray overflow bounds & restored kMaxDecimalBytes| |DuckDB Analytical Engine|duckdb/duckdb#26443|MERGED|Fixed pragma_storage_info internal compressed segment type alignment| |Apache DataFusion Query Engine|apache/datafusion#24394|MERGED|Fixed lead()/lag() window frame boundaries with non-NULL default values| |Microsoft Pyright Type Checker|microsoft/pyright#11605|MERGED|Resolved type narrowing across nested loop closures & pattern guards| |Tokio Async Rust Runtime|tokio-rs/tokio#8595|100% GREEN|Preserved Coop budget accounting across JoinSet & FuturesUnordered| |ClickHouse OLAP DBMS|ClickHouse/ClickHouse#114954|100% GREEN|Fixed StorageURL distributed cluster header forwarding & serialization| |OpenAI Triton GPU Compiler|triton-lang/triton#12221|100% GREEN|Enforced strict tensor descriptor stride & block shape invariants| |Cloudflare Agents SDK|cloudflare/agents#1248|MERGED|Hardened state synchronization & WebSocket hibernation lifecycle invariants| 5. Live Interactive PR Simulator & GitHub Action Links - Interactive PR Simulator & Product Hub: ribbsaetersystems.com - 12-Shield CWE & Zero-Retention Security Matrix: ribbsaetersystems.com - GitHub Marketplace: github.com/marketplace/actions/zeroslop-by-ribbsa…