LLMの自動化を作っていて一番怖かったのは、起動しないことではありませんでした。

同じ仕事が2つ起動することです。

利用上限のエラーは、必ず一度だけ届くわけではありません。並列で動いているセッション、フックの再送、watcherの重複起動が重なると、同じrepositoryで別々のエージェントが作業を始めます。

片方が失敗するだけならまだ分かりやすいです。両方が少しずつ成功すると、同じファイルを違う内容で直したり、同じ通知を2回送ったりします。

一つロックを置けば終わるかなと思ったのですが、重複が起きる場所が違いました。いまは5つの境界で止めています。

重複の種類が思ったより多かった

実際に考える必要があったのは次のケースです。

セッションIDで止められるものもあれば、作業場所で見ないと止められないものもあります。

なので、全部を一つのPIDファイルへ寄せず、それぞれの境界に小さい抑止を置きました。

セッションマーカー、作業場所クールダウン、最大ホップ数、watcherロック、再開マーカーによる多層の重複防止

図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つを別イベントにします。

途中で止まったものには、試行回数と最後のエラーを残すようにします。人がマーカーを解除するときも、何が起きたかを見て判断できる状態が目標です。

フックは止めない。でも失敗は消さない

利用上限のフック自体が失敗して、元の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を覚えているかは確認が必要です。

並列で壊すテストを入れる

通常のテストだけだと、重複起動はなかなか再現しません。

次のような状態をわざと作って確認しています。

期待するのは「エラーが一つも出ない」ことではありません。

新しいセッションや外部作用が一度だけ起きて、それ以外は理由付きで止まることです。

見ている数字

抑止件数はゼロでなくても大丈夫です。重複入力が来ているなら、止められた件数でもあります。

ただし急に増えた場合は、上流で再送が増えていないか、並列セッションが意図せず増えていないかを見ます。

いまの確認項目

一つの大きなロックで全部守ろうとすると、どこかで止めすぎるか、逆に抜けます。

いまは「何が2回起きると困るか」を場所ごとに見て、それぞれ一度だけ通す形にしています。少し面倒ですが、後からログを見たときも、どこで重複を止めたかがかなり分かりやすくなりました。

ローカルLLM設計・全8回

MacでローカルLLMを実際に動かして、うまくいかなかったところを8つに分けて書いています。

  1. PART 01仕事の配置
  2. PART 02コンテキストの削減
  3. PART 03handoff
  4. PART 04冪等性
  5. PART 05プロセス管理
  6. PART 06復帰監視
  7. PART 07安全ゲート
  8. PART 08完了率の計測