5,000万DLのアプリを個人で運用していると、エージェントに任せたい雑務はいくらでも出てきます。依存パッケージの一括更新、多言語リソースの整合、Crashlytics で見つかった同型クラッシュのまとめ修正。2014年からずっと手で回してきた作業ほど、自動化したい欲が強くなります。
ところが、Antigravity のエージェントに本番リポジトリの一括書き換えを初めて任せた日、私は38ファイルが一度に壊れた差分を前に固まりました。エージェントの判断は1箇所だけ間違っていたのですが、その1箇所を「全ファイルに横展開」する設計だったため、被害が一気に広がったのです。テストは通っていました。壊れたのは、テストが見ていない初期化順序でした。
このとき痛感したのは、エージェント運用で先に効いてくるのは賢さではなく ブラスト半径(blast radius/1回の失敗が及ぶ被害範囲) だということです。賢いエージェントでも必ず間違えます。問題は「間違えたとき、どこまで巻き込むか」です。ここでは、被害範囲を構造的に小さく保つ封じ込め設計を、実装とともに整理します。
ブラスト半径を3軸で測る
「事故が怖い」は感想であって設計にはなりません。封じ込めの議論を始める前に、被害範囲を測る物差しを決めます。私は次の3軸を使っています。
- 影響範囲(spread): 1回の実行が変更するファイル数・レコード数・外部呼び出し数
- コスト(cost): その実行で消費するトークン課金額と、失敗時に無駄になる金額
- 復旧時間(MTTR): 壊れてから元に戻すまでの実時間
38ファイル事故を3軸で振り返ると、影響範囲は38(過大)、コストは1回 ¥120 程度(軽微)、復旧時間は git revert とビルド検証で27分(重い)でした。つまりこの作業の本当のリスクは「お金」ではなく「広がりすぎる影響範囲」と「長い復旧時間」にあったわけです。軸を分けて測ると、どこを締めるべきかが見えてきます。
この3軸を実行前に見積もる小さなヘルパーを最初に置きます。
// risk-estimate.ts — エージェント実行前に被害範囲を見積もる
export interface RiskEstimate {
spread: number; // 変更対象の件数(ファイル/レコード/API呼び出し)
costYen: number; // 想定トークン課金(円)
mttrMinutes: number; // 失敗時の想定復旧時間(分)
}
export interface RiskPolicy {
maxSpread: number;
maxCostYen: number;
maxMttrMinutes: number;
}
export function classifyRisk(est: RiskEstimate, policy: RiskPolicy) {
const reasons: string[] = [];
if (est.spread > policy.maxSpread) reasons.push(`spread ${est.spread} > ${policy.maxSpread}`);
if (est.costYen > policy.maxCostYen) reasons.push(`cost ¥${est.costYen} > ¥${policy.maxCostYen}`);
if (est.mttrMinutes > policy.maxMttrMinutes) reasons.push(`mttr ${est.mttrMinutes}m > ${policy.maxMttrMinutes}m`);
// reasons が空なら自動実行可、そうでなければ人間の承認ゲートへ
return { autoApprove: reasons.length === 0, reasons };
}
ポイントは、3軸のどれか1つでも閾値を超えたら自動実行を止めて承認ゲートに回す、という単純な論理にすることです。複雑なスコアリングにすると、なぜ止まったのかが後で説明できなくなります。私は「説明できない自動判断は本番に置かない」を原則にしています。
第1層: dry-run で先に差分を見る
封じ込めの一番外側は、実行する前に「何が変わるか」を確定させる dry-run です。Antigravity のエージェントはツール呼び出しの形で副作用を起こすので、ツール側を「計画モード」と「適用モード」に分けるのが効きます。
// dry-run-wrapper.ts — 副作用ツールを計画/適用の2モードに分離
type Plan = { op: "write" | "delete"; path: string; preview: string }[];
interface ToolContext {
mode: "plan" | "apply";
plan: Plan;
}
export async function writeFile(ctx: ToolContext, path: string, content: string) {
if (ctx.mode === "plan") {
// 実際には書かず、差分だけ計画に積む
ctx.plan.push({ op: "write", path, preview: content.slice(0, 200) });
return { staged: true };
}
await fs.writeFile(path, content, "utf-8");
return { staged: false, written: true };
}
// エージェントの1ターンをまず plan で走らせ、spread を測ってから apply する
export async function runWithDryRun(agentTurn: (ctx: ToolContext) => Promise<void>, policy: RiskPolicy) {
const ctx: ToolContext = { mode: "plan", plan: [] };
await agentTurn(ctx);
const spread = ctx.plan.length;
if (spread > policy.maxSpread) {
return { applied: false, plan: ctx.plan, blockedBy: `spread ${spread}` };
}
await agentTurn({ mode: "apply", plan: ctx.plan });
return { applied: true, plan: ctx.plan };
}
公式のツール設計ガイドにはあまり強調されていませんが、本番で効くのは「エージェントに同じターンを2回走らせる」発想です。1回目は計画、そこで spread を数えてから2回目で適用する。38ファイル事故は、この plan フェーズがあれば「38件変更します」という表示で私が止められました。トークンは2倍弱かかりますが、1回 ¥120 の作業で復旧27分のリスクを消せるなら、安い保険です。
注意点として、plan と apply で実行結果が変わる非決定的なツール(時刻依存・乱数依存・外部状態依存)があると、この2回走行は破綻します。私は副作用ツールの入力を実行開始時にスナップショットして両フェーズに同じ値を渡すようにしています。ここを雑にすると「計画では3件だったのに適用で12件変わった」という最悪の裏切りが起きます。
第2層: スコープを物理的に限定する
dry-run で件数を見ても、エージェントが「触ってよい領域」自体が広ければ被害は広がります。第2層は 権限のスコープを物理的に絞る ことです。プロンプトで「本番DBは触らないで」とお願いするのは封じ込めではありません。触れない構造にして初めて封じ込めです。
私が使っているのは、作業ごとに許可パスのアローリストを宣言し、それ以外への書き込みを実行時に弾くガードです。
// scope-guard.ts — 許可された範囲外の副作用を実行時に拒否する
import path from "node:path";
export class ScopeGuard {
constructor(private allowGlobs: string[], private root: string) {}
private isAllowed(target: string): boolean {
const rel = path.relative(this.root, path.resolve(this.root, target));
if (rel.startsWith("..")) return false; // ルート外は即拒否
return this.allowGlobs.some((g) => this.match(rel, g));
}
assertWritable(target: string) {
if (!this.isAllowed(target)) {
throw new ScopeViolation(`書き込み拒否: ${target} は許可スコープ外です`);
}
}
private match(rel: string, glob: string): boolean {
const re = new RegExp("^" + glob.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*") + "$");
return re.test(rel);
}
}
export class ScopeViolation extends Error {}
// 多言語リソースだけ触らせたい作業の例
const guard = new ScopeGuard(["locales/**/*.json"], process.cwd());
guard.assertWritable("locales/ja/common.json"); // OK
// guard.assertWritable("src/payment/stripe.ts"); // throw ScopeViolation
スコープを絞ると「賢いエージェントの自由度を奪うのでは」と心配になりますが、実体験では逆でした。許可領域が狭いほどエージェントの計画は短く正確になり、無関係なファイルへの「ついで修正」が消えます。私は決済まわり(Stripe 連携)と本番DBマイグレーションだけは、どんなに退屈でも自動スコープから外しています。ここが壊れると復旧時間が分ではなく時間単位になるからです。被害範囲ではなく復旧時間で線を引く、というのが2つ目の判断軸です。
第3層: サーキットブレーカーで連鎖を止める
dry-run とスコープは「1回の実行」の封じ込めです。しかし夜間に何十タスクも自動で回すと、同じ失敗が連鎖する 問題が出ます。外部APIが落ちている、モデルが特定の入力で必ず誤る、といった状況で、エージェントは健気に何度もリトライして課金とゴミ差分を量産します。
ここで サーキットブレーカー を挟みます。一定回数連続で失敗したら回路を開いて、しばらく実行自体を止める仕組みです。
// circuit-breaker.ts — 連続失敗で実行を遮断する
type State = "closed" | "open" | "half-open";
export class CircuitBreaker {
private failures = 0;
private state: State = "closed";
private openedAt = 0;
constructor(
private threshold = 3, // 連続3回失敗で開放
private cooldownMs = 10 * 60_000, // 10分間は実行を止める
) {}
async run<T>(task: () => Promise<T>): Promise<T> {
if (this.state === "open") {
if (Date.now() - this.openedAt < this.cooldownMs) {
throw new CircuitOpen("回路開放中: クールダウンを待機します");
}
this.state = "half-open"; // 試しに1回だけ通す
}
try {
const result = await task();
this.reset();
return result;
} catch (err) {
this.recordFailure();
throw err;
}
}
private recordFailure() {
this.failures += 1;
if (this.failures >= this.threshold) {
this.state = "open";
this.openedAt = Date.now();
}
}
private reset() { this.failures = 0; this.state = "closed"; }
}
export class CircuitOpen extends Error {}
閾値の決め方が実務では一番悩みます。私の運用では 連続3回・クールダウン10分 を起点にしています。閾値を1にすると一時的なネットワーク揺らぎで止まりすぎ、5以上にすると壊れた状態で課金が膨らみます。実測では、AdMob の収益レポートを集計するバッチ系タスクで閾値3に下げてから、API側の一時障害日の無駄課金が月あたり ¥4,800 から ¥600 へ、おおよそ8分の1に減りました。half-open で1回だけ試す挙動を入れておくと、障害が直った瞬間に自動復帰するので、人間が回路を手で戻す手間も消えます。
第4層: デッドレターで失敗を捨てずに溜める
サーキットブレーカーで止めた失敗タスクを、ただエラーログに流して消すのはもったいないです。第4層は デッドレターキュー に失敗を退避し、原因が直ってから安全に再投入する仕組みです。
// dead-letter.ts — 失敗タスクを退避し、選別して再投入する
interface FailedTask {
id: string;
payload: unknown;
error: string;
attempts: number;
firstFailedAt: number;
}
export class DeadLetterQueue {
private queue: FailedTask[] = [];
park(id: string, payload: unknown, error: Error, attempts: number) {
this.queue.push({ id, payload, error: error.message, attempts, firstFailedAt: Date.now() });
}
// 再投入条件: 試行5回未満 かつ 同型エラーでない ものだけ戻す
drain(isRetryable: (t: FailedTask) => boolean): FailedTask[] {
const retryable = this.queue.filter((t) => t.attempts < 5 && isRetryable(t));
this.queue = this.queue.filter((t) => !retryable.includes(t));
return retryable;
}
report() {
const byError = new Map<string, number>();
for (const t of this.queue) byError.set(t.error, (byError.get(t.error) ?? 0) + 1);
return [...byError.entries()].sort((a, b) => b[1] - a[1]); // 多い順
}
}
デッドレターを入れて一番効いたのは、再投入の自動化そのものより report() が出す「失敗の偏り」でした。同じエラーメッセージが上位を占めていたら、それはエージェントの一時的な不調ではなく設計上の欠陥です。私はこの偏りを週次で眺めて、上位3件は再投入せず根本修正に回すルールにしています。失敗を捨てずに溜めると、エージェント運用の弱点が統計として見えてきます。
再投入の落とし穴として、無条件に戻すと「直っていない原因」に対して延々とリトライが走り、サーキットブレーカーと相互に殴り合います。だから drain には必ず「同型エラーは戻さない」フィルタを噛ませます。ここを省くと、せっかく止めた連鎖をデッドレター側が自分で再開させてしまいます。
4層をどこまで掛けるか — 過剰防御を避ける判断
ここまで4層を紹介しましたが、全タスクに全層を掛けるのは過剰防御です。封じ込めは生産性とのトレードオフで、締めすぎると「結局ぜんぶ自分で承認している」状態になり、自動化の意味が消えます。私は最初に作った3軸(影響範囲・コスト・復旧時間)で層を選んでいます。
- 読み取りのみ・影響範囲ゼロ(ログ集計、レポート生成): 封じ込めなしで自動実行。サーキットブレーカーだけ掛ける
- 影響範囲が中・復旧が容易(多言語リソース更新、ドキュメント整形): dry-run + スコープ限定の2層
- 影響範囲が広い or 復旧が重い(依存一括更新、スキーマ変更まわり): 4層フル + 人間の承認ゲート必須
- 決済・本番DB: そもそも自動スコープから除外。エージェントには下書きまでしかさせない
この振り分けを最初に決めておくと、新しいタスクを自動化するとき「どの箱に入れるか」を選ぶだけで済みます。私の経験では、判断の8割は読み取り系か中影響系に落ち、フル封じ込めが必要なのは2割程度でした。逆に言えば、その2割を雑に扱うと38ファイル事故が起きます。
封じ込め設計は、エージェントを信用しないための仕組みではありません。安心して任せられる範囲を、根拠を持って広げていくための足場 だと考えています。賢さは年々上がりますが、被害範囲の設計は自分で持っておく。これが、個人で大規模な本番を回し続けるための私なりの折り合いの付け方です。
今日から始めるなら、次の手順を推奨します。
- いま自動で回している(または回したい)タスクを1つ選び、影響範囲・コスト・復旧時間の3軸で被害を見積もる
- 復旧時間が10分を超える、または影響範囲が許可スコープを越えるなら、まず dry-run とスコープ限定の2層だけ入れる
- 夜間バッチ化するときにサーキットブレーカー(連続3回・10分)とデッドレターを足し、週次で
report() の偏りを見る
この順番なら、最初から4層を抱え込まずに、痛い部分だけを段階的に締められます。私はこのやり方で、自動化できる作業の幅を事故を出さずに少しずつ広げてきました。
まずは今動かしている自動タスクを1つ選んで、3軸で被害範囲を見積もってみてください。そこから「どの層が要るか」が自然と決まってきます。お読みいただきありがとうございました。