ANTIGRAVITY LABEN
記事一覧/Editor View
Editor View/2026-08-13中級

フックの待ち方が 2.6.0 で変わりました。終わらないターンを切り分ける手順

エージェントのターンが終わらないとき、原因がモデルではなく自分の書いたフックにあることがあります。IDE 2.6.0 で変わった打ち切りの扱いを確認し、止まり得るフックを洗い出す監査スクリプトと、フック側に打ち切りを持たせる書き方をまとめました。

Antigravity351フックトラブルシューティング27CLI6自動化36

夜のうちに終わっているはずの作業が、朝になっても同じ画面のままだったことがあります。エラーは出ていません。赤い文字もありません。ただ、ターンが終わっていないだけです。

個人開発で複数のリポジトリを回していると、フックは少しずつ増えます。増えた分だけ、静かに待ち得る場所も増えていました。

こういうとき、私が最初に疑っていたのはモデル側の遅延やネットワークでした。しばらくそう思い込んでいたのですが、実際に手を止めていたのは自分で書いたフックのほうでした。判定を外部に投げるフックを一つ置いていて、その相手が応答を返さないまま、フックが待ち続けていたのです。

IDE 2.6.0(8月7日)で、この種の待ち方の扱いが変わりました。変更点を確かめたうえで、手元の設定のどれが止まり得るかを洗い出し、フック側に打ち切りを持たせるところまでを組み立てていきます。

止まり方は3つに分かれます

「終わらない」と一括りにすると原因が見えません。挙動で分けると、おおむね次の3つになります。

症状起きていること気づきやすさ
ターンが進まないフックが応答を待ち続けている低い(ログに何も出ない)
終わりかけて戻るStop フックがターンの終了を拒否し続けている中(同じ処理が繰り返される)
フックが効かないそもそも発火していない/発火が遅れている低い(成功に見える)

1つ目と3つ目は、どちらもログが静かなまま進みます。前者は止まり、後者は素通りします。困るのは後者のほうで、通したくないものが通ったことに後から気づきます。matcher の当たり外れで起きる素通りについては、matcher が当たりすぎるフックを洗い出した記録 に書きました。ここでは1つ目と2つ目、つまり「止まる側」を扱います。

2.6.0 と CLI 1.1.11 で、待ち方のどこが変わったか

8月7日に出た IDE 2.6.0 と CLI 1.1.11 で、この領域にまとまった変更が入りました。

  • モデルを呼ぶフックが、設定したタイムアウトで明確なエラーとともに停止するようになりました。従来は無期限に待つことがあります
  • Stop フックがターンの終了を繰り返し拒否しても、一定回数の後にターンが完了するようになりました
  • どう転んでも実行され得ないフック設定が、読み込み時に明確なエラーで拒否されるようになりました。従来は黙って無視されます
  • 自分で定義したカスタムフックが、発火前に飛ばされるのではなく、ターンの終わりに実行されるようになりました
  • サブエージェントを停止すると、入れ子のサブエージェントとバックグラウンドタスクもまとめて停止するようになりました

並べてみると、機能の追加より「黙って止まらなくなった」ことのほうが中心です。裏を返すと、2.6.0 より前の環境では、タイムアウトを書いていないフックは本当に無期限に待ちます。まず自分のバージョンを確かめるのが出発点になります。

フックまわりのスキーマとキー名は更新が続いている領域です。導入前に Antigravity の changelog で当該バージョンの記述を確認してから設定してください。以下の例は timeoutSec を持つ形で書いています。

自分の設定のうち、どれがターンを止め得るか

設定ファイルを目で追っても、止まり得るフックは意外と見つかりません。timeoutSec が抜けている行は、書いた本人にとっては「短時間で終わるから要らない」と判断した行だからです。判断が正しかったかどうかは、目視では確かめられません。

そこで、設定を読んで機械的に分類するスクリプトを書きました。見ているのは3点です。タイムアウトが設定されているか、フックが呼ぶスクリプト自身が打ち切りを持っているか、そしてそのスクリプトが実際に起動できる状態にあるかどうかです。

#!/usr/bin/env python3
"""hooks 設定を読み、ターンを止め得るフックを洗い出す。"""
import json, os, re, stat, sys
 
SELF_GUARD = re.compile(r"\btimeout\b|\bSIGALRM\b|signal\.alarm|AbortSignal\.timeout")
STALL_EVENTS = {"PreToolUse", "PostToolUse", "Stop", "SubagentStop", "UserPromptSubmit"}
 
 
def load(path):
    with open(path, encoding="utf-8") as f:
        return json.load(f).get("hooks", {})
 
 
def script_of(command):
    """command の先頭トークンが自前スクリプトなら、そのパスを返す。"""
    token = command.split()[0] if command.split() else ""
    return token if token.endswith((".sh", ".py", ".js", ".mjs")) else ""
 
 
def audit(path, default_limit=60):
    rows = []
    for event, entries in load(path).items():
        for i, h in enumerate(entries):
            cmd = h.get("command", "")
            limit = h.get("timeoutSec")
            script = script_of(cmd)
            exists = bool(script) and os.path.exists(script)
            execbit = exists and bool(os.stat(script).st_mode & stat.S_IXUSR)
 
            guarded = False
            if exists:
                body = open(script, encoding="utf-8", errors="replace").read()
                guarded = bool(SELF_GUARD.search(body))
 
            if limit is None and not guarded:
                risk = "HIGH"          # 上限が誰も持っていない
            elif limit is None:
                risk = "MED"           # スクリプト側だけが持っている
            elif limit > default_limit and not guarded:
                risk = "MED"           # 上限は長すぎる
            else:
                risk = "LOW"
 
            # 起動できないフックは、遅い以前の問題として別枠にする
            if script and not exists:
                risk = "BROKEN"
            elif exists and not execbit:
                risk = "BROKEN"
 
            rows.append({
                "event": event, "risk": risk, "timeoutSec": limit,
                "selfGuard": guarded, "command": cmd,
                "blocksTurn": event in STALL_EVENTS,
            })
    return rows
 
 
def main():
    path = sys.argv[1] if len(sys.argv) > 1 else "hooks.json"
    rows = audit(path)
    order = {"BROKEN": 0, "HIGH": 1, "MED": 2, "LOW": 3}
    rows.sort(key=lambda r: (order[r["risk"]], r["event"]))
 
    print(f"{'risk':<7}{'event':<16}{'timeoutSec':<12}{'selfGuard':<11}command")
    for r in rows:
        t = "-" if r["timeoutSec"] is None else str(r["timeoutSec"])
        print(f"{r['risk']:<7}{r['event']:<16}{t:<12}{str(r['selfGuard']):<11}{r['command']}")
 
    bad = sum(1 for r in rows if r["risk"] in ("BROKEN", "HIGH"))
    print(f"\n{len(rows)} 件中 {bad} 件が、ターンを止め得る状態です。")
    return 1 if bad else 0
 
 
if __name__ == "__main__":
    sys.exit(main())

SELF_GUARD の正規表現で timeoutsignal.alarm を探しているのは、フック本体が自分で諦める仕組みを持っているかどうかを見たいからです。設定側の timeoutSec とスクリプト側の打ち切りは別の層にあり、どちらか一方でもあれば止まりっぱなしにはなりません。両方ない行だけを HIGH に落としています。

4件のフックを並べた設定に対して走らせると、次のように出ました。

risk   event           timeoutSec  selfGuard  command
BROKEN PostToolUse     30          False      ./fmt.sh
HIGH   PreToolUse      -           False      ./review.sh
MED    Stop            120         False      ./review.sh
LOW    PreToolUse      600         True       ./gate.sh

4 件中 2 件が、ターンを止め得る状態です。

BROKEN の行は、timeoutSec は書いてあるのに実行ビットが落ちていたフックです。この状態のフックは待つ以前に起動しないので、書いた側は「速く終わっている」と受け取ります。速いのではなく、何もしていません。

打ち切りは、設定とスクリプトの両方に置きます

設定の timeoutSec に頼り切らない理由は2つあります。1つは、バージョンによって尊重のされ方が変わる領域だからです。もう1つは、フックが CI やローカルの git フックからも呼ばれる場合、そちらの経路には設定の上限が効かないからです。

スクリプト側に打ち切りを持たせた形が、次になります。

#!/usr/bin/env bash
# 応答の遅い判定を、フック側から打ち切れるようにした版
set -u
LIMIT="${HOOK_LIMIT_SEC:-3}"
 
timeout --signal=TERM --kill-after=2 "$LIMIT" bash -c 'sleep 8; echo "judge: ok" >&2'
RC=$?          # if で包まずに、直後で受ける
 
case "$RC" in
  0)   exit 0 ;;
  124) echo "hook: ${LIMIT}s 以内に判定が返りませんでした。今回は判定を見送ります。" >&2; exit 0 ;;
  *)   echo "hook: 判定が終了コード ${RC} で失敗しました。" >&2; exit "$RC" ;;
esac

--kill-after=2 は、TERM を受け取っても終わらない相手に KILL を送るまでの猶予です。これを付けないと、行儀の悪い子プロセスに対しては打ち切り自体が効かないことがあります。

8秒かかる処理に対して上限を変えながら走らせると、所要時間は素直に上限どおりになりました。

上限実際の所要終了コード
1秒1.01秒0(見送り)
3秒3.01秒0(見送り)
5秒5.01秒0(見送り)
上限なし8.01秒0(完了)

ここで 124exit 0 に変換している点は、意図して選んでいます。判定が返らなかったことと、判定が「駄目だ」と言ったことは別の事実です。前者でツール呼び出しをブロックすると、外部サービスが不調な日にエージェントが一歩も進めなくなります。逆に、不可逆な操作を守るゲートであれば、ここは exit 1 に倒すほうが正しいはずです。押し戻す側に倒す設計は push 前に二段のゲートを重ねた記録 に書きました。同じ 124 でも、守っている対象によって答えが変わります。

if で包むと、124 が消えます

上のスクリプトで RC=$?if の外に置いているのには理由があります。私は最初、素直にこう書いていました。

if timeout --signal=TERM "$LIMIT" bash -c '...'; then
  exit 0
fi
RC=$?

読み下すと問題がなさそうに見えます。実際には、この RC には 124 が入りません。

if timeout 1 sleep 5; then :; fi
echo "if で包んだあとの \$? = $?"    # → 0
 
timeout 1 sleep 5
echo "直後に受けた \$?        = $?"  # → 124

if 文全体の終了コードは、条件が偽で else がない場合に 0 になります。そのため fi を抜けた直後の $? は、中で走ったコマンドの結果ではなく if 文自身の結果です。打ち切りを検出したくて書いたガードが、打ち切りを握り潰して「成功」を返していました。

フックの返り値は、そのままエージェントへの返事になります。ここで 0 を返すということは、時間切れになった判定を「通った」と伝えることに等しくなります。ガードを足したつもりで、実際には黙って素通りする経路を1本増やしていたわけです。

同じ構造は if ! cmd; then RC=$? でも起きます。否定した時点で $?0 に化けます。終了コードを見たいときは、コマンドの直後で受ける以外に確実な方法がありません。

切り分けは、この順番で進めます

止まったときに毎回同じ順で辿れるよう、手順を固定しておくと迷いません。

  1. バージョンを確認します。2.6.0 より前であれば、タイムアウトのない待ちは無期限になり得ます
  2. 監査スクリプトを走らせ、HIGHBROKEN の行を先に潰します。ここで半分近くが片付くことがあります
  3. 残った候補のフックを、エージェントを介さずコマンドラインから単体で実行します。手で走らせて返ってこないなら、原因はフックの中です
  4. 単体では返るのにターン内では止まる場合、Stop フックの拒否ループを疑います。同じ処理が繰り返されていないかを確認します
  5. それでも切り分かない場合に、はじめてモデルやネットワークを疑います

順番の要点は、静かな失敗を先に潰すことです。1と2は数分で終わりますし、ここで見つかるものは再現を待つ必要がありません。

今日のうちに確認できること

まずは手元の設定ファイルに監査スクリプトを一度かけてみてください。HIGH が1行も出なければ、少なくとも「無期限に待つ経路」は残っていません。

私自身、if でガードを包んでいた期間がしばらくありました。書いた本人が安全になったつもりでいるぶん、この種の抜け道は長く残ります。同じ形を書いていないか、この機会に確かめていただけたらと思います。

シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

Editor View2026-07-17
パスの空白ひとつで、エージェントのコマンド9形が「0件・成功」を返しました
パスの空白ひとつで、エージェントのコマンドが「0件・成功」を返していました。未引用パスの11形で挙動を実測し、引用の徹底では守りきれない部分を確認した上で、相対パスと入口を一本化する ws.sh、3行で足りる規約に落とし込むまでの記録です。
Editor View2026-04-08
Antigravity エディタでよくある問題と解決法:補完停止・接続エラー・プロジェクト読み込み失敗
AI 補完の停止、プロジェクトが開けない、ターミナルの無応答、Git 統合の不調、拡張機能のトラブル——Antigravity エディタで頻発する5つの問題の解決法をまとめました。設定リセットや接続確認など、順に試せる手順の形で説明しています。
Antigravity 基本2026-08-10
非対話実行でターン境界を守る — コマンド分類テーブルと fail-closed ゲートの実装メモ
使用量を記録するはずの定期ジョブが、実行のたびにモデルを呼んでいました。ターンを開始するコマンドとしないコマンドを分類表にして、未分類を実行させないゲートを実装し、実運用のスクリプトを走査して測った記録です。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →