I opened the log from an overnight run first thing in the morning. The last line said the job had succeeded. The artifact it was supposed to have written, though, stopped mid-paragraph.
The exit code was 0. My script looked at that 0 and nothing else, and recorded a success.
I went hunting through settings, then through the prompt, and finally back to the release notes. That is where the answer was. Antigravity CLI 1.1.28 (September 9) changed an expired --print-timeout from a failure into a successful exit that returns whatever output it has.
The current release as of September 12 is 1.2.2, and the behavior carries straight into the 1.2 line. If you are coming from 1.1.27 or earlier, you inherit all of the changes below at once.
What 1.1.28 really changed was how failure gets reported
Four changes matter directly to unattended runs.
| Change | 1.1.27 and earlier | 1.1.28 onward |
|---|---|---|
| --print-timeout expiry | Fails with a timeout error | Returns partial output and exits successfully with a warning on stderr (interrupts such as Ctrl+C still exit non-zero) |
| Fetching an external URL | Always allowed by default | Asks for approval first unless you granted access beforehand |
| Fatal errors | Could end silently with no output | Appear on stderr with a stable error: marker, plus a note when the response may be truncated |
| When -p exits | — | Exits promptly after the final answer. Waits for running background tasks and timers within --print-timeout, and leaves daemons such as dev servers running |
The first two rows look like they point in opposite directions. One turns a failure into a success; the other makes a previously silent failure speak up.
Put side by side, though, they read as one intention. Information that used to ride on a single bit — the exit code — moved to a channel with room in it. Zero and one cannot express "it got partway there."
I did not welcome this at first. Judging everything by the exit code kept my scripts short. I have changed my mind since. They were not short because the problem was simple. They were short because I had been rounding something unrepresentable down to 0.
Receiving a print-timeout overrun properly
The rewrite comes down to one idea: stop treating the outcome as a binary and give it a third state, partial.
#!/usr/bin/env bash
set -uo pipefail
OUT="$(mktemp)"; ERR="$(mktemp)"
trap 'rm -f "$OUT" "$ERR"' EXIT
agy -p "$PROMPT" --print-timeout 600 >"$OUT" 2>"$ERR" </dev/null
code=$?
if [ "$code" -ne 0 ]; then
status=failed # interrupts and startup failures land here
elif grep -q '^error:' "$ERR"; then
status=failed # exit 0 still means failure if the marker is there
elif [ -s "$ERR" ]; then
status=partial # a warning means the turn was probably cut short
else
status=ok
fi
printf '%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "$status" "$(wc -c <"$OUT")" >> run.tsvI match on ^error: because that marker is the one string documented as stable. The wording of the timeout warning itself can move between releases, so I test for "stderr is not empty" rather than for any particular phrase. A clever regular expression here would quietly stop matching one release from now.
One more thing worth saying plainly: even this does not tell you whether the artifact is complete. An empty stderr is compatible with a model that simply wrapped up early.
So I started recording the byte count on every run and reviewing the days that fall well off the running median. Rather than making the check smarter, I would rather leave the outliers findable after the fact. It has meant less rework.
An exit code answers whether the run finished. Whether the work finished is a question for the artifact. I keep that order even on days when I am in a hurry.
A different layer of the same trouble is the caller swallowing the exit code before you ever see it, which I wrote about in the pipe in my wrapper was swallowing Antigravity CLI's exit code. If set -o pipefail is missing from your environment, start there instead.
Fetching a URL now waits for approval
Of the four, this is the one that fails most quietly. The default permission for fetching URLs moved from always-allowed to ask-first.
Interactively that is a prompt and nothing more. 1.1.28 also made the prompts specific — Run this command?, Allow access to this URL?, Allow calling this tool? — and adds a Reason: line when the cause is not obvious, such as a hook flagging the action or a file belonging to another project.
Unattended is where it bites. Ask for approval where no one is watching, and the run sits there.
I understand the value of a pipeline that stops where it should stop. But that value holds only when you know what the stopping conditions are. If you do not, it is not a safety mechanism; it is just a run that never moves.
There are two ways out.
The first is to grant access ahead of time in your permission rules, which live in the CLI, shared, and project configuration files. Confirm the key name against the documentation for your own release. I have written a plausible-looking key before and had it do nothing at all, without a single warning. Reactions to a wrong key are not uniform: some tools fail loudly, some warn, and some stay completely silent.
Which is why I verify by behavior rather than by reading my own config back.
# check whether the grant actually took effect, without waiting around
timeout 90 agy -p "Return the h1 of https://example.com in one line." \
--print-timeout 60 </dev/null 2>err.txt
echo "exit=$?"; cat err.txtClose stdin, keep --print-timeout short, and wrap the whole thing in an outer timeout. If the grant is live you get a result; if it is not, the run is cut at 60 seconds and leaves a warning on stderr. Either way, nothing hangs.
The second way is to stop letting the agent fetch anything. In an unattended job, reading remote content means your output changes whenever the remote page does. On my scheduled side I do the fetching in a separate step and hand the CLI nothing but local files.
Exit timing, and the daemons left behind
One more change trips people up in CI. In 1.1.28, -p exits promptly once the final answer is delivered. It waits for running background tasks and scheduled timers within --print-timeout, but leaves daemons such as dev servers running.
So you can end up with a CLI that exited cleanly and a job that never finishes, because a process the CLI started is still holding a port.
If any step has the agent bring up a dev server, own the cleanup on the caller's side: tear down the whole process group, or pin the port and release it at the end. The same release also fixed non-interactive runs stalling forever on an implementation-plan approval nobody could give, so if "it sometimes never comes back" was your symptom on 1.1.27, upgrading comes before any workaround.
A smaller change worth knowing: when a model name you specify resolves to a different model — alias resolution, an --effort variant, or a deprecated model being replaced — the CLI log now records it. There is finally a way to check the suspicion that you are not running on the model you asked for.
The order I would check things in
Here is the sequence I actually went through, unedited.
| # | Do this | Look at |
|---|---|---|
| 1 | Before upgrading, reread whether your verdict rests on the exit code alone | Whether if [ $? -ne 0 ] is the only branch in the wrapper |
| 2 | Confirm you are not throwing stderr away | 2>/dev/null or 2>&1 hiding or merging the marker |
| 3 | Verify the URL grant by behavior | Whether a short run with stdin closed returns without waiting |
| 4 | After upgrading, run it once while someone is watching | How often partial shows up, and what the artifact looks like then |
| 5 | Start recording artifact size on every run | Days that sit far off the median of the last few |
Step 4 is the one I would ask you not to skip. Verify a change to unattended behavior unattended, and what you are left with is the feeling of having verified it.
Open your wrapper and look at one thing first: where stderr goes. In mine it went to 2>/dev/null, which is why the error: marker had never once reached me.