Your Unattended AI Agent Looks Healthy and Does Nothing: 7 Silent Failures on macOS launchd (2026)
Mihai Perdum
Author
12 min readJuly 27, 2026
Key takeaways
Every one of the seven bugs produced the SAME symptom: a healthy-looking log and zero work done. Runtime, not exit code, is the honest health signal.
A launchd agent gets a near-empty environment. Without USER the Claude CLI cannot read its login-keychain credential and dies in 1 second with 'Not logged in'.
ProcessType Background confines the job and every child to E-cores. Measured 5.0x slower on an M3 Ultra: 147ms interactive vs 735ms.
launchd never runs two copies of one job, so a 13-minute background task silently ate a scheduled window that came due while it ran.
A watchdog that exits 0 on timeout turns a truncated run into a reported success. Exit 1.
The loop had been running for days. launchctl list showed the job. The log had a fresh line every five minutes, all of them healthy. And it had posted nothing for two days.
That is the whole problem with unattended agents on macOS: the failure modes do not announce themselves. They produce a log that looks exactly like a quiet period. I spent a day pulling seven distinct bugs out of one scheduled Claude agent, and the striking thing was not that there were seven — it was that all seven had the identical outward signature. Healthy log. No work.
This walks through each one with the evidence that found it, and the check that makes it loud instead of silent. The through-line, if you only take one thing: stop trusting exit codes and log volume, start trusting duration and ground truth.
Note
Prerequisites — macOS (tested on macOS 26 / M3 Ultra), a LaunchAgent you control, and any CLI-driven agent. I use Claude Code, but six of the seven are tool-agnostic.
Why did the job never run at all?
Start with the dumbest possible question, because it was the actual answer for two of those days: is anything installed?
bash
1launchctl list |grep-i myjob
2ls-la ~/Library/LaunchAgents/ |grep-i myjob
Both empty. The plist existed in the project repo, so everyone assumed it was installed. It never had been on that machine. Worse, the repo copy hardcoded a different user's home directory, so installing it as-is would have failed anyway with the wrong script path, the wrong HOME, and log paths pointing into a home that does not exist.
1
Verify the job is actually loaded
launchctl print gui/$(id -u)/<label>. If this errors, nothing is scheduled, full stop.
2
Check the paths inside the plist against the machine you are on
a plist copied between machines is a very common silent no-op.
3
Confirm the log is being written where you think
an agent logging to a path that does not exist looks identical to an agent that is not running.
Why does the CLI say "Not logged in" only under launchd?
This one cost a scheduled window. The job fired exactly on time, spawned the agent, and the agent died in one second:
text
108:31:51 POST window open -> agent batch
208:31:52 batch run returned non-zero
No output. Nothing in stdout or stderr. Reproducing it with launchd's environment is what cracked it:
bash
1env-iPATH=/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin \2HOME=/Users/you \3 /bin/bash -c'claude -p "say OK" --dangerously-skip-permissions'4# Not logged in · Please run /login
The credential lives in the login keychain, not in a dotfile:
And the keychain lookup needs to know whose keychain. A LaunchAgent gets a nearly empty environment — you get what you declare in EnvironmentVariables and little else. I isolated the exact variable rather than shotgunning the whole environment:
Environment
Result
HOME + PATH
Not logged in
HOME + PATH + LOGNAME
Not logged in
HOME + PATH + USER
Authenticates
4 rows × 2 columnsHeader row enabled
USER is the load-bearing one. LOGNAME does nothing here.
The wrong turn I took — I "fixed" this by adding an auth preflight that called the agent once per tick to check it was logged in. That is 288 extra calls a day against a metered API, for the ~270 ticks that do no work at all. It plausibly helped exhaust the usage limit that later killed a scheduled window outright. A guard that runs on every tick against a metered resource is not free. Cost the check, not just the risk.
Why is the scheduled job 5x slower than my terminal?
I set ProcessType to Background as tidy-looking hygiene. Apple's own man page is explicit about what that buys you:
Background jobs are generally processes that do work that was not directly requested by the user. The resource limits applied to Background jobs are intended to prevent them from disrupting the user experience.
On Apple silicon that means E-cores. The same CPU-bound loop, same machine, same minute:
Context
Time
Interactive shell
147 ms
Inside the LaunchAgent, ProcessType Background
735 ms
3 rows × 2 columnsHeader row enabled
Five times slower, applied to the job and every child it spawns — the agent, node, a headless browser. If any of those has a timeout, you just made it far likelier to trip.
The nuance worth knowing, straight from the same man page: omitting ProcessType is not "no limits". Unspecified still applies light CPU and I/O throttling. Only Interactive runs with app-level limits, i.e. none. I removed the key (Standard) rather than claiming Interactive, because this job genuinely is background work and the light limits are fine once the E-core confinement is gone.
Why did a successful exit code mean nothing happened?
The runner had this watchdog, and I had written it myself:
A run truncated at four minutes — mid-compose, mid-submit, mid-verify — exits 0. To every caller, including an unattended scheduler that only checks the exit code, that is a clean success. This is the purest form of the bug this whole article is about: a green signal that means "nothing happened".
js
1setTimeout(()=>{2console.error("✗ watchdog: 240s exceeded — forcing exit(1). The run did NOT complete.");3 process.exit(1);4},240_000).unref();
How can a lock file wedge the loop forever?
The single-instance lock looked textbook:
bash
1if[-e"$LOCK"];then2PID=$(cat"$LOCK")3kill-0"$PID"2>/dev/null &&exit0# someone is running, skip4fi5echo$$>"$LOCK"6trap'rm -f "$LOCK"' EXIT
Two holes. After a hard reboot or an OOM kill the trap never fires, so the file survives — and the PID inside it can be recycled onto an unrelated live process. kill -0 then answers "yes, alive" forever, and every future tick skips while the log says, reassuringly, that a run is still going.
The fix is an age break, because no real run lasts that long:
Test it the way I did: plant a lock file holding a live PID, backdate it with touch -t, and confirm the tick breaks through instead of skipping.
Why did my own safety guard block my own job?
I added a guard so a human running the tool by hand could not collide with the scheduled batch. It refused to act whenever the lock was held by a live process. It worked perfectly — including against the scheduler's own child.
The chain is runner.sh (holds the lock) → agent → tool.mjs. So tool.mjs checked the lock, found a live PID, and refused. That PID was its own grandparent. Every unattended window would have completed and produced nothing, while logging normally.
The fix is to let the lock holder's own descendants through, and nobody else — walk the ancestry:
js
1let selfOwned =false;2let cur = process.ppid;3for(let i =0; i <12&& cur >1; i++){4if(String(cur)=== lockPid){ selfOwned =true;break;}5 cur =Number(execFileSync("ps",["-o","ppid=","-p",String(cur)],{encoding:"utf8"}).trim());6if(!Number.isFinite(cur))break;7}
Note the failure direction: if the walk throws, leave selfOwned false so the guard still blocks. A guard should fail closed. But test it against the path it is meant to allow, not only the one it blocks — that is the whole lesson here. A guard that cannot tell "the driver's own child" from "a competing process" is not a safety feature, it is an outage.
Why did a background task eat my scheduled window?
The schedule said a window opened at 10:19. It did not open. No error, no skip line, nothing.
The window came due at 10:19:34, in the middle of that. launchd will not start a second copy of a job while one is still running, so the tick that would have opened the window was never spawned at all. That is also why there was no "skipped" line to find: nothing ran to write one.
Filler work every 15 minutes, taking 5 to 13 minutes a go, inside a 60-minute cycle, will collide with the main event roughly half the time. The fix is a blackout — refuse to start the long optional job when the important one is close:
python
1LEARN_BLACKOUT_SEC =18*60# wider than the longest observed filler run23if(next_window_ts - now)<= LEARN_BLACKOUT_SEC:4print("WAIT: window opens soon — holding the filler so it cannot overrun it.")5return WAIT
Pick the blackout wider than the worst observed duration of the thing you are gating, not the average. Mine: worst filler 13 minutes, blackout 18.
What should the log actually say?
The common thread in all seven is that the log was reassuring and wrong. Three changes fixed that more than any individual bug fix.
Distinguish a crash from a quiet period. These were logging the same line:
bash
1elif["$CODE"-eq3];then2echo"$(ts) hold — $DUE">>"$LOG"# genuinely nothing to do3else4echo"$(ts) !! ERROR: scheduler exited $CODE (expected 0/2/3).">>"$LOG"5echo"$(ts) NOT a quiet window — nothing will run until this is fixed.">>"$LOG"6fi
Record duration, and call out impossible ones. A real run takes minutes; anything under 15 seconds never started work:
bash
1t0=$(date +%s)2out=$(claude -p"$PROMPT" --dangerously-skip-permissions < /dev/null 2>&1);code=$?3dur=$(( $(date +%s)- t0 ))4printf'%s\n'"$out">>"$LOG"5if["$code"-ne0]&&["$dur"-lt15];then6echo"$(ts) !! FAILED in ${dur}s with no work done — usage limit, auth, or API error.">>"$LOG"7fi
That < /dev/null matters too: without it the CLI waits three seconds per invocation for stdin that will never arrive.
Rotate the log. At 288 ticks a day an unrotated log becomes a wall nobody reads, and an unread log is how silence stays silent.
Success
The check that actually catches all seven — compare work done against ground truth, never the log. For this agent that is a one-liner: count today's entries in the output ledger. If the log says healthy and the ledger says zero, something in this list is happening to you.
Key takeaways
All seven failures shared one symptom: a healthy log and no work. Duration and ground-truth output are the honest signals; exit codes and log volume are not.
A LaunchAgent's environment is near-empty. USER is required for login-keychain lookups; LOGNAME is not a substitute.
ProcessType Background confines the job and its children to E-cores (measured 5.0x). Omitting the key still applies light throttling — only Interactive removes limits.
launchd runs exactly one copy of a job, so any long task can silently swallow a scheduled window. Blacklist the window with a blackout wider than your worst observed run.
Test a new guard against the path it is supposed to ALLOW. Mine blocked its own grandparent and would have produced silent, permanent no-ops.