CLI 1.1.14 のリリースノートに、目立たない一行があります。言語サーバーが起動時または実行中に失敗したとき、これまで CLI は何も表示せずに終了していました。今後はターミナルに記録され、終了コードも非ゼロになります。
つまり、これまで「成功として扱われていた失敗」が表に出るようになったわけです。
私はこれを読んで、自分の定期実行スクリプトを見に行きました。個人開発でいくつかのサイトの更新を夜間に走らせていて、その中で CLI 系のコマンドを bash から呼んでいます。もし非ゼロ終了が返ってくるようになったのなら、翌朝には赤いログが増えているはずです。
翌朝、ログは前日と同じ緑のままでした。
改善が届いていないのではなく、私の呼び出し方が受け取れていなかったのです。
呼び出し方によって、失敗は消えることがあります
終了コードは、コマンドが返した瞬間に確定します。ただし、そのコマンドをどう書いたかによって、シェルが最終的に報告する値は別のものに置き換わります。
いちばん分かりやすいのはパイプです。
antigravity-cli run ... | head -1この一行が返す終了コードは、head のものです。左側が何番で落ちていようと、head が正常に1行読めた時点で 0 になります。エラーメッセージは 2>/dev/null で捨てているか、ログの奥に流れていて、誰も見ていません。
これは bash の仕様であって、不具合ではありません。ただ、自動化のラッパーを書くときには、この仕様が「失敗の消しゴム」として働きます。
手元の16通りで、どれが失敗を伝えるのか確かめました
推測で直しても仕方がないので、実際に測りました。終了コード 1 で終わる小さなスクリプトを用意し、それを16通りの書き方で呼んで、シェルが最終的に何を返すかを記録します。
検証用のスクリプトはこれだけです。
#!/bin/bash
echo "Starting language server..."
echo "language server exited unexpectedly (code 1)" >&2
exit 1環境は GNU bash 5.1.16 です。結果を並べます。
| 呼び出し方 | 返った終了コード | 失敗が伝わるか |
|---|---|---|
cmd(そのまま) | 1 | 伝わる |
cmd | head -1 | 0 | 消える |
set -o pipefail + cmd | head -1 | 1 | 伝わる |
OUT=$(cmd)(トップレベル) | 1 | 伝わる |
RESULT="prefix-$(cmd)" | 1 | 伝わる |
if cmd; then ... fi | — | 伝わる(else に入る) |
set -e のスクリプト内で cmd | tail -1 | 0 | 消える |
関数内の local OUT=$(cmd) | 0 | 消える |
関数内の declare OUT=$(cmd) | 0 | 消える |
export OUT=$(cmd) | 0 | 消える |
cmd | tee run.log | 0 | 消える |
cmd | while read -r l; do :; done | 0 | 消える |
cmd && next | 1 | 伝わる(next は動かない) |
バックグラウンド起動+wait | 1 | 伝わる |
timeout 10 cmd | 1 | 伝わる |
cmd | head -1 の後に ${PIPESTATUS[0]} | 0(配列は 1) | 読めば伝わる |
16通りのうち7つで、失敗が消えました。しかも消えた7つは、どれも「よくやる書き方」です。ログを残したいから tee を挟む、値を関数の中で受けたいから local を付ける、出力を1行ずつ処理したいから while read に流す。悪い書き方をした覚えがないのに、結果だけが静かに 0 になっています。
私のスクリプトで該当していたのは tee と local の2つでした。
消える7つは、3つの原因に集約できます
表を眺めていると、消える条件には共通点があることが分かります。
原因1: パイプの右端が結果を上書きする
| head、| tail、| tee、| while read — いずれもパイプの右側が最後のコマンドになります。シェルが報告するのはその右側の終了コードです。左が落ちたことは、${PIPESTATUS[0]} を明示的に読まないかぎり残りません。
原因2: 変数宣言そのものが「成功したコマンド」になる
local、declare、export は、それ自体がひとつのコマンドです。local OUT=$(cmd) と書くと、シェルは「local の実行に成功した」と判断します。右辺の $(cmd) が何番で落ちたかは、local の成功で上書きされます。
これはトップレベルの OUT=$(cmd) とは挙動が違います。実測でも、トップレベルは 1 を返し、関数内の local は 0 を返しました。同じ形に見えて結果が逆になるので、私はここでいちばん長く勘違いしていました。
原因3: set -e はパイプの途中を見ていない
set -e を書いてあるから大丈夫だと、私自身がずいぶん長く思い込んでいた箇所がありました。実際に試すと、set -e のスクリプトの中で cmd | tail -1 と書いた行は素通りし、後続の処理まで到達したうえでスクリプト全体が 0 で終わりました。set -e が反応するのは、あくまでシェルが受け取った終了コードです。パイプで 0 に化けた後では、止まる理由がありません。
実際に直した書き方と、その効果
原因ごとに直し方を試して、同じ検証スクリプトで測り直しました。
| 直す前 | 直した後 | 結果 |
|---|---|---|
cmd | head -1 | 先頭に set -o pipefail | 0 → 1 |
local OUT=$(cmd) | local OUT と OUT=$(cmd) の2行に分ける | 0 → 1 |
cmd | tee run.log | cmd > >(tee run.log) | 0 → 1 |
cmd | while read ... | set -o pipefail と併用 | 0 → 1 |
set -e のみ | set -euo pipefail | 0 → 1(後続に到達しなくなる) |
| パイプで整形 | いったんファイルに落として RC=$? | 1(出力も1行保持) |
local の2行分けは、見た目の変化が小さいわりに効きます。
# 失敗が消える
check_agent() {
local OUT=$(antigravity-cli run --headless "$1")
return $?
}
# 失敗が伝わる
check_agent() {
local OUT
OUT=$(antigravity-cli run --headless "$1")
return $?
}宣言と代入を分けると、$? が見るのは代入の結果になります。実測でも 0 から 1 に変わりました。
tee をプロセス置換に変える書き方も、ログを捨てずに済むので気に入っています。
antigravity-cli run --headless "$TASK" > >(tee run.log) 2>&1
echo "exit=$?" # 1 が返るようになりますひとつ注意があります。set -o pipefail も > >(...) も bash の機能で、sh(dash)では使えません。手元で sh -c 'set -o pipefail' を実行すると Illegal option -o pipefail で拒否されました。CI の実行シェルが /bin/sh になっている場合は、シバンを #!/bin/bash にするか、中間ファイル方式に寄せるほうが確実です。
更新の前に、1分でできる棚卸し
CLI を上げる前に、呼び出し側を先に点検しておくと、更新後のログを素直に信じられるようになります。手元では次の2行で足りました。
grep -rn 'antigravity[^|]*|' ./scripts | grep -v pipefail
grep -rn 'local .*=\$(\|declare .*=\$(\|export .*=\$(' ./scripts1行目がパイプで受けている箇所、2行目が宣言と代入を同時に書いている箇所です。どちらもヒットがゼロなら、CLI 1.1.14 の非ゼロ終了はそのまま自動化まで届きます。ヒットしたら、上の表の対応を当てるだけです。
私はこの点検を、CLI を上げる作業そのものより先に置くようにしました。監視の側が壊れたまま監視対象を直しても、直ったかどうかを確かめる手段が残らないからです。同じ「無言の失敗」に別の形で出会った経験は、AIエージェントの git push が成功表示なのにリモートへ反映されないときの原因と対処 にも書きました。CLI そのものの検証を先にしたい場合は、MCP サーバーが1本も起動しない原因は、起動前の 40 行で特定できます が近い話です。
次に自動化スクリプトを開くときは、grep の1行目だけでも走らせてみてください。ヒットが1件もなければ、それはそれで安心材料になります。
最後までお読みいただき、ありがとうございました。