フェイルオーバーを作ったとき、クラウドが止まってもローカルで続きを拾えたので、一旦できた気になっていました。

2026年7月25日時点でhandoffは98件あり、そのうち86件でローカルセッションを起動できています。

ではクラウドへ戻せたのは何件かというと、0件でした。

69件は期限切れ。29件は仕組みを入れる前の過去分としてskipしています。

起動だけ見れば86/98です。元の実行先へ戻るところまで見ると0/98でした。

これは同じ処理の後半が少し弱い、という感じではなさそうでした。ローカルへ逃がす処理とクラウドへ戻す処理を、別々に作って別々に測ることにしました。

この記事は、完成した復帰処理の紹介ではありません。現在動いている仕組みと、0/98を受けて次に作る設計を分けて書きます。

最初の仕組みで戻れなかった理由

当初はhandoffの有効期限を12時間にして、15分ごとのwatcherがクラウドの回復を確認する形でした。

実装はかなり分かりやすいです。

ただ、12時間という数字は利用上限が実際に戻る時刻ではありません。解除が12時間より後なら、クラウドが使えるようになる前にhandoffが期限切れになります。

期限切れファイルを消すと運用上は片付きますが、仕事が元へ戻っていないことは変わりません。

もう一つ、現行watcherはクラウド側を起動する前に.resumedマーカーを置いています。マーカーを置いた直後に起動が失敗すると、次の試行だけ止めて仕事は残ります。

保留handoff、リセット時刻、最小プローブ、再開マーカー、結果検証を持つ復帰状態機械

図1: 0/98を受けて、次に実装する復帰状態を分けた設計です。

次は、復帰側の状態を分ける

現行実装でファイルから分かるのは、未処理、.resumed、.skipくらいです。これだと、取得済みなのか、起動要求まで進んだのか、実際に再開できたのかを分けられません。

ここから作り直すなら、復帰側だけで次の状態を持たせます。

状態 何が起きているか 次へ進む条件
pending ローカル継続中、または復帰待ち リセット見込み時刻へ到達
probing 小さいリクエストで利用可能性を確認中 明示的な成功応答
claimed 一つのwatcherがhandoffを取得 原子的なclaim成功
resume-requested クラウド起動を要求済み プロセス開始を確認
verifying handoffと現在状態を確認中 再開または完了を確認
resumed クラウド側で継続できた 検証イベントを保存
deferred まだ上限中、または資源不足 次の再試行時刻
failed 自動では戻せない 人が確認する

状態名は何でもよいと思っています。

大事なのは、時刻が来たから回復、マーカーがあるから成功、にしないことです。何を実際に見たら次へ進めるのかを、実装前に決めておきます。

pendingがないならAPIを呼ばない

watcher自体は15分ごとに動きます。

ただ、戻すhandoffが一件もなければAPIへプローブせず終了します。

常時プローブしても回復が早くなるわけではなく、上限中のリクエストとログが増えるだけでした。

ここまでは現行watcherにも入っています。ただ、pendingがあると、その先は.resumedを先に置いて起動するところまで一気に進みます。

次は一件ずつclaimし、状態を保存してから進めます。新しいhandoffから選ぶだけでなく、業務期限、重要度、再試行回数も使う方がよさそうです。復帰時にも同時実行の上限を置きます。

切替側のリセット時刻を、復帰側でも使う

切替側のhandlerは、上限エラーにリセット時刻が書かれている場合、アカウント単位の台帳へ保存しています。

{
  "account": "cloud-account-a",
  "limitedAt": "2026-08-03T01:20:00+09:00",
  "resetAt": "2026-08-03T17:00:00+09:00",
  "source": "parsed-limit-message",
  "confidence": "explicit"
}

作業ディレクトリ単位ではなくアカウント単位です。

別repositoryから試しても、同じアカウントの上限なら結果は変わりません。

ただし現行watcherはこの台帳を読んでおらず、まだ固定の12時間で期限切れを判定しています。台帳はあるのに復帰経路へつながっていない、という状態です。次はここを接続します。

週次上限などで時刻が分からない場合は、推定の根拠と信頼度を残してbackoffします。予想が外れたら次の間隔を広げます。

古いhandoffも、期限だけで自動終了にしないよう変えます。人へ知らせた上で、処理しないなら理由付きでskipします。

復帰確認で元の依頼を送らない

クラウドが使えるか確認するために、元の大きな依頼をもう一度送ると、プローブと仕事の再実行が混ざります。

なので「PING_OKだけ返す」のような小さいプローブを使っています。

現行watcherはPING_OKが返ったかどうかを見ています。ただ、これだけでは「まだ上限中」と「認証が壊れた」を分けられません。

次は結果を成功・失敗の2択にせず、少なくとも次へ分けます。

同じ429でも意味が違います。

一時的な混雑なら待ち方を変えたいですし、明示的な利用上限ならリセットまで触らない方がよいです。取得できるstatusと本文のシグナルを残して、次の待ち方を決められるようにします。

.resumedを先に置くと、再試行が難しくなる

現行watcherは、2つの実行が同じhandoffを再開しないよう、起動前に.resumedを置きます。

これは二重起動を避ける代わりに、起動失敗後の自動再試行を止める判断です。

次は一つの真偽値へまとめず、試行の途中経過を残します。

{
  "handoffId": "...",
  "claimedAt": "...",
  "resumeRequestedAt": "...",
  "attempt": 1,
  "lastError": null,
  "verifiedAt": null
}

resume-requestedのまま一定時間動かない場合は、もう一度試してよい失敗か、何か外部作用が起きたか分からない状態かを分けます。

後者は自動で再試行せず、人へ返します。

起動したあと、もう一度Gitを見るところまでを復帰にする

ここからは次の実装で入れるhandshakeです。クラウド側のプロセスが起動しても、まだ復帰完了にはしません。

ローカルでファイルを変更している間に、人や別エージェントが同じrepositoryを触っている可能性があります。

復帰後に、少なくとも次を確認します。

このhandshakeが終わってからresumedとして数えるようにします。

プロセス起動は中間結果でした。

watcherが動いているかも、別で見る

15分ごとに動く設定を書いても、launchd側が壊れれば何も起きません。

現状はwatcher自身のheartbeatがありません。launchdの設定が残っていても、定期実行が止まったことを別経路で検知できない状態です。

次はwatcherがheartbeatを残し、想定周期を超えて更新されなければ別経路で知らせます。

復帰側で見たい数字は次のとおりです。

期限切れは正常終了にしていません。なぜ戻らなかったかを理由別に数えています。

実装したら、わざと途中で止める

復帰処理は、通常系だけテストしていると「起動できた」で終わりやすいです。

次の箇所で止めるテストを入れます。

0/98は、通常の起動テストでは気づけなかった数字でした。

状態と状態の間で止めると、どこから戻れないかが見えます。

次の実装で確認する項目

98件中86件起動できた、だけならそれなりに動いて見えます。

0件しか戻っていない数字を見て、やっと片道の仕組みだったと分かりました。いまはフェイルバックを「あとでもう一度起動する処理」ではなく、止まっている仕事を現在の状態へつなぎ直す処理として作り直しています。

ローカル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完了率の計測