Claude Code 自走開発シリーズ 第5弾
← 前の記事:Claude Codeを定期実行しただけでは自走しなかった|実際に詰まった7つの問題
→ 次の記事:複数のClaude Codeを並行自走させる|Git・競合・Integrationを壊さない運用(公開後にURLを追記)
複数のClaude Codeを定期実行するようになると、同じTaskを2つのセッションが同時に触らない仕組みが必要になります。
自分の環境でも、
lane
task
worktree
ごとにロックを持たせていました。
これで二重実行は防げると思っていました。
実際には、ロックがあっても事故は起きかけました。
生きているセッションをstaleと誤判定し、自己修復がロックを解放したためです。
この記事では、そのとき何が起きたかと、そこからロック設計をどう変えたかを書きます。
この話は、複数のローカルSessionを同じRepository周辺で動かし、自前のlock / heartbeat / repairを持たせるような運用の話です。
単一Sessionで作業するだけなら、ここまでの仕組みは必要ありません。
実際のTask名、製品名、Repository名、パス、PC名、PIDなどは出していません。
数値は問題の構造が分かる範囲で実測値を丸めて記載しています。
起きたこと
あるServer側Taskが長時間動いていました。
処理内容には、
スキーマ変更
Migration生成
テスト実行
などが含まれていました。
セッション自体は生きています。
実際に、
テストプロセス稼働中
ファイル更新あり
Migration生成あり
という状態でした。
ところが自己修復処理は、heartbeatが長時間更新されていないことを見て、そのロックをstaleと判定しました。
実測では、heartbeatが80分以上空いていました。
stale判定の閾値は45分でした。
45分、80分という数値は自分の環境での実測値です。
推奨値ではありません。heartbeat周期、Taskの長さ、復旧に許容できる時間から決める必要があります。
結果、
lane lock
task lock
worktree lock
が解放されました。
次のServerルーティンまで数分でした。
ロックは空いています。
そのまま次のセッションが入れば、同じworktree、同じGit、同じ未commitファイルを2つのセッションが触ります。
このとき一番怖かったのは、Sessionがエラーで止まったことではありません。
正常に動いているSessionを、自分で作った自走基盤が「死んだ」と判断したことです。
止まるだけなら作業は進みませんが、誤ってロックを外すと別Sessionが入り、正常だった作業まで壊す可能性があります。
heartbeatが古い = Sessionが死んだ、ではない
問題の中心はここでした。
当初の判定は、ほぼ次です。
heartbeatが45分以上更新されていない
↓
stale
↓
lock回収
しかし実際には、
heartbeatは古い
+
Sessionは生存
+
子processも稼働
+
ファイルも更新中
でした。
今回のrunnerは工程の切れ目でheartbeatを更新する構造だったため、長時間コマンドの実行中はheartbeatが更新されませんでした。
例えば、
大量テスト
長いbuild
Migration
外部ツール待ち
大きな解析
です。
heartbeatをTaskの節目でしか打たない設計だと、
正常な長時間処理ほどstaleに見える
ことがあります。
この事故で、Task粒度も見直しました。
長時間Taskそのものが悪いわけではありませんが、
途中状態を保存できない
再開地点が分からない
数時間ずっとheartbeatが打てない
失敗時の巻き戻し範囲が大きい
なら、Taskやcheckpointの切り方を見直す余地があります。
目安にしているのは「短いTask」ではなく、途中で安全に止められ、再開地点を説明できる単位です。
Buildや大量Testのように分割しづらい長時間処理は、Task分割ではなく別watcherによるheartbeatで対応します。
「ロックがある」だけでは不十分だった
ロックで排他する設計は間違っていません。
問題は、誰がロックを解放できるかです。
今回の構造は、
Sessionがロック取得
↓
heartbeat更新
↓
一定時間更新なし
↓
自己修復がstale判定
↓
ロック回収
でした。
つまり自己修復側が誤判定すると、排他そのものを消せます。
ロックの信頼性は、
ロック取得処理だけでなく、回収処理の正しさにも依存します。
対策1:heartbeatを工程の切れ目だけにしない
最初の改善は単純です。
長時間Taskの途中でもheartbeatを更新します。
Task開始
↓
heartbeat
↓
長時間処理
↳ 一定間隔でheartbeat
↓
処理完了
↓
heartbeat
例えば10分や15分ごとなど、stale閾値より十分短い間隔で更新します。
ただし、プロンプトに、
長時間作業ではheartbeatを更新する
と書くだけでは弱いです。
runnerが子processの終了を同期的に待っている構造では、同じ制御ループから定期heartbeatを打てない時間ができます。
可能ならTask本体とは別のwatcherやtimerでheartbeatを更新し、長時間コマンド中でもleaseを更新できる構造にした方が安定します。
対策2:stale閾値を実処理より短くしすぎない
45分でstaleにするなら、heartbeatは45分より十分短い周期で確実に更新される必要があります。
実測で、
heartbeat間隔 43分
長い工程では80分以上
stale閾値 45分
となっているなら、境界が近すぎます。
少し処理が長引くだけで誤回収します。
そのため、
heartbeat周期
stale閾値
想定する最長処理
はセットで決めます。
閾値だけを大きくすれば誤回収は減りますが、本当に死んだセッションの回収が遅れます。
heartbeatだけを短くしても、更新できない工程があれば同じです。
両方を見ます。
対策3:lock回収前にprocessを確認する
heartbeatは「生きている可能性」を見る一つのシグナルです。
それだけで死活判定しない方が安全でした。
ロックを回収する前に、
Session processが存在するか
開始時刻が一致するか
子processが稼働しているか
対象worktreeに更新があるか
Task状態がRUNNINGか
を確認します。
概念としては次です。
ファイル更新だけを生存判定に使うのも危険です。
たまたま更新がない処理もありますし、逆に別processが更新している可能性もあります。
子processも、owner Sessionとの親子関係や記録済みprocessかを確認せず「生きているから所有Sessionも生存」とは判定しません。
process ID、開始時刻、Task状態、記録済み子processなど、複数のシグナルを組み合わせます。
対策4:次のSession側でも再確認する
自己修復が間違える可能性をゼロにはできません。
そこで、ロックが空いているからといって、次のSessionが即作業開始しないようにします。
開始前に、
同じTaskのSessionがいないか
同じworktreeを使っているprocessがいないか
TaskがRUNNINGのままではないか
直前にlockがRepairで回収されていないか
を確認します。
つまり防御を2段にします。
Repair側:
本当にstaleか確認
新Session側:
本当に入ってよいか再確認
対策5:lockに所有者情報を持たせる
ロックファイルには、単に、
server.lock
とあるだけでは足りません。
少なくとも、
lockId
kind
lane
taskId
runner
processId
sessionStartedAt
acquiredAt
heartbeatAt
worktree
branch
などを持たせます。
process IDだけでは、OS上で再利用される可能性があります。
開始時刻なども合わせて見た方が安全です。
対策6:lease世代を持たせる
さらに厳密にするなら、ロックへ世代番号を持たせます。
例えば、
leaseGeneration = 42
とします。
ロックを再取得した新Sessionは43を持つ。
旧Sessionが遅れて戻ってきても、
自分のGeneration = 42
現在のGeneration = 43
なら、自分は所有者ではありません。
処理を続けません。
これはfencing tokenに近い考え方です。
ただし、generationをロックファイルへ書くだけではfencingにはなりません。
旧Sessionがファイル変更、commit、push、ロック更新などの重要操作へ入る直前に、現在のgenerationを再確認し、不一致ならその操作を拒否する必要があります。共有資源側で世代を検証できる構成なら、さらに強くできます。
ロックファイルを復元すれば終わりではなく、
今の所有者が誰なのかを古いSession自身も確認できる
ようにします。
もう一つの問題:事故を検知しても自己修復できなかった
今回、事故寸前の状態自体は検知できました。
しかし、修復用のロックファイルを書き戻そうとしたところ、権限制御に止められました。
つまり、
事故検知
→ 成功
原因分析
→ 成功
修復方針
→ 決定
lock復元
→ 権限不足で失敗
でした。
これも自走基盤としては重要です。
通常運転で必要な権限と、事故対応で必要な権限が違います。
例えば通常のServer担当は、ロック管理領域を直接触らない方が安全です。
一方でRepair担当がロックを直せないと、自動復旧できません。
そこで、
通常runnerが持つ権限
Repairだけが持つ権限
人の承認が必要な破壊操作
を分ける必要があります。
自走基盤では、正常系の権限だけでなく、
復旧経路の権限設計
も必要でした。
自己修復は通常runnerより強い操作になりやすい
自走を長く回すと、
死んだTaskを回収する
古いlockを消す
失敗Taskを再試行する
worktreeを再利用する
といった自己修復が欲しくなります。
便利ですが、ここは通常実装より強い権限を持ちます。
誤判定すると、
生きているSessionを死んだことにする
正常なlockを消す
別Sessionを同じworktreeへ入れる
ことができます。
自己修復処理こそ、
判定条件
証拠
実行ログ
dry-run
権限
停止条件
を厳しくした方がよいと分かりました。
シリーズの前後記事
← 前の記事:Claude Codeを定期実行しただけでは自走しなかった|実際に詰まった7つの問題
→ 次の記事:複数のClaude Codeを並行自走させる|Git・競合・Integrationを壊さない運用(公開後にURLを追記)
まとめ
今回の事故寸前の原因は、
heartbeatが長時間更新されなかった
↓
stale閾値を超えた
↓
自己修復がSession不在と判断
↓
lane / task / worktree lockを回収
↓
次の定期実行が入れる状態になった
ことでした。
Session自体は生きていました。
ロックがあるだけでは二重実行を防げません。
現在は、少なくとも次をセットで考えています。
定期heartbeat
stale閾値
process生存確認
子process確認
Task状態
再入場時の確認
lock owner情報
lease generation
Repair権限
AIを並行で自走させ始めると、結局、
排他、lease、死活監視、障害回復
という昔からある並行処理の問題が戻ってきます。
自走を続けるほど、AI固有の問題というより、昔からある排他・監視・障害回復の設計へ戻ってきました。
次の記事では、複数担当が別々に作った変更を、Git・API契約・Integration Gateでどう安全に1つへ戻すかをまとめます。