定期実行で gemini -p "..." を呼んでいると、失敗の知らせより先に「何も届かない朝」がやってきます。エラーは出ていません。ログも空のままで、プロセスだけがいつまでも残っています。
応答待ちで止まったコマンドは、自分からは終わりません。終わらないものを外から終わらせる殻を、呼び出しの外側に 1 枚かぶせておくのが、私の今の運用です。
以下では、その殻(約 40 行のシェルスクリプト)と、手元で測った挙動をお伝えします。測定には本物の Gemini を使わず、止まる・空で返る・失敗する振る舞いを再現した替え玉コマンドを使いました。したがって、Gemini 側が実際にどれくらいの頻度で止まるかは、ここでは扱っておりません。外から見えた終了コードと秒数だけの話です。
守る相手は 3 つに絞ります
殻が受け持つのは、次の 3 つだけです。
| 症状 | 放っておくと | 殻での扱い |
|---|---|---|
| 応答待ちで止まる | プロセスが残り続け、次の回も動かない | timeout で止める |
| 終了コード 0 なのに出力が空 | 空のファイルが成果物として下流へ流れる | 0 バイトを失敗として扱う |
| 前回分がまだ動いている | 同じ処理が重なり、上限や書き込みを奪い合う | flock で後発を見送る |
3 つとも、呼び出す相手が Gemini でなくても起きます。ですから殻は、相手のコマンドを環境変数で差し替えられる形にしました。
殻の全文です
#!/usr/bin/env bash
# gemini-guard.sh — 使い方: gemini-guard.sh prompt.txt out.md
set -u
PROMPT_FILE="${1:?usage: gemini-guard.sh prompt.txt out.md}"
OUT="${2:?usage: gemini-guard.sh prompt.txt out.md}"
LIMIT="${GUARD_LIMIT:-120}" # 秒。ここを超えたら止める
GRACE="${GUARD_GRACE:-10}" # 停止要求から強制終了までの猶予(秒)
LOCK="${GUARD_LOCK:-/tmp/gemini-guard.lock}"
LOG="${GUARD_LOG:-$HOME/gemini-guard.jsonl}"
CMD="${GUARD_CMD:-gemini}"
log() { # log <status> <rc> <seconds> <bytes>
printf '{"ts":"%s","status":"%s","rc":%s,"sec":%s,"bytes":%s}\n' \
"$(date -u +%FT%TZ)" "$1" "$2" "$3" "$4" >> "$LOG"
}
# 前回がまだ動いていたら、重ねずに諦める
exec 9>"$LOCK"
if ! flock -n 9; then
log skipped_locked 0 0 0
exit 75
fi
start=$(date +%s)
timeout --kill-after="$GRACE" "$LIMIT" "$CMD" -p "$(cat "$PROMPT_FILE")" \
> "$OUT.tmp" 2> "$OUT.err"
rc=$?
sec=$(( $(date +%s) - start ))
bytes=$(wc -c < "$OUT.tmp" | tr -d ' ')
case "$rc" in
0)
if [ "$bytes" -eq 0 ]; then
log empty_output "$rc" "$sec" "$bytes"; rm -f "$OUT.tmp"; exit 3
fi
mv "$OUT.tmp" "$OUT" # 完成してから置く
log ok "$rc" "$sec" "$bytes"; exit 0 ;;
124) log timeout "$rc" "$sec" "$bytes"; rm -f "$OUT.tmp"; exit 124 ;;
137) log killed "$rc" "$sec" "$bytes"; rm -f "$OUT.tmp"; exit 137 ;;
*) log failed "$rc" "$sec" "$bytes"; rm -f "$OUT.tmp"; exit "$rc" ;;
esac各行の理由を、順に書き残します。
.tmp に書いてから mv で本来の名前に置くのは、途中で止まったときに「半分だけ書かれた成果物」を下流に見せないためです。mv は同じディレクトリ内なら一瞬で終わります。
終了コードは、止まった理由ごとに変えました。124 は timeout が時間切れで止めたとき、137 は停止要求に応じず強制終了したとき、75 は後発として見送ったときです。cron のメールや監視側で、原因が一目で分かります。
替え玉で測った結果です
制限 3 秒・猶予 2 秒に縮めて、5 通りの替え玉を動かしました。
| 替え玉の振る舞い | 終了コード | 所要 | 成果物 | ログの status |
|---|---|---|---|---|
| すぐ答えて終わる | 0 | 0 秒 | 置かれる | ok |
| 応答せず待ち続ける | 124 | 3 秒 | 置かれない | timeout |
| 停止要求(TERM)を無視する | 137 | 5 秒 | 置かれない | killed |
| 何も出さず 0 で終わる | 3 | 0 秒 | 置かれない | empty_output |
| エラー終了(1)する | 1 | 0 秒 | 置かれない | failed |
停止要求を無視する場合の 5 秒は、制限の 3 秒に猶予の 2 秒を足した値です。--kill-after を付けていなければ、この替え玉は 60 秒の待機が終わるまで残ります。TERM を握りつぶすプログラムは珍しくないので、猶予は外せません。
重なりの確認もしました。止まる替え玉を先に起動し、1 秒後にもう 1 本を起動すると、後発は即座に終了コード 75 で引き下がり、先発は 3 秒で 124 になりました。ログには skipped_locked と timeout が並びます。
子プロセスまで止まるかは、--foreground で変わります
測っていて意外だったのが、孫プロセスの扱いです。bash スクリプトが中で sleep 77 を呼ぶ替え玉を、2 通りで止めて数えました(GNU coreutils 8.32)。
| 呼び方 | 終了コード | 止めたあとに残った sleep |
|---|---|---|
timeout 2 ./stub | 124 | 0 本 |
timeout --foreground 2 ./stub | 124 | 1 本 |
既定の timeout は、コマンドを別のプロセスグループで走らせ、時間切れでグループごと止めます。--foreground を付けるとグループを分けないので、止まるのは直接の子だけになり、その先の孫が居残ります。同じ理由で、コマンドの内側で setsid を使うと居残りました。
殻の中では --foreground を使っておりません。gemini が内部で別プロセスを起こす場合に、片付けが外から効くようにするためです。端末から手で叩くときは --foreground が必要になる場面もあるので、用途で選んでください。
cron への載せ方
# 毎朝 6:10。後発が重なっても殻が見送る
10 6 * * * GUARD_LIMIT=180 /home/me/bin/gemini-guard.sh /home/me/jobs/morning.txt /home/me/out/morning.mdあわせて、ログの末尾を見る 1 行を置いておくと、朝の確認が楽になります。
tail -n 3 ~/gemini-guard.jsonlok 以外が並んでいたら、out/morning.md.err に標準エラーが残っています。成功した回の .err は、私は気づいたときにまとめて消しています。
macOS には標準の timeout がありません。Homebrew の coreutils を入れ、gtimeout に読み替えれば同じ動きになります。flock も標準では入っていないため、別の排他(mkdir で作る簡易ロックなど)に置き換えてください。
殻の限界です
殻は、止まったことを教えてくれますが、止まった原因は教えてくれません。応答待ちが Gemini 側なのか、ネットワークなのか、プロンプトが重すぎるのかは、.err と所要秒数の推移から推し量るしかありません。
また、制限時間の決め方には正解がありません。私は、普段の所要秒数の 3〜4 倍あたりから始め、ok のログが溜まってから詰めるようにしています。最初から短く締めると、正常な長い回を殺してしまうからです。
終わらないものは、中から直すより先に、外から終わらせるのが私の線引きです。 そのうえで、終わらなかった事実をログに残しておきます。次に手を入れるのは、ログが原因を指してからで遅くないのだと思います。
振り返ると、止まった朝にいちばん困ったのは、原因が分からないことよりも、止まった事実すら残っていないことでした。
関連して、CLI に依存した定期実行そのものを組み替える話は「Gemini CLI 停止に備え、夜間バッチに google-genai SDK フォールバックを組み込む」に、結果を取りこぼさない設計は「投げて終了する定期実行で、結果を取りこぼさない — Gemini バックグラウンド実行を再取得台帳で回す設計」にまとめています。