Originally published on Zenn (Japanese). Cross-posted here.
I had automation jobs running as launchd daemons (launchd is macOS's cron equivalent), and one day they quietly stopped — yet not a single error line showed up in any log. This is the record of how, on 2026-07-14, I pinned that state down with real logs and rebuilt the setup so that "no errors" would never be trusted again. It's aimed at people with the exact same configuration: an auto-posting pipeline driven by n8n running as a launchd daemon on macOS (M1 Max, 64GB).
Symptom: automation stops with zero errors
The auto-posting workflow (n8n) was supposed to stay resident via launchd's KeepAlive, but at some point it just stopped running entirely. launchctl reported no error. The stderr log was empty too. "No trace of the process having crashed" is the nastiest state of all, because from the logs alone you can't tell whether the monitored target "never started" or "started but isn't doing any work."
Root cause #1: the plist was a JSON-array stub
What was actually broken was a plist file under ~/Library/LaunchAgents/. Looking inside, it was like this (a generalized example for reproduction):
{"ProgramArguments": ["node", "n8n", "start"]}
This is not the valid XML plist that launchd requires — it was a JSON array containing only the contents of ProgramArguments. When launchd tries to load this, it does not report a syntax error; it silently does nothing. The process never even starts, yet no startup error is logged anywhere — this was the true nature of "automation stops with zero errors."
This kind of breakage can be detected mechanically with a single command.
$ plutil -lint ~/Library/LaunchAgents/com.example.myjob.plist
com.example.myjob.plist: OK
$ plutil -lint ~/Library/LaunchAgents/com.example.broken.plist
com.example.broken.plist: Unexpected character c (0x7b) at line 1
Running this against every plist on the actual machine, 15 were invalid at the time (the first-run value of the healthcheck log described below, 2026-07-14). The cause was an organization-wide service-renaming task carried out around the same period: 7 of the plists were converted to proper XML, but the rest — including n8n itself — were left un-fixed.
Root cause #2: the monitor itself was dead for the same reason
Even nastier was that the plist for the health-monitoring job — the very thing meant to watch for "has the automation stopped?" — was broken in exactly the same JSON-array-stub form. In other words, the watchman had never started in the first place. That nobody noticed the stoppage was no coincidence: the type of failure (invalid plist = silent ignore) was the kind that can kill both the monitored target and the monitor at the same time.
There's another structural cause: location. ~/Library/LaunchAgents/ is an OS-level configuration store, and it sits outside the folders you routinely search and take inventory of for documents or code. No matter how well-organized your knowledge management or code search setup is, this location was never on the inspection path to begin with. The only way to notice "something that's supposed to be running actually isn't" was to have a separate job that actively goes and looks at this location itself.

Trend of anomalous-plist counts across 10 healthcheck runs. First run 15 → flat at 14 for seven runs (the deferred-response portion) → 0 on the most recent run.
Permanent fix: build a setup that doesn't trust "no errors"
After rewriting the two broken ones (n8n itself and the health monitor itself) as proper XML plists and recovering them, I set up a single script — resident via launchd, running every morning at a fixed time — that mechanically does only these three things, so the same failure would never slip past again.
- Run
plutil -lintagainst all of~/Library/LaunchAgents/*.plistand judge validity. - Cross-check against the output of
launchctl listto also catch ones that are valid but not loaded. - Reflect the results into an internal ledger file by fully rewriting it every time, and send a notification only when there is at least one anomaly (on days with no anomalies, send nothing — so as not to cause notification fatigue).
Running the same check against the actual machine as I write this article, the result is as follows.
$ ls ~/Library/LaunchAgents/*.plist | wc -l
13
$ for f in ~/Library/LaunchAgents/*.plist; do plutil -lint "$f"; done | grep -c ": OK"
13
$ tail -1 ~/Library/Logs/.../healthcheck.log
healthcheck done: anomalies=0
The 13 currently existing plists are all valid (plutil -lint is OK for 13/13). The healthcheck log has 10 runs recorded, and the anomaly count traces this path: first run 15 → seven consecutive runs at 14 (the period when the deferred-response portion remained) → 0 on the most recent run. Rather than an average, the truest picture comes from the three-point set: both ends (15 and 0) plus the fact that it stalled at 14 for seven runs in between. It wasn't "it'll fix itself eventually" — it dropped to 0 all at once on the run where processing of the deferred portion finished.
Adjacent traps: TCC and the Node.js version
This configuration had two more pitfalls specific to launchd-based automation.
The first is macOS's TCC (the privacy-protection access control). When a child process launched from launchd tries to read a protected folder under ~/Documents, the Full Disk Access you supposedly granted from the GUI may not be inherited, and it gets rejected with Operation not permitted. You need to grant Full Disk Access to the executable binary that launchd uses (in this case, the node binary itself); even inserting a shell wrapper does not make the child process inherit the permission.
The second is a trap rooted in the Node.js major version. Dependencies that include native addons (in this case isolated-vm) can build or fail to build depending on the Node.js version. In my environment, Node 26.x failed like this.
serializer_nortti.o Error 1
Switching to Node 22.x (keg-only, no interference with the default Node 26) let the build pass. It wasn't "upgraded to the latest and it stopped working" but "on the latest version it won't even build in the first place" — the kind of defect where the error message alone makes it hard to isolate which layer (TCC / launchd / Node.js) is at fault.
Reusable lessons
If you're running the same setup (launchd daemons + periodically executed automation), the following four make a checklist you can reuse as-is.
-
Right now, run
plutil -lintagainst all your plists. If even one is invalid, it's dying silently without emitting any error. - Make sure the liveness of the health monitor itself can be confirmed through a path separate from the monitored target (a separate process, a separate log). A design where the monitor dies together with its target under the same failure mode does not function as a monitor.
- OS-level configuration stores (launchd plists, cron tables, systemd unit files, etc.) are often outside the folders your everyday code and document searches cover. The more a location is off the inspection path, the more you need a separate job that actively goes and looks at it.
- Don't base the evidence of "it's running" on "no errors are showing up." Base it on "the last success timestamp is recent" (the tail of a log, or the timestamp of a heartbeat file). The case of "never even started" and the case of "waiting normally, doing nothing" cannot be distinguished by error logs alone.
Top comments (0)