Managed Agents の公開プレビューが Gemini API に届いた週、私は嬉しさよりも先に一つの不安を抱えました。クラウド側で走る長時間ジョブは、こちらが画面を見ていない間に終わります。では、その「終わった」という事実は、どうやって手元に返ってくるのでしょうか。
個人開発で Cloudflare Workers を使い、複数サイトの無人処理をエージェントに任せて回している私にとって、これは他人事ではありませんでした。常時起動のサーバーを持たないと決めている以上、完了通知の受け口もサーバーレスになります。そして push 型の通知(webhook)は、便利な反面、静かに落ちます。落ちたことにさえ気づけないのが一番怖いところです。
ここから、Managed Agent の完了を「取りこぼさない」ための受信側の設計を、実際に動くコードとともに組み立てます。核になるのは、push(webhook)と pull(ポーリング)を最初から二重化し、どちらか一方が沈黙しても最終的な状態が一致する、という考え方です。
「完了したのに気づけない」はどこで起きるのか
まず、失敗の起き方を具体的に分解します。クラウドの Managed Agent → 自分の受信口、という経路には、想像以上に多くの隙間があります。
一つ目は、単純な配信欠落です。受信口が一瞬 5xx を返した、デプロイ中で数百ミリ秒だけ落ちていた、あるいはネットワークの都合で配送が諦められた。at-least-once を謳うプラットフォームでも、リトライ回数には上限があります。上限を超えれば、その通知は静かに消えます。
二つ目は、重複配信です。受信口が処理に成功したのに応答を返す直前で切れると、送信側は「失敗した」と判断して再送します。結果として、同じ完了イベントを二回受け取ります。ここで課金処理やファイル公開のような副作用を素直に二回実行すると、実害が出ます。
三つ目は、順序逆転です。running → succeeded の二つの状態遷移通知が、逆の順で到着することがあります。ネットワーク経路が違えば追い越しは普通に起きます。素朴に「最後に来た通知の状態を採用する」と書くと、完了済みのジョブが running に巻き戻ります。
四つ目が、私が実際に一番刺さったレースです。ジョブを起動して、その job_id を KV に書き込む前に、完了 webhook が到着してしまう。クラウド側の処理が速いと、こちらの「起動を記録する」書き込みが終わる前に「終わった」が届きます。受信口は未知の job_id を見て「知らないジョブだ」と捨ててしまう。これは負荷でも障害でもなく、ただの順序の問題なので、再現も説明も難しいのが厄介でした。
これらを個別のバグとして潰していくと、対症療法の山になります。私は、経路そのものを二重化して「どちらかが真実を運んでくれればよい」構造にする方が、結局は堅牢だと考えています。
設計の前提: push と pull を最初から両輪にする
受信の経路を二つ用意します。
一つは push 経路。Managed Agent が完了した瞬間に webhook を叩き、こちらの Cloudflare Workers が受けます。速いですが、前述のとおり落ちます。
もう一つは pull 経路。こちらから定期的に「あのジョブ、終わった?」とクラウドへ問い合わせ、状態を突き合わせます。遅いですが、落ちません。問い合わせる限り、いつかは正しい状態が取れます。
この二つを合流させる場所が、KV に持つジョブレコードです。push でも pull でも、同じレコードの同じ状態遷移を目指します。両者が競合したときにどちらを採用するかは、状態機械のルールで決めます。「最後に来た方」ではなく「より進んだ状態」を採用する、という単調性を守るのが肝心です。
言い換えると、webhook は「速い近道」であって「唯一の真実」ではありません。真実の最終保証はポーリングが担い、webhook はその到達を早めるための最適化として位置づけます。この役割分担を最初に決めておくと、後の実装がぶれません。
冪等な受信口を Cloudflare Workers で組む
まず push 経路の受信口です。設計目標は三つ。署名を検証すること、重複を弾くこと、未知のジョブでも取りこぼさないこと。
// worker: Managed Agent 完了 webhook の受信口
// KV binding: JOBS(ジョブレコード) / SEEN(配信重複の記録)
interface Env {
JOBS: KVNamespace;
SEEN: KVNamespace;
WEBHOOK_SECRET: string;
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
if (req.method !== "POST") return new Response("nope", { status: 405 });
const raw = await req.text();
// 1) 署名検証(本文と共有秘密から HMAC を再計算して定数時間比較)
const ok = await verifySignature(raw, req.headers.get("x-agent-signature"), env.WEBHOOK_SECRET);
if (!ok) return new Response("bad signature", { status: 401 });
const ev = JSON.parse(raw) as {
deliveryId: string; // 配信ごとに一意(重複検知に使う)
jobId: string; // ジョブの一意キー
state: "running" | "succeeded" | "failed";
seq: number; // 送信側が振る単調増加の版番号
finishedAt?: string;
};
// 2) 配信重複を弾く(同じ deliveryId を二度処理しない)
if (await env.SEEN.get(ev.deliveryId)) {
return new Response("duplicate", { status: 200 }); // 200 で返し再送を止める
}
// 3) 状態遷移を単調に適用(未知ジョブでもレコードを作って受け止める)
await applyTransition(env, ev);
// deliveryId は 3 日保持すれば十分(送信側のリトライ窓を超える長さ)
await env.SEEN.put(ev.deliveryId, "1", { expirationTtl: 60 * 60 * 24 * 3 });
return new Response("ok", { status: 200 });
},
};
ここで大事なのは、重複を検知したときに 4xx ではなく 200 を返す点です。送信側にとって 200 は「受理済み」の合図であり、これでリトライが止まります。エラーを返すと、律儀な相手ほど再送を続けて負荷になります。本番で一度、重複に対して 409 を返していたら送信側のリトライが延々と回り続けた経験があり、以来ここは 200 に固定しています。
そして未知の jobId を捨てないことです。次の applyTransition は、レコードが無ければ作ってから遷移を当てます。これが前章のレース(受信が起動記録より速い問題)への答えになります。
状態遷移を単調にして「唯一の真実」を守る
applyTransition の中身です。ここが二重化の合流点であり、push でも pull でも同じ関数を通します。
type JobState = "unknown" | "running" | "succeeded" | "failed";
const RANK: Record<JobState, number> = { unknown: 0, running: 1, succeeded: 2, failed: 2 };
interface JobRecord {
jobId: string;
state: JobState;
seq: number; // 採用済みイベントの最大 seq
finishedAt?: string;
claimedAt?: string; // 副作用を実行した印(後述)
}
async function applyTransition(env: Env, ev: { jobId: string; state: JobState; seq: number; finishedAt?: string }) {
const key = `job:${ev.jobId}`;
const cur = (await env.JOBS.get(key, "json")) as JobRecord | null;
// レコードが無ければ unknown から始める(未知ジョブの取りこぼし防止)
const base: JobRecord = cur ?? { jobId: ev.jobId, state: "unknown", seq: -1 };
// 単調性: より新しい seq、かつ後退しない状態遷移のみ採用する
const forward = ev.seq > base.seq && RANK[ev.state] >= RANK[base.state];
if (!forward) return; // 古い通知・巻き戻し通知は無視
const next: JobRecord = {
...base,
state: ev.state,
seq: ev.seq,
finishedAt: ev.finishedAt ?? base.finishedAt,
};
await env.JOBS.put(key, JSON.stringify(next));
// 終端状態に初めて到達したときだけ、副作用を一度実行する
if ((next.state === "succeeded" || next.state === "failed") && !base.claimedAt) {
await runSideEffectOnce(env, next);
}
}
RANK で状態に順位を付け、seq(送信側が振る単調増加の版番号)と組み合わせて「後退しない」を保証します。順序が逆転して古い running が succeeded の後に届いても、forward が false になって無視されます。これで、完了済みジョブが running に巻き戻る事故は原理的に起きません。
副作用(ファイル公開・後続ジョブの起動・通知送信など)は、終端状態に初めて達したときだけ実行します。push と pull が同時に succeeded を運んできても、先に到達した方が claimedAt を立て、もう一方はそれを見て実行を控えます。二重化した以上、この一回性の保証は必須です。
ポーリング突き合わせをバックストップにする
push が沈黙しても最終的に状態が揃うのは、pull があるからです。Cron Triggers で定期的に走らせ、終端に達していないジョブだけをクラウドへ問い合わせます。
// scheduled(): 一定間隔で未完了ジョブをクラウドと突き合わせる
export async function reconcile(env: Env) {
const list = await env.JOBS.list({ prefix: "job:" });
const now = Date.now();
for (const k of list.keys) {
const rec = (await env.JOBS.get(k.name, "json")) as JobRecord;
if (rec.state === "succeeded" || rec.state === "failed") continue; // 終端は照合不要
// クラウド側の権威的な状態を取得(Managed Agents の取得 API)
const remote = await fetchJobStatus(env, rec.jobId);
if (!remote) continue;
// webhook と同じ applyTransition に通す(合流点は一つに保つ)
await applyTransition(env, {
jobId: rec.jobId,
state: remote.state,
seq: remote.seq, // 取得 API も同じ seq を返す前提にする
finishedAt: remote.finishedAt,
});
// 「起動したのに長時間 unknown/running のまま」を異常として拾う
const ageMin = (now - Date.parse(rec.createdAt)) / 60000;
if (rec.state !== "succeeded" && rec.state !== "failed" && ageMin > 30) {
await alertStuckJob(env, rec.jobId, Math.round(ageMin));
}
}
}
ポーリングを webhook と同じ applyTransition に流し込むのがこの設計の要点です。合流点を一つに保つことで、「webhook 経由のレコード」と「ポーリング経由のレコード」が別々の真実を持つ事故を防げます。どちらの経路も、単調性のルールを通った結果しか書き込めません。
間隔の勘所は、ジョブの想定所要時間から逆算します。数分で終わるジョブなら 1〜2 分間隔、数十分かかるものは 5 分間隔で十分でした。KV の list はキーが増えると重くなるので、終端に達したレコードは翌日に別プレフィックスへ退避するか、TTL で自然消滅させると、照合対象が肥大化しません。私は 6 サイト分の無人ジョブを回していますが、終端レコードを 24 時間で畳む運用にしてから、reconcile 一周のコストがほぼ一定に収まりました。
個人開発での運用: しきい値と監視の勘所
二重化は「保険をかけた」だけでは意味がなく、保険が効いているかを見える化して初めて安心できます。私が実際に監視している指標を挙げます。
| 指標 | 意味 | 目安・対処 |
| webhook 先着率 | 終端到達を push が先に運んだ割合 | 健全なら 95% 以上。急落は受信口かネットワークの不調 |
| reconcile 救済数 | ポーリングが先に終端を確定した件数 | 常時 0 が理想。増えたら webhook 経路を点検 |
| stuck 検知数 | 30 分超えても未完了のジョブ数 | 1 件でも出たらクラウド側の失敗かキュー詰まりを疑う |
| 重複配信率 | SEEN で弾いた再送の割合 | 0.1〜0.5% 程度は正常。極端に高いなら 200 応答の欠落を疑う |
とりわけ「reconcile 救済数」は先行指標として優秀です。ふだんは 0 のこの値が増え始めたら、それは webhook が静かに落ち始めた合図であり、ユーザーや自分が実害に気づく前に手を打てます。push だけの構成では、この兆候そのものが観測できません。二重化の本当の価値は、速さの担保ではなく、この「片方の沈黙を可視化できること」にあると私は考えています。
しきい値は自分の運用実感で決めて構いません。たとえば私が App Store と AdMob 向けの集計を回している無人ジョブでは、この二重化を入れてから完了の取りこぼしが体感でゼロになり、以来この構成を個人開発の標準として推奨しています。私の場合、無人ジョブの完了は「起動から 5 分以内に手元のレコードが終端になる」ことを内部 SLA にしています。この基準を割った回数を週次で眺めるだけでも、経路のどこが弱っているかが見えてきます。
実装でつまずきやすい点
いくつか、本番で実際に踏んだ落とし穴を共有します。
送信側が振る seq を信じきらないこと。プラットフォームによっては、状態ごとに独立した版番号を振る場合があり、running と succeeded で seq の連番が保証されないことがあります。その場合は状態の RANK を優先し、seq は同一状態内のタイブレークにとどめる、と割り切る方が安全でした。
KV の結果整合性を過信しないこと。SEEN.put の直後に別リージョンの読み取りが古い値を返すことがあり、ごく短時間なら重複が通り抜けます。だからこそ、重複検知は「一次防御」に過ぎず、最終的な一回性は applyTransition 内の claimedAt チェックで担保する、という二段構えにしておきます。重複防御と副作用の一回性を別レイヤーに分けるのが、私の学んだ教訓です。
署名検証を本文パース後に回さないこと。JSON.parse してから検証すると、不正な本文で例外が飛び、攻撃者に受信口の挙動を探る余地を与えます。生の本文で検証し、通過してからパースする順序を崩さないでください。この受信口の基本形については、Antigravity で受信エンドポイントを堅牢に作る観点をまとめたWebhook 受信エンドポイントを取りこぼしなく作る実践パターンも併せて参考になります。
副作用の一回性は、冪等キーで守ること。claimedAt の印だけでは、KV 書き込みと副作用実行の間で落ちると取りこぼしが残ります。副作用側(決済・公開 API など)が冪等キーを受け付けるなら、jobId をそのキーに使い、受信側の印と二重に守るのが堅実です。無人バッチ全体を再実行で壊さない設計についてはManaged Agents の無人バッチを再実行で壊さない — 冪等性とチェックポイントの設計で扱っています。
そもそもジョブをどう安全に起動するかは受信設計と表裏一体です。起動側の作法はManaged Agents API でバックグラウンドタスクを安全に走らせる設計にまとめています。
この設計を今日から始めるなら
まず一本、手元の無人ジョブに reconcile のポーリングだけを足してみてください。webhook をまだ持っていなくても、pull 経路だけで「完了を取りこぼさない」土台は成立します。そのうえで push を最適化として重ね、reconcile 救済数 が 0 に張り付くのを確認できたら、二重化は正しく効いています。
Managed Agents のようにクラウドで非同期に動く仕組みは、これから個人開発でも当たり前になっていくはずです。私自身まだ運用しながら学んでいる最中ですが、「速い経路」と「落ちない経路」を分けて設計する発想は、他の非同期処理にもそのまま応用できると感じています。お読みいただきありがとうございました。