6月18日で Gemini CLI と Gemini Code Assist の IDE 拡張が、AI Pro・Ultra・無料個人向けのリクエスト処理を停止します。後継は Go で書き直された Antigravity CLI です。対話で叩く分には、当日に gemini を antigravity へ読み替えれば済みます。
困るのは、人間が画面を見ていない時間帯です。個人開発で App Store と Google Play に出している複数アプリと、複数サイトの更新を夜間のスケジュールに載せていると、CLI が新しい実装へ切り替わった初日には、認証トークンの形式が変わっていたり、当日の混雑でモデルが一時的に応答しなかったり、といった「環境側の小さな不調」が起こり得ます。そして厄介なことに、エージェント型の CLI はこうした不調のときでも、終了コード 0 のまま何も生成せずに終わることがあります。私自身、別件で「成功した扱いなのに成果物が増えていない」朝を経験して以来、本処理を起動する前に CLI の生存を一度だけ確かめる小さな関門を、すべてのスケジュールの前段に置くようにしました。
「失敗」より怖い「何も起きなかった」
終了コードが非ゼロで落ちてくれるなら、まだ扱いやすいのです。リトライを組み、回数を超えたら通知すればいい。本当に見つけにくいのは、CLI が「正常終了」として返ってくるのに、実際にはモデルへ一度も到達していないケースです。
新しいバイナリへ載せ替えた初日には、これが現実に起こり得ます。認証の参照先が変わってトークンが空のまま渡る、設定ファイルの探索パスがずれて既定モデルが解決できない、当日のリクエスト集中でゲートウェイが一時的に断る。いずれも「コマンドとしては起動して、すぐ静かに返る」挙動になりがちです。後段のスケジュールはそれを成功とみなして次へ進み、結果として「その晩だけ、何も更新されなかった」という穴があきます。
ここで効くのが、本処理そのものではなく、本処理を起動してよいかを先に問う段です。発想は単純で、ごく短いプロンプトを一度だけ投げて、期待した文字列が返ってくるかを確かめます。返ってこなければ、その晩は本処理を起動しません。空振りに高いコストを払う前に、安いカナリアで様子を見るという順番です。
preflight が確かめるべき4つの層
ひとことで「生きているか」と言っても、止まり方には段階があります。preflight が切り分けたい層は、次の4つです。
バイナリそのものが解決できるか
認証トークンが生きているか
モデルへ到達して応答が返るか
返ってきた応答が空でないか
第1に、バイナリそのものが解決できるか。移行直後はパスが通っていない、別アカウントの環境で antigravity が存在しない、といった初歩的な不在が起こります。これは command -v で即座に判定できます。
第2に、認証が生きているか。トークンの期限切れや参照先の取り違えは、移行初日に最も起こりやすい層です。ここは応答本文にエラー文言が含まれるかで推定します。
第3に、モデルへ実際に到達して応答が返るか。既定モデルが解決できない、当日の混雑で断られる、といった一過性の不達はこの層です。短いカナリアを投げて、期待文字列の有無で判定します。
第4に、応答が空でないか。終了コードが 0 でも本文が空、というのが今回いちばん潰したいパターンです。文字数がしきい値未満なら「到達していない」と見なします。
この4層を順に確かめ、どこで止まったかを別々の終了コードで返すのが preflight の役割です。一括で「失敗」とだけ返すと、原因の切り分けが翌朝の手作業に押し戻されてしまいます。
カナリアプロンプトで生存を1回だけ確かめる
実装の中心は、決め打ちの短いプロンプトを投げて、決め打ちの文字列が返るかを見るだけです。重い処理は一切させません。次のスクリプトは、CLI 名を環境変数で差し替えられるようにしてあります(移行期は gemini と antigravity を1箇所で切り替えたいからです)。非対話実行のフラグはお使いの版に合わせて読み替えてください。
#!/usr/bin/env bash
# preflight.sh — スケジュール本体を起動する前に CLI の生存を確かめる
# 終了コード: 0=OK / 10=バイナリ不在 / 11=タイムアウト / 12=認証 / 13=クォータ / 14=空応答・モデル不達
set -u
CLI_BIN = "${ AGENT_CLI :- antigravity }" # 移行期は AGENT_CLI=gemini で旧CLIへ即時フォールバック
CANARY_PROMPT = 'Reply with exactly this token and nothing else: PREFLIGHT_OK'
EXPECT = 'PREFLIGHT_OK'
TIMEOUT_SEC = "${ PREFLIGHT_TIMEOUT :- 25 }"
LOG = "${ PREFLIGHT_LOG :- $HOME / preflight . log }"
log () { echo "$( TZ = Asia/Tokyo date '+%F %T') [ $CLI_BIN ] $1 " >> " $LOG " ; }
# 第1層: バイナリ解決
if ! command -v " $CLI_BIN " > /dev/null 2>&1 ; then
log "NG binary-not-found" ; exit 10
fi
# 第2〜4層: カナリアを1回だけ投げる
OUT = "$( timeout " $TIMEOUT_SEC " " $CLI_BIN " -p " $CANARY_PROMPT " 2>&1 )"
RC = $?
if [ " $RC " -eq 124 ]; then
log "NG timeout(${ TIMEOUT_SEC }s)" ; exit 11
fi
# 認証・クォータは応答本文から推定(大文字小文字を無視)
LOWER = "$( printf '%s' " $OUT " | tr '[:upper:]' '[:lower:]')"
case " $LOWER " in
* unauthorized *|* authentication *|* credential *|* token * expired *|* not * logged * in * )
log "NG auth" ; exit 12 ;;
* quota *|* rate * limit *|* resource * exhausted *|* 429 * )
log "NG quota" ; exit 13 ;;
esac
# 第4層: 期待文字列の有無と空応答
if printf '%s' " $OUT " | grep -q " $EXPECT " ; then
log "OK" ; exit 0
else
CHARS = $( printf '%s' " $OUT " | wc -c | tr -d ' ' )
log "NG empty-or-unreachable chars=${ CHARS }" ; exit 14
fi
ポイントは3つあります。まず timeout で必ず上限を切ること。応答しないモデルに preflight 自体がぶら下がっては本末転倒です。次に、認証とクォータを応答本文から推定していること。これは厳密な API ステータスではなく文言ベースの推定なので、版によって文言が変わる前提で、case のパターンは運用しながら足していきます。最後に、最終判定を「期待文字列の有無」に置くこと。終了コードを信じないのがこの設計の肝です。
失敗を握りつぶさず、種類で振り分ける
preflight が返す終了コードは、後段でそのまま振り分けに使えます。同じ「起動しない」でも、認証切れは人間が再ログインしないと直らない一方、クォータ枯渇は時間をおけば回復し、タイムアウトは一過性かもしれません。原因ごとに次の手が違うので、一律のリトライではなく分岐させます。
#!/usr/bin/env bash
# run_job.sh — preflight を通ってから本処理を起動する
set -u
HERE = "$( cd "$( dirname " $0 ")" && pwd )"
LOG = "${ PREFLIGHT_LOG :- $HOME / preflight . log }"
jlog () { echo "$( TZ = Asia/Tokyo date '+%F %T') $1 " >> " $LOG " ; }
bash " $HERE /preflight.sh"
case $? in
0 ) jlog "preflight passed -> start job"
bash " $HERE /the_real_job.sh"
jlog "job done rc= $? " ;;
10 ) jlog "ABORT binary missing — 通知のみ。自動回復しない" ;;
12 ) jlog "ABORT auth — 再ログインが必要。本処理は起動しない" ;;
13 ) jlog "SKIP quota — 今回は見送り。次回スロットで再評価" ;;
11 | 14 ) jlog "RETRY-ONCE — 60秒後に1回だけ再preflight"
sleep 60
if bash " $HERE /preflight.sh" ; then
bash " $HERE /the_real_job.sh" ; jlog "job done after retry rc= $? "
else
jlog "ABORT — 再preflightも失敗。今回は起動しない"
fi ;;
esac
ここで意識しているのは、回復しない失敗(バイナリ不在・認証切れ)に対してリトライを回さないことです。直らないものを叩き続けても、ログが汚れるだけで朝の自分が困ります。逆にタイムアウトと空応答は一過性の可能性があるので、60秒だけ置いて一度だけ再評価します。回復する見込みのある層にだけリトライを使う、という配分です。
スケジュール本体の前段に差し込む
既存の cron やスケジューラに後付けする場合、本処理のコマンドを run_job.sh に置き換えるだけで済みます。本処理側のスクリプトは一切変えません。preflight はあくまで前段の関門であって、本処理の中身には手を入れない、という分離が後々の保守を楽にします。
二重起動への配慮も一緒に入れておくと安心です。preflight が軽いとはいえ、前のジョブが長引いているときに次が重なると、カナリアだけが二重に走ってクォータを無駄に消費します。排他ロックを1行足しておきます。
# crontab の例(毎時 0 分にロック付きで起動)
0 * * * * /usr/bin/flock -n /tmp/agent_job.lock /home/me/jobs/run_job.sh
flock -n は、すでにロックを持つ実行があれば即座に諦めます。重なったときに待たずに降りる方が、スケジュール実行では安全です。待ち行列ができると、結局どこかで二重に動くからです。
観測モードから始めて、止める基準を決める
preflight をいきなり「失敗したら本処理を止める」モードで入れると、文言推定の誤判定で正常な晩まで止めてしまう恐れがあります。最初の数日は、preflight の判定をログに残すだけにして、本処理は従来どおり起動します。run_job.sh の case を一時的に「ログのみ・常に起動」へ寄せておく形です。
数日ぶんのログを眺めて、認証やクォータの文言が実際にどう出るかを確認してから、止める分岐を有効にします。私自身は、誤って止めた回数がゼロで、かつ本物の不調を1回でも正しく捕まえたタイミングを、本番モードへ移す目安にしています。誤判定で止めすぎる preflight は、無いよりたちが悪いからです。文言パターンは運用しながら case に足していけば、版が変わっても追従できます。
次の一歩
まず preflight.sh を1ファイル置いて、手元で bash preflight.sh; echo $? を何度か叩き、OK のときに 0 が返ることだけ確かめてみてください。次に、いちばん落ちて困る夜間ジョブを1本だけ run_job.sh 経由に差し替え、数日はログのみで様子を見ます。移行当日の朝に「何も起きなかった」穴をあけないための、いちばん小さな保険になります。