JavaEye

Eight maintenance jobs moved out of my chat sessions and into launchd. Two of them broke on the way.

This post records one migration on macOS in October 2026, on one machine, with one Claude Code install. TCC, AMFI and launchd behavior has changed between OS releases and will change again. Treat the commands and failure codes below as a snapshot, not a specification.

On October 19, 2026, I moved every recurring job in my tool-site operation from in-session timers to macOS launchd. Eight jobs: overnight site checks at 00:47 and 00:52, a morning report at 06:08, a WeChat article scan every two hours, an evening backlink task at 19:00, a blogger-source scan at 02:00, a weekly inspection at 21:47, and a Sunday YouTube scan. Each one is a shell script that calls claude -p in headless mode, hands it a runbook, and lets it work without a terminal.

By 14:17 the same afternoon, one of them had already failed silently in a way that took me an hour to understand. The job ran, the model ran, and then a Python script died on PermissionError reading a WeChat database. Nothing in the chat session I had been using ever showed that error, because the chat session was never the thing that ran the job.

That sentence is the whole post. A scheduled agent is not the agent you tested.

Why session timers had to go

I had been scheduling these jobs with in-session cron, the timer that lives inside a chat session. It works until it does not. The session closes, the timer is gone. Leave the machine for a week and the session expires on its own schedule. Every Monday I was re-arming the same jobs, and every re-arm was an opportunity to forget one.

launchd fixes the first problem trivially: the schedule lives in a plist file, the OS starts the job whether or not I have a terminal open. The cost is that the job now runs as a child of launchd, with whatever permission and environment that implies. The permission surface is much smaller than a terminal app in front of me.

What the job actually is

The shape that settled after a week of iteration:

plist ──> bin/runner-bash ──> scripts/run-task.sh <task> <workdir> <runbook>
                                   └── claude -p --dangerously-skip-permissions

run-task.sh does four things. It reads the runbook, strips the YAML frontmatter, and pipes the body into claude -p as stdin, so the prompt has no length limit and no shell quoting problems. It sets PATH to put Node 22 first, because the default launchd PATH is nearly empty and Claude Code needs a modern Node. It sets LC_CTYPE so UTF-8 file names survive. And when the model exits non-zero, it fires a desktop notification with osascript and writes the exit code to a log.

The runbook is a markdown file with frontmatter: name, schedule, owner, red lines. The body is written for the model, not for me. It starts with “read the ledger and the pitfall log first”, then numbered steps, then the write-back instructions. One file per job, one source of truth. When the schedule changes, I edit the runbook; the plist only holds the trigger time.

Failure one: exit 137 and the copy of bash

I wanted the job to read WeChat’s local database, which lives inside a protected container. That requires Full Disk Access (FDA). A launchd job does not have it, and you cannot grant it to “the job”. You grant FDA to a binary.

So I copied bash and pointed the plist at the copy:

cp /bin/bash bin/runner-bash
chmod 755 bin/runner-bash

Running it produced exit code 137. SIGKILL. No output, no error message, just death. For a while I assumed the extended attribute macOS puts on copied system binaries was the problem, and removing it changed nothing:

xattr -d com.apple.provenance bin/runner-bash
# still exit 137

The real cause is Apple’s code signing. /bin/bash on a modern macOS is a platform binary: it is signed by Apple, and the kernel’s AMFI component validates it against a trust cache rather than a certificate chain. Copying the file gives you bytes with an Apple signature attached to a path the trust cache does not know. AMFI rejects it, and rejection at that layer means the process is killed before it can print anything.

The fix is one line, and its implication is larger than the line:

codesign -fs - bin/runner-bash

Ad-hoc signing replaces the Apple signature with a local one. Now the binary runs. But TCC authorizes ad-hoc-signed binaries by their code directory hash, cdhash, because that is the only stable identity an ad-hoc signature has. Apple’s code signing notes (TN3127) make the same point from the other direction: the designated requirement for a signed app binds to identity plus a hash; strip the identity and the hash is all that is left.

The practical consequence: the moment you grant FDA to the copy, the grant is tied to those exact bytes. Overwrite bin/runner-bash and the permission silently stops applying. Rebuilding the copy means re-granting FDA by hand. I wrote a bootstrap-runner.sh that does copy, chmod and codesign. A comment at the top says not to run it casually, because every run costs a manual trip to System Settings.

Failure two: FDA belongs to a binary, not to a tree

The second failure was the one I mentioned at the top. WeChat’s article scan ran fine from my terminal and died from launchd. The log showed Errno 1 Operation not permitted on open contact.db, and even ls on the container directory was denied. This was not a Python problem or a sandbox problem. It was macOS refusing access at the TCC layer.

Two things were true at once. My terminal app had FDA because I granted it months ago, and every process it spawned inherited that. The launchd job had no such ancestor. The process tree was launchd → runner-bash → sh → python3, and none of those had ever been granted anything.

The general pattern, confirmed by Apple’s Service Management documentation and by the discussion around it: background daemons cannot show a permission dialog. Approval has to come from a foreground app with a stable Developer ID signature and a designated requirement, and the helper the daemon runs must be covered by that approval. For a personal machine the practical shortcut is exactly what I did: grant FDA to one dedicated binary and make every job run under it. The job tree inherits from that binary. Nothing else on the machine needs a new permission.

That trade-off deserves to be stated plainly. A shared copy of bash with FDA is a broad grant pointed at every script that uses it. The safety here comes from who can write to that file. It sits in a git repo I control, it is gitignored, and I am the only user on the machine. On a shared or multi-tenant system I would not do it this way; I would use a separate low-privilege user and grant the permission to a small helper, not to a shell.

Reliability is mostly about the second run

Once the jobs could run, the interesting failures were scheduling failures.

Two of them: a job that fires twice in the same window, and two schedulers each firing their own copy. Both produce the same damage: a scan runs twice, a ledger is written twice, a batch of API calls goes out twice. Both look like nothing in the logs, because each run is individually fine.

I use three cheap guards.

The first is an idempotency gate at the top of every runbook. The two-hour WeChat scan checks whether the ledger’s last_scan is less than thirty minutes old and the latest log line matches this run’s time. If yes, it writes one line saying a previous run already covered this window and exits. The weekly inspection checks whether today’s weekday cell is already marked done. Both gates take seconds and have saved real duplicate work.

The second is a ban on double scheduling. When a job moves to launchd, the session cron is deleted in the same commit. This sounds like a policy, and it is one, but it exists because I had a day where the same scan ran from both places.

The third is a health check that belongs in the morning report: launchctl list | grep com.liangnan.ops- must return eight lines. Not seven. A job that fails to load is invisible. It simply never appears in logs. So the check counts jobs, not successes.

The equivalent in Kubernetes is concurrencyPolicy: Forbid on a CronJob, and the equivalent in a shell script is flock. Apple’s launchd docs have no concurrency policy; you get whatever discipline you build. If I were running this at scale I would wrap every runbook in a lock file and stop trusting the humans who read the runbook.

Failure notification is the part people skip. Every nonzero exit pages me with osascript and appends to a log with the job name, exit code, and duration. A silent scheduled agent is worse than no scheduled agent, because it consumes your trust without doing the work. I learned that the hard way last week, when a job fired on schedule and did nothing for two nights.

What I still do not hand to it

--dangerously-skip-permissions does what it says. In headless mode there is no human to approve a file write or a command. Everything the model can reach, it touches without asking. Anthropic’s own docs recommend narrower alternatives for production: allowlists in project settings, per-run budgets. OWASP’s LLM06 entry (“Excessive Agency”) names the same risk from the security side: an agent with more tools and permissions than the task needs is a liability, not a feature.

So the boundary is drawn by task type, not by task size. The scheduled agent gets tasks with a single output surface: run a check and write a report, fetch a feed and update a ledger, summarize logs and notify. It does not get tasks where a wrong write is expensive or hard to reverse. No deployments, no database migrations, no account operations, no anything that spends money without a cap.

When a task needs to write to production, the job stops at a draft or a PR and a human merges it. That is slower, and I have kept it anyway, because the failure mode of an autonomous agent on a payment flow is not “the report is late”.

Cost control gets the same treatment. Per-run budget caps stop a runaway loop from burning a month of tokens in an afternoon; a two-hour schedule means the worst case is bounded before anyone notices. The Gemini quota rules I follow look arbitrary until you have watched a retry loop eat a daily allowance in twenty minutes.

The checklist, if you are copying this

PieceWhat it isWhy it exists
plistcom.liangnan.ops-<task> in ~/Library/LaunchAgentsOS-owned schedule; survives logout
dedicated bash copybin/runner-bash, ad-hoc signedcarries the one FDA grant for the whole tree
bootstrap scriptcopy, chmod, codesign, with a warning commentrebuilds the binary without forgetting the re-grant
run-task.shstrips frontmatter, sets PATH and locale, notifies on failureone execution path for every job
runbookmarkdown per job, frontmatter holds schedule and red linessingle source of truth for what the job does
idempotency gatetime-window check at the top of the runbookduplicate runs are the most common real failure
health checkcount ops jobs in the morning reporta job that did not load leaves no trace
budget capper-run token/dollar limitthe failure mode of autonomy is fast and quiet

Eight jobs, one messy afternoon, and two failures that both came down to the same idea: the agent is not the thing that runs. The scheduler is. And the scheduler has opinions about what a process is allowed to be.

I still keep a terminal open while I watch the first run of any new job. Old habits are hard to retire and the ones that are hard to retire are usually right.