Antigravity 2.0 のデスクトップ版で 3 つのエージェントを同時に走らせ、ある朝の作業ログを見て手が止まりました。コンポーネントを生成させたエージェントの変更が、設定ファイルを更新した別のエージェントの上書きで丸ごと消えていたのです。テストは緑のまま、git の差分にも矛盾は出ません。ただ、片方の仕事だけが静かに無かったことになっていました。
並列実行は速度を生みますが、同時にこの「書き込みの取り合い」という固有の問題を持ち込みます。ワークツリー分離の記事はよく見かけますが、あれはリポジトリを分ける話で、1 つのワークスペースを複数エージェントが共有して同じファイルに触れる状況は守ってくれません。私自身が個人開発で 4 つのサイトを並行運用するなかで何度か踏んだので、この調停をどう設計するかを整理します。
なぜワークツリー分離では足りないのか
ワークツリー分離は、エージェントごとに独立した作業コピーを与える方法です。リポジトリ単位で並行作業する場合には有効で、最後に merge すれば衝突は git が検出してくれます。
問題は、1 つの機能を複数エージェントで分担するときです。「A がコンポーネントを書き、B が API ルートを構成し、C がビジュアル回帰テストを走らせる」という Antigravity 2.0 が推す並列パターンでは、3 者が同じ package.json、同じ型定義ファイル、同じルーティング設定に同時に触れます。ここでワークツリーを分けると、最後の merge で人間が捌ききれない量の衝突が積み上がります。
つまり必要なのは、リポジトリの分離ではなく、共有された資源への書き込みをどう順序づけるかという並行制御の設計です。
衝突が起きる 3 つの典型パターン
実際に観測したのは次の 3 つでした。
| パターン | 起きること | 検出の難しさ |
| ロストアップデート | A の書き込みを B が読まずに上書きし、A の変更が消える | 高い(差分に矛盾が出ない) |
| 破れた整合性 | A が型を変えた直後、古い型を前提に B がコードを書く | 中(型エラーで気づける場合あり) |
| インターリーブ破損 | 2 者が同じファイルを部分編集し、構文が壊れる | 低い(ビルドが落ちる) |
いちばん厄介なのは最上段のロストアップデートです。ビルドもテストも通り、後から「あの変更どこへ行った」と気づくまで時間が経ちます。設計の主眼はここを潰すことに置きます。
戦略 1: 所有権分割(最優先で検討する)
最も堅いのは、そもそも同じファイルに 2 者を触れさせないことです。タスクを割る段階で、ファイル群をエージェントごとの所有権に分けます。
具体的には、オーケストレータがサブタスクを発行する前に「触ってよいパスのグロブ」を各エージェントに渡し、所有外への書き込みを禁止します。
from dataclasses import dataclass, field
from fnmatch import fnmatch
@dataclass
class AgentScope:
agent_id: str
owned_globs: list[str] # 書き込み許可パターン
shared_readonly: list[str] = field(default_factory=list)
def can_write(self, path: str) -> bool:
# 所有グロブに一致すれば書き込み可。それ以外は拒否
return any(fnmatch(path, g) for g in self.owned_globs)
def assign_scopes() -> list[AgentScope]:
return [
AgentScope("ui", ["src/components/**", "src/styles/**"]),
AgentScope("api", ["src/app/api/**", "src/lib/server/**"]),
AgentScope("test", ["tests/**", "e2e/**"]),
]
def guard_write(scopes: dict[str, AgentScope], agent_id: str, path: str) -> None:
if not scopes[agent_id].can_write(path):
raise PermissionError(f"{agent_id} は {path} の所有権を持ちません")
この guard_write をエージェントのファイル書き込みフックに挟むだけで、所有外の上書きは実行前に止まります。Antigravity のエージェント設定で「書き込み可能ディレクトリ」を絞れる場合は、そちらを一次防御に使い、このガードは二次防御として併用するのが堅実です。
所有権分割の限界は、package.json や型定義のような「全員が触りたい共有ファイル」です。これは次の 2 戦略で扱います。
戦略 2: 直列化点(共有ファイルへの書き込みを 1 本に絞る)
共有ファイルへの変更は、エージェントが直接書かずに「変更要求」としてキューに積み、専任の 1 エージェント(またはオーケストレータ自身)が順番に適用します。
import json, queue, threading
write_queue: "queue.Queue[dict]" = queue.Queue()
def request_shared_edit(file: str, mutate) -> None:
# mutate: 現在の内容(dict)を受け取り新しい内容を返す純粋関数
write_queue.put({"file": file, "mutate": mutate})
def serializer_loop(stop: threading.Event) -> None:
while not stop.is_set():
try:
job = write_queue.get(timeout=0.5)
except queue.Empty:
continue
with open(job["file"]) as f: # 直前に読み直す = 最新状態を反映
current = json.load(f)
updated = job["mutate"](current) # 各要求を順に適用
with open(job["file"], "w") as f:
json.dump(updated, f, indent=2, ensure_ascii=False)
write_queue.task_done()
肝は mutate を「現在の内容を受け取って新しい内容を返す関数」にする点です。各エージェントが「依存パッケージを 1 つ足したい」という意図だけを送り、適用直前に最新を読み直してから反映するので、ロストアップデートが構造的に起きません。package.json への複数エージェントの追記は、この方式が最もきれいに収まります。
戦略 3: リース方式のファイル所有権コーディネータ
所有権を静的に分けられず、直列化キューを挟むほどでもない中間のケースには、短命のリース(時限付きロック)が向きます。書き込み前にリースを取得し、終わったら返す。取得できなければ待つか別タスクへ回します。
import time, threading
class FileLeaseCoordinator:
def __init__(self, lease_ttl: float = 30.0):
self._lock = threading.Lock()
self._leases: dict[str, tuple[str, float]] = {} # path -> (agent, expiry)
self._ttl = lease_ttl
def acquire(self, path: str, agent_id: str) -> bool:
with self._lock:
holder = self._leases.get(path)
now = time.monotonic()
# 空き、または期限切れのリースなら奪取できる
if holder is None or holder[1] < now:
self._leases[path] = (agent_id, now + self._ttl)
return True
return holder[0] == agent_id # 自分が保持中なら再入可
def release(self, path: str, agent_id: str) -> None:
with self._lock:
if self._leases.get(path, (None, 0))[0] == agent_id:
del self._leases[path]
TTL を入れているのは、エージェントがクラッシュしてリースを返さないまま落ちたときに、ファイルが永久ロックされるのを防ぐためです。私はここで一度ハマりました。最初は無期限ロックにしていて、1 つのエージェントがタイムアウトで止まった瞬間、残り全員がそのファイル待ちで凍りつきました。本番では必ず期限を切り、期限切れリースは奪取可能にしてください。
3 戦略の使い分け
1. まず所有権分割を試す(同じファイルに2者を触れさせない)
2. 全員が触る共有ファイルは直列化点に逃がす
3. 動的で短時間の競合だけリースで捌く
判断の目安を表にします。
| 状況 | 推奨戦略 | 理由 |
| 機能ごとにファイルが分かれている | 所有権分割 | 衝突を発生前に消せる |
| package.json / 型定義など全員が触る | 直列化点 | 最新読み直しでロストを構造的に防ぐ |
| 一時ファイル・キャッシュへの散発的書き込み | リース | 軽量で待ち時間が短い |
私の場合は、まず所有権分割でファイルの 8 割を静的に分け、残る共有ファイルを直列化点に集約し、リースは本当に動的な一時ファイルだけに限定する三層構成を推奨します。リースを主役にすると、待ちが増えて並列化の旨味が薄れます。あくまで補助に置くことを個人的にはお勧めします。
検証: 衝突を検出する番兵を置く
設計を入れても、抜け道がないかは別問題です。本番運用では、書き込み後にファイルのハッシュを記録し、自分が書いた値と次に読んだ値がずれていないかを照合する番兵を仕込みました。
import hashlib
def fingerprint(path: str) -> str:
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()[:12]
def verify_no_clobber(path: str, expected: str) -> None:
actual = fingerprint(path)
if actual != expected:
# 自分の書き込み後に第三者が触れた = ロストアップデートの疑い
raise RuntimeError(f"{path} が想定外に変化 expected={expected} actual={actual}")
これをエージェントの書き込み直後とコミット直前に挟むと、静かに消える変更を「壊れたビルド」として早期に表に出せます。検出できないロストアップデートこそ最大の罠なので、調停の設計と同じくらい、この番兵に価値があると感じています。
並列エージェントは魅力的ですが、共有資源の調停を設計しないまま台数だけ増やすと、速くなったぶんだけ静かな取りこぼしが増えます。所有権分割を一次防御に、直列化点とリースを補助に、そして番兵で検証する。この順番で組むのが、いま私が個人開発で実際に頼っている構成です。同じ並列化の壁に向き合っている方の設計の足がかりになればうれしいです。