Claude Codeが利用上限になったら、Mac上のQwenへ続きを渡す仕組みを作っています。
最初は、それまでの会話を全部渡せば続きから動けるだろうと思っていました。
ただ、会話には採用しなかった案も、古いファイルの話も、途中の推測も残っています。受け取った側からすると、何が今も正しいのか分かりにくいです。
なのでhandoffの目的を「会話を再現する」から「現在の作業ツリーへ戻って、次の一手を選べるようにする」へ変えました。
切れるのは会話だけではない
モデルが変わると、2つのものが途切れます。
1つは、何を目指していて、どこまで進んだかという話の流れです。
もう1つは、実際のファイルの状態です。会話で「修正しました」と書かれていても、そのあと人が編集したり、別のエージェントがcommitしたりしているかもしれません。
handoffには前者を短く残します。ただし後者は信用しすぎず、再開時に現物を見直します。
図1: handoffは会話のコピーではなく、現物へ戻るための小さいチェックポイントにしています。
handoffへ残しているもの
自分の実装では、主に次の5つを保存しています。
| 残すもの | 何のためか | 入れすぎないための上限 |
|---|---|---|
| 直前のユーザー指示 | 最後に何を求められたか | 一番新しい依頼を優先する |
| 未完了TODO | どこまで進み、何が残ったか | 会話から進捗を推測させない |
| branchとGit status | 触ってはいけない変更があるか | statusは先頭40行まで |
| 直近5コミット | 基準点と変更の方向 | 長い履歴はその場で読まない |
| 直前回答の末尾 | TODOへ出ていない直近の流れ | 末尾だけ残す |
加えて、作業ディレクトリ、作成時刻、切り替わった理由、上限のリセット見込み、handoff IDも機械で読める形にしています。
各項目に上限を置いているのは、handoff自体が次の巨大コンテキストになるのを避けるためです。
省略したことは明記します。必要ならGitやファイルをもう一度読めばよい、という前提です。
事実と、自分の判断を分ける
自由文だけでhandoffを書くと、確認済みの事実と途中の見立てが混ざります。
いまは次のように分けています。
# Handoff
## Objective
ユーザーが求める最終状態
## Verified facts
- 実際に確認したファイル、コマンド結果、数値
## Decisions
- 採用した方針と理由
## Remaining work
- [ ] 次に行う一つ目の作業
- [ ] 検証
## Working tree
- branch / HEAD / status summary
## Safety boundary
- 自動実行してよいこと
- 人の承認が必要なこと
Verified factsへ推測は入れません。
まだ見ていないなら「未確認」と書きます。受け取る側もhandoffを全部信じるのではなく、どこから確認するかを決められます。
この分け方は、きれいな文書を作るためというより、間違っているかもしれない部分を目立たせるためにやっています。
保存場所で一度失敗した
handoffファイル自体は作れているのに、受け取ったモデルが読んでいないことがありました。
原因はかなり単純で、Claude Code側とローカル側でメモリの保存場所が違っていました。片方のプロファイルへだけ保存しても、もう片方では自動読込されません。
いまは正本を共有ディレクトリへ置き、起動時にファイルパスを明示しています。必要なら各プロジェクトのメモリ側には参照だけ置きます。
保存先について見ているのは次のあたりです。
- 渡す側と受け取る側の両方から読める
- 別repositoryのhandoffと混ざらない
- IDで一意に指定できる
- 書きかけのファイルを読まない
- 秘密情報を余計な場所へコピーしない
- 完了、期限切れ、手動停止を区別できる
ここは実装が2つに分かれています。
新しく作ったClaude CodeからCodexへのhandoffは、一時ファイルへ書き、fsyncしてからrenameしています。途中までしか書けていないhandoffを拾わないためです。
一方、先に作ったrate limit時のhandoffは、まだ正本へ直接書いています。途中でプロセスが落ちたときに部分ファイルが残る可能性があるので、こちらも同じ書き方へ揃える必要があります。
再開時は、handoffより現在のGitを優先する
handoffに入っているGit情報は、保存した瞬間のスナップショットです。
保存後に人が作業している可能性もあるので、受け取った側は最初に次を確認します。
git branch --show-current
git rev-parse HEAD
git status --short
git diff --stat
handoffと違っていたら、現在のworking treeを正とします。
古いhandoffへ合わせるために差分を捨てたり、保存時点まで戻したりはしません。競合していそうなら編集を始めず、現在分かったことを追記して人へ戻します。
ここを決めていないと、handoffが便利なメモではなく、古い状態へ巻き戻す命令書になってしまいます。
handoffの本文をshellへ埋め込まない、だけでは足りなかった
handoffには、ユーザーが書いた文章やコード片がそのまま入ります。
これをtmuxの起動コマンドへ文字列として展開すると、引用符や$()などをshellがもう一度解釈する可能性があります。
rate limitを検知するhandlerからランチャーまでは、handoffのファイルパスだけを渡しています。
ただ、復帰用watcherでは最後に本文を展開し、claude -pの引数へ渡していました。配列引数なのでshellへもう一度評価させてはいませんが、プロセス一覧から見えることと、OSの引数長上限に当たる問題は残ります。
watcher → launcher /path/to/handoff.md
launcher → model processへpathだけ渡す
model process → fileをデータとして読む
これは目標の流れです。stdinやfile descriptorで渡す方法も含め、本文をプロセス引数へ出さない形へ揃えたいです。
文章をshellのコードへ戻さないことに加えて、本文をコマンドラインへ載せないところまでが境界でした。
handoffにも状態を持たせたい
作ったhandoffを置きっぱなしにすると、次に誰が触っているのか分からなくなります。
いまファイルから判別できるのは、未処理、.resumed、.skipくらいです。ただ、この3つだけでは起動途中の失敗を表せません。
次に揃えたい状態はこうです。
| 状態 | どういう状態か |
|---|---|
| pending | 引き継ぎ待ち、またはローカル作業中 |
| claimed | 一つの実行者が取得した |
| verified | 完了条件を確認できた |
| resumed | 元の実行先で再開できた |
| skipped | 今回は処理しないと決めた |
| failed | 自動では戻せず、人の確認が必要 |
.resumedのようなマーカーは重複防止に便利です。
ただ、マーカーを作れたことと、再開に成功したことは別です。起動結果や検証ログと一緒に見ないと、失敗した仕事へ「再開済み」の印だけが付くことがあります。
文章のうまさではなく、再開できたかを見る
handoffの品質を見るときは、読みやすい文章になっているかより、次の実行者がどれくらい迷ったかを見ています。
- handoffを最後まで作れた割合
- 受取側が最初の有効なツールを使うまでの時間
- 追加質問なしで始められた割合
- handoffとworking treeが違っていた件数
- 再開後に同じ変更を重ねた件数
- handoffのサイズ
- 検証完了か、人への返却まで到達した割合
最初のツール実行までが長い場合、情報不足だけでなく、書きすぎも疑っています。
いまの確認項目
- 最終目的と残りTODOを分けたか
- 確認済みの事実と見立てを分けたか
- branch、HEAD、未コミット変更を残したか
- 各項目に長さの上限があるか
- 両方の実行環境から保存先を読めるか
- 完全に書けてから公開し、一つの実行者だけがclaimするか
- 再開時に現在のGit状態を見直すか
- 本文をshellで再評価していないか
- 完了、失敗、skipを後から追えるか
handoffを作り始めたときは、モデルへ記憶を渡そうとしていました。
いまは、記憶を移すというより、目的と現在地を書いた小さい札を置いている感覚です。受け取った側はその札を見て、まず現物を確認する。このくらいの距離感の方が安全に続きから始められました。
ローカルLLM設計・全8回
MacでローカルLLMを実際に動かして、うまくいかなかったところを8つに分けて書いています。