夜のうちに終わっているはずの作業が、朝になっても同じ画面のままだったことがあります。エラーは出ていません。赤い文字もありません。ただ、ターンが終わっていないだけです。
個人開発で複数のリポジトリを回していると、フックは少しずつ増えます。増えた分だけ、静かに待ち得る場所も増えていました。
こういうとき、私が最初に疑っていたのはモデル側の遅延やネットワークでした。しばらくそう思い込んでいたのですが、実際に手を止めていたのは自分で書いたフックのほうでした。判定を外部に投げるフックを一つ置いていて、その相手が応答を返さないまま、フックが待ち続けていたのです。
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 の正規表現で timeout や signal.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(完了) |
ここで 124 を exit 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 "直後に受けた \$? = $?" # → 124if 文全体の終了コードは、条件が偽で else がない場合に 0 になります。そのため fi を抜けた直後の $? は、中で走ったコマンドの結果ではなく if 文自身の結果です。打ち切りを検出したくて書いたガードが、打ち切りを握り潰して「成功」を返していました。
フックの返り値は、そのままエージェントへの返事になります。ここで 0 を返すということは、時間切れになった判定を「通った」と伝えることに等しくなります。ガードを足したつもりで、実際には黙って素通りする経路を1本増やしていたわけです。
同じ構造は if ! cmd; then RC=$? でも起きます。否定した時点で $? は 0 に化けます。終了コードを見たいときは、コマンドの直後で受ける以外に確実な方法がありません。
切り分けは、この順番で進めます
止まったときに毎回同じ順で辿れるよう、手順を固定しておくと迷いません。
- バージョンを確認します。2.6.0 より前であれば、タイムアウトのない待ちは無期限になり得ます
- 監査スクリプトを走らせ、
HIGHとBROKENの行を先に潰します。ここで半分近くが片付くことがあります - 残った候補のフックを、エージェントを介さずコマンドラインから単体で実行します。手で走らせて返ってこないなら、原因はフックの中です
- 単体では返るのにターン内では止まる場合、Stop フックの拒否ループを疑います。同じ処理が繰り返されていないかを確認します
- それでも切り分かない場合に、はじめてモデルやネットワークを疑います
順番の要点は、静かな失敗を先に潰すことです。1と2は数分で終わりますし、ここで見つかるものは再現を待つ必要がありません。
今日のうちに確認できること
まずは手元の設定ファイルに監査スクリプトを一度かけてみてください。HIGH が1行も出なければ、少なくとも「無期限に待つ経路」は残っていません。
私自身、if でガードを包んでいた期間がしばらくありました。書いた本人が安全になったつもりでいるぶん、この種の抜け道は長く残ります。同じ形を書いていないか、この機会に確かめていただけたらと思います。