🚦

AIエージェント64体に1つのリポジトリを書かせたら、マージの57/64が衝突した。0にするまでの話

に公開

64体の書き手に1つのリポジトリを並列で書かせると、何が起きるか。

素のgit worktreeで実測したら、64回のマージのうち57回が衝突し、衝突ハンクは132個になりました。

ガードを入れたら、衝突は0件になりました。

ただし、この「0」はタダではありません。0は危ない書き込みを断ることで買っていて、断った件数も全部数字で残しています。この記事は、その仕組み(リース台帳・git hooks・行域オーナーシップ)と、開発中に踏んだ一番静かなバグの話です。

計測はすべてリポジトリ内のスクリプトで再現できます。

https://github.com/tacyan/zaivern-code

なぜ「AIにお願いする」では防げないのか

Claude Codeに実装、Codexにテスト、Gemini CLIにドキュメント。並列で走らせると実装速度は確かに上がります。問題は統合のときに来ます。複数のエージェントが同じファイルを書き、片方の変更がもう片方を消し、マージで衝突の山ができる。

最初に試すのは、たぶん全員同じで、プロンプトにこう書くことです。

「他のエージェントが編集中のファイルには触らないでください」

これは効きません。エージェントは他のエージェントの状態を知らないし、知っていても長いセッションの途中で指示を忘れます。人間のチームでも「注意してね」だけで運用される担当分けが長続きしないのと同じです。

必要なのは、お願いではなく関所でした。書き込みが実際に起きる場所——つまりgitの入口に、機械的なルールを置くことです。

設計: リース台帳 + git hooks

Zaivern Code(Rust製のマルチエージェント用コックピット)では、エージェントがファイルを編集する前に、リポジトリごとのリース台帳へ「この範囲は自分が書く」と登録します。そして、git hooksが台帳と矛盾するコミットを拒否します。

# 台帳・git hooks・マージドライバを導入して自己診断
zai czero init

# 使い捨てリポジトリで実際に衝突を作り、ちゃんと止まるか検証
zai czero verify

ポイントは、強制ポイントをAIツールに依存させないことです。Claude Codeから書いても、Codexから書いても、人間がVimで書いても、コミットの段階で同じルールが適用されます。ツール側の協力を前提にした仕組みは、ツールが増えるたびに壊れるからです。

衝突を起こさせないための鎖は4本あります。

# 鎖 いつ効くか
① 配る前のタスク分割 (zai split) 割り当ての瞬間
② 実行中の強制 (リース + zai hook) 書き込みの直前
③ 統合の順序付け (zai train) マージの時
④ 共有面の自動解決 (マージドライバ) CHANGELOG等の追記ファイル

この記事では主に②と、その進化版である②'(行域オーナーシップ)を扱います。

実測1: ファイル単位のリースは衝突を0にする。ただし代償がある

tools/conflict-zero-bench.sh で、書き手8体・48ファイル・担当の重なり(overlap)を振って測った結果です。

素のgit(保護なし):

重なり 衝突したマージ 衝突ハンク
0.00 0 / 8 0
0.50 4 / 8 7
1.00 7 / 8 18

同じ担当表にリースゲートを掛けた場合:

重なり 書けた / 計画 衝突ハンク
0.00 48 / 48 0
0.50 27 / 48 0
1.00 10 / 48 0

衝突ハンクは全条件で0。ただし表をよく見てください。重なり1.00では48件中10件しか書けていません。ゼロは、重複する書き込みを断ることで買っています。断られた作業は消えるのではなく、担当を変えるか、待つか、再配分する必要があります。

「衝突0」とだけ書けば強い成果に見えますが、それは半分しか言っていない。**衝突ゼロの正体は「危険な書き込みの拒否」**で、これは設計上のトレードオフです。

規模を上げるとこうなります(重なり0.5固定)。

書き手 素のgit: 衝突マージ / ハンク ガード: 書けた / 計画 / ハンク
8 4/8 · 7 27/48 · 0
16 11/16 · 19 55/96 · 0
32 27/32 · 56 106/192 · 0
64 57/64 · 132 202/384 · 0

冒頭の数字はこれです。素のgitは書き手が増えると線形より速く壊れ、64体ではほぼ全マージが衝突します。

もうひとつ正直に書くと、判定ゲート自体もタダではありません。4〜8体ではゲートのp50は40〜50msですが、64体ではp50約300msまで伸び、ゲートが臨界路になります。並列度を上げるほど、調整コストが表に出てくる——人間のチームとまったく同じ構図です。

実測2: ファイル単位の限界 — 1ファイルに64体をぶつけると並列度は1になる

ファイル単位のリースには構造的な限界があります。対象ファイルが1個なら、何体いても同時に書けるのは1体だけです。

tools/coedit-bench.sh で、2,000行のファイル1個に複数体をぶつけて測りました。

体数 ファイル単位リース: 完了 / 拒否
8 1 / 7
64 1 / 63

64体中63体が門前払い。安全ではありますが、並列開発としては本末転倒です。

そこで粒度をファイルから行域に下げました。「同じファイルでも、違う行なら2体が同時に書ける」というルールです。

# ファイル全体ではなく、行域を確保する
zai lease claim 'src/wide.rs#L10-40'

同じベンチを行域オーナーシップで回した結果:

体数 行域リース: 完了 / 拒否 / 衝突ハンク
8 8 / 0 / 0
64 64 / 0 / 0

拒否63 → 0、完了1 → 64。1つのファイルを64体が同時に書いて、マージ競合0・手作業0で統合が通ります。

ここでも正直な注記をひとつ。行域が十分に離れている場合、素のgitもそもそも衝突しません(同ベンチのA段で確認済み)。つまり行域オーナーシップの価値は「gitにできないことをする」ことではなく、ファイル単位リースが捨てていた並列度を、安全性を保ったまま取り戻すことにあります。行域が近接・交錯するレイアウトでの挙動と限界も、リポジトリのドキュメントに測定つきで残しています。

一番静かなバグ: 行は動く。だから錨で守る。その錨が誤マッチした

行域リースの実装で一番難しいのは、行番号が信用できないことです。誰かが上のほうに10行足せば、あなたの「L21」は物理的にL31へ移動します。行番号だけで管理すると、台帳は数分で現実と乖離します。

そこでZaivern Codeは、確保の瞬間に先頭行と末尾行の中身を「錨」として記録し(region::capture_anchor)、判定のたびに現在のファイルから錨を探し直して域を追跡します(region::resolve)。行が流れても、中身が同じなら追える——はずでした。

実在リポジトリでの検証スクリプト(tools/anyrepo-prove.sh)を回したら、8回中3回、二重書き込みが出ました。間欠的に見えたので最初は競合状態を疑いましたが、乱数種を固定したら決定的に再現。追った結果、原因はロックでも容量でもなく、錨でした。

  1. w1が README.md#L21 を確保する(錨 = そのときの21行目の中身)
  2. w1が21行目を書く → 自分の編集で、自分の錨が合わなくなる
  3. w1が同じファイルの別の域を確保し直すとき、錨の再解決が走る。錨は似た行——空行、```、---、READMEに山ほどある——に誤マッチし、台帳上のL21がL15へ「移動」した
  4. 21行目が空いたことになり、w2が正当に同じ行を確保する → 2体が同じ行を書いた

たちが悪いのは、台帳の最終形に重なりが残らないことです(域が移動しただけなので)。台帳をいくら監査しても矛盾はない。書かれたファイルを突き合わせて初めて分かる。分散システムで一番怖いのは、大きく壊れるバグではなく、こういう静かに正しくなくなるバグだと思います。

修正は、たった1行ぶんの判断でした。

取り直した読みが記録と違ったら、その読みを採らない。

自分が確保して書いた行を、再解決の結果を信じて手放すより、記録のまま留まるほうが必ず安全側に倒れます(lease::absorb)。修正後、12通りの乱数種で回し直して、二重書き込み0・衝突0を確認しています(同条件の保護なしベースラインは毎回4〜9行の二重書き込みを出します)。

実測3: 合成ベンチではなく、実在のリポジトリで

ここまでの数字は合成リポジトリのものでした。合成は都合のよい編集しか作らないので、実在のコードで同じことが起きる保証にはなりません。tools/anyrepo-prove.sh は実在のリポジトリを複製して同じ実験を回します。

リポジトリ 書き手 素のgit リースあり
zaivern-code (Rust・259ファイル) 16 26ファイル / 28ハンク衝突 0 / 0 (96/96成立)
hyperframes (TS/HTML・1194ファイル) 16 26 / 28 0 / 0 (96/96成立)

素のgitは、言語にもファイル数にも依らず同じように壊れます。リース側は96件の編集計画がすべて成立して衝突0です(近接する要求は自動で衝突しない位置へずらして成立させています。初期バージョンでは64体規模で拒否が残っていましたが、混雑に応じてずらし上限を広げる変更で拒否0になりました)。

できないことも、同じ解像度で書く

このアプローチが防げないものをはっきりさせておきます。

セマンティック競合は防げません。 AI Aが関数のシグネチャを変え、AI Bが別ファイルで古い呼び方のまま使う。ファイルが違うのでリースは両方を通し、gitも綺麗にマージします。壊れるのはビルド時かテスト時です。これはテキストの衝突ではなく意味の不整合で、この層はビルド・テスト・レビューの仕事です。エージェントを増やすほど、自動テストの価値はむしろ上がります。

「全員が全部書けて衝突0」ではありません。 前述の通り、ファイル単位リースのゼロは拒否で買っています(64体・1,536編集の別ベンチでは、書けたのは565件=37%でした)。行域化とずらし交渉でこの拒否は大きく減りましたが、「止められた作業をどう再配分するか」は依然として設計の一部です。

重なりのない作業では、そもそも何もしていません。 重なり0.00の行を見ると、ゲートは1件も止めていません。使わない人はコストを払わない設計ですが、裏を返せば、この仕組みの価値は担当が重なるときにしか出ません。

うまくいった数字だけを並べれば強く見えますが、測っていないこと・効かなかったことを伏せると、ツールは信用を失うと思っています。線引きの全文はリポジトリの docs/conflict-zero.md にあります。

再現手順

すべて手元で再現できます。

git clone https://github.com/tacyan/zaivern-code.git
cd zaivern-code

# ベースライン(zaiが無くても走る)と、重なりを振った比較
tools/conflict-zero-bench.sh --writers 8 --files 48 --overlap 0.5

# 2,000行×1ファイルに64体: ファイル単位 vs 行域
tools/coedit-bench.sh --agents 1,4,8,16,32,64 --lines 2000

# 実在リポジトリでの証明(乱数種も固定できます)
tools/anyrepo-prove.sh

Zaivern Code本体はApache-2.0の無料OSSで、macOS / Windows / Linuxのビルド済みバイナリがあります。

# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/tacyan/zaivern-code/main/install.sh | sh
zai .

インストーラは展開前に checksums.txt とSHA-256を照合し、一致しなければ何も実行せず中断します。手動検証の手順とSBOMは SECURITY.md にあります。

まとめ

  • 64体を素のgitで並列に走らせると、マージの57/64が衝突する
  • リース台帳 + git hooksで衝突は0になる。ただし0は書き込みの拒否で買っている
  • ファイル単位のリースは1ファイル勝負で並列度1に潰れる。行域オーナーシップで64/64が通る
  • 行は動くので行番号ではなく錨で守る。その錨の誤マッチが、台帳に痕跡を残さない二重配布を起こした。「再解決の結果が記録と違ったら採らない」で修正
  • セマンティック競合は守備範囲外。そこはテストの仕事

複数エージェントの並列開発は、モデルの賢さの問題である以上に、調整の仕組みの問題だと考えています。同じ問題を踏んでいる方、別の解き方をしている方の話が聞きたいです。「この条件だと壊れるのでは?」という指摘は特に歓迎します。ベンチに条件を足して測り直します。

https://github.com/tacyan/zaivern-code

役に立ちそうだと思ったら、⭐で応援してもらえると開発の励みになります。

Discussion