# 4 Ways a 'Retired' launchd Job Kept Running on a 160+ Job Fleet — and the 3 Commands That Actually Stop It

> Source: <https://dev.to/bokuwalily/4-ways-a-retired-launchd-job-kept-running-on-a-160-job-fleet-and-the-3-commands-that-actually-1fk6>
> Published: 2026-09-17 11:00:06+00:00

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.**

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`.

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.

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 → 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 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).

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  → エントリなし     │
│      → 発火しない                                 │
└─────────────────────────────────────────────────┘
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.

```
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
```

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`.

```
# 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`?"

`.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.**

`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.

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).

``` python
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`.

`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.

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.

**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.

`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.

`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.

What the four cases have in common is a structure where "the error is invisible."

`bootout` → the job keeps running, but nothing appears in launchd's error logs`2>/dev/null`, silence
**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.

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 -lint` before `bootout → bootstrap`.** If the plist has a syntax error, `bootstrap` looks successful and the job even shows in `launchctl list`, but it never fires — not once. macOS silently ignores broken plists. Even in `launchctl print` it stays at `state = waiting` and doesn't run when the time comes. Twice I spent an hour investigating with no idea of the cause, only to finally run `plutil -lint com.lily.something.plist` and see the syntax error. **Whenever you touch a file, always go `plutil -lint` → `bootout` → `bootstrap` → `launchctl print`, in that order.** `plutil -lint` alone 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 with `exit 127: command not found`." If you look at the pre-change `ProgramArguments` in `~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired`, it doesn't actually say `~/.discord/run-and-notify.sh`. `cat` the 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`, and `LowPriorityIO` in 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 `StartCalendarInterval` as a single dict and placed the firing time during hours when the Mac sleeps.** If the Mac is asleep at a `StartCalendarInterval` firing 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 reason `com.shun.zenn-daily` has 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 `StartCalendarInterval` from 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-autopost` case 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: `renappi` launched 617 times with 255 `exit 1` s, `bokuwalily` launched 784 times with 458 `exit 1` s — 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 get `command not found` but 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 for `launchctl bootout`.** User-level LaunchAgents live in the `gui/$UID` domain. `system/` is the LaunchDaemons domain, managed by root. Running `bootout` against `system/com.shun.zenn-daily` just ends with `No such process` and the job doesn't stop. A plist placed in `~/Library/LaunchAgents/` always uses `gui/$UID`.

**Passed a relative path to the wrapper script's `--log` argument.** Wrappers like `run-with-retry.sh` may `cd` internally. 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 `--log` an absolute path.

**Left a new job at `RunAtLoad: true` while also setting `StartCalendarInterval`.** `RunAtLoad: true` also runs once the moment the plist is loaded (right after `bootstrap`). Combined with `StartCalendarInterval`, you get one run right after load plus one at each scheduled time. A case had crept into the fleet where `RunAtLoad` was temporarily set to `true` for debugging and then left there. `RunAtLoad` in a production plist is `false` as a rule.

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."

`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.

`print`, not `list`, 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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
