先日の深夜、自分の自動化が原因で眠りを妨げられました。スケジュールに載せた処理がほんの少し詰まっただけで、手元のスマートフォンが何度も短く震えたのです。翌朝ログを開くと、本当に対処が必要だった失敗は一件だけ。残りはすべて、数分後には自然に回復していた一過性の揺れでした。
Gemini CLI の提供が 6 月 18 日に終わり、後継として Go で書き直された Antigravity CLI へ移行したことで、私自身が手元に持っていた無人実行のスクリプトも一斉に載せ替えることになりました。新しい CLI は応答が速く、起動も軽い。けれど「何を通知するか」という設計は CLI が変わっても自分で決めなければなりません。今日は、成功時は黙り、人が動くべき失敗だけを静かに知らせる、その小さな仕組みを共有します。
鳴りすぎる通知は、鳴らない通知と同じくらい危ない
無人実行を組み始めた頃、私は「とにかく結果を全部通知する」設定にしていました。成功も失敗も、すべて手元に届く。安心できそうに見えて、実際には逆でした。
毎回届く成功通知に目が慣れてしまうと、その中に紛れた一件の失敗を見落とします。四つのブログサイトの自動更新と、いくつかのアプリの AdMob 収益チェックを並行で回していた時期は、一日に数十件の通知が積み上がり、肝心な異常がスクロールの彼方へ消えていきました。
通知の価値は、数ではなく「届いたら必ず手が止まる」という信頼にあります。だからこそ、設計の出発点は足し算ではなく引き算に置きます。届けないものを先に決め、残ったものだけを鳴らす、という順序です。
成功時の沈黙を、終了コードで担保する
最初の土台は、Antigravity CLI の終了コードです。シェルから起動した CLI は、正常終了で 0 を、異常終了で 0 以外を返します。この値こそが、通知するかしないかを分ける一次判定になります。
標準出力と標準エラーは一つのログファイルへ集約しておきます。後で失敗の中身を読むときも、通知の本文を組み立てるときも、この一本のログが頼りになるためです。
#!/usr/bin/env bash
set -uo pipefail
LOG_DIR="${HOME}/.agy-runs"
STATE_DIR="${HOME}/.agy-state"
mkdir -p "$LOG_DIR" "$STATE_DIR"
# 第1引数をタスク名として受け取り、残りを CLI へ素通しする
TASK="${1:?task name required}"
shift
LOG="${LOG_DIR}/${TASK}-$(date +%Y%m%d-%H%M%S).log"
# Antigravity CLI を実行し、出力をすべてログへ集約する
agy run "$@" >"$LOG" 2>&1
CODE=$?
echo "task=${TASK} exit=${CODE} log=${LOG}"
ここで大切なのは set -e を使わない判断です。CLI が失敗した瞬間にスクリプト全体が止まってしまうと、その後の「失敗を分類して通知するかどうか決める」処理に進めません。失敗は止める対象ではなく、観察して扱う対象です。set -uo pipefail だけを残し、終了コードは自分の手で受け取ります。
「一過性の揺れ」と「人が見るべき失敗」を分ける
終了コードが 0 以外でも、その全部が人を呼ぶべき失敗とは限りません。レート制限や一時的なネットワーク断は、数分後の再実行であっさり通る種類の揺れです。一方で、認証トークンの期限切れや、コードの構文エラーは、人が手を入れない限り何度回しても直りません。
そこで、ログの内容から失敗の性質を三つに振り分けます。一過性、認証、そして中身を見るべき失敗、の三つです。
classify() {
local code="$1" log="$2"
# 終了コード 0 は成功。通知の対象外とする
[ "$code" -eq 0 ] && { echo "ok"; return; }
# 一過性: レート制限・タイムアウト・一時的な切断
if grep -qiE 'rate limit|429|timeout|temporarily|ETIMEDOUT|ECONNRESET|50[23]' "$log"; then
echo "transient"; return
fi
# 認証: 無人では回復できず、人の再ログインが要る
if grep -qiE '401|unauthorized|token .*(expired|invalid)|re-?auth' "$log"; then
echo "auth"; return
fi
# それ以外は、翌朝あなたが中身を読むべき失敗
echo "actionable"
}
この振り分けは完璧である必要はありません。最初は荒くて構わないのです。運用しながら、誤って一過性に入れてしまった本当の失敗や、その逆を見つけるたびに grep のパターンを一つずつ育てていきます。私の場合、半年ほど回す間に育ったパターンは十数個ほどでした。
分類ごとの扱いを一覧にすると、設計の意図が見えやすくなります。
| 失敗の種別 | 典型的な原因 | 無人実行での扱い |
| ok | 正常終了(終了コード 0) | 沈黙。一過性カウンタをリセット |
| transient | レート制限・タイムアウト・一時的な切断 | 原則は見送り。3回連続したときだけ通知 |
| auth | トークン期限切れ・再ログイン要求 | 即通知。ただし6時間は重複抑制 |
| actionable | 構文エラー・想定外の例外 | 即通知。翌朝に中身を読む前提 |
一過性の失敗は、続いたときだけ鳴らす
一過性に分類した失敗を、最初の一回で通知してしまうと、結局のところ鳴りすぎる元の状態へ戻ります。揺れは無視し、けれど揺れが「続いている」ときだけは知りたい。この線引きを、連続回数のカウンタで表現します。
# 一過性が連続で続いているかを判定する。3回連続で初めて true
transient_persisted() {
local task="$1"
local f="${STATE_DIR}/${task}.transient"
local n
n="$(cat "$f" 2>/dev/null || echo 0)"
n=$((n + 1))
echo "$n" > "$f"
[ "$n" -ge 3 ]
}
# 成功したら一過性カウンタをリセットする
reset_transient() {
rm -f "${STATE_DIR}/${1}.transient"
}
成功が一度でも挟まればカウンタは消えます。つまり、たまたま一回詰まっただけの揺れは静かに見送られ、三回連続で詰まり続けたとき、初めて「これは揺れではないかもしれない」という信号として手元へ届きます。
同じ失敗を、二度三度と鳴らさない
認証切れや構文エラーのように、人が直すまで何度でも再現する失敗は、放っておくと実行のたびに通知を生みます。一晩で同じ内容が十件並ぶと、それはもうノイズです。そこで、失敗の「指紋」を取り、一定時間は同じ指紋を鳴らさない重複抑制を入れます。
# 失敗の指紋: 種別 + ログ末尾を短いハッシュにする
should_notify() {
local task="$1" kind="$2" log="$3"
local fp f now last
fp="$(printf '%s|%s' "$kind" "$(tail -n 20 "$log")" | shasum | cut -c1-12)"
f="${STATE_DIR}/${task}.${fp}"
now="$(date +%s)"
last="$(cat "$f" 2>/dev/null || echo 0)"
# 同じ指紋を 6 時間(21600 秒)以内には再通知しない
if [ "$((now - last))" -lt 21600 ]; then
return 1
fi
echo "$now" > "$f"
return 0
}
ログ末尾を指紋に含めるのは、エラーの種類が同じでも対象ファイルが違えば別物として扱いたいためです。六時間という窓は、私が一日に回す頻度に合わせた値にすぎません。一時間ごとに回す処理なら短く、日次なら長く、自分のスケジュールに合わせて決めてください。
通知の本文は「寝起きの自分」に向けて書く
通知が届く相手は、たいてい少し前の自分です。文脈を忘れた状態でも一瞬で状況がつかめるよう、本文にはホスト名・タスク名・失敗の種別・ログの末尾だけを、短く詰めます。
notify() {
local task="$1" kind="$2" log="$3"
local tail_text
tail_text="$(tail -n 8 "$log" | sed 's/"/\\"/g')"
# WEBHOOK_URL は環境変数で渡す(Slack の Incoming Webhook など)
curl -fsS -X POST "$WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "$(printf '{"text":"[%s] %s failed (%s)\n%s"}' \
"$(hostname)" "$task" "$kind" "$tail_text")" \
>/dev/null || echo "notify itself failed" >&2
}
最後に、ここまでの部品を一本の流れにつなぎます。
run_and_watch() {
local task="$1"; shift
local log code kind
log="${LOG_DIR}/${task}-$(date +%Y%m%d-%H%M%S).log"
agy run "$@" >"$log" 2>&1
code=$?
kind="$(classify "$code" "$log")"
if [ "$kind" = "ok" ]; then
reset_transient "$task"
exit 0
fi
# 一過性は、3回続いたときだけ先へ進める
if [ "$kind" = "transient" ] && ! transient_persisted "$task"; then
exit 0
fi
if should_notify "$task" "$kind" "$log"; then
notify "$task" "$kind" "$log"
fi
exit "$code"
}
run_and_watch "$@"
あとは cron や macOS の launchd から、run_and_watch.sh blog-update agy run --task publish のように呼ぶだけです。成功した夜は、何も届きません。その静けさこそが、仕組みが正しく働いている証です。
本物の失敗を待たずに、通知経路を確かめる
通知の仕組みでいちばん怖いのは、いざ失敗したときに肝心の通知が飛ばないことです。けれど本物の失敗が起きるのを待っていては、確認のしようがありません。そこで、わざと失敗する小さなコマンドを CLI の代わりに走らせて、経路だけを先に検証します。
# agy run の代わりに、必ず非0で終わるコマンドを差し込んで経路を試す
WEBHOOK_URL="$WEBHOOK_URL" \
run_and_watch "smoke-test" bash -c 'echo "401 unauthorized: token expired" >&2; exit 1'
ログに 401 を含めておくと、分類は auth に落ち、重複抑制を通過した一回だけ通知が飛びます。手元に通知が届けば、本番の失敗時にも同じ経路で確実に届くと確認できます。私はこの煙テストを、新しいタスクをスケジュールへ載せる前の決まりごとにしています。一度通しておくだけで、無人運用の安心感がまるで変わります。
小さく始めて、鳴らない夜を増やしていく
最初から完璧な分類を目指す必要はありません。私が勧めたいのは、まず終了コードと重複抑制の二つだけを入れて回し始めることです。一過性の判定は、実際に誤って鳴った通知を見てから、パターンを一つ足す。その繰り返しで、仕組みは自分の運用に馴染んでいきます。
無人実行の良し悪しは、動いている時間ではなく、鳴らずに済んだ夜の数で測れると私は考えています。今夜、あなたのスケジュールに載っている処理を一つ選び、この沈黙する仕組みに包んでみてください。明日の朝が、少し静かになるはずです。