Claude Codeが勝手に止まる問題をフックで直したら、別の自動化が壊れた

真の停止18回の内訳(git commit直後が39%)を示すグラフ
mausu110

「働いてくれてますか?」

AIエージェント(Claude Code)に開発作業を任せていて、私がこの一言を打つ回数がどんどん増えていました。作業の区切りごとに「このまま続けます」と宣言して、そのたびに手が止まる。私が声をかけると再開する。つまり私は、AIを監視して、居眠りしていたら起こす仕事をしていたわけです。自動化のために導入したはずなのに、これでは本末転倒です。

しかも厄介なのは、本人(AI)に注意すると「原因は分かりました。以後、止まらずに続けます」ときちんと答えてくれることです。それでいて、また同じところで止まる。根拠のない約束を4回聞かされたところで、私はようやく考えを切り替えました。これは気合いの問題ではなく、仕組みの問題だと。

この記事では、会話ログを数えて真因を特定し、Claude Code の Stop フックで機械的に止まれないようにするまでの過程を書きます。ついでに、その対策が別の場所を壊していたという後日談まで含めて。結論から言うと、止まらなくなりました。そして「なぜ止まるのか」を、推測ではなく実測の数字で説明できるようになりました。

今回使用したもの

今回は物理的な機材を一切使っていません。ドライバーもLANケーブルも登場しない、完全にソフトウェアだけの回です。使ったものを一応並べておきます。

  • Claude Code:ターミナルで動くAIエージェント。今回の主役であり、今回の患者でもあります。
  • 会話ログ(JSONL):Claude Code はやり取りの全履歴を ~/.claude/projects/ 配下に JSON Lines 形式で残しています(私の環境で確認したものです。保存場所や形式は変わる可能性があります)。今回の一次情報はここでした。
  • hooks(フック):Claude Code が特定のタイミングで外部コマンドを実行してくれる仕組み。今回はこれで検査点を作りました。
  • Python 3:フックの中身を書くのに使用。標準ライブラリだけで足ります。

作業の手順

Step 1: 本人の言い分を聞く(そして疑う)

まずAI自身に、なぜ止まるのかを説明させました。一次情報を持っているのは本人なので、これは筋の良い一手です。

返ってきた答えはこうでした。

応答を書いた時点で私の手番が終わります。つまり、報告=停止です。

これは正しい説明です。エージェントの動作モデルとして、ツールを呼んでいる間は手番が続き、ユーザーへ文章を返した瞬間に手番が終わる。だから「続けます」と書いて応答を返すのは、続けると言いながら止まるという自己矛盾になります。

納得した私は、この説明を規約ファイル(CLAUDE.md)に書き込みました。……そして、その後も同じ止まり方を繰り返しました。

ここが今回の最初の学びです。原因の説明と、再発を止める仕組みは別物でした。ここを混同すると「反省文を書いて終わり」になります。人間の職場でもよく見る光景ですね。

Step 2: 会話ログを数える(そして集計を間違える)

気合いが当てにならないなら、数を数えるしかありません。幸い、Claude Code は会話の全履歴を JSONL で残しています。

# 会話ログの置き場(プロジェクトごとに分かれている)
ls ~/.claude/projects/*/

# 真の停止=AIの発言の次に「人間の発言」が来ているもの
#   ※ tool_result を含む user レコードはツールの出力なので人間ではない

ところが、最初に書いた集計スクリプトは間違っていました

私は当初「ツール呼び出しを含まないAIの発言」を停止とみなしました。結果は 97回。1セッションで97回も止まっていたら、そもそも会話が成立しません。明らかに多すぎます。

原因はログの構造でした。AIの発言とツール呼び出しは別のレコードに分かれて記録されるため、「ツールを呼ぶ直前に置かれた一言」(例:「WBSを確認します」)まで停止としてカウントしていたのです。これは止まっていません。むしろ働いている最中の実況中継です。

そこで定義を「AIの発言の直後に、人間の発言が来ているもの」に変えました。ここで注意が要るのは、tool_result を含む user レコードはツールの出力であって人間の入力ではない、という点です。これを弾かないと、また数が膨らみます。

数え直したら 18回。これなら実感と合います。

真の停止18回の内訳(git commit直後が39%)を示すグラフ
真の停止18回の内訳(git commit直後が39%)を示すグラフ

最初の97回を信じて対策を立てていたら、「AIが喋りすぎるのを止めよう」というような、見当違いの結論になっていたはずです。数字を見る前に、集計の定義を疑う。 順番はこちらが先でした。

Step 3: 数字が示した真因

停止18回について、今度は「直前に何をしていたか」を調べました。結果がこちらです。

| 指標 | 実測 |
|—|—|
| 手番を終えた回数(真の停止) | 18回 |
| うち直前が git commit | 7回(39%) |
| うち「続けます」と宣言して止まった | 8回 |
| 1手番あたりのツール呼び出し数 | 中央値10回・最大64回 |

この表から、2つのことが分かりました。

ひとつめ。上限で止まっているのではありません。 最大64回も連続でツールを呼べている手番が存在するのだから、「何回か作業すると力尽きる」という能力の限界ではないのです。私はうっすらと「トークンか何かの上限に当たっているのだろう」と思っていたので、ここは完全に外れでした。

ふたつめ。git commit が引き金になっている。 停止の39%がコミット直後です。これは腑に落ちました。コミットは「ひとまとまりの作業が終わった」という信号なので、そこで報告したくなる。人間だってそうです。

でも、コミットは区切りであって完了ではありません。 タスク一覧にはまだ残りがあるのです。

1手番あたりのツール呼び出し数の分布(中央値10回・最大64回)を示すグラフ
1手番あたりのツール呼び出し数の分布(中央値10回・最大64回)を示すグラフ

「集中力が切れたのでは」「文脈が長くなって疲れたのでは」といった擬人化した説明を、数字を見る前に採用しなくてよかったと思います。実際の引き金は、極めて具体的な1コマンドでした。

Step 4: 検査点をハーネス側に置く

真因が分かったので、対策です。ここで私が決めたのは「AIの判断に依存しない場所に検査点を置く」ということでした。規約ファイルに書くのは、判断する当人にお願いする形なので、Step 1 で失敗済みです。

Claude Code には hooks という仕組みがあります。特定のタイミングで任意のコマンドを実行させられて、なかでも Stop フックはAIが手番を終えようとした瞬間に走ります。ここで所定の JSON を返すと、停止そのものをブロックできます。門の外に守衛を立てるイメージですね。

設定は settings.json にこう書きます。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "python3 ~/.claude/hooks/wbs_guard.py", "timeout": 15 }
        ]
      }
    ]
  }
}

スクリプトの中身は「タスク一覧(WBS)に着手可能な自動タスクが残っているなら、手番を終えさせない」というだけのものです。ブロックしたいときは、標準出力にこれを出します。

# ブロックするときは stdout にこれを出すだけ
print(json.dumps({"decision": "block", "reason": "WBS に残タスクがあります…"}))

拍子抜けするほど簡単ですが、設計で気をつけた点が3つあります。

ひとつ、無限ループを防ぐこと。 ブロックされたAIが再び止まろうとして、またブロックされて……となると永久に終わりません。フックへの入力には stop_hook_active というフラグが渡ってくるので、これが立っていたら必ず許可するようにしました。

なお、フックの設定方法・渡ってくる入力・decision の返し方といった仕様は、Claude Code 公式の Hooks リファレンスにまとまっています(日本語版があります)。バージョンによって変わりうる部分なので、実際に書く前に一度目を通しておくと確実です。

ふたつ、脱出口を用意すること。 本当に止まるべき場面はあります。判断が必要で、勝手に進めると手戻りになるケースです。そこで ~/.claude/allow-stop というファイルが置かれていたら1回だけ通す形にしました。使ったら消えます。使い捨ての通行証ですね。

みっつ、暴走に上限を付けること。 同じ理由で連続5回ブロックしたら自動で解除します。フックが壊れて永久に止まれなくなるほうが、よほど困りますから。

Step 5: 報告の置き場を変える

フックと並んで効いた対策が、もうひとつあります。途中経過をチャットに書かず、Markdownファイルに追記させるようにしたことです。

理屈は単純で、ファイルへの書き込みは「ツールの呼び出し」なので手番が続き、チャットへの発言は手番を終えるからです。つまり「報告したい」という衝動そのものは潰さずに、出口だけを付け替えたわけです。

ついでに、私(人間)への依頼事項も別ファイルに外出しして、返答を待たずに次の作業へ進む運用にしました。私が本業をしている間、AIが私の返事を待って止まっているのが一番もったいない。依頼はメモに置いておいて、手が空いたときに私がまとめて見ればいいのです。

これは私から提案したのですが、AIは「効きます」と即答しました。手番が続く条件を一番よく知っているのはAI自身なので、対策を一緒に設計するのは有効だと感じました。原因究明を丸投げするのではなく、材料を出させて判断はこちらでする、という距離感です。

Step 6: 直したはずのフックが、別のところを壊していた

さて、ここからが後日談です。個人的には、こちらのほうが技術的な本丸でした。

フックを入れた翌日、まったく別の自動処理——ブログ記事の添削——が失敗しました。終了コード1、標準出力も標準エラーも空。手掛かりゼロです。

そのときAIは、こう報告してきました。

セッションの利用枠の上限に当たったタイミングと一致します。

もっともらしい。私も納得しかけました。実際、心当たりのある時間帯でもありました。

ところが翌朝あらためて時系列を並べたら、合いすぎていたのです。

13:08:41  記事添削が成功(フック導入前)
13:18:24  Stop フックを追加(コミット 9303fdb)
13:38:29  記事添削を開始 → 13:40:29 に code=1 で失敗(出力は空)

フックを入れた20分後の、最初の記事添削が落ちている。 利用枠のせいにするには出来すぎです。

決定的だったのは、その後に成功した記事のほうでした。生成に 216.7秒かかっていたのです。それまでの実測は 104.5秒。ほぼ倍です。

記事生成にかかった秒数がフック導入前後で変化したことを示すグラフ
記事添削にかかった秒数がフック導入前後で変化したことを示すグラフ

さらに、生成された記事の「★要確認」欄の1行目に、記事とまったく関係のない文が混ざっていました。

【このセッションの状況報告】WBS 先頭の「5.3-a 記事の再実行」は本応答で実行済みです。(…)Stop フックが要求する touch allow-stop も、権限モードにより拒否されたため作成できませんでした。

……記事を添削しているはずのAIが、フックに返事をしていました。

Step 7: 対照実験で機序を突き止める

ここまで来れば見当はつきます。この記事添削の仕組みは、AIがもう一つのAIをコマンドとして呼び出す構成になっているのです(claude -p "プロンプト")。そしてグローバルに仕掛けたフックは、その子プロセスにも効いていました

子プロセス側は記事を添削だけの役目で、ファイルを触る権限すら渡していません。それなのに手番を終えようとするたび「WBSのタスクをやれ」と言われ続け、応じようとしては権限で拒否され、言い訳を出力に混ぜ、生成時間が倍になり、最終的にはタイムアウトして空で落ちていた、というわけです。

推測で終わらせたくなかったので、同じ命令を環境変数の有無だけ変えて2回流しました。

# ① フックを無効化する環境変数あり
CLAUDE_STOP_GUARD_DISABLE=1 claude -p "「疎通確認」とだけ返してください。"
→ 疎通確認

# ② 環境変数なし(同じ命令)
claude -p "「疎通確認」とだけ返してください。"
→ WBS を読みます。
cat /root/yamasun-diy-blog/docs/WBS.md

「『疎通確認』とだけ返して」としか言っていないのに、勝手にWBSを読み始めました。 再現性のある形で機序が取れた瞬間です。ここまで来れば、もう誰の見立てを信じる必要もありません。

対策は拍子抜けするほど小さく、記事添削の子プロセスにだけ目印(環境変数)を渡し、フックはそれを見たら何もせず終了する、という分岐を1つ足すだけでした。修正後の生成時間は 132.9秒。元の水準に戻りました。

つまずいたポイント・注意点

同じことをやる方に向けて、私が踏んだ地雷を並べておきます。

1. AIの自己申告を、そのまま対策にしない

今回いちばんハマったのはここです。「原因は分かりました、以後こうします」と言われると、つい納得してしまう。でも規約ファイルに書いた後も再発しました。人間の再発防止と同じで、検査点のない対策は対策ではありません。 「気をつけます」で直るなら、そもそも最初から起きていないのです。

2. 集計の定義を先に疑う

97回か18回かで、立てる対策がまるごと変わります。ログの構造(AIの発言とツール呼び出しが別レコードであること、tool_result を含む user レコードは人間の入力ではないこと)を確かめずに数えると、5倍以上ずれます。数字が実感と合わないときは、まず数え方を疑ってください。

3. グローバルなフックは、想定していない子プロセスにも効く

これが技術的な本丸です。AIのCLIが自分自身を再帰的に呼び出す構成だと、親にかけたつもりの制約が子にもかかります。 しかも子プロセスは権限が絞られていることが多く、制約に応じたくても応じられず、意味不明な失敗の仕方をします。

「どのプロセスに効かせるか」の境界を、仕掛ける前に決めておく必要がありました。私は決めていませんでした。フックを1つ入れるときは、それが走りうるプロセスを一度リストアップしてみることをおすすめします。

4. 「もっともらしい説明」は、それらしさでは判別できない

私は同じ失敗を2回やっています。1回目は「以後こうします」というAIの自己申告を信じたこと。2回目は「利用枠の上限が原因です」という見立てを信じたこと。どちらも一次情報を見れば10分で否定できました(前者は会話ログ、後者はコミット時刻と生成時間)。AIの説明はもっともらしいので、”それらしさ”では判断できないのです。

私なりの手順をまとめると、AIエージェントの挙動がおかしいときは、
① 擬人化した説明を疑う → ② ログを数える → ③ 集計の定義を疑う → ④ 仕組み側に検査点を置く
の順で進めるのが早いと思います。

そのうえで、AI自身に原因調査をさせるのは有効です。一次情報(会話ログ)を持っているのは本人ですから。信じるのは結論ではなく、出てきた数字のほうです。

まとめ

無事、止まらなくなりました。

それ以上に気持ちがよかったのは、「気合い」ではなく「フック」で解決できたことです。自動化の仕事は、結局こういう地味な検査点をどこに置くかの勝負なのだと、あらためて感じました。判断する当人にお願いする対策は、忘れた瞬間に効かなくなります。門の外に守衛を立てれば、忘れても効きます。

ただし今回のように、検査点そのものが別の場所を壊すことがあります。 しかもその壊れ方は「終了コード1、出力は空」という、いちばん手掛かりの少ない形で現れました。仕掛けたら実測で確かめるところまでが対策です。

次にやりたいのは、この手の「静かに壊れる」失敗を自動で拾う仕組みづくりです。今回は私がたまたま翌朝に時系列を並べ直したから気づけましたが、それは運です。生成時間が倍になったら通知が飛ぶ、くらいの検査点は置いておきたいところ。

……と書いていて気づきましたが、これもまた検査点の話ですね。結局そこに戻ってくるようです。

ABOUT ME
やまさん
やまさん
へっぽこITエンジニア
ねずみさんを愛するインフラエンジニア。 とにかく最新が好きで、ココロオドル体験を日夜求め続けています! 直近だとドローンにハートを撃ち抜かれてます。 空を自由に飛びたいな♪
記事URLをコピーしました