6本のアプリを個人で並行運用していると、エージェントに任せたい作業はいくらでも出てきます。依存パッケージの更新、多言語リソースの整合、Remote Config のフラグ調整、AdMob のメディエーション設定の見直し。2014年から手で回してきた作業ほど、自動化したい気持ちが強くなります。
ところが今年の春、ある木曜の深夜に肝が冷えました。Antigravity のエージェントに「広告まわりの設定を整理して」とだけ伝えたところ、エージェントは Remote Config のあるフラグを本番に反映してしまったのです。コード編集なら git で戻せます。けれど本番の Remote Config は、すでに 5,000万DL のうちその時間に起動した端末へ配信が始まっていました。取り消しても、もう新しい値を受け取った端末は元に戻りません。テストは通っていました。問題はテストの外、「取り消せるかどうか」にありました。
このとき気づいたのは、エージェント運用で先に効いてくるのは賢さではなく その操作をあとから取り消せるか だということです。賢いエージェントでも必ず間違えます。間違えたときに git で戻せる操作と、世界に痕跡が残って戻せない操作とでは、任せ方をまったく変えるべきでした。ここでは、操作を可逆性で分類し、自律度を段階的に切り替える設計を実装とともに整理します。
操作を「やり直せるか」で二分する
ソフトウェアの意思決定には、あとから引き返せる「両開きの扉(two-way door)」と、通り抜けたら戻れない「片開きの扉(one-way door)」がある、という考え方があります。これをエージェントの操作にそのまま当てはめると、運用の見通しが一気に良くなります。
両開きの扉にあたる操作は、ファイルの編集、ブランチへのコミット、下書きの生成、ローカルでのビルドなどです。失敗しても git で戻せますし、誰の目にも触れません。こうした操作にいちいち人間の承認を挟むのは、エージェントを雇った意味を消してしまいます。
片開きの扉にあたる操作は、App Store への申請、本番データベースへのマイグレーション、Remote Config の本番反映、課金イベントの発火、外部への通知送信などです。一度実行すると、ユーザーの端末・決済・外部システムに痕跡が残り、コマンド一つでは戻せません。ここを賢さに任せてはいけません。
私が最初に作った仕組みは「重要な操作は承認を挟む」というものでした。けれど「重要」という基準が曖昧で、結局あらゆる操作で確認ダイアログが出て、エージェントはほとんど自律的に動けませんでした。基準を「重要かどうか」ではなく 「取り消せるかどうか」 に置き換えたとき、初めて運用が回り始めました。
可逆性は3階層で捉えると運用しやすい
二分だけでは現場に合いませんでした。両開きと片開きの間に、もう一段が必要だったからです。私は最終的に、操作を次の3階層に分けています。
第1階層は 自動可逆 です。git で戻せる、外部に痕跡が残らない、巻き戻しコストがほぼゼロ。コード編集、ローカルテスト、下書き生成などが該当します。これはエージェントに無条件で任せます。
第2階層は 要チェックポイント です。技術的には戻せるけれど、戻すのに手間がかかる、あるいは戻し忘れると危ない操作です。ステージング環境への反映、スキーマ変更を伴わないデータ更新、ブランチ間のマージなどが該当します。実行する前に必ず巻き戻し点(スナップショット)を残し、戻せる状態を保証してから自動実行します。
第3階層は 不可逆 です。実行したら世界に痕跡が残り、人間にしか責任を持てない操作です。ストア申請、本番マイグレーション、課金・通知の発火がここに入ります。この階層だけは、エージェントが「実行直前」で止まり、人間が中身を確認してから通します。
この3階層の良いところは、判断の重さが操作の取り消しやすさに正確に比例することです。安全な操作は速く、危険な操作だけ慎重に。賢さの議論を持ち込まずに、機械的に振り分けられます。
操作を分類する — 可逆性分類器の実装
まず、エージェントが実行しようとする操作を3階層のどれかに振り分ける分類器を用意します。操作は「種別」と「対象スコープ」を持つオブジェクトとして表現し、ルールベースで判定します。機械学習に頼らないのは、可逆性の判定こそ曖昧であってはならないからです。
// reversibility.ts — 操作を可逆性で3階層に分類する
export type Tier = "auto" | "checkpoint" | "irreversible" ;
export interface AgentOperation {
kind : string ; // 例: "file.edit", "remoteConfig.publish"
target : string ; // 例: "src/App.tsx", "production"
reversalCostSec ?: number ; // 戻すのに必要な秒数の見積もり(任意)
}
// 不可逆操作は「許可リスト」ではなく「明示列挙」で管理する。
// 列挙漏れがあった場合は安全側(irreversible)に倒す。
const IRREVERSIBLE_KINDS = new Set ([
"appstore.submit" ,
"playstore.release" ,
"db.migrate" ,
"remoteConfig.publish" ,
"billing.charge" ,
"notification.send" ,
"email.send" ,
]);
const AUTO_KINDS = new Set ([
"file.edit" ,
"file.create" ,
"test.run" ,
"build.local" ,
"draft.generate" ,
]);
export function classify ( op : AgentOperation ) : Tier {
// 1. 本番対象の操作は、種別が安全でも一段引き上げる
const hitsProduction = op.target === "production" || op.target. startsWith ( "prod" );
if ( IRREVERSIBLE_KINDS . has (op.kind)) return "irreversible" ;
if (hitsProduction) return "irreversible" ; // 本番に触れるなら不可逆扱い
if ( AUTO_KINDS . has (op.kind)) return "auto" ;
// 2. 戻すコストが大きい操作はチェックポイントを要求する
if ((op.reversalCostSec ?? 0 ) > 30 ) return "checkpoint" ;
// 3. 判定不能なものは安全側に倒す(=チェックポイントは最低限残す)
return "checkpoint" ;
}
ここで一番大切なのは、最後の return "checkpoint" です。分類器が知らない操作種別が来たとき、auto に落とすと無防備になります。未知のものは安全側に倒す ―― この一行が、列挙漏れによる事故をまるごと回避できます。私は当初これを auto にしていて、新しく追加した操作種別が分類器に登録される前に自動実行され、肝を冷やしました。
ガードミドルウェアで自律度を切り替える
分類器ができたら、すべての操作をこのガードに通します。階層ごとに振る舞いを変えるだけの薄いミドルウェアです。
// guard.ts — 階層ごとに実行のしかたを切り替える
import { classify, AgentOperation } from "./reversibility" ;
import { snapshot, requireApproval } from "./checkpoint" ;
export interface ExecResult {
executed : boolean ;
checkpointId ?: string ;
reason ?: string ;
}
export async function guardedExecute (
op : AgentOperation ,
run : () => Promise < void >,
) : Promise < ExecResult > {
const tier = classify (op);
if (tier === "auto" ) {
await run ();
return { executed: true };
}
if (tier === "checkpoint" ) {
// 戻せる点を残してから実行する。スナップショットに失敗したら実行しない。
const checkpointId = await snapshot (op);
if ( ! checkpointId) {
return { executed: false , reason: "snapshot-failed" };
}
await run ();
return { executed: true , checkpointId };
}
// irreversible: 人間が中身を見て通すまで実行しない
const approved = await requireApproval (op);
if ( ! approved) {
return { executed: false , reason: "rejected-by-human" };
}
await run ();
return { executed: true };
}
このミドルウェアの効果は、運用の数字にはっきり出ました。導入前は「やり直せない操作をエージェントが勝手に実行した未遂」が週に1〜2回ありました。導入してからの3週間は、その種の未遂がゼロになりました。操作の内訳を測ると、全体の約70%が自動可逆(第1階層)、約22%が要チェックポイント(第2階層)、人間ゲートが必要な不可逆操作は約8%でした。承認待ちで実際に止まる回数は1日あたり平均3.4回です。1日に何十回も動くエージェントの中で、本当に止めるべき3.4回だけを止められれば、それで十分です。
可逆操作には「巻き戻し点」を必ず残す
第2階層の肝は、実行の前にスナップショットを取り、いつでも戻せる状態を保証することです。対象によって巻き戻し点の作り方は変わります。コードなら git、設定ファイルならコピー、データなら論理スナップショットです。
// checkpoint.ts — 操作の種別に応じた巻き戻し点を作る
import { execFile } from "node:child_process" ;
import { promisify } from "node:util" ;
const sh = promisify (execFile);
export async function snapshot ( op : { kind : string ; target : string }) : Promise < string | null > {
const id = `cp-${ Date . now () }` ;
try {
if (op.kind. startsWith ( "file." ) || op.kind. startsWith ( "build." )) {
// git stash で作業ツリーごと退避し、いつでも戻せるようにする
await sh ( "git" , [ "stash" , "push" , "-u" , "-m" , id]);
return id;
}
if (op.kind. startsWith ( "config." )) {
// 設定ファイルはタイムスタンプ付きでコピーしておく
await sh ( "cp" , [op.target, `${ op . target }.${ id }.bak` ]);
return id;
}
// それ以外は巻き戻し点を作れない可能性がある。null を返して実行を止める。
return null ;
} catch {
return null ;
}
}
export async function rollback ( checkpointId : string ) : Promise < void > {
// git stash の一覧から該当 ID を探して戻す
const { stdout } = await sh ( "git" , [ "stash" , "list" ]);
const line = stdout. split ( " \n " ). find (( l ) => l. includes (checkpointId));
if (line) {
const ref = line. split ( ":" )[ 0 ]; // 例: stash@{0}
await sh ( "git" , [ "stash" , "pop" , ref]);
}
}
ここで一つ、実運用で学んだ落とし穴があります。git stash でコードを退避したつもりでも、-u(追跡外ファイルを含む)を付け忘れると、エージェントが新規生成したファイルが退避されず、ロールバックしても残ってしまいます。巻き戻し点は「全部戻せる」ことを保証して初めて意味を持ちます。半分しか戻せないスナップショットは、戻せると思い込ませる分だけ危険です。
スナップショットのオーバーヘッドは、私の6アプリの環境で平均120ミリ秒でした。第2階層に該当する操作は全体の約20%なので、エージェント全体の速度低下は体感できないレベルです。この程度のコストで「いつでも戻せる」という安心が買えるなら、安いものだと考えています。
不可逆操作の前に挟む「最小確認」の設計
第3階層で人間が止める設計にしたとき、最初に失敗したのは確認画面に情報を出しすぎたことでした。エージェントの思考ログを全部見せたら、私は逆に判断できなくなりました。人間が片開きの扉の前で見たいのは、ただ3つだけです。
// approval.ts — 不可逆操作の直前に最小限の情報だけ提示する
export interface ApprovalCard {
whatChanges : string ; // 何が変わるか(1行)
whoIsAffected : string ; // 誰に影響するか
ifWrong : string ; // 間違っていたらどうなるか
}
export function buildCard ( op : { kind : string ; target : string }) : ApprovalCard {
if (op.kind === "remoteConfig.publish" ) {
return {
whatChanges: `Remote Config「${ op . target }」を本番に反映` ,
whoIsAffected: "次回起動する全ユーザー(取り消し後も配信済み端末は戻らない)" ,
ifWrong: "誤った値が端末にキャッシュされ、強制更新まで残る" ,
};
}
if (op.kind === "appstore.submit" ) {
return {
whatChanges: `App Store へ「${ op . target }」を申請` ,
whoIsAffected: "審査チーム・既存ユーザー" ,
ifWrong: "審査リジェクトまたは不本意なバージョン公開、取り下げに数日" ,
};
}
return {
whatChanges: `${ op . kind } を ${ op . target } に実行` ,
whoIsAffected: "外部システム" ,
ifWrong: "手動での巻き戻しが必要" ,
};
}
「何が変わるか」「誰に影響するか」「間違っていたらどうなるか」。この3つだけを1枚のカードにすると、判断は数秒で済むようになりました。とくに3つ目の「間違っていたらどうなるか」が重要です。これがその操作の不可逆性そのものを言語化しているからです。戻すのに数日かかると書いてあれば、人は自然と慎重になります。
公式ドキュメントには書かれていない運用知見
ここまでの仕組みを数週間の本番運用で回して見えてきた、設計図だけでは分からない点を3つ共有します。
可逆性には賞味期限があります。 git のコミットは直後なら戻せますが、その上に10個コミットが積まれたあとでは「戻せるが戻すと危ない」操作に変わります。第1階層だった操作が時間とともに第2階層へ移ることを前提に、巻き戻し点の有効期限を設けておくと安全です。私は1時間を超えた stash は自動で確認対象に格上げしています。
「戻せるように見えて戻せない」操作が一番危険です。 データベースの UPDATE は技術的には元の値に戻せます。けれど、その間にユーザーが新しい値を前提に別の操作をしていたら、値を戻しても整合性は戻りません。可逆性は操作単体ではなく、その操作が外の世界とどう絡むかで決まります。迷ったら不可逆として扱うことを推奨します。
分類器こそ最も慎重に変更すべきコードです。 新しい操作種別を追加するとき、分類器への登録を忘れると、安全側に倒す設計のおかげで事故にはなりませんが、エージェントが余計に止まります。逆に不可逆操作を誤って auto 側へ登録すると、防御が一瞬で崩れます。私は分類器の変更だけはレビューを必ず人間が通すルールにしています。皮肉なことに、自律実行を支える土台のコードが、最も自律実行を許してはいけない場所でした。
どこから入れるか
全部を一度に作る必要はありません。まず、自分のエージェントが触る不可逆操作を10個書き出してみてください。ストア申請、本番反映、課金、通知。おそらく数は多くないはずです。私の6アプリ環境でも、本当に不可逆なのは8種類ほどでした。その8種類だけを IRREVERSIBLE_KINDS に列挙し、ガードで人間ゲートを挟む。これだけで、夜中にエージェントが取り返しのつかない操作をする確率を、ほぼゼロにできます。チェックポイント層やカードの作り込みは、運用しながら少しずつ足していけば十分です。
可逆な操作は思い切って任せ、不可逆な操作だけ自分の手元に残す。賢さではなく取り消しやすさで線を引くこの考え方が、私が6本のアプリをエージェントと並行運用するうえで、いちばん安心して眠れるようになった工夫でした。同じように本番をエージェントに任せ始めた方の参考になれば嬉しいです。