There is a quiet line in the CLI 1.1.14 release notes. When the language server fails at startup or during a run, the CLI used to exit without printing anything. Now the failure is logged to the terminal and the exit code is non-zero.
In other words, failures that used to pass as successes are finally visible.
I read that and went to look at my own scheduled scripts. As an indie developer I run nightly updates for a handful of sites I maintain alone, and those scripts call CLI tooling from bash. If a non-zero exit was now coming back, my morning logs should have picked up some red.
The next morning the logs were exactly as green as the day before.
The improvement had landed. My wrapper simply was not built to receive it.
The way you call a command decides whether its failure survives
An exit code is fixed the moment a command returns. What the shell finally reports, though, depends on the shape of the line you wrote around it.
A pipe is the clearest case.
antigravity-cli run ... | head -1The exit code of that line belongs to head. No matter what number the left side died with, once head reads one line successfully the result is 0. The error message either went to 2>/dev/null or scrolled past in a log nobody opens.
This is documented bash behaviour, not a bug. But when you are writing an automation wrapper, that behaviour works as an eraser for failures. The wrapper reports a clean run, the scheduler records a success, and the only evidence that anything went wrong sits in a stream you redirected away weeks ago. That gap is easy to live with for months, because nothing ever asks you to look.
I measured sixteen calling patterns instead of guessing
Guessing at the fix seemed like a poor use of a morning, so I measured. I wrote a tiny script that exits with code 1, called it sixteen different ways, and recorded what the shell reported at the end.
The test script is this small:
#!/bin/bash
echo "Starting language server..."
echo "language server exited unexpectedly (code 1)" >&2
exit 1The environment is GNU bash 5.1.16. Here is what came back.
| Calling pattern | Reported exit code | Failure survives? |
|---|---|---|
cmd (direct) | 1 | Yes |
cmd | head -1 | 0 | No |
set -o pipefail with cmd | head -1 | 1 | Yes |
OUT=$(cmd) at top level | 1 | Yes |
RESULT="prefix-$(cmd)" | 1 | Yes |
if cmd; then ... fi | — | Yes (takes the else branch) |
cmd | tail -1 inside a set -e script | 0 | No |
local OUT=$(cmd) inside a function | 0 | No |
declare OUT=$(cmd) inside a function | 0 | No |
export OUT=$(cmd) | 0 | No |
cmd | tee run.log | 0 | No |
cmd | while read -r l; do :; done | 0 | No |
cmd && next | 1 | Yes (next never runs) |
background start plus wait | 1 | Yes |
timeout 10 cmd | 1 | Yes |
cmd | head -1 then read ${PIPESTATUS[0]} | 0 (array holds 1) | Yes, if you read it |
Seven of the sixteen erased the failure. What stings is that all seven are ordinary, sensible-looking lines. You add tee because you want a log. You add local because you are assigning inside a function. You pipe into while read because you want to handle output line by line. Nothing about them looks careless, and yet the result quietly becomes 0.
Two of the seven were live in my own scripts: tee and local. Neither had been touched in months, and both were sitting on the exact path a language server failure would have taken. That is the part I keep coming back to: the wrapper had been silently downgrading real failures the whole time, and the CLI update simply gave me a reason to notice.
The seven come down to three causes
Once the table is in front of you, the pattern is easy to see.
Cause 1: the right end of a pipe overwrites the result
| head, | tail, | tee, | while read — in every case the right side is the last command in the pipeline, and the shell reports its exit code. The left side's failure lives only in ${PIPESTATUS[0]}, and only if you go read it.
Cause 2: a variable declaration is itself a command that succeeds
local, declare, and export are commands in their own right. Write local OUT=$(cmd) and the shell reports that local ran successfully. Whatever the substitution on the right side exited with is overwritten by that success.
This behaves differently from a plain OUT=$(cmd) at the top level. In my run, the top-level form returned 1 while the local form inside a function returned 0. The two lines look nearly identical and behave in opposite ways, which is where I spent the longest being wrong.
Cause 3: set -e never sees the middle of a pipeline
I had assumed a few scripts were covered because they open with set -e. Testing it, a cmd | tail -1 line inside a set -e script sailed straight through, the following statements ran, and the script finished with 0. set -e reacts to the exit code the shell received. Once the pipe has turned that into 0, there is nothing left to react to.
What I changed, and what the change measured
I applied a fix per cause and re-ran the same test script against each one.
| Before | After | Result |
|---|---|---|
cmd | head -1 | add set -o pipefail at the top | 0 → 1 |
local OUT=$(cmd) | split into local OUT then OUT=$(cmd) | 0 → 1 |
cmd | tee run.log | cmd > >(tee run.log) | 0 → 1 |
cmd | while read ... | combine with set -o pipefail | 0 → 1 |
set -e alone | set -euo pipefail | 0 → 1 (later lines stop running) |
| piping for formatting | write to a file first, then RC=$? | 1 (output still kept) |
The two-line split for local is the smallest edit with the largest payoff.
# failure disappears
check_agent() {
local OUT=$(antigravity-cli run --headless "$1")
return $?
}
# failure survives
check_agent() {
local OUT
OUT=$(antigravity-cli run --headless "$1")
return $?
}Separating the declaration from the assignment means $? now reflects the assignment. Measured, it moved from 0 to 1.
Swapping tee for a process substitution is my favourite of the set, because you keep the log and keep the code.
antigravity-cli run --headless "$TASK" > >(tee run.log) 2>&1
echo "exit=$?" # now returns 1One caveat worth knowing. Both set -o pipefail and > >(...) are bash features and are not available in sh (dash). Running sh -c 'set -o pipefail' on my machine returned Illegal option -o pipefail. If your CI runs scripts under /bin/sh, either pin the shebang to #!/bin/bash or fall back to the write-to-a-file approach.
A one-minute audit before you upgrade
Checking the calling side before you bump the CLI means you can actually trust the logs afterwards. Two lines were enough for me.
grep -rn 'antigravity[^|]*|' ./scripts | grep -v pipefail
grep -rn 'local .*=\$(\|declare .*=\$(\|export .*=\$(' ./scriptsThe first finds places where output is received through a pipe. The second finds declarations that assign on the same line. If both come back empty, the non-zero exit from CLI 1.1.14 will reach your automation untouched. If they hit, apply the matching row from the table above.
I have moved this audit ahead of the upgrade itself. Fixing what you are monitoring while the monitor is broken leaves you with no way to confirm the fix. I ran into the same category of silent failure from a different direction in When an AI Agent's git push Reports Success but Nothing Reaches the Remote. If you would rather validate the CLI side first, Find the one broken MCP entry before Antigravity starts, with 40 lines of Node covers adjacent ground.
Next time you open an automation script, try running just the first grep. Zero hits is a perfectly good result to get.