Exit codes for unattended agents: retry vs stay-dead
An unattended agent that just exits non-zero on failure is giving whatever runs it
next — cron, systemd, a CI retry policy — nothing to decide with. The fix isn't a bigger error
message; it's picking a small, consistent set of exit codes and having every script in the fleet
agree on what they mean. This is the general version of that convention — see
running claude -p on cron for the same ideas applied
specifically to Claude Code's own headless mode, including MCP_TOOL_TIMEOUT and
wake-up patterns this guide doesn't cover.
On this page
Where 75 comes from: sysexits.h
BSD's sysexits.h defines a small table of exit codes for command-line programs,
meant to be more informative than a bare 0/1. Most of it predates agents entirely, but two codes
map cleanly onto what an unattended run needs to say about its own failure:
| Code | Name | Meaning |
|---|---|---|
0 | — | Success. |
75 | EX_TEMPFAIL | Temporary failure — the caller should retry later. |
69 | EX_UNAVAILABLE | A required service was unavailable. |
78 | EX_CONFIG | Something in the configuration is wrong. |
1 | — | Generic failure (no standardized meaning beyond "not success"). |
Nothing in the OS or the shell enforces these meanings — sysexits.h is a
convention, not a contract the kernel checks. That's exactly why it's worth adopting deliberately
for your own scripts rather than reinventing a different scheme per project: your on-call rotation
only has to learn it once.
The 75/1 convention for agent scripts
For an agent invoked non-interactively, the distinction that actually matters is narrower than
the full sysexits.h table: exit 75 for "this might resolve itself, retry the
next tick," and exit 1 for "this needs a human, don't retry
automatically." A permission-prompt tool or approval flow is a natural producer of
exactly that split — "nobody answered in time" is temporary (retry), "someone explicitly said no"
is not (stop):
claude -p "Deploy to prod. If a tool permission is denied and the reason is \"timeout\", \
exit with code 75 (EX_TEMPFAIL — retry next tick). If the reason is \"operator_denied\", exit with \
code 1 (a human said no; don't retry)." \
--permission-prompt-tool mcp__yourservice__permission_prompt
Be honest about what this buys you when the exit code is coming from a model following a
prompt instruction rather than code you wrote: it depends on the model actually calling the right
exit code, not on anything the runtime enforces. Test both branches deliberately — force a deny,
force a timeout — before trusting the split in production, and prefer wrapping the model's own
process in a script that derives the code from a structured result (JSON output, a file it wrote)
wherever you can, rather than trusting an LLM's raw exit call as the only signal.
A wrapper that acts on it
The exit code is only useful if something downstream branches on it. A thin wrapper is enough:
#!/usr/bin/env bash
set -uo pipefail
./run-agent.sh "$@"
code=$?
case $code in
0) echo "ok" ;;
75) echo "temp failure — will retry next tick, no page" ;;
*) echo "hard failure ($code) — paging on-call"
/usr/local/bin/page-oncall.sh "agent failed with exit $code" ;;
esac
exit "$code"
Note set -uo pipefail without -e: this wrapper deliberately keeps
running after run-agent.sh fails so it can inspect $? and act on it —
set -e would abort the script at the first non-zero exit, before the
case statement ever runs.
flock: locking against overlapping runs
A job that can block — a slow API, a permission prompt, a deferred tool call waiting on a
human — can still be "running" when its next scheduled tick fires, and without a lock, the
scheduler has no way to know that. flock -n against a fixed lock file makes the
second invocation exit immediately instead of stacking up behind the first:
flock -n /var/run/agent-job.lock ./run-agent.sh || echo "skipped: lock held"
Two details worth getting right:
-n(non-blocking) is almost always what you want for a scheduled job — "skip this tick" beats "queue up and run twice as long once the first finally finishes." Drop it (or use-w <seconds>for a bounded wait instead) only if a queued run is genuinely safer than a skipped one for that particular job.- Put the lock file somewhere that does not survive a reboot unless you specifically
want a stale lock from a killed process to persist across one —
/var/runor/tmp, not a path under version control or a shared network mount that multiple hosts might resolve to the same file for jobs that were never meant to share a lock.
Cron hygiene checklist
Most cron incidents trace back to one of a handful of repeat offenders, not to the exit-code logic above:
PATHis minimal under cron — it is not your interactive shell'sPATH. A script that works fine from a terminal and mysteriously can't findnodeorclaudeunder cron is almost always this. SetPATHexplicitly at the top of the crontab or inside the script itself.MAILTO=""unless you actually read the mailbox cron sends stdout/stderr to by default — an unread mail spool silently accumulating output is not a logging strategy, and a script that's noisy on purpose (progress lines, debug output) will otherwise flood a mailbox nobody checks. Redirect explicitly to a log file instead.- Cron runs in the system timezone, not necessarily the one you were thinking
in when you wrote the schedule — a job meant for "3am local" can quietly run at the wrong wall-clock
hour on a server provisioned in UTC. Set
CRON_TZper job if your cron implementation supports it, or convert the hour yourself and leave a comment saying which timezone it's in. - Environment variables from your login shell don't carry over. Cron invokes a
minimal, non-login shell — anything set in
.bashrc/.profilehas to be sourced explicitly (. /etc/agent/secrets.env && ...) rather than assumed present. - Rotate logs. A job that runs nightly for a year with no rotation is a
surprisingly effective way to fill a disk; a plain
logrotateconfig against the log path is enough. - Add an outer timeout. Cron itself never kills a hung job —
timeout 1800 flock -n /var/run/agent-job.lock ./run-agent.shgives a hard ceiling independent of whatever timeouts the agent's own runtime does or doesn't enforce internally.
A systemd timer alternative
On a host that already runs systemd, a timer unit gets you overlap protection, timeouts, and
structured logging without flock or manual log rotation, at the cost of more
boilerplate up front:
# /etc/systemd/system/agent-job.service
[Unit]
Description=Unattended agent job
[Service]
Type=oneshot
ExecStart=/opt/agent/run-agent.sh
TimeoutStartSec=1800
Restart=no
# /etc/systemd/system/agent-job.timer
[Unit]
Description=Run agent-job nightly
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
A service unit's own instance can't run twice concurrently by default — systemd refuses to
start a second instance of an already-running unit — which is the overlap protection
flock exists to bolt onto cron. journalctl -u agent-job.service replaces
the log-file-and-rotation setup entirely, and the exit-code convention from earlier still applies
unchanged: have the systemd unit's ExecStart be the same wrapper script that branches
on 75 vs. everything else, and point OnFailure= at a second unit that pages on-call
only for the non-75 case.