AIエージェントを並列で走らせる境界 - セッションではなく共有リソースで決まる
この記事では、AIコーディングエージェントを複数同時に走らせるとき、どこまでを並列にしてよいのかを判断する基準について解説します。判断の軸は、起動したセッションが同じかどうかよりも、何のリソースを共有しているかに置きます。
- 複数のエージェントを同時に走らせていて、作業ツリーを壊した経験がある人
- これから並列にするので、何が壊れるかを先に知っておきたい人
- サブエージェントの担当範囲をどこで切るか迷っている人
- 担当外への書き込みを機械で見つけたい人
サブエージェントの並列実行で起きる巻き込み
サブエージェントは、1つのエージェントが別のエージェントを起動して作業を分担させる構成を指します。起動する側を親、起動される側を子と呼び、子は親から渡された指示だけを見て動きます。
複数のAIエージェントに同時に作業させるとき、同じセッションから起動した子同士なら互いの動きが見えているので巻き込みは起きない、とまず期待しました。ところがこの期待は、部分的にしか当たっていません。
ただし、以下はコーディングエージェントを親子構成で走らせている環境での話です。実行環境の準備そのものは本題から外れるため触れません。
例えば次の3件のような問題が起きます。原因の層で分けると、同一セッションで消えるものと、セッションを揃えても消えないものに分かれます。
| トラブル | 原因の層 | 同一セッションで消えるか |
|---|---|---|
作業途中のコミットを別のエージェントが git reset した | 調整の欠如 | ✅ 消える |
git stash が他のエージェントの編集中ファイルを巻き込んだ | .git の共有 | ❌ 消えない |
| 全体チェックの実行中にツリーを触り、無関係なパッケージが落ちた | ファイルシステムの共有 | ❌ 消えない |
作業途中のコミットが巻き戻されたのは調整の問題で、あるエージェントが作ったコミットを、別のエージェントが「見知らぬコミット」と判断して git reset しました。
誰がコミットを作ってよいかが決まってさえいれば起きないので、状況が共有されていれば確かに消えます。
パス指定の git stash は性質が違い、指定していないはずの他のエージェントの作業中ファイルまで巻き込み、pop が拒否されました。
git stash は、コミット前の変更を一時的に退避するコマンドです。退避の対象は指定したパスだけに見えて、実際にはコミット待ちの一覧(インデックス)と、ディスク上のファイル(作業ツリー)の両方を書き換えます。
呼び出し元がどのセッションであっても、.git はリポジトリにひとつしかありません。
全体チェック中の編集も同じ構造で、CIと同じチェックをローカルで回している最中に別の編集を当てると、チェックは変更後のファイルをディスクから読み、担当外のパッケージが落ちて処理が中断します。
チェックのプロセスは「誰が編集したか」を知らず、ディスク上の状態だけを見ています。
並列化の境界を決める軸
この3件を並べると、並列化の境界は「誰が呼ぶか」ではなく「何を共有するか」で決まっていると分かります。
同一セッションかどうかで防げるのは、調整の欠如から来るトラブルだけです。
セッションが共有するのは知識で、誰が何を作業中か、どのファイルを開いているか、どんな方針で進めているかといった情報です。
これらは伝われば消える種類の問題で、コミットの巻き戻しがここに属します。
一方、セッションを揃えてもリソースは共有したままで、.git のインデックスはリポジトリにひとつ、作業ツリーもひとつしかありません。
プロセスがどこから起動されても、書き込みは同じファイルを書き換えます。全体チェックやテストの実行ツールはディスクを読むので、同じセッションの中の編集も、外から来た編集と同じ扱いになります。
原因が調整の欠如にあるトラブルなら、「同じセッションだから安全」と言えます。.git とファイルシステムを共有している限り、書き込みの衝突はセッションの外側で起きます。エージェントの構成を変えても、この層は変わりません。
リソースごとの所有者
共有リソースが問題なら対処は素直で、リソースごとに単一の所有者を割り当てます。
所有者以外は読んでよいが書いてはいけない、というルールにすると、書き込みの衝突が自動的に起きなくなります。
どこまでを1体の担当にできるかは、依存関係を数えて決めます。数え方は次の節で、ここでは数え終わったあとの割り当てを先に示します。
| リソース | 所有者 | 理由 |
|---|---|---|
.git(add / commit / stash / checkout) | 親だけ | リポジトリにひとつしかなく、部分的な操作が他の作業を巻き込む |
| 全体チェック(リポジトリ全体を対象に走らせる、CIと同じチェック一式) | 親だけ。全員の手を止めてから1回だけ回す | 実行中のツリーの変更が結果を汚す |
| 共有設定(lockファイル・ルート設定・ビルド成果物) | 親だけ | どのパッケージからも参照され、担当を割り当てられない |
| パッケージ内のソースとテスト | 担当する子エージェント1体の専用にする | 担当パスが1つも重ならなければ同時に書いても衝突しない |
コミットは親の仕事として設計し、子は編集して報告するところまでを担当して、履歴は親だけが進めます。
全体チェックも同じで、子が個別に走らせることはせず、親が全員の作業をいったん止めてから1回だけ回します。
破線が禁止された経路で、子エージェントは自分の担当パッケージには自由に書けますが、.git と共有設定は親だけが扱います。
読むことは制限しないので、担当外のコードを参照しながら実装できます。
git worktree で作業ツリーごと分ければ、ファイルシステムの共有も外せます。Claude Codeは 2.1.49 から --worktree を持っていて、作業ツリーの作成そのものは手作業ではなくなりました。サブエージェントの定義に isolation: worktree と書けば、そのサブエージェントを一時的な作業ツリーで動かせます。
保留にしている理由は、作成の手間ではなく、その先を計っていないことにあります。公式ドキュメントも、作業ツリーは新しいチェックアウトなので依存関係はそこで別に入れる、と書いています。ビルド成果物が作業ツリーの数だけ必要になるという前提は変わりません。計測してから判断する領域です。
依存関係による境界の計算
所有者の表は、どこまでを重ならないパスの範囲にできるかを依存関係から数えて決めます。
パッケージに分割されたリポジトリ(21個のパッケージを1つのワークスペースとして扱う構成)で確かめると、次の2点が分かりました。
- 機能ごとに分けた13個のパッケージは互いに独立していて、各パッケージの依存定義に相互参照がないので、パッケージ単位で重ならないパスの範囲が作れます
- ビルド成果物の置き場はパッケージごとに作られていて、21のメンバーがそれぞれ持つため、作業ツリーを分けなくても既に分かれています
境界を確認するスクリプト
同じ確認は、リポジトリのルートで次を走らせるとできます。読み取りしかせず、依存定義ファイルの名前で言語を見分けます。
# ビルド成果物の置き場。掘らないし、これ自体が奪い合いの対象になる
outs() { find . -name .git -prune -o -type d \( -name node_modules -o -name target \
-o -name vendor -o -name build -o -name dist -o -name .dart_tool -o -name .venv \) "$@"; }
# パッケージ=依存定義ファイルのあるディレクトリ。ルート直下の1枚は全体の定義なので除く
mans=$(outs -prune -o \( -name package.json -o -name Cargo.toml -o -name go.mod \
-o -name pubspec.yaml -o -name pyproject.toml \) -print | grep -v '^\./[^/]*$' | sort)
# 名前はディレクトリ名とは限らない。定義ファイルに書かれた名前も拾う
names() { sed -n -e 's/.*"name"[^"]*"\([^"]*\)".*/\1/p' -e 's/^name *[:=] *"\{0,1\}\([^"]*\)"\{0,1\} *$/\1/p' \
-e 's/^module *\(.*\)$/\1/p' "$1" | head -2; echo "${1%/*}" | sed 's|.*/||'; }
refs=$(for m in $(echo "$mans"); do
for o in $(echo "$mans"); do
[ "$m" = "$o" ] && continue
for n in $(names "$o"); do
grep -nwF "$n" "$m" | sed "s|^|${m%/*} -> ${o%/*} |"
done
done
done | sort -u)
arts=$(outs -prune -print | sort)
echo "== パッケージ(この名前で互いを探す)"; echo "$mans" | sed 's|/[^/]*$||' | sort -u
echo "== 相互参照(依存の宣言なら、その2つは同時に触れない)"; echo "${refs:-なし}"
echo "== 成果物(パッケージの下に並べば競合しない)"; echo "${arts:-なし。まだビルドしていない}"
TypeScriptのモノレポで走らせると、次のように出ます。
== パッケージ(この名前で互いを探す)
./packages/api
./packages/core
./packages/ui
./packages/web
== 相互参照(依存の宣言なら、その2つは同時に触れない)
なし
== 成果物(パッケージの下に並べば競合しない)
./packages/api/node_modules
./packages/core/node_modules
./packages/ui/node_modules
./packages/web/node_modules
出力は3つの節に分かれていて、判断に使うのは後ろの2つです。
| 見る節 | 出た内容 | 読み |
|---|---|---|
== 相互参照 | なし | 依存の宣言で繋がっていない。パスを分けられる |
== 相互参照 | ./packages/web -> ./packages/core のような行 | その2つは同時に触れない |
== 成果物 | ./packages/api/node_modules のようにパッケージの下に並ぶ | 生成物が競合しない。テストも分けられる |
== 成果物 | ./node_modules のようにルートに1つだけ | パスを分けても、テストは同じ場所を奪い合う |
上の出力は両方とも表の上段なので、このリポジトリは境界を引けます。同じ4パッケージでも、web が core を依存に宣言していて node_modules がルートに巻き上がっている構成だと、両方とも下段に落ちます。パッケージの数も名前も変わらないのに、判断だけが逆になります。
相互参照に行が出たときは、その行が依存の宣言か、名前がたまたま説明文に出ているだけかを、表示された行そのもので判断します。
見ているのは依存定義ファイルに直接書かれた名前だけです。間に別のパッケージを挟んだ依存や、宣言せずに相対パスで読み込んでいる箇所は出てきません。
モジュール境界が明確なコードベースほどエージェントを並列化しやすく、並列化のしやすさにはアーキテクチャの品質がそのまま出ます。
相互依存が多く、どのパッケージを触っても他が動くようなコードベースでは、パスを重ならないように分けられません。その場合は1つずつ順番に回すのが正しい判断です。無理に並列化しません。
ワークスペースのメンバー構成は、そのまま境界の計算の入力になります。Dartのワークスペースへの移行手順はmelos 7.x.xへのマイグレーションにまとめています。
単一の node_modules のように、ビルド成果物がルートへ集約される構成では、ソースのパスを分けてもテストは同じファイルを奪い合います。依存関係だけを見て安全だと判断せず、成果物がどこに出るかを確認してから決めます。
担当範囲の機械的な検出
境界は「担当外に書かないように」という申し送りでは守れないので、親が機械的に検出します。
全員の作業をいったん止めた地点で、変更されたパスを一覧し、担当の割り当てと突き合わせて確認します。
git status --porcelain
出力の各行のパスが、そのエージェントに割り当てた範囲に収まっているかを見て、担当外に書いていたら成果ごと受け取らずに戻します。
受け取らないのが要点で、差分の一部だけを採用すると、採用したつもりのない変更が一緒に入ります。
これはエージェントの誠実さよりも、与えた指示の形の問題です。
「テストを通す」と指示されたエージェントにとって、テストそのものを書き換える動きは指示に対する最短経路になります。同じように「担当パッケージを直す」と指示されたエージェントにとって、共有設定を1行変えれば通る場面では、それが最短経路になります。
だからこの最短経路は、ルールで禁じるだけでは塞がりません。通ったら必ず気づく状態にして、通った分を丸ごと戻します。
ツール側が共有ファイルを書き換えることもあり、新しいバージョンのFlutterは analysis_options.yaml や pubspec.lock に行を書き足します。
これはどのエージェントの担当でもない変更なので、親が最後に確認して戻す必要があります。git status --porcelain による確認は、この種の「誰も意図していない変更」も同時に拾えます。
まとめ
この記事では、AIエージェントを並列で走らせる境界が、同じセッションかどうかよりも共有リソースによって決まることを、実際に起きたトラブルから解説しました。.git も作業ツリーも、誰が呼び出してもひとつしかありません。リソースごとに単一の所有者を決め、git status --porcelain で担当範囲を機械的に確認したときに、はじめて並列化が安全になります。
どこまで分けられるかは、依存関係を数えれば決まります。相互参照が出た2つは同時に触れず、ビルド成果物がルートに集約されていればパスを分けてもテストは競合します。
ただし境界が引けることと、並列化して速くなることは別の話です。テストを同時に回すとむしろ遅くなった実測は、AIエージェントの並列化は速くなるのかにまとめています。
参考リンク: