ある朝、夜のうちに4本のエージェントへ振っておいたタスクのうち、2本が「依存が見つからない」というエラーで途中停止していました。個人開発で複数のアプリと Dolice Labs のブログ群を並行して回している都合上、私はオフピークの時間帯にエージェントをまとめて走らせています。その晩は React の画面実装、API ルートの追加、Lint 修正、テスト追補を別々のエージェントに同時に任せていました。
朝に残っていたのは壊れた pnpm-lock.yaml と、半分だけ展開された node_modules でした。コードの差分そのものは悪くありません。問題は、4本のエージェントがほぼ同じ瞬間に pnpm install を叩き、同じ store と同じ lockfile を奪い合ったことでした。
この記事は、その後しばらく安定して回せている対処をまとめたものです。要点は単純で、コード生成は並列のままにして、依存インストールという一点だけを直列化します。
何が壊れていたのか — 二つの共有資源
並列エージェントの衝突というと、同じソースファイルを二人が書き換える話を思い浮かべます。けれど今回の故障はそこではありませんでした。壊れていたのは、エージェントから見えにくい二つの共有資源です。
一つ目は pnpm の content-addressable store (既定では ~/.local/share/pnpm/store、または ~/.pnpm-store)です。pnpm はパッケージの実体を一箇所の store に置き、各プロジェクトの node_modules からハードリンクを張ります。store への書き込みが複数プロセスで重なると、展開途中のパッケージに別プロセスがリンクを張りにいき、中身が欠けたディレクトリが残ります。
二つ目は pnpm-lock.yaml です。インストール中に解決結果が変わると pnpm は lockfile を書き戻します。二本のインストールが同時に書き戻すと、片方の途中状態が混ざった YAML が残り、次回以降の解決が壊れます。
私の環境では、ワークツリーは git worktree で分けていたのに store と lockfile が共有のままでした。つまり「ソースは隔離したが、依存解決は隔離していなかった」状態です。並列の単位を一段見誤っていたわけです。
ログでどう見分けるか
同じ「インストール失敗」でも、ネットワーク起因や単純なバージョン不整合とは現れ方が違います。並列の競合に固有のサインは、次のようなものでした。
観察されたサイン 意味
`ENOENT` が store 配下のパスで出る 別プロセスが展開途中の実体にリンクを張った
`Cannot read properties of undefined (reading 'integrity')` lockfile の途中書き戻しで整合性ハッシュが欠落
`pnpm-lock.yaml` の `git diff` が巨大かつ無秩序 二本の解決結果が交互に混ざった
再実行(単独)だと毎回成功する コード自体は正しく、競合が原因という強い証拠
`node_modules/.pnpm` に空ディレクトリが残る 展開が中断された痕跡
最後の行が判定の決め手でした。単独で再実行すると必ず通る なら、それは内容のバグではなく、タイミングの問題です。私はこの観察を最初の切り分けに据えています。
直列化の前にまず試した「失敗する対処」
最初に手を出したのは、エージェントの数を4から2へ減らすことでした。確かに頻度は下がりましたが、ゼロにはなりません。並列度を落とすのは、根本ではなく確率を薄めているだけでした。
次に試したのが、各ワークツリーへ別々の store を割り当てる方法(pnpm config set store-dir をワークツリーごとに変える)です。lockfile の衝突は減りましたが、store を分けるとハードリンクの共有が失われ、ディスクが膨らみ、初回インストールが目に見えて遅くなりました。4ワークツリーで store を完全分離したところ、依存のディスク使用量が約3.4倍になりました。
この二つの回り道から、設計の方針が決まりました。store は一つに保ったまま、それを触る瞬間を一度に一本へ絞る 。つまり排他制御です。
インストールだけをファイルロックで直列化する
flock(Linux/macOS で利用できるアドバイザリロック)を一枚かぶせるだけで、インストールの同時実行を防げます。エージェントには pnpm install を直接ではなく、このラッパー経由で呼ばせます。
#!/usr/bin/env bash
# locked-install.sh — 依存インストールだけを直列化する
set -euo pipefail
# リポジトリ全体で一つのロックファイル。store と lockfile を守る対象に合わせる
LOCK_FILE = "${ PNPM_INSTALL_LOCK :-/ tmp / pnpm-install . lock }"
TIMEOUT_SEC = "${ INSTALL_LOCK_TIMEOUT :- 600 }"
exec 9> " $LOCK_FILE "
# -w でタイムアウト付き。待ちきれなければ非ゼロで抜けて、呼び出し側がリトライ
if ! flock -w " $TIMEOUT_SEC " 9 ; then
echo "install lock timeout after ${ TIMEOUT_SEC }s" >&2
exit 75 # EX_TEMPFAIL: 一時的失敗として扱う
fi
echo "[locked-install] acquired lock, running: pnpm install $* " >&2
pnpm install " $@ "
# exec 9 のスコープを抜けるとロックは自動解放される
ポイントは三つあります。
ロックは1リポジトリにつき1枚 にして、store と lockfile という守りたい資源の粒度に合わせます。プロジェクトごとに別ロックにすると、共有 store の競合を防げません。
タイムアウトを付ける ことで、万一ロックを握ったプロセスが死んでも、待ち側が無限に止まりません。exit 75(一時的失敗)にしておくと、エージェント側のリトライ方針と噛み合います。
コード生成・編集・テストはロックの外 に置きます。直列化するのはインストールの一点だけで、並列の旨味は損なわれません。
Antigravity 側では、各エージェントのワークフローでインストール手順を pnpm install から ./locked-install.sh に差し替えます。タスク定義(AGENTS.md やタスクのセットアップ手順)に明記しておくと、エージェントが勝手に素の pnpm install へ戻すのを防げます。
ワークツリーごとに node_modules を分け、store は共有のまま
直列化で衝突は止まりますが、もう一段だけ整えると安定します。node_modules はワークツリーごとに独立 させ、store だけを共有 する形です。これは pnpm の既定動作にほぼ沿っていますが、git worktree を使うときは明示しておくと事故が減ります。
# 各ワークツリー直下の .npmrc
# store は共有(ハードリンクでディスク節約)、node_modules はワークツリー固有
store-dir =~/.pnpm-store
# シンボリックリンクを実体化せず、厳密な node_modules を作る
node-linker =isolated
# 並列ダウンロードは保ちつつ、書き込み競合は flock 側で防ぐ
node-linker=isolated にしておくと、各ワークツリーの依存ツリーが独立し、別ワークツリーの中途半端な状態が漏れ込みません。store は共有のままなので、二度目以降のインストールはハードリンクで速いままです。
この構成にした理由は、私が同時に触るリポジトリが日によって入れ替わるからです。store を分けると初回コストが毎回かかりますが、共有しておけば一度落としたパッケージは使い回せます。開発現場では、ディスクと初回速度のこの天秤が地味に効いてきます。
導入前後で何が変わったか
夜間バッチ(4エージェント並列・1晩に十数回のインストールが走る構成)で、約2週間ぶんを比べました。
指標 導入前 導入後
lockfile 破損による停止 12回中3回 0回
朝に手で復旧した回数(2週間) 5回 0回
並列インストールの最大待ち時間 — 約40秒
依存のディスク使用量(store 分離案との比較) — 分離案の約1/3.4
エージェントの並列度 4 4(据え置き)
待ち時間の最大約40秒は、四本のうち最後のインストールが前の三本を待つ最悪ケースです。コード生成は待たないので、体感の総時間はほとんど変わりませんでした。手作業の朝の復旧がゼロになったことのほうが、私にとっては大きな差でした。
同じ考え方が効く場面
この「並列のまま、共有資源を触る一点だけ直列化する」という形は、依存インストールに限りません。私が同じパターンを当てているのは次のような場面です。
データベースのマイグレーション : スキーマを変える瞬間だけロックし、読み書きのクエリ生成は並列のまま。
コード生成の最終フォーマット : 各エージェントの編集は並列、リポジトリ全体への format 一括適用だけを直列に。
共有キャッシュの再構築 : ビルドキャッシュを作り直す瞬間だけ排他。
共通するのは、「並列度を下げる」のではなく「直列化する対象を最小の一点に絞る」という発想です。並列の単位を一段細かく見直すと、全体を遅くせずに事故だけを消せます。
並列エージェントを増やしていくと、衝突は必ずどこかに現れます。けれど多くの場合、原因はソースファイルそのものではなく、その裏にある store や lockfile やキャッシュといった、目に入りにくい共有資源です。まずはそこを疑ってみてください。同じ朝の復旧作業に時間を取られている方の助けになれば幸いです。