Your Unattended AI Agent Looks Healthy and Does Nothing: 7 Silent Failures on macOS launchd (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.
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?
launchctl list | grep -i myjob
ls -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.
- Verify the job is actually loaded launchctl print gui/$(id -u)/. If this errors, nothing is scheduled, full stop.
- Check the paths inside the plist against the machine you are on a plist copied between machines is a very common silent no-op.
- 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:
08:31:51 POST window open -> agent batch
08:31:52 batch run returned non-zero
No output. Nothing in stdout or stderr. Reproducing it with launchd's environment is what cracked it:
env -i PATH=/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin \
HOME=/Users/you \
/bin/bash -c 'claude -p "say OK" --dangerously-skip-permissions'
# Not logged in · Please run /login
The credential lives in the login keychain, not in a dotfile:
security find-generic-password -s "Claude Code-credentials"
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 |
USER is the load-bearing one. LOGNAME does nothing here.
**~/Library/LaunchAgents/net.example.myjob.plist**
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
<key>HOME</key>
<string>/Users/you</string>
<key>USER</key>
<string>you</string>
</dict>
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 |
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:
setTimeout(() => {
console.error("watchdog: forcing exit");
process.exit(0); // <-- reports SUCCESS
}, 240_000).unref();
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".
setTimeout(() => {
console.error("✗ watchdog: 240s exceeded — forcing exit(1). The run did NOT complete.");
process.exit(1);
}, 240_000).unref();
How can a lock file wedge the loop forever?
The single-instance lock looked textbook:
if [ -e "$LOCK" ]; then
PID=$(cat "$LOCK")
kill -0 "$PID" 2>/dev/null && exit 0 # someone is running, skip
fi
echo $$ > "$LOCK"
trap '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:
LOCK_AGE_MIN=$(( ( $(date +%s) - $(stat -f %m "$LOCK") ) / 60 ))
if [ "$LOCK_AGE_MIN" -ge 90 ]; then
echo "breaking STALE lock (age ${LOCK_AGE_MIN}m) — presumed orphaned"
rm -f "$LOCK"
elif kill -0 "$PID" 2>/dev/null; then
exit 0
fi
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:
let selfOwned = false;
let cur = process.ppid;
for (let i = 0; i < 12 && cur > 1; i++) {
if (String(cur) === lockPid) { selfOwned = true; break; }
cur = Number(execFileSync("ps", ["-o", "ppid=", "-p", String(cur)], {encoding:"utf8"}).trim());
if (!Number.isFinite(cur)) break;
}
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.
10:10:57 LEARN pass -> agent
10:24:10 learn tick done <- 13 minutes
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:
LEARN_BLACKOUT_SEC = 18 * 60 # wider than the longest observed filler run
if (next_window_ts - now) <= LEARN_BLACKOUT_SEC:
print("WAIT: window opens soon — holding the filler so it cannot overrun it.")
return 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:
elif [ "$CODE" -eq 3 ]; then
echo "$(ts) hold — $DUE" >> "$LOG" # genuinely nothing to do
else
echo "$(ts) !! ERROR: scheduler exited $CODE (expected 0/2/3)." >> "$LOG"
echo "$(ts) NOT a quiet window — nothing will run until this is fixed." >> "$LOG"
fi
Record duration, and call out impossible ones. A real run takes minutes; anything under 15 seconds never started work:
t0=$(date +%s)
out=$(claude -p "$PROMPT" --dangerously-skip-permissions < /dev/null 2>&1); code=$?
dur=$(( $(date +%s) - t0 ))
printf '%s\n' "$out" >> "$LOG"
if [ "$code" -ne 0 ] && [ "$dur" -lt 15 ]; then
echo "$(ts) !! FAILED in ${dur}s with no work done — usage limit, auth, or API error." >> "$LOG"
fi
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.
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.
Top comments (0)