無人で回るAIエージェントが「許可を求めて」4日間止まっていた話

  • #AI
  • #自動化
  • #launchd
  • #ウォッチドッグ
  • #headless
  • #macOS
無人で回るAIエージェントが「許可を求めて」4日間止まっていた話

この記事について

複数の媒体に対して headless AI エージェントが夜間スケジュールで記事を自動起稿している。そのうち1本が、許可ディレクトリ外のドキュメントを参照しようとして承認プロンプトで止まり、93.5時間ハングし続けた。「止まっているのにエラーログが出ない・Discord 通知もゼロ・プロセスは生存している」という状況の記録と、対策として書いた純シェルのウォッチドッグの設計メモ。

実装ステータス: 実装済み(2026-07-23・全4ジョブに適用済み)

何が起きたか

headless AI エージェントは macOS の launchd から毎晩21:30 に起動し、プロンプトファイルを読んでリポジトリ上の作業を行う設計だった。このエージェントがアクセスしようとしたパスが、Claude Code の許可ディレクトリ外のドキュメントファイルだった。

Claude Code は実行可能なディレクトリをホワイトリストで管理しており、対象外のパスへのアクセス時には承認プロンプトを出す。端末が目の前にある人間にとっては便利な安全弁だが、夜中に誰もいない状態で起動した headless プロセスにとっては「無限に待つ」と同義になる。

launchd は前回の実行プロセスが生存している間は次回の起動をスキップする。7/19 夜に起動したプロセスが承認待ちのまま生き続けたため、7/20・7/21・7/22 の3晩が丸ごと発火しなかった。起動を試みた形跡がログに残らないため、外から見ると「何もなかった」と同じになる。

なぜ気づかなかったか

エラーログは 0 バイトのままで、Discord 通知もゼロだった。承認待ち状態のプロセスは CPU をほぼ消費しない。pslaunchctl list では「起動している」と表示される。Discordの自動化通知が「今日も届いている」状態であれば、正常稼働とほぼ区別できない外観になる。

異変に気づいたのは、出力されているはずの成果物が止まっていることに気づいたときだった。診断は Claude Code のセッションログ(JSONL ファイル)のタイムスタンプを時系列に並べ、「30分以上の無活動区間」を探す方法が効いた。正常な実行は最長でも 90 分以内で終わる設計になっているため、93.5 時間の区間は一目でわかった。

対策: 純シェルのウォッチドッグ

根本的な対策は「無人ジョブには必ず上限時間を付ける」こと。macOS に GNU coreutils の timeout(1) は標準では入っていない。brew install coreutilsgtimeout を追加する方法もあるが、無人の自動化ジョブに外部依存を増やすと「依存が消えたときに静かに壊れる」リスクが積み上がる。

そこで scripts/lib/agent_watchdog.sh として純シェルのウォッチドッグを実装した。

source "$REPO/scripts/lib/agent_watchdog.sh"

run_with_watchdog 5400 "zash-autodraft" \
  claude -p --permission-mode acceptEdits "$(cat prompt.md)"
rc=$?

[ "${AGENT_WATCHDOG_TIMED_OUT}" = 1 ] && agent_notify "⏱ タイムアウトしました。承認プロンプト待ちのハングが疑われます"

呼び出し側はこれだけ。戻り値は通常のコマンド終了コード(タイムアウト時は 124、GNU timeout の慣習に合わせた)。

設計の3点

1. プロセスグループごと殺す

headless Claude は内部で git・python3・他のコマンドを孫プロセスとして抱える。親プロセスだけを kill -TERM $pid しても孫が残り、ゾンビになる。set -m でジョブ制御を有効にしてバックグラウンド起動すると、子は独立したプロセスグループ(PGID = PID)に入る。kill -TERM -$pid(マイナス付き)でプロセスグループ全体を一度に落とせる。

set -m
"$@" &
local pid=$!
set +m
# タイムアウト時: kill -TERM -"$pid"

2. TERM → 20秒待機 → KILL の順序

SIGTERM は「終了してください」という要請で、受け取ったプロセスが無視することもある。20 秒待って生存していれば SIGKILL で強制終了する。これは GNU timeout と同じシーケンス。

3. フラグファイルで状態を伝える

ウォッチドッグのタイマー部分はバックグラウンドサブシェルで動く。タイムアウトが発生した事実を親の実行コンテキストに伝えるため、一時ファイル(mktemp で作成)の有無を使う。環境変数の継承ではサブシェルの壁を超えられないため、ファイルで渡す。

local flag
# mktemp で一時ファイルパスを予約し、即座に削除する。
# タイムアウト時だけ : > "$flag" でファイルを再作成して「タイムアウトした」事実を示す。
flag="$(mktemp)"; rm -f "$flag"

# タイムアウトしたら flag を作る
: > "$flag"

# 親側で判定
if [ -f "$flag" ]; then
    AGENT_WATCHDOG_TIMED_OUT=1
    return 124
fi

通知の信頼性にも手当てした

ウォッチドッグには agent_notify() 関数も含めた。notify_discord.py(Python の requests 使用)を呼ぶが、環境によっては requests が入っておらず通知だけ落ちることがあった。フォールバックとして Python 標準ライブラリの urllib.request を使う curl 相当の実装を直書きし、追加インストールなしで動くようにした。

教訓

  • 無人ジョブには必ず上限時間を付ける。「プロセスが生きている」は「正常に動いている」ではない
  • エラーログ 0 バイトと Discord 通知ゼロが同時に続いているなら、「ジョブが何もしていない」可能性がある
  • 承認プロンプトが出るパスは設計段階で許可ディレクトリ内に収める。後からアクセスすることになったドキュメントをプロンプトに組み込むときも、パスが許可範囲内かを確認する
  • 診断には成果物の欠落が有効な検出器になる。「通知が来ていた」ではなく「今日の成果物があるか」を確認する

動作環境: macOS 13 Ventura / bash / zsh
更新履歴: 2026-07-23 — 初稿
訂正履歴: なし