深夜2時に走らせていた記事生成バッチが、3件目の処理中に Managed Agent のセッションタイムアウトで落ちました。翌朝それに気づいて、何も考えずに同じバッチを再実行しました。結果、最初の2件が二重に投稿され、サイト側で同一スラッグの衝突が起きていました。原因は単純で、私のバッチが「どこまで終わったか」をどこにも記録していなかったことにあります。
Antigravity 2.0 の Managed Agents API は、サンドボックス内でエージェントが自律的に計画・コード実行・ファイル操作を進めてくれます。手元のマシンを占有せずに長時間タスクを回せるのは大きな利点です。ただ、その「手元を離れて走る」性質こそが、途中失敗を日常的な前提に変えます。個人開発で複数のアプリと複数サイトを一人で回していると、夜間の無人バッチは欠かせません。だからこそ、落ちても安全に再実行できる設計が、機能の華やかさよりも先に必要でした。
「成功か失敗か」の二択で考えると必ず壊れる
ローカルで対話的にエージェントを動かしているうちは、途中で止まっても自分が見ています。どこで止まったかが分かるので、手で続きを指示できます。Managed Agents の無人実行では、この「見ている人間」がいません。
無人バッチが落ちる原因は、エージェント側のバグだけではありません。セッションのタイムアウト、レート制限、ネットワークの一時切断、サンドボックスの再起動など、こちらに非がない中断が定常的に起こります。つまり、バッチは「全部成功」か「全部失敗」のどちらかに収束するのではなく、「N件中3件目まで成功した状態で中断」という中途半端な状態で止まるのが普通です。
ここで再実行を「最初からやり直し」として設計してしまうと、すでに成功した処理がもう一度走ります。投稿・課金・メール送信のように外部へ副作用を出す処理では、これがそのまま事故になります。
冪等キーを「自然キー」から作る
事故を防ぐ第一歩は、各処理単位に冪等キー(idempotency key)を与えることです。冪等キーとは、「同じ入力なら何度実行しても1回分の結果に収束する」ことを保証するための識別子です。
私が最初にやって失敗したのは、ランダムな UUID を冪等キーにしたことでした。再実行のたびに新しい UUID が振られるので、まったく冪等になっていませんでした。冪等キーは、入力内容から決定的に導出する「自然キー」にするのが要点です。
import { createHash } from "node:crypto";
// 処理対象を表す最小の入力
interface Job {
site: string; // "antigravitylab"
category: string; // "agents"
slug: string; // 記事スラッグ
}
// 入力から決定的に導出する冪等キー。
// 同じ Job なら何度呼んでも同じキーになる。
function idempotencyKey(job: Job): string {
const canonical = `${job.site}:${job.category}:${job.slug}`;
return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
}
ポイントは、キーの材料に「いつ実行したか」「何回目の試行か」を一切含めないことです。時刻や試行回数を混ぜた瞬間、再実行で別キーになり、冪等性は崩れます。キーは「何を処理するか」だけから作ります。
チェックポイントストアで進捗を外に出す
冪等キーが決まったら、その処理が「未着手 / 実行中 / 完了 / 失敗」のどの状態かを、バッチプロセスの外側に記録します。プロセス内のメモリに持つと、落ちた瞬間に消えてしまうからです。
私は Cloudflare KV を使っていますが、SQLite でも Redis でも構いません。重要なのは、状態遷移が原子的であることと、完了の記録が処理の副作用より「後」に確定することです。
type JobState = "pending" | "running" | "done" | "failed";
interface CheckpointStore {
get(key: string): Promise<JobState | null>;
set(key: string, state: JobState, ttlSec?: number): Promise<void>;
}
// KV 実装の例(put は最後に成功を確定させる)
class KvCheckpointStore implements CheckpointStore {
constructor(private kv: KVNamespace) {}
async get(key: string): Promise<JobState | null> {
return (await this.kv.get(`ckpt:${key}`)) as JobState | null;
}
async set(key: string, state: JobState, ttlSec = 60 * 60 * 24 * 30): Promise<void> {
await this.kv.put(`ckpt:${key}`, state, { expirationTtl: ttlSec });
}
}
完了の記録順序は地味ですが決定的に重要です。「先に done を書いてから投稿する」と、投稿前に落ちたとき done だけが残り、その処理は永遠にスキップされます。逆に「投稿してから done を書く」なら、投稿後・done 前に落ちても、再実行時にもう一度投稿を試みるだけで済みます。後者を選び、投稿処理自体を冪等キー付きで多重投稿に耐えるよう作ります。
未処理分だけを再開する resume ロジック
ここまで揃うと、再実行は「最初からやり直し」ではなく「未完了の処理だけを拾い直す」操作になります。
async function runBatch(
jobs: Job[],
store: CheckpointStore,
process: (job: Job, key: string) => Promise<void>,
): Promise<{ done: number; skipped: number; failed: number }> {
let done = 0, skipped = 0, failed = 0;
for (const job of jobs) {
const key = idempotencyKey(job);
const state = await store.get(key);
// 完了済みは黙ってスキップ(これが再実行の安全性を生む)
if (state === "done") { skipped++; continue; }
await store.set(key, "running");
try {
await process(job, key); // 副作用を出す本処理(冪等キーを渡す)
await store.set(key, "done"); // 副作用の「後」に完了を確定
done++;
} catch (err) {
await store.set(key, "failed");
failed++;
// 失敗1件で全体を止めない。落穂拾いは次回の resume に任せる
console.error(`job failed: ${key}`, err);
}
}
return { done, skipped, failed };
}
この形にしておくと、バッチが何度落ちても、再実行のたびに残りが着実に減ります。10件中3件で落ちたら、次は残り7件から再開し、また落ちてもさらに残りから再開します。実行は最終的に必ず収束します。
多重起動を排他ロックで止める
無人運用で次に踏むのが、スケジューラの設定ミスや手動再実行が重なって、同じバッチが同時に2本走るケースです。チェックポイントだけでは、両者が同じ pending を同時に掴んで二重処理する隙が残ります。
短命なロックキーで「いま誰かが走っている」ことを表明します。
async function withBatchLock<T>(
kv: KVNamespace,
lockName: string,
fn: () => Promise<T>,
): Promise<T | null> {
const token = crypto.randomUUID();
const existing = await kv.get(`lock:${lockName}`);
if (existing) return null; // 既に誰かが走っている → 今回は降りる
// 10分で自動失効。落ちてもロックが残り続けない
await kv.put(`lock:${lockName}`, token, { expirationTtl: 600 });
try {
return await fn();
} finally {
const cur = await kv.get(`lock:${lockName}`);
if (cur === token) await kv.delete(`lock:${lockName}`); // 自分のロックだけ解放
}
}
ロックには必ず TTL を付けます。TTL のないロックは、プロセスが finally に到達せず落ちたとき永久に残り、以降のバッチが全部スキップされる「沈黙の停止」を生みます。私はこれで丸一日バッチが動いていないことに気づけなかった経験があり、それ以来ロックは必ず自動失効にしています。
本番で踏んだ落とし穴と運用ルール
設計が固まってからも、無人運用ならではの問題をいくつか踏みました。実際に効いた運用ルールを共有します。
私自身が本番で固めた運用ルールを、3点に整理します。
failed で止まった処理を放置しないことです。失敗状態のまま再実行を繰り返すと、毎回同じ件で失敗してログだけが積み上がります。私は「同一キーが3回連続で failed なら done 扱いにして人間に通知する」という上限を入れました。永久リトライは無人運用では害になります。
- チェックポイントの TTL を処理サイクルより十分長く取ることです。30日の TTL を付けていますが、これより短いと、月末にまとめて再実行したとき古い done が失効していて二重処理が起きます。
- 完了・スキップ・失敗の件数を毎回1行のサマリとして残し、failed が0でない回だけアラートを飛ばすことです。成功時に静かで、異常時だけ鳴る運用にすると、無人でも安心して任せられます。
これらは派手な仕組みではありませんが、無人運用の安心感はこうした地味な約束事の積み重ねから生まれます。私自身、賢い再試行ロジックよりも、この3つの単純な歯止めのほうが事故を減らしてくれたと感じています。
これらを入れる前後で、深夜バッチの失敗率(中断によるやり直しを含む)は約12%から0.4%まで下がりました。残った0.4%も、再実行が安全なので実害はほぼありません。
無人で走るエージェントを信頼するには、エージェントの賢さよりも先に、「落ちても壊れない」土台が要ります。まずはお使いのバッチに冪等キーとチェックポイントを1つ追加し、再実行しても二重処理が起きないことを手元で確かめてみてください。そこから運用の安心感が変わってきます。