Fleet Ops

One Command Audited 27 Scheduled Agents. Seven Had Failed, and One Never Ran at All.

August 2, 2026 · By Italo Campilii

One Command Audited 27 Scheduled Agents. Seven Had Failed, and One Never Ran at All.

Every scheduled agent I run is a launchd job on one Mac. On 2026-08-02 I ran a single command against all of them and read the second column. Of 27 jobs carrying my own labels, seven had a non-zero last exit status. Two of those seven were failing loudly and correctly — the alert rail caught them. One had been scheduled every weekday morning for weeks and had never executed a single line of its script, because macOS was refusing to let launchd read the file.

This is the audit, the exit-code triage table that came out of it, and the reason a scheduler that reports "loaded" tells you almost nothing.

TL;DR

launchctl list prints three columns: PID, last exit status, label. The second column is the cheapest fleet health check that exists on macOS, and almost nobody reads it. My audit found 7 of 27 jobs non-zero. Exit 1 usually means your code ran and failed — recoverable, and mine alert. Exit 126 means the shell found the program and could not execute it; on a modern Mac that is usually a privacy permission, not a chmod. A negative number is a signal, not an exit code. Run the audit weekly and treat any label you cannot explain as broken.

The command

launchctl list

Three columns, and the middle one is the point. The ss64 reference for launchctl states it precisely: "The first column displays the PID of the job if it is running. The second column displays the last exit status of the job. If the number in this column is negative, it represents the negative of the signal which stopped the job."

To read only your own fleet, filter by label prefix:

launchctl list | awk '$3 ~ /^com\.yourprefix/ {print}' | sort -k3

Two caveats worth stating up front, because I would rather you trust the method than the tool. First, treat this output as a human triage view rather than a stable parsing interface. Apple is explicit about that for at least one field: on the developer forums, an Apple DTS engineer explains that the trailing number you sometimes see appended to a label — for processes outside normal launchd lifecycle management — "is a unique ID that's used to distinguish between multiple processes with the same bundle ID. It has no documented meaning that you can rely on," and steers developers toward NSRunningApplication instead for information about running apps. That caveat is about the label suffix, not the exit-status column, which ss64 documents directly; but it is a fair warning that list is a convenience surface, not an API contract. The modern equivalent is launchctl print gui/$UID/<label>, which the same ss64 reference describes as exposing a service's origin on disk, current state, execution context, and most recent exit status. Use list to triage the whole fleet in one screen, then print on anything that looks wrong. Second, a scheduled job that is not currently running shows - in the PID column. That is normal, not a fault.

What the audit found

640 jobs were loaded in my user domain — the overwhelming majority of them Apple's own agents, which is what a Mac looks like. 27 carried one of my brand label prefixes. Seven were non-zero:

Last exit Jobs What it actually meant
1 5 The script ran and returned a failure. Two of these are daily publishing engines whose quality gate rejected that morning's post — a correct refusal, already reported to my alert channel. Recoverable and visible.
78 1 An inventory watcher polling every 600 seconds. Its error log ends in ssl.SSLEOFError — the remote end dropped the TLS handshake. Application-level, transient, worth a retry policy.
126 1 The one that mattered. The script never executed.

Exit 126: the job that fired every weekday and did nothing

The job is a weekday 07:30 automation. Its ProgramArguments are /bin/bash plus a script path under ~/Documents. Its StandardOutPath log, tailed, contained this on repeat:

shell-init: error retrieving current directory: getcwd: cannot access parent directories: Operation not permitted
/bin/bash: /Users/italo/Documents/.../auto-linkedin.sh: Operation not permitted

The GNU Bash reference manual is unambiguous about what that status means: "If a command is found but is not executable, the return status is 126." Compare exit 127, which the same manual defines as the command not being found at all. The distinction is the whole diagnosis. The path was correct. The file was there. Bash located it and was refused.

The refusal did not come from file mode bits. It came from macOS privacy protection. Apple's own guidance on controlling access to files and folders explains that apps need explicit permission to reach your Desktop, Downloads, and Documents folders, granted per app under System Settings. A launchd agent running /bin/bash inherits whatever consent /bin/bash has been given — which, by default, is none for ~/Documents. So the scheduler fired on time, every weekday, indefinitely, and produced exactly zero work. launchctl list reported it as loaded the entire time. It was, in every sense that matters, dead.

The same permission boundary has bitten two other systems of mine that keep state under ~/Documents. That is not a coincidence — it is a fleet-wide architectural mistake I made once and repeated: automation state should not live in a TCC-protected directory. The permanent fix is to move it. The stopgap is granting Full Disk Access to the binaries launchd actually invokes.

Why "loaded" is not "working"

This is the third variation on the same failure I have written up this quarter, and the pattern is now unmistakable.

In July, a job died silently at line 75 for days because launchd does not source your shell profile, so its PATH was missing the interpreter it needed. Later that month, a publishing agent ran on schedule, failed correctly, and exited 0 for nine consecutive days with nobody told — because a clean exit is not a health signal. Now: a job that never got as far as executing its own script.

Three different causes, one identical symptom set. The scheduler says loaded. The plist is valid. The StartCalendarInterval is correct. Nothing errors anywhere a human would look. And no work happens. The mistake underneath all three is treating arming as evidence — believing that because a job is registered, the system it represents is alive.

For a one-person operation running agent fleets across five brands, that assumption is expensive in a specific way: the failures are silent and uncorrelated. Nothing tells you that three unrelated jobs stopped for three unrelated reasons. You find out when a client asks where last week's posts went.

The triage table I use now

Second column Read it as First move
0Last run returned success. Not proof it did anything.Check the job's own output artifact, not its exit code.
1 and other small numbersYour code ran and reported failure.Read the job's log. If it did not also alert you, that is a second bug.
126Found, not executable. On macOS: usually a privacy permission or a missing execute bit.Read StandardOutPath for "Operation not permitted". Move the file out of a protected folder or grant Full Disk Access.
127Command not found — classically a PATH problem, because launchd does not load your shell profile.Use absolute paths in ProgramArguments, or set PATH in EnvironmentVariables.
Any negative numberNot an exit code. It is the negation of the signal that stopped the job — -9 is SIGKILL.Look for resource pressure, timeouts, or something killing the process externally.
- in the PID columnNot running right now. Expected for scheduled jobs.Nothing. Read column two instead.

The row that deserves the most suspicion is the first one. Exit 0 means the process returned success — it does not mean the post published, the file uploaded, or the report sent. That is exactly the gap the nine-day silent-exit postmortem was about, and it is why my engines now fail loudly and alert on their own rather than relying on a status code to be read by a human who will not read it.

The standing rule

Two additions to fleet doctrine came out of this audit.

Every scheduled job proves liveness by artifact, not by status. A job is healthy when the thing it exists to produce exists, with today's timestamp. Exit codes are a triage aid for when the artifact is missing — never the primary signal.

The exit-code audit is a scheduled job itself. Reading 27 rows by hand once a quarter is how you find a 126 six weeks late. The same command, filtered to my label prefixes, now runs on a schedule and reports any non-zero row it cannot match against an alert that was already sent. The rail that watches the fleet has to be part of the fleet.

The micro-lesson

Ask of every automation you own: if this stopped today, what would tell me, and how long would it take? On macOS, launchctl list answers that for two minutes of effort and a filter, and it will surface the failure classes that never generate an error anybody sees — the wrong PATH, the protected directory, the process killed by a signal. Do it now, on your own machine, before reading further. If there is a label in that output you cannot explain, you have found something.

FAQ

What does the second column of launchctl list mean?

It is the last exit status of that job. Zero means the last run returned success; a small positive number means your program ran and returned a failure code; a negative number is not an exit code at all but the negation of the signal that terminated the process, so -9 means the job was killed with SIGKILL. The first column is the PID if the job is currently running, and a dash if it is not, which is the normal state for anything on a schedule.

Why does my launchd job exit with 126?

Because the shell found the program and could not execute it. The Bash manual defines 126 as "a command is found but is not executable," distinct from 127, which means the command was not found. On macOS the most common non-obvious cause is not the execute bit but privacy protection: scripts stored in ~/Documents, ~/Desktop, or ~/Downloads are blocked from processes that have not been granted access, and the job's log will contain "Operation not permitted." Move the script out of the protected folder or grant Full Disk Access to the binary launchd invokes.

How do I check whether all my scheduled agents are actually working?

Run launchctl list, filter to your own label prefix, and read the exit-status column for every row. Then, for anything non-zero, run launchctl print gui/$UID/<label> for the service's state and last exit status, and read its StandardOutPath and StandardErrorPath logs. Finally, and most importantly, verify that each job's expected output artifact exists with a current timestamp — a status of 0 proves the process returned success, not that it did its work.

Is launchctl list output safe to parse in a script?

Treat it as triage, not as an API. An Apple DTS engineer has said publicly that the numeric suffix sometimes appended to a label has no documented meaning you can rely on, and the three columns are documented by third-party references rather than by any stability guarantee from Apple. For a personal fleet audit that runs on one machine you control, parsing the three columns is pragmatic and useful. For anything you ship to other people, prefer the artifact check — does the expected output exist and is it fresh — which does not depend on undocumented tool output at all.

Why do scheduled scripts that work in Terminal fail under launchd?

Because launchd does not run your login shell and does not load .zshrc or .bash_profile, so your interactive PATH, environment variables, and shell functions do not exist in the job's context. It also runs under a different consent state for macOS privacy-protected folders than your Terminal app does. The two fixes are complementary: use absolute paths and an explicit EnvironmentVariables block in the plist, and keep automation files and state out of ~/Documents, ~/Desktop, and ~/Downloads.

RUNNING AGENTS UNATTENDED?

The hard part of a fleet is not the model. It is everything that has to hold up while nobody is watching — scheduling, permissions, verification, and the rail that tells you when a run produced nothing. That's the work I do. Get in touch, or see the press and hire page.

— Italo Campilii. Audit run 2026-08-02 on the machine that hosts the fleet; every exit code, log line, and plist detail above was read from that run.

IC

Italo Campilii

AI systems builder and one-person operator. I run the infrastructure for five brands solo with Claude Code agent fleets — SEO, content, e-commerce ops, and the verification layer that keeps it honest. The Build Log is where I write down what actually works.