When I first thought about putting agy -p on a nightly schedule as an indie developer, one question came before everything else: what happens if it is still running when morning comes?
The docs help here. Headless runs have --print-timeout, which defaults to 5 minutes, and a run without cached credentials exits with an authentication error instead of hanging. What I could not find is any mention of a limit on the number of turns.
I would rather not operate on a guarantee that isn't written down. So I decided to keep part of the stopping logic outside the CLI, in the wrapper that calls it.
Three stop conditions that live outside
This is not distrust of the CLI. Behavior changes between versions, and an outer frame stays put while that happens. I added three things:
- Time: a hard
timeout, independent of the CLI's own wait - Output size: cut standard output at a byte limit so a runaway run can't fill the disk
- Overlap: if the previous run is still alive, don't start another
Plus one log line per run, so the morning read is a single glance at one file.
The whole wrapper
#!/usr/bin/env bash
# 夜間に agy を headless で回すときの外側の枠。
# 使い方: agy_nightly.sh "プロンプト"
set -u
PROMPT="${1:?prompt required}"
LOG_DIR="${LOG_DIR:-$HOME/agy-nightly-logs}"
HARD_LIMIT="${HARD_LIMIT:-20m}" # 時間: CLI の待ち時間とは別に、外側で必ず打ち切る
MAX_BYTES="${MAX_BYTES:-200000}" # 出力: これを超えたら以降は捨てる
LOCK="$LOG_DIR/.lock"
mkdir -p "$LOG_DIR"
STAMP="$(date +%Y%m%d-%H%M%S)"
OUT="$LOG_DIR/$STAMP.out"
META="$LOG_DIR/$STAMP.meta"
# 重なり: 前回が残っていたら今回は始めない
exec 9>"$LOCK"
if ! flock -n 9; then
echo "$STAMP skipped: previous run still holds the lock" >> "$LOG_DIR/summary.log"
exit 75
fi
START=$(date +%s)
# timeout は TERM を送り、10 秒待っても残れば KILL する
timeout --kill-after=10s "$HARD_LIMIT" \
${AGY_BIN:-agy} -p "$PROMPT" --output-format json 2>"$OUT.err" \
| head -c "$MAX_BYTES" > "$OUT"
CODE=${PIPESTATUS[0]}
END=$(date +%s)
BYTES=$(wc -c < "$OUT")
LABEL="ok"
case "$CODE" in
0) LABEL="ok" ;;
124|137) LABEL="timeout" ;; # 124 = TERM で終了 / 137 = TERM を無視されて KILL
141) LABEL="truncated" ;; # head が先に閉じて SIGPIPE
*) LABEL="error" ;;
esac
printf '%s exit=%s secs=%s bytes=%s label=%s\n' \
"$STAMP" "$CODE" "$((END-START))" "$BYTES" "$LABEL" | tee "$META" >> "$LOG_DIR/summary.log"
exit "$CODE"--output-format json gives one JSON envelope on completion, with fields such as status and error, which makes the log easier to read by machine. AGY_BIN lets me substitute a fake for the real agy while testing; normally it is unset.
I read the exit code from PIPESTATUS[0] so I get timeout's status rather than head's. The failure mode of reading the wrong one is covered in The pipe in my wrapper was swallowing Antigravity CLI's exit code.
Four endings, tried with fake agy scripts
Reproducing a long runaway with the real agy would cost time and quota, so I wrote four tiny scripts that behave differently and plugged them in through AGY_BIN.
| Fake behavior | Result |
|---|---|
| Prints JSON and exits | `exit=0` `label=ok` |
| Sleeps for 300 s (`HARD_LIMIT=2s`) | `exit=124` `secs=2` `label=timeout` |
| Ignores TERM and sleeps (`HARD_LIMIT=2s`) | `exit=137` `secs=12` `label=timeout` |
| Emits 5 MB (`MAX_BYTES=1000`) | `exit=141` `bytes=1000` `label=truncated` |
The third row stopped me. I had assumed a timeout always returns 124 and had written only [ "$CODE" -eq 124 ]. When the process ignores TERM, timeout sends KILL, and the status becomes 137. My first version logged that run as a plain error. I now count both 124 and 137 as a timeout. I don't think I would have written that line without trying it.
Notice also that the run took 12 seconds, not 2. The 10-second grace in --kill-after=10s is added on top, so budget the nightly window as HARD_LIMIT plus that grace.
The 141 in the last row means head finished reading and closed the pipe, so the producer died from SIGPIPE. That is closer to "output was capped" than to a failure, which is why it has its own label.
For overlap, I started a stub that sits there, launched a second run a second later, and the second one exited with 75 and left a skipped line in summary.log.
What this does not tell you
All of this was checked against fake scripts. How the real agy streams output while it works, and which of --print-timeout and HARD_LIMIT fires first, still need a check with the real thing. I started with HARD_LIMIT=1m and a single light request.
One more thing. --dangerously-skip-permissions looks convenient for unattended runs, but an outer frame only guards time, size and overlap. It cannot stop a file from being deleted. Whether to pair it with --sandbox or narrow the permissions first is a decision that stays outside the frame.
One line for the morning
summary.log has one line per run, so the morning check is just filtering out the ok lines.
# Show everything that is not ok (timeout / truncated / error / skipped)
grep -v 'label=ok' ~/agy-nightly-logs/summary.log | tail -n 5
# Show only runs that took longer than 10 minutes (600 s)
awk '{for(i=1;i<=NF;i++) if($i ~ /^secs=/){split($i,a,"="); if(a[2]+0>600) print "SLOW:", $0}}' ~/agy-nightly-logs/summary.logI ran the first one against the log from the four test endings above, and only the three non-ok lines came back (a skipped line has no label=, so it shows up too). The second picks lines whose seconds exceed the threshold. Once you know your run usually finishes in about the same number of seconds each night, a night that suddenly stretches is easy to notice.
The next step
Before tonight's schedule, run one light request with HARD_LIMIT=1m and confirm that summary.log gains exactly one line. Extend the limit only after you can read that line in the morning.
The CLI's job is to stop; knowing that it stopped is mine. I keep to that line even when the settings change between versions.
If version drift is the bigger worry, Keeping Scheduled Runs Reproducible: Pinning the Antigravity CLI Version to Tame Behavior Drift pairs well with this frame.