複数の AI コーディングエージェントを並列で動かすこと自体は簡単です。エージェントごとに git worktree とターミナルを分ければ、互いのファイルを上書きしなくなります。難しくなるのは、並列の AI エージェントで E2E テストを実行するときです。worktree が分けるのはファイルだけで、マシンは分けません。
この記事は、私たち自身のリポジトリで実際に起きた事例です。複数のエージェントが同時に E2E テストを走らせたときに何が壊れたか、そして順番待ちのために作った小さな「ロック + キュー」の仕組みを紹介します。
worktree では分けられないもの
git worktree は、エージェントごとに別のチェックアウト、別のブランチ、別の node_modules を用意します。ソースツリーはこれで分かれます。しかし E2E の実行はそれ以上のものに触れます。
- 画面 — 私たちのデスクトップアプリの E2E スイートはテスト用ディスプレイに実際のウィンドウを開き、一部のスペックはその時点で画面に映っているものをスクリーンショットします。
- インストール済みのブラウザ — 実際の Chrome を操作するスペック同士が取り合います。
- ウィンドウのフォーカス — スイートはアプリのアクティベーション回数を数え、フォーカスを奪ったテストを失敗にします。ある実行が起こしたアクティベーションが、別の実行の失敗として数えられます。
- CPU — スイートが 2 つ同時に走れば、すべてのタイムアウトが厳しくなります。
どれも worktree の中にはありません。マシン上の全エージェントが共有しています。
私たちはこれを身をもって知りました。あるリリースゲートが 21 件の失敗で返ってきました。すべて実際の Chrome を開くか画面をキャプチャするスペックで、その間、別の worktree のエージェントが実行中でした。コードに問題はありませんでした。2 つの実行が互いを測っていたのです。
「E2E は同時に 1 つだけ」というルールは、誰かの頭の中にだけありました。守られなくなる日まで守られていたにすぎません。そこで、これを仕組みにしました。
最初の試み:ロックを読んでから書く
最初のロックは /tmp に置いたファイルでした。worktree の中ではありません。調整したい実行は 別々の worktree にあるので、worktree ごとのロックでは互いが見えないからです。ファイルを読み、マシンが空いていると判断し、それから書き込む方式でした。
典型的な競合状態です。どちらも書く前に読んだ 2 つの実行が、両方とも自分がマシンを持っていると思い込みました。さらに悪いことに、短い方が終わると、その解放処理が長い方の実行中にファイルを消してしまいました。ある design:qa の実行は、約 3 分間ロックファイルなしで動いていました。
修正はフラグ 1 つです。ファイルを 排他的に 作成します。O_CREAT | O_EXCL、Node では wx フラグ です。
export function createExclusive(path, text) {
try {
writeFileSync(path, text, { flag: 'wx' })
return true
} catch (error) {
if (error?.code === 'EEXIST') return false
throw error
}
}ファイルを得られる呼び出し元はちょうど 1 つだと OS が保証します。「読んでから書く」の隙間はなくなります。
ロックはクラッシュを生き延びなければならない
プロセスより長生きするロックファイルは、全員を永久に止めてしまいます。そこでファイルに保持者の pid を書き、待つ側は kill -0 で確認します。シグナルは送らず、プロセスが存在するかを聞くだけです。EPERM は「存在するが他人のもの」という意味なので、生きているとみなします。pid が死んでいるロックは古いものとして引き継ぎます。
同じことは素の bash でもできます。noclobber を有効にすると > が既存ファイルを上書きしなくなり、排他的な作成になります。
$ ( set -o noclobber; echo $$ > /tmp/e2e.lock ) && echo "acquired"
acquired
$ kill -0 "$(cat /tmp/e2e.lock)" && echo "holder alive"ロックがない ≠ マシンが空いている
見落としかけた罠がこれです。ロック導入前に始まった実行はロックを書きません。手で始めた実行も同じです。「ロックファイルがない」を青信号と読むのは、ロックが防ぐべき誤りそのものです。
そこでロックがないときは、マシン自体に問い合わせます。プロセス一覧に Playwright のランナーはあるか。ただし照合は単語ではなく 実行ファイルの形 で行います。プロセス一覧への雑な grep playwright は、他のエージェントのセッションに一致しました。エージェントのセッションはプロンプト全体をコマンドラインに持っていて、私たちのプロンプトには Playwright の話が出てくるからです。スキャナーを書いていたエージェント自身のシェルまで、自分のスキャンで「実行中のゲート」として見つかりました。何かについて書かれたテキストは、その何か自体とまったく同じに見えます。
ポーリングループは 2 回失敗する:競合と飢餓
ロックが正しくなっても、各エージェントはそれを 待つ 必要があり、それぞれが独自のポーリングループを書きました。その結果、キューが本来解決する 2 つの問題が起きました。
- 競合 — 複数のループが同じ秒に試し、ロックが空いた瞬間に飛びつく
- 飢餓 — 行儀のよいループ(「空き」を見てから 20 秒待って試す)は一度も勝てない。ある worker は 50 分待って、一度も順番が来ませんでした
そこでロックの前に、先着順(FIFO)のキューを置きました。
図をテキストで見る
flowchart TD
start["エージェントが E2E 実行を開始"] --> join["チケットファイルを排他的に作成"]
join --> head{"自分のチケットが先頭か"}
head -->|"いいえ"| wait["2 秒 + ジッター待機、ハートビート更新"]
wait --> head
head -->|"はい"| lock{"ロックファイルを排他的に作成"}
lock -->|"生きている pid が保持"| wait
lock -->|"作成できた"| run["対象のスペック 1 つを実行"]
run --> release["ロックを削除し、ログを 1 行書く"]設計は 5 つのルールです。
- 待機者ごとに 1 ファイル、排他的に作成。 列に並ぶときに共有リストを読み書きしません。ファイル名がそのまま順番です:
<band>-<joined-at ms>-<sequence>-<pid>.json。名前を並べればキューの順になります。 - ロックを取れるのは先頭だけ。 ロックが空いていても先頭でなければ待ち続けます。列に並ばずに来た呼び出しは、誰かが待っている間は拒否されます。
- 死んだ・固まった待機者は外す。 pid が死んだチケットは削除します。ハートビートが 60 秒より古いチケットも同様です。停止した待機者が列の先頭でマシンを人質に取ってはいけません。自分のチケットが消されていたら、最後尾に並び直します。
- ポーリングにジッターを入れる。 2 秒ごと+最大 500 ms のランダムな遅延。待機者が同時に起きないようにします。
- 優先度はチケットを前に出すだけで、保持者を追い出さない。 先頭の帯を使えるのはリリースゲートだけで、チケットとログに記録されます。それでもロックを持っている実行の終了は待ちます。
60 分待っても順番が来なければ、待機者はあきらめて終了コード 3 で終わります(拒否と同じコード)。--no-wait フラグを付けるとすぐに拒否されます。誰かが待っている間は、割り込みを許さず拒否します。
すべての遷移を記録する
あのロックが 3 分間消えたとき、後から誰も説明できませんでした。誰がファイルに触れたかを記録するものがなかったからです。今は取得、拒否、古いロックの引き継ぎ、列への参加・離脱・除外、解放のすべてが、ロックの隣のログに 1 行ずつ残ります(上限 1 MB)。「14:02 にマシンを使っていたのは誰か」は議論ではなく grep で分かります。
エージェントには指示ファイルで伝える
エージェントが回避できてしまう仕組みは、仕組みではありません。私たちのリポジトリの CLAUDE.md(Claude Code がプロジェクトのメモリとして読むファイル)には、はっきり書いてあります。待つのがデフォルトであり、自分でポーリングループを書かないこと。また、各エージェントには自分の変更をカバーするスペックだけを 1 回だけ実行するよう求めています。実行が減れば、列も短くなります。

並列エージェントを動かすなら、このチェックリストを
| エージェントごとのポーリングループ | ロック + FIFO キュー | |
|---|---|---|
| 1 つのディスプレイで 2 つ同時に実行 | ✓ 起こりうる | ✗ |
| クラッシュした実行が全員を止める | ✓(pid 確認がない場合) | ✗ |
| 待機者が飢餓状態になる | ✓ | ✗ |
| 誰がマシンを使っていたか分かる | ✗ | ✓ |
- ロックはすべての worktree から見える場所に置く(
/tmp、Windows では OS の一時ディレクトリ) - 排他的に作成し、pid を保存し、死んだ pid は空きとして扱う
- ロックがないことを信用しない。本物のランナーのバイナリを確認する
- 待機者ごとに排他ファイル 1 つ、ハートビートとジッターで並ぶ
- すべての遷移をログに残す
- ルールはエージェントの指示ファイルに書く
SuperDevSpace ではどうしているか
SuperDevSpace では、manager がスプリントをタスクに分け、各 worker が専用の git worktree とターミナルで作業します。同じリポジトリで並列に作業しても、ファイルがぶつかりません。上のロックとキューは製品の機能ではありません。SuperDevSpace を開発しているエージェントたちが、worktree では分けられない唯一の資源のために書いた、私たち自身のリポジトリのスクリプトです。エージェント同士がテスト用ディスプレイやブラウザ、ポートを共有しているなら、同じものが必要になるはずです。真似できるくらい小さな仕組みです。Claude Code は完全サポート、Codex や Gemini CLI など他の CLI は実験的サポートです。
worktree 側の話は git worktree で複数のコーディングエージェントを並行して動かす を、アプリのセットアップは ドキュメント をご覧ください。