macOSlaunchdAutomationAI AgentDevOpsCLI

Running AI agents on a schedule: three pitfalls, starting with "exit 0 does not mean success"

·4 min read

Putting an AI agent on a schedule is the obvious next step: organize data every morning, generate the report, run the digest — wake up and read the results.

I did this on macOS with launchd. The script passed every manual run in the terminal, a hundred out of a hundred. On its first scheduled night, it died.

Worse: I only found out the next morning — because it died quietly.

Pitfall one: the scheduled environment cannot touch directories you assume it can

macOS has a privacy layer called TCC: directories like ~/Documents, ~/Desktop, and ~/Downloads require per-app user consent to read. You never notice in a terminal because your terminal app was granted access long ago.

A process launched directly by launchd inherits no such grant. When it reads a project directory under ~/Documents, it gets EPERM — or the more confusing behavior: it simply hangs.

For AI agents this is especially lethal, because headless agent CLIs are working-directory-centric. Put the working directory under a protected path and the entire agent is soaking in permission errors.

EnvironmentReading ~/DocumentsWhy
Manual, in a terminalWorksThe terminal app holds a TCC grant; child processes inherit it
Scheduled, under launchdEPERM or hangA launchd process has no grant to inherit
The same script, two environments

The fix is not to grant launchd access (possible, but brittle — grants bind to a binary's hash and silently vanish when the tool updates). The fix is to route around the problem entirely:

Scheduled jobs put their working directory and every file they touch under a neutral path such as ~/.local/share/<job-name>/. If a result genuinely needs to appear in Documents, symlink it there — interactive use through a symlink is completely transparent.

Related: the scheduled environment does not load your shell profile either. Your PATH does not contain your tools. Export it explicitly inside the script; never assume.

Pitfall two: non-TTY stdin makes CLIs wait forever

The second pitfall has the strangest symptom: the agent CLI starts under the schedule and then does nothing. No error, no output, no exit — not even a session file.

The cause is stdin. Interactively, stdin is your keyboard. Under a scheduler, stdin is — what, exactly? If you did not specify, many CLIs will try to read input from it and then wait forever for an EOF that never comes.

bash
# Wrong: the CLI waits on stdin for EOF, blocking forever
codex exec "organize today's data" > log 2>&1

# Right: hand it a stdin that EOFs immediately
codex exec "organize today's data" < /dev/null > log 2>&1

The general rule: any CLI may change behavior without a TTY — reading stdin, disabling color, demanding interactive confirmation. Before running anything in the background, decide what its stdin is; when unsure, give it /dev/null.

Same for output: write to a file and parse afterwards rather than piping — SIGPIPE from a downstream exit is a whole other nest of problems.

Pitfall three: exit codes lie

The third pitfall is the most expensive, because its failure mode is "everything looks fine".

My job script originally judged success by exit code: tool returns 0, mark it green. Until I noticed several consecutive days of "success" during which the output file had not gained a single character — the headless agent CLI had hit a permission error, printed EPERM into its output, and returned exit 0.

For ordinary programs the exit code is a reliable contract. For AI agents — tools that swallow errors internally and keep trying — it often means only "the process ended normally", not "the task succeeded".

So every scheduled job now carries a four-piece kit, built on one principle: when it breaks, it must be impossible to miss.

#PracticeDefends against
1Verify the result itself: check the output file for today's date and expected content — never the exit codeexit 0 with actual failure
2Watchdog: cap the child process's runtime, kill on timeout (macOS has no timeout command; poll with kill -0)Hangs that never end and never report
3Failure notifies actively: system notification + status file + a loud FAILED in the log + nonzero exitFailing quietly for days
4End-to-end verification through a real scheduled trigger, not just manual runsEvery environment difference between manual and scheduled
The fail-visibly four-piece kit

Point four deserves expansion, because it is exactly what the first two pitfalls teach: passing manually proves nothing about the schedule. On macOS you can force one genuine scheduled execution:

bash
launchctl kickstart gui/$(id -u)/<your-job-label>

The environment that command produces is the one your job actually faces every night. One end-to-end pass through it before going live is worth more than ten manual runs.

The diagnostic habit: suspect the environment before the logic

The three pitfalls compress into one sentence: if it passes interactively and fails on the schedule, suspect environment differences first — TCC, PATH, stdin, keychain, HOME — not your program logic.

The effective isolation move is a disposable probe job that tests minimal actions inside the real scheduling context: can it read that directory, can the tool execute at all. Bisect from there — far faster than staring at your own code and guessing.

The hard part of unattended automation was never the automation. It is accepting one fact: that environment is not your shell. Permissions, input, and failure reporting all have to be rethought under its rules.

Sources