エージェントを並列で走らせ始めた最初の週、私は深夜にビルドを壊しました。3 つの Antigravity エージェントを同時に走らせ、それぞれに別々の機能追加を任せたところ、package.json を 3 つが同時に書き換えて、最後の書き込みが他の 2 つを上書きしました。git の履歴上は 3 つの PR が綺麗に並んでいるのに、実際の package.json には依存関係が一つしか追加されていなかったのです。
並列エージェント運用の難しさは、どれが「いつ」何を触ったかが、人間の目では追えなくなる ところにあります。本記事は、Antigravity を本気で並列運用しようとして私が踏んだ落とし穴を、競合の種類ごとに整理し、再現可能な最小例と現実的な対処を一緒に書き出したものです。「並列にすると速くなる」という素朴な期待を抱いている時期にこそ読んでおいてほしい内容です。
並列エージェントが衝突する 5 つのリソース
並列実行を一週間ほど運用してみると、競合が起きる場所はだいたい次の 5 つに収れんします。
同じファイルへの書き込み — package.json、tsconfig.json、ロックファイル、共有 schema。
同じブランチでのコミット — 同じ親コミットから 2 つ以上のエージェントが分岐すると、後発が rebase の地獄に巻き込まれる。
共有キャッシュ/ビルド成果物 — .next/、node_modules/.cache/、dist/ など、片方が読んでいる最中にもう片方が書く。
外部 API のレートリミット — 同じ API キーで 3 つのエージェントが同時に Gemini や Stripe を叩くと、429 が連鎖します。
作業用ディレクトリ/一時ファイル — /tmp/ の同名ファイル、ログファイルへの追記、SQLite ファイルの WAL 競合。
最初に大事なのは、競合が「起きるかも」ではなく 「起きる」前提で設計する ことです。エージェントの数が増えれば確率は時間に比例して上がります。1 % の確率の競合でも、毎日 100 回走らせれば必ず 1 回は失敗します。
失敗を再現できる最小例 — package.json レース
並列エージェントが起こす最も典型的な競合を、まずは手元で再現できる形で書いてみます。Node でわざと「読んで書く」操作を 2 並列にすると、最後の書き込みが先の書き込みを消すレースが容易に起きます。
// reproduce/package-race.ts
import { readFile, writeFile } from "node:fs/promises" ;
async function addDep ( name : string , version : string ) {
const pkg = JSON . parse ( await readFile ( "package.json" , "utf8" ));
pkg.dependencies = pkg.dependencies ?? {};
pkg.dependencies[name] = version;
// 「考えている時間」をエージェントに見立てる
await new Promise (( r ) => setTimeout (r, 200 ));
await writeFile ( "package.json" , JSON . stringify (pkg, null , 2 ) + " \n " );
}
await Promise . all ([
addDep ( "zod" , "^3.23.0" ),
addDep ( "date-fns" , "^3.6.0" ),
addDep ( "nanoid" , "^5.0.0" ),
]);
このスクリプトを実行すると、3 つの dependencies のうち最後に書き込んだものだけが残ります。読み込みのタイミングが重なるからです。
エージェントの並列実行は、本質的にこのコードと同じ構造です。違いは「考えている時間」が 200ms ではなく数十秒〜数分であることだけ。タイムウィンドウが長い分、衝突は より起きやすく なります。
対処 1:ファイルロック — 単一ノードでの最も実用的な選択
単一マシン上で並列エージェントを走らせる場合、最も単純で堅いのが OS のファイルロック です。Node.js では proper-lockfile が広く使われており、ロックファイル方式で排他制御を提供してくれます。
// utils/with-lock.ts
import lockfile from "proper-lockfile" ;
export async function withLock < T >(
path : string ,
fn : () => Promise < T >,
) : Promise < T > {
const release = await lockfile. lock (path, {
retries: { retries: 30 , minTimeout: 200 , maxTimeout: 2000 },
stale: 60_000 ,
});
try {
return await fn ();
} finally {
await release ();
}
}
addDep をこのラッパで包むだけで、3 並列でも書き込みが一つずつシリアルになります。
await withLock ( "package.json" , () => addDep ( "zod" , "^3.23.0" ));
注意点が一つだけあります。stale のタイムアウトを必ず設定すること。エージェントが途中でクラッシュした場合、ロックが残ったままになり、次の実行が永久に待ち続けます。私の運用では 60 秒に置いていますが、長時間処理を含む場合はもう少し延ばします。
対処 2:楽観的ロック — Git ブランチ競合への有効解
ブランチに対する競合は、ロックではなく 楽観的ロック (読み込み時点のバージョンを覚えておき、書き込み時に変わっていたらやり直す)の方が実用的です。Git そのものが楽観的ロック構造になっているので、エージェントには次のように指示しています。
# antigravity/agents/branch-aware.yaml
name : branch-aware-coder
constraints :
- branch_per_task : true # タスクごとに新しいブランチ
- rebase_before_push : true
- max_rebase_attempts : 3
- on_conflict : "abort_and_report"
branch_per_task は単純で強力なルールです。エージェントは自分専用のブランチで作業し、push の直前に git pull --rebase origin main を試みる。コンフリクトが解消できなければ作業を中断し、何が衝突したかをレポートとして人間に渡します。
「自動で解決を試みる」設定をオフにしているのは、ある夜エージェントが package.json のコンフリクトマーカーを残したまま整形してしまい、本番ビルドを壊した苦い経験があるためです。コンフリクトの自動解決は、人間が読まずにマージできてしまう仕組みです。私はこの設定を基本的に止めています。
対処 3:キュー — 共有 API への並列アクセス
外部 API のレートリミットは、ロックでもブランチでも対処できません。ここは エージェントの外側にキューを置く のが現実的です。私自身は個人開発の規模ですが、それでも Cloudflare Workers の Queue を薄く挟み、Gemini API への呼び出しはすべてキュー経由に統一しています。
// services/gemini-queue.ts
import { Hono } from "hono" ;
const app = new Hono <{ Bindings : { GEMINI_QUEUE : Queue < unknown > } }>();
app. post ( "/enqueue" , async ( c ) => {
const job = await c.req. json ();
await c.env. GEMINI_QUEUE . send (job);
return c. json ({ status: "queued" });
});
export default app;
export async function queue ( env : { GEMINI_QUEUE : Queue < unknown > }, batch : MessageBatch <{ prompt : string }>) {
// ワーカーがレートリミット内で順番に処理
for ( const msg of batch.messages) {
await callGemini (msg.body.prompt);
msg. ack ();
}
}
エージェントは「直接 API を叩かない」というルールを守るだけ。429 エラーがエージェントの並列度ではなく、ワーカーの設定で制御できるようになります。エージェントを増やしても 429 が増えなくなったとき、初めて並列運用が「拡張可能」だと感じられました。
対処 4:競合を「起こさない」— 書き込みスコープの宣言と機械検証
ここまでの 3 つは、競合が起きたあとに壊れないようにする話です。運用が半年を超えたあたりから、私はもう一段手前に重心を移しました。そもそも二つのエージェントが同じファイルに手を伸ばさないように、書き込み範囲を先に割る という考え方です。
やることは二つだけ。エージェントごとに git worktree で独立した作業ディレクトリを与え、触ってよいパスを宣言させます。
# 各エージェントに独立したチェックアウトと専用ブランチを割り当てる
git worktree add ../wt-billing -b agent/billing origin/main
git worktree add ../wt-editor -b agent/editor origin/main
git worktree add ../wt-docs -b agent/docs origin/main
worktree は .git を共有しつつ作業ツリーだけを分けます。.next/ や dist/ が worktree ごとに別ディレクトリになるため、5 つの競合ポイントのうち「共有キャッシュ」と「一時ファイル」は、この一手でほぼ消えます。私が最初にやるべきだったのは、ロックの導入ではなくこれでした。
残るのは「同じファイルへの書き込み」です。ここはオーナーシップ表を 1 枚置きます。
# agents/write-scope.yaml
agents :
billing :
allow : [ "src/billing/**" , "src/config/pricing.ts" ]
editor :
allow : [ "src/editor/**" , "src/components/editor/**" ]
docs :
allow : [ "docs/**" , "README.md" ]
shared :
# 誰も直接書かない。差分を提案し、コーディネーターが順に適用する
coordinator_only : [ "package.json" , "pnpm-lock.yaml" , "tsconfig.json" ]
宣言しただけでは守られません。push 直前に、実際の差分が宣言の内側に収まっているかを機械で確かめます。
#!/usr/bin/env bash
# scripts/check-write-scope.sh <agent-name>
set -euo pipefail
AGENT = " $1 "
BASE = "$( git merge-base HEAD origin/main)"
# 判定対象のコマンドはパイプにつながない(終了コードが握り潰される)
CHANGED = "$( git diff --name-only " $BASE "..HEAD)"
VIOLATIONS = 0
while IFS = read -r f ; do
[ -z " $f " ] && continue
if ! yq -r ".agents.${ AGENT }.allow[]" agents/write-scope.yaml \
| while read -r pat ; do case " $f " in $pat ) exit 9 ;; esac ; done ; then
continue # exit 9 = allow にマッチ(許可)
fi
echo "❌ scope violation: $AGENT touched $f "
VIOLATIONS = $(( VIOLATIONS + 1 ))
done <<< " $CHANGED "
[ " $VIOLATIONS " -eq 0 ] || { echo "書き込みスコープ違反 ${ VIOLATIONS } 件。push を中止します" ; exit 1 ; }
echo "✅ write scope OK ( $AGENT )"
このスクリプトの価値は、競合を直すことではありません。競合が「起きうる状態」に入った瞬間に、push の手前で気づけること にあります。ロックは競合を安全に順番待ちさせますが、待ち時間はそのまま失われた並列度です。スコープの分割は、待ち時間そのものを発生させません。
一つ正直に書いておくと、この分割は万能ではありません。機能追加が複数の領域にまたがる日は、coordinator_only に指定した共有ファイルへの提案が積み上がり、結局そこが直列になります。私の手元では、依存追加を伴うタスクは 1 日 1 エージェントに限る、という運用ルールで折り合いをつけています。設計で消せない直列は、スケジュールで消すしかない、というのが今のところの結論です。
キューイングと統一パーミッションを予防に組み込む
2026 年 7 月の Antigravity の更新は、この話と地続きです。v2.3.0(7/13)でメッセージのキューイングと Send Now が入り、エージェントが作業中でも次の指示を積んでおけるようになりました。
ここで一つ、区別しておきたいことがあります。v2.3.0 のキューイングが直列化するのは 1 つのエージェントに対する指示の順番 であって、複数エージェントによる書き込み ではありません。指示が綺麗に並ぶことと、ファイルへの書き込みが並ぶことは別の問題です。キューイングを入れたから競合対策は済んだ、と受け取ってしまうと、この記事の冒頭と同じ夜が来ます。
同じ v2.3.0 の、バックエンド過負荷時の自動リトライにも同じ注意が要ります。リトライは失敗した処理をもう一度走らせる仕組みなので、書き込みが冪等でなければ、二重適用の窓を新しく作ります。私は差分の適用処理に task_id を持たせ、適用済みなら黙って成功を返す形にしました。
一方、v2.2.1 で入った統一パーミッションは、予防として素直に効きます。オーナーシップ表を「宣言」ではなく「そもそも触れない設定」に落とせるからです。書き込みスコープの yaml とパーミッション設定を同じ台帳から生成しておくと、宣言と実権限がずれません。二重管理は必ずずれる、というのはこの半年で一番高くついた学びでした。
レース条件の三類型を観察する
並列運用を続けると、上の 5 つのリソース競合とは別に、もう一段ややこしい レース条件 に遭遇します。私が繰り返し見たのは次の 3 種類です。
1. Read-Modify-Write レース
最も古典的で、最も多発する型。package.json レースはこれに該当します。A が読む → B が読む → A が書く → B が書く の順序で B の書き込みが A を消す。対処は前述のファイルロック、または「変更要求を 1 か所に集約してマージするコーディネーター」を別プロセスで走らせる方法。エージェントは差分を提案するだけで、実書き込みは別の単一プロセスがやります。
2. Check-Then-Act レース
exists() でファイルがないことを確認 → write() のように、確認と行動の間に他者が割り込む型。Antigravity エージェントが「テスト用の新しい branch を作ろうとして既存と衝突する」のはほぼこれです。対処は アトミックな操作 1 つにまとめる こと。git checkout -b new-branch は branch がすでにあると失敗するので、git rev-parse --verify new-branch && exit 1 || git checkout -b new-branch のように 1 行でチェックと作成をまとめます。
3. ABA 問題
ブランチ A から枝分かれしたエージェントが、戻ってきたら同名のブランチが「別物に作り直されていた」というケース。コミットハッシュで覚える のが解決策です。エージェントには「ブランチ名ではなく、最初に分岐したときのコミットハッシュを保持して、push 前にそのハッシュが祖先に含まれるか確認しろ」と指示しておきます。
INITIAL_COMMIT = $( git rev-parse HEAD )
# ... エージェントの作業 ...
git fetch origin main
git merge-base --is-ancestor " $INITIAL_COMMIT " origin/main \
|| { echo "Base branch was rewritten" ; exit 1 ; }
三つのロック方式の使い分け
ここまでの対処を整理すると、ロックは大きく 3 種類です。
方式 向く条件 当てる資源 代償
悲観的ロック (ファイルロック・mutex)競合が頻繁・書き込みが短時間 共有ファイル・SQLite 待ち時間の分だけ並列度が削られる
楽観的ロック (Git rebase)競合が稀・書き込みが長時間 ブランチ・コミット やり直しのコストが大きい
キュー (順序保証 + バックプレッシャー)グローバルな上限がある 外部 API・レートリミット レイテンシと運用対象が増える
スコープ分割 (worktree + 宣言)役割が分かれている 作業ツリー・キャッシュ 領域をまたぐタスクを直列化する必要
私の経験則は単純で、触る対象が「ファイル」なら悲観、「ブランチ」なら楽観、「API」ならキュー、「作業ツリー」なら分割 。例外は二つあって、SQLite のように WAL モードでも「同時書き込みは 1 つだけ」の制約があるリソースは悲観ロックでくるみます。それと、長時間動くマイグレーション処理は専用のジョブランナー(キュー + 単独実行ワーカー)に移します。
観測:競合が起きたことを「気づける」ようにする
競合は、対処より先に 検出できることが大切 です。発生したことに気づかない競合は、本番事故になってから初めて発見されます。私の手元で定着している観測点は次の 5 つです。
ファイルロックの取得待ち時間 — 1 秒を超えたら警告ログ、10 秒を超えたら BLOCK
rebase 失敗回数 — 同じエージェントが連続で 3 回 rebase 失敗したら自動停止
API の 429 比率 — 5 % を超えたらキューワーカーの並列度を自動で半分に
ジョブの重複検出 — 同一の task_id が 2 回 enqueue されたら片方を破棄
書き込みスコープ違反の件数 — 1 件でも出たら、その日の並列度を上げない
これらを Grafana や Cloudflare のダッシュボードに並べておくと、「並列度を一段階上げた直後の数時間」だけ集中して見るだけで、リスクが顕在化したかどうかを判定できます。5 番目だけは指標というより線引きで、違反が出た日は設計が現実に追いついていないという合図として扱っています。
失敗から学んだ「並列度を上げる前」のチェック項目
最後に、私が並列度を上げるときに毎回確認しているチェックリストを共有します。並列度を倍にする前に、これを一度通すだけで事故率が大きく下がりました。
[ ] 各エージェントは独立した worktree を持ち、書き込んでよいパスを宣言しているか
[ ] エージェントが書き込む共有ファイルは、ロック(or コーディネーター)で保護されているか
[ ] 各エージェントは独立したブランチを使い、push 前に rebase するか
[ ] 外部 API は単一の API キーを共有していないか、キーごとにレート割り当てがあるか
[ ] 自動リトライが走る処理は冪等か(task_id で二重適用を防いでいるか)
[ ] ログとトレースは、agent_id でフィルタできる形で出力しているか
並列エージェントは、確かに速さをもたらします。けれど速さは、競合が起きた時に拾い上げられる足場 を組んでから取りに行くものだ、というのが、私が深夜に package.json を作り直した夜から学んだことでした。あなたが今、3 つ目のエージェントを起動するボタンに指をかけているなら、まずは worktree を 3 つ切って、書き込んでよい範囲を 1 枚の yaml に書き出すところから始めてみてください。順番はきっと、そちらが先です。私も三度、順番を間違えてから、ようやくそう思えるようになりました。