夜のうちにリリース前の確認をエージェントへ渡して、朝に結果を見る。個人開発でアプリを何本も抱えていると、この回し方に頼る日が増えます。工程を分けてサブエージェントへ委ねれば、寝ている間に一巡してくれるはずでした。
その朝、画面はまだ動いている顔をしていました。スピナーは回っていて、エラーも出ていません。ただ、最後の出力の時刻が 30 分以上前で止まっていました。
サブエージェント側のログを開いて、原因を探しました。異常な行は見当たりません。モデルもツールも、待っているだけです。
見るべき場所を間違えていました。止まっていたのは子ではなく、親のほうでした。
承認を待っているのが親なら、子はどれだけ健全でも進めません
サブエージェントが親から委譲を受けて走るとき、生成したアーティファクトの扱いは親の側に返ります。その親が別の確認ダイアログを出したまま止まっていると、子から戻ってきた承認要求は処理される順番が回ってきません。
子のログには「待っている」以外の情報が出ません。エラーではないので、タイムアウトも発火しないことがあります。結果として、いちばん静かな状態で朝まで残ります。
この構図は Google も課題として認識していて、2026 年 8 月には親がブロックされた状態でサブエージェントが待ち続ける問題への対処として、アーティファクトを自動承認する「always proceeds」モードが入りました。裏を返せば、それまでは待ち続けるのが既定の挙動だったということです。
同じ「終わらない」でも、フックの待ち方に起因するものは切り分けの順序が違います。そちらはフックの待ち方が 2.6.0 で変わりました。終わらないターンを切り分ける手順に分けて書いています。今回扱うのは、フックを使っていなくても起きるほうです。
滞留している会話をログから洗い出す
目視で追うのは、階層が二段を超えたところで無理になります。私はログを機械的に走らせて、承認要求を出したまま次のイベントが来ていない会話を出すようにしました。
8 月の更新で、委譲されたサブエージェントには subagent_info が付き、その中に conversation_id と log_uri が入るようになりました。親子の突き合わせが、勘ではなく識別子でできます。ツール呼び出しごとの tool_info には正規化されたツール名が入るので、何を待っているのかもそこから読めます。
先に、自分のログに実際どのイベント名が出ているかを確かめてください。名前はバージョンで変わります。
# types.py — 手元のログにどの type が出ているかを数える
import json, sys, collections
c = collections.Counter()
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
c[ev.get("type", "(type なし)")] += 1
for k, v in c.most_common():
print(f"{v:>5} {k}")手元の環境(Python 3.10.12)で流すと、こう出ました。
2 approval_request
2 subagent_start
2 tool_call
1 turn_start
1 result
1 heartbeat
ここで得た名前を、次のスクリプトの判定に入れます。承認要求を記録し、解決または結果が来たら消す。最後まで残ったものが滞留です。
#!/usr/bin/env python3
"""stream-json の行を読み、承認待ちのまま止まっている会話を洗い出す。"""
import json, sys
from datetime import datetime, timezone
PENDING = {} # conversation_id -> (時刻, 待っているツール名)
LAST = {} # conversation_id -> 最終イベント時刻
PARENT = {} # 子 conversation_id -> 親 conversation_id
LOGURI = {} # conversation_id -> log_uri
def ts(v):
return datetime.fromisoformat(v.replace("Z", "+00:00"))
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue # 途中で切れた行は捨てる。走行中のログでは普通に起きます
sub = ev.get("subagent_info") or {}
cid = sub.get("conversation_id") or ev.get("conversation_id")
if not cid:
continue
if sub.get("log_uri"):
LOGURI[cid] = sub["log_uri"]
if sub.get("parent_conversation_id"):
PARENT[cid] = sub["parent_conversation_id"]
t = ts(ev["timestamp"])
LAST[cid] = t
kind = ev.get("type")
if kind == "approval_request":
PENDING[cid] = (t, (ev.get("tool_info") or {}).get("name", "unknown"))
elif kind in ("approval_resolved", "result"):
PENDING.pop(cid, None)
now = max(LAST.values()) if LAST else datetime.now(timezone.utc)
rows = sorted(
((round((now - t).total_seconds()), cid, tool,
PARENT.get(cid, "-"), LOGURI.get(cid, "-"))
for cid, (t, tool) in PENDING.items()),
reverse=True,
)
if not rows:
print("承認待ちで滞留している会話はありません")
sys.exit(0)
print(f"{'wait_s':>7} {'conversation_id':<16} {'tool':<12} {'parent':<16} log_uri")
for sec, cid, tool, par, uri in rows:
print(f"{sec:>7} {cid:<16} {tool:<12} {par:<16} {uri}")
blocked = {r[3] for r in rows if r[3] != "-"}
for cid, _ in PENDING.items():
if cid in blocked:
print(f"\n→ {cid} は自身も承認待ちです。子はここが動くまで進めません。")冒頭の朝と同じ形のログを作って流すと、次の出力になりました。
wait_s conversation_id tool parent log_uri
2028 conv-parent-01 write_file - -
1977 conv-child-a write_file conv-parent-01 file:///logs/conv-child-a.jsonl
→ conv-parent-01 は自身も承認待ちです。子はここが動くまで進めません。
読みたいのは順位ではなく、最後の 1 行です。滞留の先頭は親で 2,028 秒、子は 1,977 秒。51 秒しか違いません。子は自分の要求を出してから、ほぼずっと親に足を止められていたことになります。
親の承認を解決した行を足して流し直すと、出力は「承認待ちで滞留している会話はありません」に変わりました。子だけを再実行しても、この状態は解けません。
書き込み中のログに対しても、そのまま流せます。コピーを指すか、末尾を流し込んでください。途中で切れた最終行は捨てられるので、走行中でも止まりません。私は朝いちばんにこれを走らせてから、開くログを決めるようにしました。親が滞留していればダイアログを片づけに行き、子が滞留していれば子の要求内容を読みに行く。順序が先に決まります。
このスクリプトがあえて見ていないものにも触れておきます。ハートビートは、作業が進んでいなくても最終イベントの時刻を新しくします。「直近に動きがある」だけでは判断材料になりません。ここで滞留と数えるのは、解決されていない承認要求だけです。
なお、このログは 8 月時点で公開されている subagent_info と tool_info の形に合わせて自分で組み立てたものです。数値は実際にスクリプトを走らせて得た値ですが、製品の本番実行から採取した記録ではありません。
「always proceeds」が塞ぐのは、どの穴か
always proceeds は、サブエージェントが出したアーティファクトを自動で承認します。親が別件で止まっていても、子はそこで足を止めません。
効くのは、待ち合わせそのものが原因の停止です。承認の中身に危険があって止まっていたわけではなく、単に順番待ちだった場合、この 1 行で朝の 30 分が消えます。
深さも関係します。8 月の更新では、ネストしたサブエージェントが孫以降も表示されるようになり、どの深さでもツールの確認要求を処理できるようになりました。それ以前は、二段下で止まっていると、そもそも見ている範囲の外にいたことになります。識別子で走査する価値がいちばん出るのが、この場合です。
効かないのは、親が出しているダイアログのほうです。親の承認は依然として人間を待ちます。同じダイアログが繰り返し出て手が止まる症状であれば、原因は別のところにあります。そちらはAntigravity の許可ダイアログが何度も表示される・「常に許可」が効かないときの診断と解決で扱いました。
| 症状 | 滞留スクリプトの出方 | 手を入れる場所 |
|---|---|---|
| 子だけが止まり、親は動いている | 子のみ滞留 | 子の要求内容を確認。ツール権限を見直す |
| 親子が同時に止まっている | 親と子が近い秒数で並ぶ | 親の承認を解決する。再発防止に always proceeds |
| 同じ確認が何度も出る | 滞留は出るが秒数が短く入れ替わる | 承認ルールの一致条件を見直す |
| 孫以降で止まる | 親の欄が子の ID になっている行が残る | 深さごとに確認要求を処理できているか確かめる |
自動承認をどこで止めるか
便利さと引き換えに、承認をひとつ手放す変更です。私はここを一律には決めませんでした。
線を引いたのは、成果物が出る先です。作業ディレクトリの中に閉じた生成物であれば、自動承認にしています。読み取りと解析、下書きの生成、比較用の一時ファイル。壊れても捨てて作り直せます。
私自身、ひとりで作っている以上、誤った成果物を止めてくれる second reviewer はいません。その役目を境界のほうに持たせています。
一方で、リポジトリの外へ出るもの、署名や配信に関わるもの、資格情報を触るものは、朝の自分に判断を残しました。夜のうちに片づく気持ちよさより、朝に取り返しがつくことのほうを取っています。
判断が難しいのは、承認ルールの一致条件が思ったより広い場合です。自動承認の範囲を決めたつもりでも、ルールの書き方によっては意図より広く通ってしまいます。私はここで一度痛い目を見ていて、その棚卸しの手順は何にも一致しないつもりの1行が、全コマンドを承認していましたにまとめました。自動承認を広げる前に、いま通っている範囲を確かめておくほうが安全です。
明日の朝のために置いておくもの
止まった朝に慌てないための備えは、三つで足ります。
- 滞留スクリプトを、実行ログの出力先と同じ場所に置く。朝いちばんに走らせるだけで、親と子のどちらを見るべきかが決まります
subagent_infoのlog_uriを控える運用にする。滞留した子のログへ、探さずに飛べます- 自動承認の範囲を、成果物の出る先で書き出しておく。判断を毎回やり直さずに済みます
止まっている場所を最初に取り違えると、健全なほうのログを 20 分読むことになります。私はそれをやりました。次の朝は、まず親を見てください。
エージェントに夜を任せる運用は、うまく回れば静かで気持ちのいいものです。この記事が、その静けさを取り戻す助けになれば嬉しく思います。