0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeを複数並走させたら3回事故った——Git競合・死んだロック・想定外のコストをどう塞いだか

0
Last updated at Posted at 2026-08-18

AIエージェントを2体以上、同じgitリポジトリ・同じブランチで並走させたことはありますか。

私は個人の業務自動化のために、Claude Codeのエージェントを部署に見立てて複数並走させ、タスクを依存グラフ(DAG)で連鎖・自走させる仕組みを自作して約1ヶ月運用してきました。1体だけ動かしていた頃には一度も起きなかった事故が、並走を始めた途端に立て続けに起きました。この記事はその実際に起きた3つの事故(git並行競合・死んだロックの残存・想定外のコスト構造)と、コードでどう塞いだかの記録です。

依存グラフの実装自体は驚くほど小さく済みます。難しいのはそこではなく「複数のAIが同じ資源を触る運用」の方でした。同じ構成を組もうとしている人が、私と同じ場所で転ばずに済むように書いておきます。

どんな構成で自走させているか(前提)

  • タスク台帳はMarkdown(tasks.md)。1行=1タスクで、依存は 依存: フィールドに書く
  • 実行主体は夜間cronで起動するheadlessなClaude Codeワーカー。複数マシン・複数プロセスが同じブランチを共有
  • 「依存が解決したタスクだけを拾って実行し、完了したら次の工程が拾える状態になる」を周期スイープで回す

台帳はこういう見た目です。

- [ ] [T-100] 記事テーマの調査   | 分類:調査
- [ ] [T-101] 下書き生成         | 依存:T-100 | 分類:制作
- [ ] [T-102] 品質チェック       | 依存:T-101 | 分類:制作
- [ ] [T-103] 投稿(外部・承認制)| 依存:T-102 | 分類:外部

依存解決のコア実装は、LLMを一切使わないNodeの純関数だけです。正規表現で 依存: を配列に落とし、「依存が全て完了済みの未着手タスク」だけを通します。

// 依存:T-100,T-101 → ["T-100","T-101"]
// 不正な依存IDが1つでも含まれていたら例外にし、誤って点火させない
function parseDependsOn(raw) {
  if (!raw) return [];

  const ids = raw.split(",").map((s) => s.trim());

  if (ids.some((id) => !/^T-\d+$/.test(id))) {
    throw new Error(`Invalid dependency: ${raw}`);
  }

  return ids;
}

// 依存が1つでも「未完了 or 見つからない」ならブロック(見つからない=解決不能も止める)
function isBlocked(task, allTasks) {
  if (!task.dependsOn.length) return false;
  const byId = new Map(allTasks.map((t) => [t.id, t]));
  return task.dependsOn.some((id) => !byId.get(id) || !byId.get(id).done);
}

// 点火してよいタスク = 未完了 かつ 依存解決済み かつ 実行資格あり(外部工程・ロック中は除外)
function ignitable(allTasks, lockedIds) {
  return allTasks.filter(
    (t) => !t.done && !isBlocked(t, allTasks) && !t.external && !lockedIds.has(t.id)
  );
}

発想としてはDocker Composeの depends_on と同じです。私は以前、5サービス構成の起動順を手動でミスし続けて depends_on + healthcheckに寄せた経験があるのですが、タスクの依存も同じで、順序をAIの記憶や善意に頼らず、宣言されたグラフから機械的に導出するのが肝でした。プロンプトで「T-101はT-100の後にやってね」と頼む方式は、セッションが分かれた瞬間に忘れられます。

ここまでは正直、簡単でした。事故は全部この外側で起きました。

事故1:夜中にgitのrebaseが止まり、翌朝まで誰も気づかなかった

運用2週目のことです。対話セッションで私が作業していた裏で、自律ワーカーがoriginと同期しようと git pull --rebase --autostash を実行。追記型のログファイルが両側で伸びていたため競合し、rebaseが途中停止のままdetached HEAD・indexにUU(未マージ)が残留しました。

無人運用の怖さはここからです。rebaseが止まっても誰も解決しない。後続のワーカーも全部そこで詰まる。私が気づいたのは翌朝でした。丸一晩の停止です。昔、サーバー障害の検知がユーザー連絡頼みで平均1時間かかっていたのを監視導入で1分に縮めた経験があるのに、自分のAI基盤では「止まったことに気づく仕組み」を用意し忘れていた、というのが情けない実態です。

対処は3点セットにしました。

# .gitattributes — 追記専用ファイルは競合させず「両方残す」
audit-log.md      merge=union
nightly-log.md    merge=union
# 全ワーカー・全セッションがcommit/pushを共通ゲート経由に統一する。
# 同一マシン内ではflockで直列化する。複数マシン間はGitのpushそのものを楽観ロックとして使う——
# commit後にもう一度fetch + mergeしてからpushし、それでも他マシンに先行されていたらpush失敗を
# 競合シグナルとして再fetch・再merge・再pushする(統合はrebaseではなくfetch + merge --no-edit)
flock /tmp/ai-git.lock -c '
  git fetch origin &&
  git merge --no-edit origin/main &&
  git add "$@" &&
  git commit -m "$MSG" &&

  # commit中に別マシンが先にpushしている可能性があるため、push直前にもう一度再同期する
  git fetch origin &&
  git merge --no-edit origin/main &&
  git push origin HEAD:main
'
  1. 追記専用ファイルは merge=union。独立した追記行だけで構成され、行順序に意味を持たせないログでは「両方残す」を優先できるため、競合を自動解消させる。逆に、行順序や一意性に意味があるファイルには使わない
  2. rebaseを常用しないpull --rebase をやめ fetch + merge --no-edit に統一。「途中停止したrebase」という壊れた中間状態の温床を消す
  3. 同一マシン内の書き手はflockで直列化する。ただしflockはローカルマシン内の排他なので、複数マシン間の競合までは防げない。複数マシンから書き込む場合はcommit後・push直前にもう一度fetch・mergeし、それでも他マシンに先行されていたらpush失敗を正常な競合シグナルとして扱い、再fetch・再merge・再pushする。最後のfetch〜push間の競合窓は完全にはゼロにならないが、Gitはリモートが進んでいるpushを拒否するので「無音の事故」ではなく「検出可能な失敗」として扱える。競合そのものを避けたいなら、中央の実行キューや共有ロックなど別の分散排他機構が必要になる

私の環境で再発防止への寄与が大きかったと感じた順では、merge=union、rebaseの常用停止、書き込みゲートへの集約でした。導入後、同種のrebase停止は再発ゼロ。ただし白状すると、その後 git stash 経由の競合すり抜けで1回だけ類似の事故が再発し、最終的にstash系コマンド自体をフックで機械ブロックして落ち着きました。「AIにこのコマンドは使わないでと指示する」は破られます。破れないのはコマンドを物理的に塞ぐ側だけでした。

事故2:死んだワーカーのロックが「永遠の勝者」になった

複数ワーカーが同じタスクを二重実行しないよう、タスクロックを実装しました。方式は 1 claim = 1ファイルです。

.company/task-locks/T-101.worker-macbook-8821   ← ファイル名 = タスクID + 所有者
(中身の1行目はISO時刻。24時間を超えたclaimはstale=死亡とみなし選択時に無視)

単一のロック台帳ファイルに全員が追記する方式を最初に検討しましたが、やめました。追記だとmergeは通ってしまうので「push失敗=敗北」のシグナルが消え、両者が自分を勝者だと誤認します。1 claim=1ファイルで所有者ごとに別パスを使えば、claim同士が同一ファイルを直接編集する競合を避けられます。そのうえでorigin上に存在するclaimを列挙し、決めたルールで勝者を選ぶことで、各ワーカーが同じ結果を計算できるようにしました。

で、事故はその勝者判定で起きました。試走2回目で、あるタスクが誰にも実行されないまま静かに放置され続けたんです。原因を追うと、勝者判定を「全履歴で最初に追加されたclaim」で行っていたため、過去に一度claimして取り消した(削除済みの)死んだclaimが永遠に勝者になっていました。生きているワーカーは全員「自分は敗者」と判定して退く。

誰も悪くない。全員がルール通りに譲っただけ。それで永久に止まる。

修正は「判定対象を今originに存在するclaimに限定する」の1点です。

# 生きているclaimだけを列挙(削除済みclaimの履歴は勝者判定に混ぜない)
git ls-tree -r --name-only origin/main -- .company/task-locks/ | grep "^.*T-101\."
# → claimをpushした後にoriginを再取得し、「現在originに存在し、かつstale閾値を超えていないclaim」
#   だけを候補として勝者を決定する。自分が勝者であることを確認できたワーカーだけがタスク本体を実行する。

私は昔、受注処理のデッドロックが1日5〜10回出るMySQLをロック順序の統一で0件にしたことがあります。あのときの教訓「ロックは取り方より解け方と死に方の設計が本体」は、AIワーカーのファイルロックでもそっくりそのまま通用しました。プロセスはRestart=always的な仕組みで勝手に生き返りますが、ロックは勝手には死んでくれない。stale閾値(24時間)と、完了処理が残骸claimを掃除する経路をセットで入れて、ようやく安定しました。

事故3:請求を押し上げていたのは「モデルの賢さ」ではなかった

自走を始めて最初の請求を見たとき、1タスクあたり約$2かかっていました。反射的に「モデルを安い階層に下げよう」と考えたのですが、実測ログを分解して認識が変わりました。

直近のある1実行(28ターン)の内訳です。以下の金額は、実行時に利用していたモデルと当時のAPI料金をもとに概算しています。モデルやPrompt Cachingの料金は変更される可能性があるため、現在の料金とは異なる場合があります。

項目 トークン数 概算コスト
cache_read 1,012,642 約$0.30
cache_create 74,064 約$0.28
output 14,451 約$0.22

cache_readのトークン量はoutputの約70倍でした。ただしPrompt Cachingのcache readは通常入力よりかなり低単価です。少なくとも現在のAnthropic公式料金では基本入力単価の10%相当なので、「cache_readが70倍=コストも70倍」ではありません。実際にはcache read / cache create / outputをそれぞれ単価込みで比較したところ、長いコンテキストを多数ターン参照する構造が大きなコスト要因になっていると判断しました。会話型エージェントでは、ターンが増えるほど過去の会話やツール結果など参照すべきコンテキストが増えやすく、累積コストも膨らみます。Prompt Cachingによって再利用部分の単価は下がるものの、長いコンテキスト自体はコンテキストウィンドウを消費するため、「各ターンで参照する文脈量 × ターン数」を意識する必要があります。

なので対処もモデル選定ではなく掛け算の両辺を削る方向にしました。

  • 履歴に乗る固定荷物を削る(巨大な台帳ファイルをワーカーに読ませない・不要なMCPツール定義をロードしない)
  • ターン上限を設定(私の環境では25。それ以下に絞ると途中打ち切り→再実行でむしろ3倍に跳ねた)
  • 単価の安い階層への切替は「効く前提」ではなく実測とセットで(切替自体で1件$1.71→$0.80、約53%減)

現在は1タスク平均$0.67〜0.82まで下がっています。この件の学びは一つで、エージェント運用のコストはダッシュボードの請求額ではなくトークン内訳ログで見る。cache_readの行を見るまで、私は1ヶ月間ずっと間違った変数を最適化しようとしていました。

最小構成チートシート——自分の環境に持ち帰るなら

私の構成をフルコピーする必要はありません。複数エージェント×自走をやるなら、最低限これだけで主要な事故は塞げます。

部品 最小実装 塞げる事故
依存グラフ タスク行に 依存: フィールド+正規表現パース+ブロック判定の純関数 順序の暗黙知依存・手戻り・二重実行
gitゲート 同一マシン内のflock+複数マシン間の競合検出+追記ログへのmerge=union+rebase常用停止 並行書き込みによる途中停止・無人スタック
ロック 1 claim=1ファイル+stale閾値+完了時の残骸掃除 二重着手・死んだロックによる永久停止
コスト観測 トークン内訳(特にcache_read)のログ+ターン上限 請求額の原因誤診
外部工程の隔離 「外部」タグ付きタスクを点火対象から機械的に除外し承認キューへ 自走の勢いで不可逆アクションが飛ぶ事故

どれも共通するのは、AIへの指示文ではなくAIの外側のコード(フィルタ・ロック・ゲート)で強制していることです。プロンプトの約束は数十セッションに一度は破られますが、フィルタは破られませんでした。

まとめ

  • 依存グラフの実装は小さい。難所は「複数AIが同じ資源(git・ロック・予算)を共有する運用」側にある
  • 事故は3種類とも「1体では表面化しにくい」もの:並行書き込み、ロックの死に際、長いコンテキスト×多数ターンによる累積コスト
  • 対処は全て機械側(union・ローカル排他・origin上でのclaim判定・stale閾値・ターン上限)。プロンプトによる「守ってください」は安全策に数えない

結局のところ、この1ヶ月で一番効いたのは賢い設計ではなく、事故のたびに「AIに頼む」を「コードで塞ぐ」に置き換え続けたことでした。自走の信頼性は、AIの性能ではなく周辺の地味な排他制御で決まる——それが私の実測での結論です。


この記事の要点だけ持ち帰るなら、「並走の事故はプロンプトでなくロックとゲートで塞ぐ」だけ覚えておけば十分です。自分の環境で複数エージェントを回すときのメモ代わりにストックしておくと、最初の事故が起きた夜にすぐ引けます。

関連記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?