LLMの自動化を作っていて一番怖かったのは、起動しないことではありませんでした。
同じ仕事が2つ起動することです。
利用上限のエラーは、必ず一度だけ届くわけではありません。並列で動いているセッション、フックの再送、watcherの重複起動が重なると、同じrepositoryで別々のエージェントが作業を始めます。
片方が失敗するだけならまだ分かりやすいです。両方が少しずつ成功すると、同じファイルを違う内容で直したり、同じ通知を2回送ったりします。
一つロックを置けば終わるかなと思ったのですが、重複が起きる場所が違いました。いまは5つの境界で止めています。
重複の種類が思ったより多かった
実際に考える必要があったのは次のケースです。
- 同じセッションが同じ上限エラーを何度も通知する
- 別セッションが同じ作業ディレクトリで同時に止まる
- 同じhandoffを複数のwatcherが拾う
- クラウドAからB、BからAへ戻り続ける
- 起動失敗なのに「処理済み」の印だけ残る
セッションIDで止められるものもあれば、作業場所で見ないと止められないものもあります。
なので、全部を一つのPIDファイルへ寄せず、それぞれの境界に小さい抑止を置きました。
図1: 重複が起きる場所ごとに、違うキーと有効期間で一度だけ通します。
1. 同じセッションからの2回目を止める
最初はセッションIDでマーカーを作ります。
同じセッションから2回目の通知が来た場合は、handoffも新しいプロセスも作らず終了します。
ここで「ファイルがあるか確認して、なければ作る」と実装すると競合します。2つの処理が同時に確認し、両方とも「まだない」と判断できるためです。
排他的なファイル作成か、mkdirの原子性を使っています。
markerを排他的に作る
作れた → 最初のhandlerなので続行
既にあった → 重複として記録して終了
マーカー名へ外部入力をそのまま使わず、許可した文字だけにするかhashへ変えます。
2. 同じ作業場所では、すぐに次を起動しない
セッションが違っても、同じrepositoryで同じ仕事をしている場合があります。
ここには作業ディレクトリ単位で600秒のクールダウンを置きました。98件のhandoffのうち12件は、この判定で新しい継続セッションを起動していません。
クールダウンで地味にハマったのが、時刻を更新するタイミングです。
フックが呼ばれるたびに更新すると、エラーが続く限り終了時刻も後ろへ延びます。結果としてずっと起動できません。
なので、時刻を更新するのは「新しい継続セッションを実際に起動する」と決めたときだけです。
抑止したイベントも消さずに残します。既存handoffへ追加するか、memory-onlyとして記録し、起動失敗とは別に数えています。
3. 実行先を行ったり来たりさせない
クラウドの実行先を複数持つと、空いているところへ順番に回したくなります。
ただ、利用上限の見積りや経路判定が外れると、A→B→Aのようなループになります。
handoffへ通った経路とhop数を残し、上限を超えたらローカルか人へ戻します。
{
"routeHistory": ["cloud-a", "cloud-b"],
"hopCount": 2,
"maxHops": 3,
"nextOnFailure": "local-or-human"
}
一度通った実行先を同じhandoffで選び直さないルールも入れています。
最大ホップ数は、最適な実行先を見つけるための機能ではありません。ルーターの見立てが外れたときに、永遠に回り続けないための上限です。
4. watcherは一つだけ動かす
15分ごとのような定期実行は、前回の処理が終わる前に次が始まることがあります。
watcher全体へmkdirベースのロックを置き、同時に一つだけ動くようにしました。
ただ、異常終了するとロックだけ残ります。
いまの復帰watcherは、ロックの更新時刻が30分を超えたら古いとみなして消しています。ここは正直、まだ弱いです。処理が30分以上かかれば、生きているwatcherのロックを外す可能性があります。
次はPIDと開始時刻を記録し、そのPIDがまだ同じプロセスとして生きているか確認する形へ変えます。PIDも再利用されるので、開始時刻やコマンドまで合わせて見る必要があります。
5. handoffを誰が取ったか残す
同じhandoffを2つの実行者が拾わないよう、現在は処理前に.resumedマーカーを置いています。実質的にclaimと重複防止を一つの印で兼ねています。
クラウドへ戻すときも、重複再開を避けるため.resumedに相当するマーカーを先に作っていました。
ここは悩ましいところです・・・!
先にマーカーを作れば二重起動は防げます。ただ、その直後にモデル起動が失敗すると、次回は再開済みに見えます。
次の実装では、少なくとも次の3つを別イベントにします。
- handoffをclaimした
- 起動要求を送った
- 再開後の検証に通った
途中で止まったものには、試行回数と最後のエラーを残すようにします。人がマーカーを解除するときも、何が起きたかを見て判断できる状態が目標です。
フックは止めない。でも失敗は消さない
利用上限のフック自体が失敗して、元のClaude Codeまで巻き込むのは避けたかったので、フックは原則exit 0にしています。
これは成功扱いにするという意味ではありません。
処理結果はappend-onlyなイベントログへ残します。
detected
marker-created | duplicate-session
handoff-written
spawned | cooldown-suppressed | memory-only
claimed
resume-requested
verified | failed | skipped
各イベントへhandoff ID、セッションID、作業場所のhash、経路、理由、時刻を付けます。パスや本文は、そのままログへ出さないようにしています。
元の処理は安全に返しつつ、裏側の失敗は後から追える状態です。
何度動いてもよい処理まで止めない
冪等性というと、何でも一度しか実行しないように見えます。
自分は、安全に繰り返せる確認と、一度だけにしたい副作用を分けています。
| 処理 | 再実行 | どう考えているか |
|---|---|---|
| Git状態の読取 | 何度でも可 | 現在状態を見直したい |
| 上限の最小プローブ | 間隔を空けて可 | 状態は時間で変わる |
| テスト実行 | 原則可 | 外部副作用がない範囲 |
| handoffのclaim | 一度だけ | 同時編集を防ぐ |
| 新規セッション起動 | 一度だけ | 同じ仕事を増やさない |
| 外部投稿・デプロイ | 自動再試行しない | 二重実行の影響が大きい |
外部API側にidempotency keyがある場合はhandoff IDから作ります。ただ、APIがいつまで同じkeyを覚えているかは確認が必要です。
並列で壊すテストを入れる
通常のテストだけだと、重複起動はなかなか再現しません。
次のような状態をわざと作って確認しています。
- 同じセッションIDでフックを10並列実行する
- 別セッションID、同じ作業場所で同時に動かす
- watcherを2つ同時に起動する
- claim直後、起動直前、起動直後で強制終了する
- 古いロックを残したまま再起動する
- 最大ホップ直前で経路選択を失敗させる
期待するのは「エラーが一つも出ない」ことではありません。
新しいセッションや外部作用が一度だけ起きて、それ以外は理由付きで止まることです。
見ている数字
- 検知件数に対する一意なhandoff数
- 同じセッションを止めた件数
- 作業場所のクールダウンで止めた件数
- claim競合の件数
- 古いロック、取り残されたclaimの件数
- handoff一件あたりの起動数
- 同じ実行先へ戻った回数
- 人がマーカーを解除した件数
抑止件数はゼロでなくても大丈夫です。重複入力が来ているなら、止められた件数でもあります。
ただし急に増えた場合は、上流で再送が増えていないか、並列セッションが意図せず増えていないかを見ます。
いまの確認項目
- セッション、作業場所、handoff、watcher、経路でキーを分けたか
- 存在確認してから作成、ではなく原子的に作ったか
- クールダウン時刻を実際の起動決定時だけ更新するか
- 最大ホップ数と同じ経路への戻り防止があるか
- claim、起動要求、検証成功を分けたか
- フックを安全に返しても失敗理由を記録できるか
- 古いロックから戻る方法があるか
- 外部作用を無条件に自動再試行していないか
- 競合を並列テストしたか
一つの大きなロックで全部守ろうとすると、どこかで止めすぎるか、逆に抜けます。
いまは「何が2回起きると困るか」を場所ごとに見て、それぞれ一度だけ通す形にしています。少し面倒ですが、後からログを見たときも、どこで重複を止めたかがかなり分かりやすくなりました。
ローカルLLM設計・全8回
MacでローカルLLMを実際に動かして、うまくいかなかったところを8つに分けて書いています。