◉GEMINI LABJP
●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…●ARGON — gemini-4-argon is generally available as of 10/13, aimed at complex coding, agents, math and science●DEEP RES — deep-research-pro-preview-12-2025 shuts down 10/23, 6 days left. Move to deep-research-preview-04-2026●NANO — gemini-nano-banana-2.1 is GA (10/06). gemini-3.1-flash-image is deprecated with no shutdown date yet●MCP — One write-up cut an 85,000-token tool result to 310 tokens by wrapping it in an ADK FunctionTool●FLASH — gemini-3.7-flash and 3.5-flash now route automatically to newer Flash models (10/08)●NEW — Stop Re-Reading Every Crash Report: Gre…
Articles/Dev Tools
⟐ Dev Tools/2026-10-17Intermediate

Hung Jobs Fail Quietly — Wrapping a Scheduled Gemini Call With a Timeout, an Empty-Output Check, and a Lock

An outer shell script that protects a cron-driven `gemini -p` call from three quiet failures: hanging on a response, exiting 0 with empty output, and overlapping with the previous run. Includes exit codes and timings measured with stand-in commands, and how far `timeout` reaches into child processes.

gemini-cli8crontimeout5flockautomation55shell

When a scheduled job calls gemini -p "...", the first sign of trouble is rarely an error. It is a morning when nothing arrives. No error message, an empty log, and a process that is still sitting there.

A command waiting on a response will not end on its own. My current habit is to wrap the call in an outer layer that ends it from the outside.

Below is that wrapper, about 40 lines of shell, and what I measured while testing it. I did not use the real Gemini for the measurements. I used stand-in commands that hang, return nothing, or fail, so how often Gemini itself stalls is outside this note. Everything here is what you can see from the outside: exit codes and seconds.

Three things to guard against

SymptomIf left aloneWhat the wrapper does
Hangs waiting for a responseThe process lingers and the next run is blockedStops it with timeout
Exit code 0 but empty outputAn empty file flows downstream as a resultTreats 0 bytes as a failure
The previous run is still goingTwo runs fight over quota and output filesSkips the late starter with flock

All three can happen with any command, not just Gemini, so the command to run is swappable through an environment variable.

The full script

#!/usr/bin/env bash
# gemini-guard.sh — usage: 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}"          # seconds; stop the job after this
GRACE="${GUARD_GRACE:-10}"           # seconds between the stop request and a forced kill
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"
}
 
# If the previous run is still going, give up instead of piling on
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"            # only publish a finished result
    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

A few notes on why it looks this way.

Writing to .tmp and renaming with mv keeps a half-written result away from whatever reads the file next. Within one directory the rename is effectively instant.

The exit codes differ by cause: 124 when timeout ended the job, 137 when it ignored the stop request and was force-killed, 75 when the run was skipped as a late starter. A cron mail or a monitor can tell them apart at a glance.

What the stand-ins showed

I shrank the limit to 3 seconds and the grace period to 2, then ran five stand-ins.

Stand-in behaviorExit codeElapsedOutput fileLog status
Answers immediately00 sCreatedok
Waits forever1243 sNot createdtimeout
Ignores the stop request (TERM)1375 sNot createdkilled
Prints nothing, exits 030 sNot createdempty_output
Exits with an error (1)10 sNot createdfailed

The 5 seconds for the TERM-ignoring case is the 3-second limit plus the 2-second grace. Without --kill-after, that stand-in would have stayed until its 60-second wait ended. Programs that swallow TERM are not rare, so I keep the grace period.

I also checked overlap. I started a hanging stand-in, then a second run one second later. The second exited immediately with 75, and the first ended at 3 seconds with 124. The log shows skipped_locked followed by timeout.

--foreground changes how far the stop reaches

The part I did not expect was what happens to grandchildren. I used a stand-in script that calls sleep 77 internally, stopped it two ways, and counted what remained (GNU coreutils 8.32).

InvocationExit codesleep processes left behind
timeout 2 ./stub1240
timeout --foreground 2 ./stub1241

By default, timeout runs the command in its own process group and stops the whole group when time is up. With --foreground it does not separate the group, so only the direct child is stopped and a grandchild stays alive. Calling setsid inside the command left a process behind for the same reason.

The wrapper does not use --foreground, so that cleanup works even if gemini spawns helpers. There are situations at an interactive terminal where --foreground is needed, so choose by use.

Putting it in cron

# 06:10 daily. If runs overlap, the wrapper skips the late one
10 6 * * * GUARD_LIMIT=180 /home/me/bin/gemini-guard.sh /home/me/jobs/morning.txt /home/me/out/morning.md

One line for the morning check makes it easier.

tail -n 3 ~/gemini-guard.jsonl

If anything other than ok shows up, standard error is saved in out/morning.md.err. I clear the .err files from successful runs in batches when I remember to.

macOS has no timeout by default. Install coreutils with Homebrew and use gtimeout instead. flock is also absent, so swap in another lock, such as a simple mkdir-based one.

Where the wrapper stops helping

The wrapper tells you that a job stopped, not why. Whether the wait was on Gemini, the network, or an oversized prompt has to be inferred from the .err file and the trend in elapsed seconds.

Choosing the limit has no single right answer either. I start at three to four times the usual runtime and tighten it once enough ok entries have piled up. Starting too tight kills normal, long runs.

End what will not end from the outside first, and record that it did not end. Fixing the inside can wait until the log points at a cause.

For related reading, rebuilding a CLI-dependent scheduled job is covered in Keeping Nightly Batches Alive After the Gemini CLI Stops Responding: A google-genai SDK Fallback, and not losing results from fire-and-forget runs is in Fire-and-Forget on a Cron That Never Loses a Result: Reclaiming Gemini Background Executions with a Submission Ledger.

Share

Thank You for Reading

Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Dev Tools2026-09-15
The approval rules I had written never matched once — auditing my Gemini CLI policies
A deny rule I trusted for half a year had never matched. The pattern was tested against a JSON string, the folder I kept my rules in is not read, and ask_user turns into deny the moment nobody is watching. Here is the audit, and the script I wrote to keep doing it.
⟐ Dev Tools2026-10-14
Stop Re-Reading Every Crash Report: Grep a Known-Bug Table First and Keep Only an Index in GEMINI.md
A routine for investigating app crash reports without making the model read every past bug in full: a one-line-per-bug lookup table, what belongs in GEMINI.md, and a grep helper you can paste in.
⟐ Dev Tools2026-09-29
Gemini CLI Kept Saying 'API key not valid' After I Gave It a New Key — It Was Reading an Old One From a Third Place
I switched Gemini CLI from Google login to a paid API key, and the 400 'API key not valid' error would not go away. The key can come from four places — the shell, a project .env, a home .env, and settings.json — and a short script that lights them up in order narrows the cause to one.