July 17, 2026 · Italo Campilii

My Agents Died Silently for Days: A launchd PATH Postmortem

TL;DR

If a launchd or cron script fails silently, check PATH first. launchd runs jobs with a minimal environment — no shell profile, no aliases — so any binary that lives in ~/.local/bin (like claude) resolves to "command not found" even though it works perfectly in your terminal. My nightly blog agent died this way for days while launchctl list showed it armed the whole time. The fixes: absolute binary paths (or PATH set explicitly in the script), no GNU-isms like timeout on macOS, and always unload/reload the plist and then run the script manually end-to-end. A job listed as loaded proves nothing; only a real artifact — a published URL, a real file — proves the automation is alive.

On July 14, 2026, I found a corpse in my agent fleet.

The job was com.acromatico.atlas-daily-blog — a launchd-scheduled agent that fires at 3:00 a.m. and publishes a blog post while I sleep. It's one node in the fleet I've written about in Scheduled AI Agents That Work While You Sleep. By every signal I had, it was running. launchctl list showed it loaded. The log file showed fresh timestamps every morning. The plist was correct. The Mac was on.

It had not published a single post in days.

Every night it fired at 03:00, wrote its log header, reached line 75 of the script, and died. No error notification. No crash report. No failed-run alert, because I hadn't built one. Just a log file that opened with a cheerful header and then stopped mid-thought, night after night, like a diary of someone who keeps falling asleep at the same sentence.

This is the postmortem. It's about one specific bug — launchd's minimal PATH — but it's really about a category of failure that I now think is the default state of most "24/7" automation: the system that is scheduled, loaded, listed, and completely dead.

The bug: launchd doesn't run your shell

Here's the exact failure, so you can pattern-match it against your own machine.

The script at the heart of the job shells out to claude — the Claude Code CLI. On my Mac, that binary lives at ~/.local/bin/claude. In an interactive terminal, claude resolves two ways: my shell profile puts ~/.local/bin on PATH, and I also have a shell alias for claude. Type it, and it works. Every time. Which is exactly why the bug was invisible: every manual test I had ever run happened in a terminal, where the command could not fail to resolve.

launchd runs none of that. When launchd executes a job, there is no login shell, no .zshrc, no aliases, and a stripped-down PATH. The PATH I had set in the script — /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin, the standard "safe cron PATH" you'll find in a thousand tutorials — looks responsible. It contains Homebrew. It contains the system paths. It does not contain ~/.local/bin.

So at line 75, the script ran claude, the shell said command not found, and the script exited. The log header above line 75 had already been written, which is what made the log look alive. The job wasn't crashing. It was completing — a very short, very useless run.

The search phrase that eventually describes this problem — launchd script fails silently cron — is its own diagnosis. launchd jobs and cron jobs fail silently for the same root cause: the execution environment is not your terminal. Anything that works because of your shell configuration — PATH additions, aliases, environment variables, nvm/pyenv shims — evaporates under the scheduler. The script isn't broken. The room it runs in is different.

How I actually found it

Not through monitoring, which is the embarrassing part. I found it because I went looking at the output side instead of the process side: the posts weren't there. The artifact was missing, and the artifact doesn't lie.

Once I read the log carefully instead of glancing at its timestamps, the shape was obvious — header, then silence, at the same line every night. Running the script manually through launchd's eyes (with a stripped environment, not from my cozy terminal) reproduced it instantly: claude: command not found.

I also verified the model ID the script was calling was still live, because a retired model produces an almost identical silent-death signature. It was fine. PATH was the only bug. One line, days of dead automation.

Three traps, one signature

While fixing this, I was building another scheduled pipeline the same day, and I hit two more macOS-specific traps that produce the same symptom — a job that looks armed and does nothing. They belong in the same postmortem because if you fix the PATH and stop there, the next one gets you.

TrapWhat happensThe fix
launchd's minimal PATH omits ~/.local/binclaude: command not found; script exits after the log header; launchctl list still shows the job loadedPut /Users/you/.local/bin first in the PATH the script sets, or call the binary by absolute path. Never rely on shell profiles or aliases.
timeout doesn't exist on macOStimeout 900 ./x.sh exits 127 — that command is GNU coreutils (gtimeout), and stock macOS doesn't ship itDrop the GNU-ism, or install coreutils and call gtimeout by its real name. Audit scripts for every Linux-ism they smuggled in.
launchd caches the loaded plistYou edit the plist, the fix "doesn't work" — because launchd is still running the old registrationlaunchctl unload then launchctl load after every edit, then verify what's actually registered: launchctl print gui/$(id -u)/<label> | grep PATH

The third one deserves a beat, because it compounds the first two. Suppose you diagnose the PATH bug correctly, edit the plist, and go to bed satisfied. launchd does not re-read a loaded plist. Tomorrow's 3 a.m. run uses the old, broken registration, and your correct fix silently doesn't apply. You now have a bug you already fixed still killing your job, which is a spectacular way to start doubting a diagnosis that was right.

launchctl print is the antidote: it shows you what launchd actually has registered, not what's on disk. If those two disagree, you haven't deployed your fix yet, whatever the file says.

The deeper failure: I trusted the schedule as proof of the system

The one-line bug is not the interesting part. The interesting part is that for days, I believed a dead system was alive, and every signal I checked confirmed my belief.

launchctl list showed the job: confirmed. Log timestamps were fresh: confirmed. The plist looked correct: confirmed. All three of those signals measure the same thing — the scheduler fired — and none of them measure the thing I actually cared about, which is the work got done. I had built an automation and instrumented the trigger instead of the outcome.

This is the exact gap I later wrote up in Agents Went to Production. Nobody Built the Verification Layer: the difference between a demo and production is that a demo has a human watching, and production doesn't. A scheduled job is production by definition — the entire point is that nobody's watching at 3 a.m. Which means the verification has to be structural, or it doesn't exist.

The doctrine that came out of this postmortem is now a hard rule across every fleet I run, including the five-brand operation this job belongs to:

"24/7" means actually scheduled AND verified armed. A script that exists is not an automation. A job that's loaded is not a working job. Nothing gets called armed until the script has been run manually end-to-end, in conditions matching the scheduler's environment, and the real artifact — a published URL, a file that exists, a row that landed — has been confirmed with my own eyes.

The second half is the part I'd been skipping. "Scheduled" I had. "Verified armed" I had substituted with "listed in launchctl," which, as this incident proved, verifies nothing but the scheduler's own bookkeeping.

The checklist I run now before calling any macOS automation live

Every launchd job in my fleet now has to pass this before I'm allowed to say it's armed:

  1. Absolute paths for every binary the script calls — or an explicit PATH set at the top of the script that includes every nonstandard location, ~/.local/bin first. Aliases don't exist here. Shell profiles don't exist here.
  2. No GNU-isms. Grep the script for timeout, GNU flags, and anything else that only exists on Linux or via coreutils. macOS is BSD-flavored; it will exit 127 without ceremony.
  3. Unload and reload after every plist edit, then launchctl print to confirm the registered job matches the file on disk.
  4. One real end-to-end proof run — executed the way launchd will execute it, not from my terminal — ending in me looking at the actual artifact. Not the log. Not the exit code. The post, the file, the URL.
  5. The failure mode gets a tripwire. The cheapest version is a check on the output: if the expected artifact doesn't exist by 15 minutes after the scheduled run, something screams. Instrument the outcome, not the trigger.

None of this is sophisticated. That's the point. The bug was one line, the fix was one line, and the days of dead automation came entirely from the gap between "I set it up" and "I proved it works." That gap doesn't close with better tools. It closes with a rule you refuse to break.

Why I tell this story in interviews

One more angle, since this site is partly about how I work. When I talk to teams about agent operations, I tell them to ask candidates for exactly this kind of story — a specific automation failure, discovered how, root-caused to what, and what process rule exists now because of it.

The people who have actually run agents in production all have one. It's usually humbling, it's usually a one-line bug, and it always ends with a doctrine. The people who've only demoed agents have stories about capabilities. If you're hiring for this work, that's the screen: not "have your agents ever failed," because everyone's have, but "show me the process fix your failure produced." A candidate whose fleet has never silently died hasn't run a fleet long enough to be trusted with yours.

Mine died at line 75 for days, and the rule it left behind — no automation is armed until the artifact is verified — has caught problems since that would each have cost me more than the original incident did.

FAQ

Why do launchd scripts fail silently when they work in the terminal?

Because launchd doesn't run your shell. There's no login profile, no aliases, and a minimal PATH — so binaries in user-level locations like ~/.local/bin resolve fine interactively and fail with "command not found" under the scheduler. The script exits early, launchd considers the run complete, and nothing reports an error unless you built the reporting yourself.

Does launchctl list showing my job mean it's working?

No. launchctl list proves the job is registered with launchd — the scheduler's bookkeeping — not that the script succeeds. My job appeared in launchctl list every day it was dead. The only proof of a working automation is the artifact it's supposed to produce: run the script end-to-end manually and confirm the real output exists.

Why doesn't the timeout command work on macOS?

timeout is GNU coreutils, and stock macOS doesn't include it — the call exits with code 127 (command not found). Either remove it or install coreutils, where it's available as gtimeout. Under a scheduler, that 127 is one more way a job dies with no visible error.

I fixed my plist but the job still fails. Why?

launchd caches the plist at load time and does not re-read edits. Until you launchctl unload and launchctl load the job, the old registration keeps running — so your fix exists on disk and nowhere else. Verify with launchctl print gui/$(id -u)/<label> that what launchd has registered matches your file.

What's the fastest way to debug a silent launchd or cron failure?

Reproduce the scheduler's environment instead of testing in your terminal: run the script with a stripped environment (env -i PATH=/usr/bin:/bin sh yourscript.sh is a rough approximation) and watch it fail the way launchd sees it. In my experience it's almost always PATH, a missing environment variable, or a tool that only exists in your interactive setup.

Related

This postmortem is the kind of operating discipline I bring to teams running agents in production. If you're hiring someone to build — and actually verify — an agent fleet, get in touch.

— Italo Campilii