I run more than 160 launchd jobs on a Mac that almost never reboots. That fleet is what carried me from a ¥100k/month student side gig to ¥600k juggling multiple jobs, back down to zero after a layoff, and then — six months of rebuilding a Claude Code autonomous setup later — past ¥1.2M/month in revenue. Keeping that automation alive has taught me something I didn't expect: cleanly stopping a job is far harder than starting one.
Renaming a file to .plist.retired does not stop the job. launchd keeps running it.
Why this happens
launchd doesn't identify jobs by file name. It identifies them by the value of the Label key inside the plist.
Take a file that actually sits in my environment in the .retired state: ~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.shun.zenn-daily</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>~/.discord/run-and-notify.sh</string>
<string>zenn</string>
<string>Zenn日次公開</string>
<string>/bin/bash</string>
<string>~/.claude/scripts/zenn-daily-publish.sh</string>
<string>apply</string>
</array>
<key>StartCalendarInterval</key>
<array>
<dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>30</integer></dict>
<dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>14</integer><key>Minute</key><integer>0</integer></dict>
<dict><key>Hour</key><integer>19</integer><key>Minute</key><integer>0</integer></dict>
</array>
<key>RunAtLoad</key>
<false/>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
The file name is com.shun.zenn-daily.plist.retired, but the Label inside is still com.shun.zenn-daily. launchd already read this file at login and registered it in its internal table under the label com.shun.zenn-daily. Renaming the file afterwards does nothing to that table entry.
This job has four times in StartCalendarInterval: 7:30, 10:00, 14:00, and 19:00. After the rename, launchd keeps calling ~/.claude/scripts/zenn-daily-publish.sh apply at every one of those times. Check the logs and you'll find execution traces piling up in ~/.claude/logs/zenn-daily.out.log and ~/.claude/logs/zenn-daily.err.log.
launchd's ID is the Label, not the path
macOS launchd is designed differently from Linux systemd. systemd ties the unit file path tightly to the service name, whereas launchd treats the plist path as nothing more than "where to load from the first time." Everything after that is managed entirely by the Label value.
There's a rational design reason for this. launchd sits close to the core of macOS, and it needs to detect conflicts when jobs with the same Label are scattered across multiple locations (~/Library/LaunchAgents/, /Library/LaunchAgents/, /Library/LaunchDaemons/). The flip side: when a file disappears from LaunchAgents/, there is no mechanism that automatically removes the already-loaded entry.
Why it keeps running after the rename
macOS watches ~/Library/LaunchAgents/ as a directory. When a file is added, launchd reads and loads it; when one is removed, that triggers an unload. But a rename is reported as "deleted under the old name, added under the new name."
Here's the catch. Even if com.shun.zenn-daily.plist is deleted, what launchd would unload is "the job whose Label was written in that file." On macOS 13 and later, when deciding whether com.shun.zenn-daily.plist.retired should be treated as a plist, files whose extension isn't .plist are excluded from watching and ignored. In other words:
-
com.shun.zenn-daily.plist→ delete event → should be unloaded in principle - But within an already-loaded session, there are cases where auto-unload via filesystem watching doesn't take effect
You may get lucky and have it unloaded at the moment of the rename, but across a login session boundary it will definitely still be there. If you leave the file in the .retired state and reboot, it never gets loaded in the first place, so no problem appears. But on a Mac that has been running for a long time without a reboot, a rename alone means the job absolutely keeps running. As your automation grows, that Mac reboots less and less often. That's why "it's supposed to be retired but it's running" is such an easy trap to fall into.
What the damage looks like
What actually happened in my environment: "a lane that was supposed to be retired kept running on a different scheduler, and the day after I moved its working folder, it started firing blanks." After the folder move, execution simply fails, so exit 1 piles up in the logs — but from launchd's point of view it's just "the job ran (and failed)," so nothing gets unloaded. A pattern of quiet failures that keep going.
When you operate a fleet of 160+ jobs, several of these "ghost jobs" can be mixed in at once. Unless you periodically take inventory with launchctl list | grep com.lily, unintentionally leftover jobs keep consuming resources that other jobs need (AVD locks, browser slots, quota).
The overall flow
Retiring a launchd job correctly takes three steps. File operations come afterwards — getting bootout through comes first.
┌─────────────────────────────────────────────────┐
│ plist.retired にリネームした状態(NG) │
│ │
│ ~/Library/LaunchAgents/ │
│ com.shun.zenn-daily.plist.retired ← ファイル名変更済み │
│ │
│ launchd 内部テーブル │
│ Label: com.shun.zenn-daily ← まだ生きている │
│ → 7:30 / 10:00 / 14:00 / 19:00 に発火 │
└─────────────────────────────────────────────────┘
↓ bootout を実行する
┌─────────────────────────────────────────────────┐
│ 正しい退役後の状態(OK) │
│ │
│ ~/Library/LaunchAgents/ │
│ com.shun.zenn-daily.plist.retired │
│ │
│ launchd 内部テーブル │
│ Label: com.shun.zenn-daily → エントリなし │
│ → 発火しない │
└─────────────────────────────────────────────────┘
Step 1: Unload with bootout
launchctl bootout gui/$UID/com.shun.zenn-daily
gui/$UID is the login session domain. $UID expands to the current user's UID as-is. If you get a No such process error, ignore it and continue. That just means you called bootout on a job that was already unloaded — not an anomaly.
There's an important pitfall. If you bootout a running job and immediately try to bootstrap (reload) it, it can fail with 5: Input/output error. This is a race condition where the next command runs before the kernel has finished tearing down the service. In my environment, com.lily.threadspilot.engage vanished once because of this. When you do need to reload, insert a script that retries up to 15 times at 1-second intervals, or sleep 2 before bootstrap.
The goal here is only to "stop," so no bootstrap is needed.
Step 2: Confirm it's gone with launchctl list
launchctl list | grep zenn-daily
If the output is empty, the entry is gone from launchd's table. The job will not fire.
However, not appearing in launchctl list is not a sufficient condition for "retirement complete." The reverse problem matters more: even if a job appears in launchctl list, that only means it's "registered" — not that it's "loaded with the latest plist definition."
I fell into this trap when I bulk-injected a quota-guard wrapper into 44 jobs. In launchctl list, every job looked registered, but when I actually checked the contents with launchctl print, several jobs still had the old ProgramArguments. Only the live definition returned by launchctl print is trustworthy evidence of state.
For retirement checks, disappearing from list is enough; but for post-reload checks, make it a habit to always use print.
# 退役確認用(消えていればOK)
launchctl list | grep com.shun.zenn-daily
# 再ロード後の定義確認用(新しいProgramArgumentsが反映されているか)
launchctl print gui/$UID/com.shun.zenn-daily
Step 3: Tidy up the file (optional)
Once bootout goes through, the job won't fire even if the file remains in LaunchAgents/. And since .plist.retired files are outside launchd's watch scope, they won't be auto-loaded at the next login or reboot either.
Leaving it like this causes no operational problem, but it makes the fleet harder to read. My policy is to move retired plists to a backup directory.
mkdir -p ~/content/launchagents-backup-$(date +%Y%m%d)
mv ~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired \
~/content/launchagents-backup-$(date +%Y%m%d)/
After moving, confirm there are no leftovers with ls ~/Library/LaunchAgents/ | grep zenn.
Putting it together: the complete retirement command sequence
# 1. アンロード(No such process は無視してよい)
launchctl bootout gui/$UID/com.shun.zenn-daily
# 2. テーブルから消えたか確認
launchctl list | grep zenn-daily
# 出力が空であればOK
# 3. plistをバックアップへ移動
BACKUP=~/content/launchagents-backup-$(date +%Y%m%d)
mkdir -p "$BACKUP"
mv ~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired "$BACKUP/"
# 4. 移動後の残骸確認
ls ~/Library/LaunchAgents/ | grep zenn
These four steps are the answer to "what should I actually have done when I renamed it to .plist.retired?"
Why the .retired rename became a habit
The macOS convention of renaming to .plist.disabled or .plist.retired exists because it's "easy to bring back later." Deleting outright makes it expensive to reconstruct the original plist. Keeping it in the same place under a .retired name as a backup is a reasonable idea.
The problem is that, unless you know how launchd behaves, it's hard to see that a rename is a "declaration of intent to retire," not the "execution of retirement." If you managed plists under Git, git log would give you the change history and the backup rename would be unnecessary — but almost nobody puts ~/Library/LaunchAgents/ under Git. So the rename technique lives on.
To state the procedure precisely: the rename is the backup. bootout is the stop. They are two separate operations.
Implementation details
Adding just 4 elements to ProgramArguments
The bootout → bootstrap cycle is the same whether you're "stopping" a job or "switching it to run through a wrapper." In August 2026, when I bulk-inserted a quota guard into a fleet of 160+ jobs, this was the entire change.
Before (the actual structure of com.shun.zenn-daily):
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>~/.discord/run-and-notify.sh</string>
<string>zenn</string>
<string>Zenn日次公開</string>
<string>/bin/bash</string>
<string>~/.claude/scripts/zenn-daily-publish.sh</string>
<string>apply</string>
</array>
After (only 4 elements added at the front):
<key>ProgramArguments</key>
<array>
<string>~/.claude/scripts/quota-guard.sh</string>
<string>--job</string>
<string>com.shun.zenn-daily</string>
<string>--</string>
<string>/bin/bash</string>
<string>~/.discord/run-and-notify.sh</string>
<string>zenn</string>
<string>Zenn日次公開</string>
<string>/bin/bash</string>
<string>~/.claude/scripts/zenn-daily-publish.sh</string>
<string>apply</string>
</array>
Touch only ProgramArguments. Not a single key among StartCalendarInterval (the 4 slots at 7:30, 10:00, 14:00, 19:00), ProcessType (Background), RunAtLoad (false), StandardOutPath, or StandardErrorPath changes. If you change the schedule and the output destinations at the same time, you can no longer tell "is this a wrapper problem or a timing problem?" It's a design choice that narrows things to a single variable when something breaks.
Why write the semantic diff check in Python
Run XML through a text diff and you get piles of line differences from nothing but attribute order and whitespace. Eyeballing "did any key I didn't want to change get changed?" falls apart at 44 files. The right approach is a key-by-key comparison with plistlib (Python standard library).
import plistlib
from pathlib import Path
def verify_minimal_change(original: Path, modified: Path) -> bool:
orig = plistlib.loads(original.read_bytes())
mod = plistlib.loads(modified.read_bytes())
# ProgramArguments以外のキーが変わっていないか
for key in orig:
if key == "ProgramArguments":
continue
assert orig[key] == mod.get(key), f"意図しないキー変更: {key}"
# 既存コマンドのsuffixが保持されているか
orig_args = orig["ProgramArguments"]
new_args = mod["ProgramArguments"]
assert new_args[-len(orig_args):] == orig_args, \
"既存ProgramArgumentsのsuffixが変わっている"
return True
Run this verification across every target to mechanically confirm "only ProgramArguments changed, and the existing command suffix matches exactly" — only then proceed to plutil -lint → bootout → bootstrap.
The bootout → bootstrap retry pattern
Retirement (just stopping) is complete with bootout, but when a reload is needed, use this shape.
reload_job() {
local label="$1"
local plist_path="$2"
local domain="gui/$UID"
# bootout(No such process は正常・無視してよい)
launchctl bootout "${domain}/${label}" 2>/dev/null || true
# bootstrap は最大15回・1秒間隔でリトライ
# カーネル側のサービス消滅完了を待つため
local max_retry=15
for i in $(seq 1 $max_retry); do
if launchctl bootstrap "$domain" "$plist_path" 2>/dev/null; then
break
fi
if [ "$i" -eq "$max_retry" ]; then
echo "RELOAD FAILED: $label" >&2
return 1
fi
sleep 1
done
# live定義で検証(listではなくprintを使う)
if launchctl print "${domain}/${label}" | grep -q "quota-guard"; then
echo "RELOAD OK: $label"
else
echo "RELOAD WARN: live定義にラッパが見えない $label" >&2
return 1
fi
}
The key to this pattern is its asymmetry. bootout errors can be ignored; bootstrap errors must not be. bootout is idempotent (an operation toward a state that's already "not there"), but if bootstrap fails, the job is left unloaded and gone. Swallow that with || true and you'll land in the next trap.
launchctl print is the only way to see the real state
# 退役確認(消えていればOK)
launchctl list | grep com.shun.zenn-daily
# → 出力が空であればアンロード済み
# 再ロード後の定義確認(新しいProgramArgumentsが反映されているか)
launchctl print gui/$UID/com.shun.zenn-daily | grep "program ="
# → program = /path/to/quota-guard.sh であればラッパが効いている
The output of launchctl print contains a line program = with the path of the binary that actually gets launched. If this still shows the old claude binary, you're in the state of "I edited the file but the reload didn't take." Even if the string quota-guard shows up in the logs, unless you check this line, you've only convinced yourself it went through.
Where I got stuck
When managing 160+ jobs, three patterns overlap: "I stopped it but it's running," "I changed it but it didn't take," and "it's running but producing zero results." Here are four cases where I actually got stuck, in symptom → cause → fix order.
① A "retired" job quietly died the day after a folder move
Symptom: The three ai-portraits series jobs — blur, partial, and swim — all started failing together with 0/2 (zero targets) from the morning after I moved their working folder. The logs just showed exit 1 stacking up. From launchd's point of view it was only "the job ran (and failed)," so nothing got unloaded.
Cause: I'd assumed the .plist.retired rename alone had stopped them, and never ran launchctl bootout. The morning after the rename on a long-running Mac, no session boundary had been crossed, so the jobs were still alive. Since I'd already moved the working folder the day before, all three jobs immediately hit exit 1 with "folder missing."
Fix: Just keep the order — get bootout through before moving the folder.
# フォルダを移動する前に必ずやる
for label in com.lily.ai-portraits-blur com.lily.ai-portraits-partial com.lily.ai-portraits-swim; do
launchctl bootout gui/$UID/$label 2>/dev/null || true
done
# テーブルから消えたか全件確認
launchctl list | grep ai-portraits
# 出力が空になってからフォルダを移動する
# これが安全な順序
mv ~/dev/ai-portraits/work ~/dev/ai-portraits/work-archived
I had the order backwards. I pulled out the infrastructure prerequisites before declaring the intent to retire.
② bootstrap returned 5: I/O error and erased the job
Symptom: After a rewiring, com.lily.threadspilot.engage had vanished from launchctl list. No firing traces in the script logs either. It was left unloaded and gone.
Cause: A race condition from calling bootstrap immediately after bootout on a running job. bootstrap ran before the kernel had finished tearing down the service, and returned 5: Input/output error. The reload script swallowed that error with 2>/dev/null and moved on to the next job as if it had succeeded. The result: only threadspilot.engage was left unloaded, with its registration gone.
Fix: Add the retry pattern shown above. Pair retries (up to 15 times at 1-second intervals) with a live-definition check via launchctl print. You can only say "bootout succeeded and bootstrap succeeded" once you've actually confirmed the program path in launchctl print. Discard errors only on the bootout side.
③ I trusted launchctl list and declared all 44 jobs "done"
Symptom: The day after bulk-inserting the quota guard into 44 jobs, I discovered some jobs were still calling the bare claude binary directly, bypassing the guard entirely. Quota usage was above expectations, and I only noticed after investigating.
Cause: The reload script confirmed "registered" via the output of launchctl list | grep $label and then printed RELOAD OK. But showing up in list is evidence of "registered," not evidence of "running with the new plist definition." Several jobs had failed bootstrap with 5: I/O error, and their definitions from the old session were still there, still showing in list.
Fix: Change the verification command from list to print.
# NG: listに出ることしか確認できない(旧い定義のままでも出る)
launchctl list | grep com.lily.some-job
# OK: live定義のprogramを確認する
launchctl print gui/$UID/com.lily.some-job \
| grep "^ program =" \
| grep -q "quota-guard" && echo "OK" || echo "NG: ラッパが入っていない"
I rewrote the script to run print on all 44 jobs and mechanically check whether the wrapper appears in program =, then re-ran bootout → bootstrap on the 6 jobs that still had old definitions.
④ A script was unconditionally overwriting the CLAUDE_BIN environment variable
Symptom: The ig-autoreply-ig-2 logs were full of "DM判定失敗: claude exit 1". The quota guard was supposed to be passing CLAUDE_BIN=~/.claude/scripts/quota-guard.sh to child processes, yet what was actually being called was the bare ~/.local/bin/claude.
Cause: Opening ~/dev/social-autolike/scripts/run-ig-autoreply.sh, the top of the file said this:
export CLAUDE_BIN=~/.local/bin/claude
An unconditional overwrite. Even if the launchd plist passes the wrapper path, the moment the child shell executes this line, the guard's path is gone. The same pattern existed in run-comment-reply.sh, run-rewrite.sh, and run-editor.sh. Four scripts in total. Wiring doesn't end at the plist. You have to check whether anyone along the path is overwriting unconditionally.
Fix: Change all four to the :- form.
# 変更前(無条件上書き・ガードを殺す)
export CLAUDE_BIN=~/.local/bin/claude
# 変更後(既存値がある場合は尊重する)
export CLAUDE_BIN="${CLAUDE_BIN:-~/.local/bin/claude}"
After the fix, I added a test case that asserts, with an exact match, that "this assignment line is written in the :- form." A test that only checks "does the string CLAUDE_BIN exist?" passes even when an old commented-out line is present. The existing test was exactly like that, and I missed it once. Instead of confirming existence with grep, you need to verify the form of the assignment line itself.
The structure of accumulating silent failures
What the four cases have in common is a structure where "the error is invisible."
- Rename to
.retiredand forgetbootout→ the job keeps running, but nothing appears in launchd's error logs -
bootstrapfails with5: I/O error→ if the script discards it with2>/dev/null, silence -
launchctl listshows jobs with old definitions as "registered" → looks normal on the surface, stale on the inside - Environment variable overwrite → you think the guard is in place, but it's bypassed. The job itself runs, so the exit code is normal
At a scale of 160 jobs, "running" and "running as intended" are different states. Feeling reassured because you see a log of the job running means your verification isn't deep enough.
To detect this problem regularly, I run the following inventory locally once a month.
# launchdに登録されている自分のジョブ一覧
launchctl list | grep 'com\.lily\|com\.shun' | awk '{print $3}' | sort > /tmp/loaded.txt
# LaunchAgentsにplistとして存在するジョブのLabel一覧
for f in ~/Library/LaunchAgents/com.{lily,shun}.*.plist; do
defaults read "$f" Label 2>/dev/null
done | sort > /tmp/files.txt
# 差分を見る
echo "=== ファイルがないのにloaded ===" && comm -23 /tmp/loaded.txt /tmp/files.txt
echo "=== plistがあるのにunloaded ===" && comm -13 /tmp/loaded.txt /tmp/files.txt
If a line shows up under "loaded but no file," that's a ghost job. Jobs you thought you'd stopped with a .retired rename back in the day, still surviving because no session boundary was crossed, appear here.
com.shun.zenn-daily.plist.retired also shows up instantly if you run this inventory. Because the file's extension isn't .plist, it won't appear in /tmp/files.txt, but if bootout was never run, it will appear in /tmp/loaded.txt. That gap reveals the existence of a ghost job.
Changing a file's name and removing an entry from launchd's table are separate operations. If you have a mechanism that makes that gap visible, any retirement you forgot to finish will be caught in next month's inventory, without fail.
Stumbling points
The four cases where I got stuck are above. Here I'll list, comprehensively, the points that are "easy to do but rarely said out loud." Only things I actually hit while running a 160+ job fleet.
Skipped
plutil -lintbeforebootout → bootstrap. If the plist has a syntax error,bootstraplooks successful and the job even shows inlaunchctl list, but it never fires — not once. macOS silently ignores broken plists. Even inlaunchctl printit stays atstate = waitingand doesn't run when the time comes. Twice I spent an hour investigating with no idea of the cause, only to finally runplutil -lint com.lily.something.plistand see the syntax error. Whenever you touch a file, always goplutil -lint→bootout→bootstrap→launchctl print, in that order.plutil -lintalone comes before every other step.Wrote a tilde (
~) in a path inside the plist. launchd is not a shell, so it does not expand~to/Users/yourname. This is the cause of the vast majority of "the job fires but keeps ending withexit 127: command not found." If you look at the pre-changeProgramArgumentsin~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired, it doesn't actually say~/.discord/run-and-notify.sh.catthe real file and you'll find absolute paths. Everything inside a plist is an absolute path. Be strict about the split: tilde notation in articles and explanations, absolute paths in the real file.Forgot to include the three keys
ProcessType,Nice, andLowPriorityIOin a new plist. These three keys are what let macOS lower a job's priority.
<key>ProcessType</key>
<string>Background</string>
<key>Nice</key>
<integer>10</integer>
<key>LowPriorityIO</key>
<true/>
Omit them and a job that's supposed to run in the background executes at the same priority as the foreground. At 160-job scale, the Mac gets noticeably sluggish at daytime peak. When I wired browser-slot into 23 jobs in August 2026, I found these three keys missing from 18 of them. Always include them in your template when writing a new plist.
Left
StartCalendarIntervalas a single dict and placed the firing time during hours when the Mac sleeps. If the Mac is asleep at aStartCalendarIntervalfiring time, macOS drops that firing entirely. cron catches up on accumulated runs at startup; launchd does not. If you placed a once-a-day job in the middle of the night, on days when the screen isn't on it may never run at all. Convert single-time jobs to a multi-slot dict array. The reasoncom.shun.zenn-dailyhas 4 slots — 7:30, 10:00, 14:00, 19:00 — is so that if one slot is lost to sleep, the others pick it up.Switched to multiple slots without adding an idempotency guard, and posted 4 times in one day. Multi-slot is a "any one getting through is enough" design. Unless you simultaneously add a mechanism to skip the rest once the first slot succeeds (e.g., checking a state file for the day), all 4 slots go through and it runs 4 times. Changing
StartCalendarIntervalfrom single to multiple and adding an idempotency guard are a set. Do only one and you get real damage.Designed the schedule interval based on "how long one job takes on its own." The
tiktok-autopostcase is a textbook example. I'd set the comment job to a 15-minute interval, but the job for another account sharing the exclusive lock (tiktok-avd.lock) uses the same lock. Fire a job that takes about 10 minutes per run at 15-minute intervals across 2 accounts combined (meaning the other one arrives every 7.5 minutes), and the lock is permanently occupied. Measured from July 21 to August 14, 2026:renappilaunched 617 times with 255exit 1s,bokuwalilylaunched 784 times with 458exit 1s — all with zero results, while the follow lane hadn't updated its log in 3 weeks. Calculate intervals from the total occupancy of every job sharing the exclusive resource. A schedule designed around individual jobs' convenience will inevitably crush other lanes as scale grows.Didn't verify a zero-result job's reason for existing before lowering its frequency. In the tiktok case above, I'd run the comment job 600+ times with the account still configured at
commentActions=0. That a job is running (exit code 0) and that a job is producing its intended result are two different things. Before adjusting frequency, check "has this job produced even a single result today?"Didn't set
EnvironmentVariables, so PATH was missing. The default PATH for jobs launched by launchd is roughly/usr/bin:/bin:/usr/sbin:/sbin. Homebrew binaries (/opt/homebrew/bin) and nvm's Node (~/.nvm/versions/node/...) aren't in it. If you getcommand not foundbut the same command works when run manually from the terminal, this is it. Always add this to the plist:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
Used
system/as the domain forlaunchctl bootout. User-level LaunchAgents live in thegui/$UIDdomain.system/is the LaunchDaemons domain, managed by root. Runningbootoutagainstsystem/com.shun.zenn-dailyjust ends withNo such processand the job doesn't stop. A plist placed in~/Library/LaunchAgents/always usesgui/$UID.Passed a relative path to the wrapper script's
--logargument. Wrappers likerun-with-retry.shmaycdinternally. If you pass a relative path to--log, the wrapper expands it after changing directory, so the log gets written somewhere unexpected — or the file isn't created and the output is discarded. Always pass--logan absolute path.Left a new job at
RunAtLoad: truewhile also settingStartCalendarInterval.RunAtLoad: truealso runs once the moment the plist is loaded (right afterbootstrap). Combined withStartCalendarInterval, you get one run right after load plus one at each scheduled time. A case had crept into the fleet whereRunAtLoadwas temporarily set totruefor debugging and then left there.RunAtLoadin a production plist isfalseas a rule.
Best practices
From the experience of maintaining a 160-job fleet, here are only the rules that, had I followed them, would have prevented an actual incident.
1. Whenever you touch a plist, always follow the order plutil -lint → bootout → bootstrap → launchctl print.
Break this order and you can no longer isolate why something doesn't work. plutil -lint is the one verification you can run "before stopping the job." Skip it and a broken plist silently surfaces.
2. bootout errors can be ignored; bootstrap errors must not be.
bootout is idempotent. Running it on an unloaded job just returns No such process, which isn't an anomaly. On the other hand, if bootstrap fails with 5: Input/output error, the job is left unloaded and gone. The only place 2>/dev/null belongs in a reload script is on the bootout side.
3. For verification, use launchctl print, not launchctl list.
# 退役確認(消えていればOK)
launchctl list | grep com.shun.zenn-daily
# 再ロード後の定義確認(新しい定義で動いているか)
launchctl print gui/$UID/com.shun.zenn-daily | grep "program ="
Showing up in launchctl list is evidence of "registered," not evidence of "loaded with the latest plist definition." Use launchctl print to verify the definition.
4. Build the three keys ProcessType=Background / Nice=10 / LowPriorityIO=true into your template.
Adding them by hand every time you write a new plist leads to omissions. Put these three keys in your standard template file and copy from it.
5. Make StartCalendarInterval a multi-slot dict array, not a single dict.
It's insurance against firings lost to sleep. With 3 slots, if one is lost to sleep, one of the remaining two gets through. When switching to multi-slot, always add an idempotency guard (skip if already succeeded today) at the same time.
6. Design schedules from the total occupancy of the exclusive resource.
If multiple jobs use the same lock, first compute the maximum frequency from the fleet-wide concurrency and per-run duration. Fitting 11 lanes into 120 minutes means the maximum adjacent interval is 120/11 ≈ 10.9 minutes. "All gaps 11+ minutes" and "strictly every 2 hours" are mathematically incompatible. If you compromise in the design, leave a comment stating explicitly which one you broke.
7. Write every path inside a plist as an absolute path.
~ isn't expanded because launchd doesn't run as a shell. Tilde notation is fine in articles and explanations, but the real file gets absolute paths only. The same applies when setting PATH in EnvironmentVariables.
8. Write environment variable assignments in the :- form.
# NG: ラッパが渡した値を上書きする
export CLAUDE_BIN=~/.local/bin/claude
# OK: 既存値がある場合は尊重する
export CLAUDE_BIN="${CLAUDE_BIN:-~/.local/bin/claude}"
Even with the wrapper wired in, if a shell script along the path assigns unconditionally, the guard never arrives. After wiring, sweep everything with grep -r 'CLAUDE_BIN' ~/dev ~/.claude/scripts and eliminate any assignment not in the :- form.
9. Put a count assertion on the test's LABELS constant.
Manage "the list of jobs that should have this wrapper" in a LABELS constant in the test, and assert the count too, like assert len(labels) == 44. When you add a job, the count test fails and "the new job doesn't have the wrapper" is detected mechanically. A test that only checks label existence passes even on an old commented-out definition.
10. Back up to a dated directory and confirm full byte-for-byte match with cmp before moving.
BACKUP=~/content/launchagents-backup-$(date +%Y%m%d)
mkdir -p "$BACKUP"
cp ~/Library/LaunchAgents/com.shun.zenn-daily.plist "$BACKUP/"
cmp ~/Library/LaunchAgents/com.shun.zenn-daily.plist "$BACKUP/com.shun.zenn-daily.plist" \
&& echo "backup OK" || echo "backup FAILED"
cp occasionally produces a file with a different byte count without raising an error (iCloud write delays, etc.). I make it a habit to confirm with cmp before touching the original.
11. Run a monthly inventory script on a schedule to flush out "ghost jobs."
launchctl list | grep 'com\.lily\|com\.shun' \
| awk '{print $3}' | sort > /tmp/loaded.txt
for f in ~/Library/LaunchAgents/com.{lily,shun}.*.plist; do
defaults read "$f" Label 2>/dev/null
done | sort > /tmp/files.txt
echo "=== ファイルなし・ロード済み(幽霊)===" && comm -23 /tmp/loaded.txt /tmp/files.txt
echo "=== plistあり・アンロード済み ===" && comm -13 /tmp/loaded.txt /tmp/files.txt
Lines under "no file, loaded" are ghost jobs. Any job you thought you'd stopped with a .retired rename that's survived across sessions will show up here without fail. Run it once a month and any missed step gets caught in next month's inventory.
12. Before lowering a zero-result job's frequency, verify its reason for existing.
That a job is running (exit 0) and that it's producing results (posts, follows, replies actually happening) are different things. There was a real case where, past the point of judging "it's running" from exit 0 in the logs alone, everything had been zero for 3 weeks. Frequency adjustment comes later; first, check in the actual logs whether "this job produced even one real result today."
Summary
com.shun.zenn-daily.plist.retired still exists on the filesystem today. The file name changed, but the Label inside is still com.shun.zenn-daily. Unless launchctl bootout gui/$UID/com.shun.zenn-daily was run, this job fires again today at 7:30, 10:00, 14:00, and 19:00.
Declaring retirement and executing it are separate operations.
- The rename is the declaration. It doesn't change launchd's table.
-
bootoutis the execution. Only once this goes through does the entry leave the table. -
launchctl printis the verification. Only after confirming the live definition withprint, notlist, is retirement "complete."
As automation scales up, you reboot the Mac less often. The longer a session goes without a reboot, the longer jobs you "stopped" with a rename alone survive. On a fleet past 160 jobs, "I stopped it but it's running," "I changed it but it didn't take," and "it's running but producing zero" all happen at the same time.
One monthly inventory script and the bootout → list → print verification habit prevent almost every failure in this class. The commands are three lines. It's not about the procedure — it's entirely about whether you know.
How many jobs are sitting in your launchctl list right now that you're sure you retired?
The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure are in a paid note (Japanese).
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)