DEV Community

Lily
Lily

Posted on Originally published at dev.to

My Outreach Schedule Got Rewritten to 2:31 AM Without Me: Building a launchd Guard That Self-Heals in Under 10 Minutes

Six months after a layoff left me with zero take-home pay, I had built an autonomous Claude Code setup that now brings in a steady ¥1.2M a month. The whole thing rests on one assumption: outbound sales automation that runs at midnight, on weekends, without me touching it. I took "never stops" for granted right up until the early hours of August 23, 2026.

Why This Setup Works

"Building the environment" and "doing the work" are two different things

When solo developers want to grow revenue, most jump straight into "working faster." Send DMs quicker, process bigger lists, write copy with a higher reply rate. All of that matters. But somewhere past ¥600K a month, the bottleneck stopped being the speed of the work and became the robustness of the environment.

My outreach (sales DMs) is fully automated by four LaunchAgents.

Agent Sends per day Schedule
com.lily.outreach-ig 8 8:20–22:20 (every 2 hours)
com.lily.outreach-th 7 9:38–21:38 (every 2 hours)
com.lily.followers-outreach 8 8:35–22:35 (every 2 hours)
com.lily.outreach-yt 6 10:52–20:52 (every 2 hours)

That's 29 runs a day, firing automatically every time the Mac is up, with no input from me. As long as this runs normally, I can focus on content production.

The problem: these configuration files (plists) got rewritten from outside. That was a real incident.

The real incident on 2026-08-23

It's right there in the comment at the top of the script.

# 2026-08-23 12:04 に outreach-ig が 8回/日 -> 3回/日(2:31/10:31/18:31)、
# outreach-th が 7回/日 -> 3回/日 に一斉に書き換えられ、
# 深夜帯に営業DMを送る設定になっていた。
Enter fullscreen mode Exit fullscreen mode

(Translation: at 12:04 on 2026-08-23, outreach-ig was changed from 8 runs/day to 3 runs/day at 2:31/10:31/18:31, and outreach-th from 7 runs/day to 3 runs/day, all at once, leaving the setup sending sales DMs in the middle of the night.)

It wasn't just that 8 runs got cut to 3. The times had shifted to include a 2:31 AM slot. An account that keeps sending DMs in the dead of night gets flagged by platform spam detection. Worst case, the account is frozen, and the sales lists and followers I'd built up are gone.

I never identified the culprit. Maybe some script I'd installed at the time, maybe a side effect of a system update. But the important thing isn't finding the culprit. It's building a mechanism that detects the change immediately and reverts it.

"Fix it when I notice" is over. The machine fixes it before a human notices.

What breaks when the schedule breaks, in numbers

outreach-ig's normal schedule is 8 runs a day. Drop that to 3 and daily touchpoints fall by 62.5%. Factoring in my DM-to-meeting conversion rate, leaving the schedule broken for just half a day wipes out dozens of meeting opportunities on a monthly basis.

On top of that, when a 2:31 AM send is detected, the risk of the account being flagged isn't a vague "that seems bad." It's a concrete risk spelled out in each platform's API terms of service. Repeated unintended late-night sends drag down the account's reputation score in short order.

So instead of "fix it later," I designed the solution as "automatically revert within 10 minutes."

The Overall Flow

Architecture overview

┌───────────────────────────────────────────────────────────┐
│  macOS launchd                                            │
│                                                           │
│  ┌─────────────────────┐  StartCalendarInterval          │
│  │ outreach-ig.plist   │  8:20, 10:20, 12:20 ...  ──────►│ browser-slot.sh
│  │ outreach-th.plist   │  9:38, 11:38, 13:38 ...  ──────►│ run-lane.sh
│  │ followers-outreach  │  8:35, 10:35, 12:35 ...  ──────►│ (営業DM送信)
│  │ outreach-yt.plist   │  10:52, 12:52, 14:52 ... ──────►│
│  └─────────────────────┘                                  │
│           ▲ 書き換え検知 → 即時復元                        │
│           │                                               │
│  ┌─────────────────────────────────────────────┐          │
│  │ outreach-schedule-guard.plist               │          │
│  │   StartInterval: 600(10分ごと)            │          │
│  │        │                                   │          │
│  │        ▼                                   │          │
│  │ outreach-schedule-guard.sh                 │          │
│  │   1. plist を python3+plistlib で読む       │          │
│  │   2. SPECS の期待値と実値を比較             │          │
│  │   3. 差異あり → forensicログ + 復元 + reload│          │
│  └─────────────────────────────────────────────┘          │
└───────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

(In the diagram: "営業DM送信" = sends sales DMs; "書き換え検知 → 即時復元" = tamper detected → immediate restore; "10分ごと" = every 10 minutes; steps 1–3 = read the plist with python3+plistlib, compare expected values in SPECS against actual values, and on mismatch write a forensic log, restore, and reload.)

The watchdog (guard) is itself driven by its own plist (com.lily.outreach-schedule-guard.plist). By handing even the "watchdog that watches the watchdog" to launchd, I delegate the script's own stop/restart/crash resilience to the OS layer.

The SPECS array: declaring expected values

The core of outreach-schedule-guard.sh is that the monitored targets are declared as a string array of label:minute:hour-list.

SPECS=(
  "com.lily.outreach-ig:20:8,10,12,14,16,18,20,22"
  "com.lily.outreach-th:38:9,11,13,15,17,19,21"
  "com.lily.followers-outreach:35:8,10,12,14,16,18,20,22"
  "com.lily.outreach-yt:52:10,12,14,16,18,20"
)
Enter fullscreen mode Exit fullscreen mode

Read com.lily.outreach-ig:20:8,10,12,14,16,18,20,22 as "outreach-ig's StartCalendarInterval should be 8 entries at minute 20 with Hour=8,10,12,14,16,18,20,22." To add a new outreach lane, you add one line to the SPECS array and it's monitored.

Extracting actual values: parsing directly with plistlib

To read StartCalendarInterval from the XML plist, I chose Python's standard library plistlib over /usr/libexec/PlistBuddy or hand-rolled XML parsing, and for a reason.

actual="$(python3 - "$plist" <<'PY'
import plistlib, sys
try:
    with open(sys.argv[1],'rb') as f: d = plistlib.load(f)
    rows = d.get('StartCalendarInterval') or []
    if isinstance(rows, dict): rows = [rows]
    print(','.join(f"{r.get('Hour')}:{r.get('Minute')}" for r in rows))
except Exception as e:
    print('ERR')
PY
)"
Enter fullscreen mode Exit fullscreen mode

plistlib.load() handles both binary and XML plists. StartCalendarInterval comes back as a dict when there's a single entry and a list when there are several, so isinstance(rows, dict) absorbs that. The output is a comma-separated string like 8:20,10:20,12:20,....

The expected value is generated in the same format on the bash side.

expected=""
IFS=',' read -r -a harr <<< "$hours"
for h in "${harr[@]}"; do expected="${expected}${expected:+,}${h}:${minute}"; done
Enter fullscreen mode Exit fullscreen mode

${expected:+,} is the idiom for "prepend a comma only if expected is non-empty." It produces 8:20,10:20,..., and a plain string comparison against actual is all the diff detection needs.

From diff detection to restore: a 4-step implementation

[ "$actual" = "$expected" ] && continue
Enter fullscreen mode Exit fullscreen mode

If they match, continue skips. If they differ, the next block runs.

Step 1: forensic log

log "$label: 書き換え検知 mtime=$(stat -f '%Sm' -t '%F %T' "$plist")"
log "$label:   現在   = $actual"
log "$label:   あるべき= $expected"
ps -Ao pid,lstart,comm | tail -n +2 | while read -r p rest2; do echo "$p $rest2"; done \
  | grep -iE "python|node|bash|launchctl|plutil" | tail -25 | while read -r l; do log "$label:   ps> $l"; done
Enter fullscreen mode Exit fullscreen mode

stat -f '%Sm' records the plist's last-modified time. The ps command logs up to 25 processes matching python, node, bash, launchctl, or plutil. Even if it doesn't identify the culprit, a timestamp plus a process list is useful for investigation later.

Step 2: backup

cp "$plist" "$plist.bak-guard-$(date +%Y%m%d-%H%M%S)"
Enter fullscreen mode Exit fullscreen mode

The tampered plist is saved under a name like com.lily.outreach-ig.plist.bak-guard-20260823-120412. This preserves evidence so the pre-restore state can be reproduced afterward.

Step 3: overwrite with correct values via plistlib

python3 - "$plist" "$minute" "$hours" <<'PY'
import plistlib, sys
path, minute, hours = sys.argv[1], int(sys.argv[2]), sys.argv[3]
with open(path,'rb') as f: d = plistlib.load(f)
d['StartCalendarInterval'] = [{'Hour': int(h), 'Minute': minute} for h in hours.split(',')]
with open(path,'wb') as f: plistlib.dump(d, f)
PY
Enter fullscreen mode Exit fullscreen mode

Other keys such as Label, ProgramArguments, and EnvironmentVariables are left untouched. Only StartCalendarInterval is replaced. Because it's written out with plistlib.dump, the generated XML conforms to Apple's official format.

Step 4: validate and reload

if plutil -lint "$plist" >/dev/null 2>&1; then
  launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
  sleep 1
  if launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null; then
    log "$label: 復元して再読込した"
  else
    log "$label: 🔴 bootstrap に失敗した(手動確認が要る)"
  fi
else
  log "$label: 🔴 復元後のplistが壊れている(戻していない)"
fi
Enter fullscreen mode Exit fullscreen mode

plutil -lint confirms the file is valid before launchctl bootoutbootstrap. bootout is safe to call even against an agent that isn't loaded, so there's no need to pre-check whether the label is registered. If bootstrap fails, the log entry gets a 🔴 and explicitly says "manual check required." The design doesn't hide situations it can't resolve on its own. (Log strings: "復元して再読込した" = restored and reloaded; "bootstrap に失敗した(手動確認が要る)" = bootstrap failed, manual check required; "復元後のplistが壊れている(戻していない)" = restored plist is broken, not reloaded.)

Running the watchdog itself on a fixed interval: guard.plist

<key>StartInterval</key>
<integer>600</integer>
Enter fullscreen mode Exit fullscreen mode

StartInterval in com.lily.outreach-schedule-guard.plist is 600 seconds, i.e. 10 minutes. launchd's StartInterval keeps counting time during system sleep, and on wake it detects "how many runs should have happened" and fires. So even if the tampering occurs during the three hours the Mac was asleep, the automatic restore runs within 10 minutes of waking.

Log output is split into two files, ~/.claude/logs/outreach-schedule-guard.out.log and outreach-schedule-guard.err.log. Normal logs go to stdout, unexpected bash errors to stderr, so there's no mixed noise when monitoring in real time with tail -f.

Implementation Details

Why -e is dropped from set -uo pipefail

Line one of the script contains a quiet but important decision.

set -uo pipefail
Enter fullscreen mode Exit fullscreen mode

There's no -e (exit immediately on error). That's deliberate. With -euo pipefail, the whole script dies the moment launchctl bootout returns non-zero.

bootout returns an error when the specified label isn't registered with launchd. Since the four agents are checked in sequence inside a for loop, a failed bootout on the first one would skip monitoring of the remaining three. That's not a watchdog. So I keep -u (error on undefined variables) and pipefail (catch failures mid-pipe) but drop -e. The bootout call also gets 2>/dev/null to silence the "not registered" message on stderr.

launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
Enter fullscreen mode Exit fullscreen mode

The launchd PATH problem and explicit declaration

export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
Enter fullscreen mode Exit fullscreen mode

Scripts started by launchd inherit nothing from your login shell's ~/.zshrc or ~/.zprofile. However carefully you've grown PATH in the terminal, none of it reaches the launchd execution environment. If python3 can't be found, every step that uses plistlib goes silent.

The same PATH is defined in guard.plist's EnvironmentVariables block.

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
Enter fullscreen mode Exit fullscreen mode

The monitored outreach-ig.plist, by contrast, has a somewhat longer PATH.

<key>BROWSER_SLOT_TIMEOUT_SEC</key>
<string>2700</string>
<key>PATH</key>
<string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:...</string>
Enter fullscreen mode Exit fullscreen mode

It has BROWSER_SLOT_TIMEOUT_SEC (45-minute session cap) and the nvm Node.js path, because outreach-ig drives a browser via Playwright. The guard only uses python3, stat, launchctl, and plutil, so Homebrew plus the system PATH is enough. The "minimum required" PATH differs between watcher and watched.

The log() function and stdout/stderr separation

log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
Enter fullscreen mode Exit fullscreen mode

printf instead of echo because echo interprets flags like -e depending on the shell implementation. date '+%F %T' produces a timestamp in the form 2026-08-23 13:07:37.

$LOG is a file path defined inside the script (~/.claude/logs/outreach-schedule-guard.log) and appended to directly. It's separate from the StandardOutPath and StandardErrorPath defined in guard.plist.

<key>StandardOutPath</key>
<string>~/.claude/logs/outreach-schedule-guard.out.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/outreach-schedule-guard.err.log</string>
Enter fullscreen mode Exit fullscreen mode

The result is three log files with distinct roles. .log is the monitoring output the guard writes on purpose. .out.log is the script's stdout (whatever printf or echo emits). .err.log is unexpected errors thrown by bash. If tail -f ~/.claude/logs/outreach-schedule-guard.err.log is empty, you know instantly that there are zero bash-level problems. Mixing monitoring logs and shell errors in one file means parsing which noise is which every single time.

The forensic log: actual output from the day of the incident

Here is the log the guard wrote for outreach-th during the 2026-08-23 incident, verbatim.

2026-08-23 13:07:37 com.lily.outreach-th: 書き換え検知 mtime=2026-08-23 13:07:37
2026-08-23 13:07:37 com.lily.outreach-th:   現在   = 2:31,10:31,18:31
2026-08-23 13:07:37 com.lily.outreach-th:   あるべき= 9:38,11:38,13:38,15:38,17:38,19:38,21:38
2026-08-23 13:07:37 com.lily.outreach-th:   ps> 80406 日  8/23 12:50:01 2026  Python
2026-08-23 13:07:37 com.lily.outreach-th:   ps> 80842 金  8/21 11:06:32 2026  node
2026-08-23 13:07:37 com.lily.outreach-th:   ps> 81332 日  8/23 12:50:08 2026  bash
2026-08-23 13:07:37 com.lily.outreach-th:   ps> 82665 日  8/23 12:50:32 2026  playwright/driver/node
(以下、合計25件)
2026-08-23 13:07:38 com.lily.outreach-th: 復元して再読込した
Enter fullscreen mode Exit fullscreen mode

(Line labels: "書き換え検知" = tamper detected; "現在" = current; "あるべき" = expected; "(以下、合計25件)" = 25 entries in total; "復元して再読込した" = restored and reloaded.)

A few facts can be read from this.

mtime=2026-08-23 13:07:37: the same second as the guard's own execution timestamp. Either the guard ran right after the plist was modified, or the modification happened just before the 10-minute poll. Either way, the numbers confirm detection within 10 minutes.

The Playwright process in ps (PID 82665): a Playwright driver started at 12:50:32 was still around. That overlaps with the plist's modification window. Not conclusive evidence, but it supports the hypothesis that "the plist changed during some Playwright run."

"Restored and reloaded" at 13:07:38: less than one second from detection to restore. Everything, including re-registration with launchd, completes in real time.

Why plutil -lint and sleep 1 are necessary

if plutil -lint "$plist" >/dev/null 2>&1; then
  launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
  sleep 1
  if launchctl bootstrap "gui/$(id -u)" "$plist" 2>/dev/null; then
    log "$label: 復元して再読込した"
  else
    log "$label: 🔴 bootstrap に失敗した(手動確認が要る)"
  fi
else
  log "$label: 🔴 復元後のplistが壊れている(戻していない)"
fi
Enter fullscreen mode Exit fullscreen mode

plutil -lint is Apple's official plist validator. plistlib.dump() generally produces valid XML, but an interruption mid-write or an unexpected filesystem error leaving a half-written byte sequence is not a zero-probability event. Bootstrapping a corrupt plist can put launchd in an unexpected state, so right after Python writes the file, it always goes through Apple's validation before re-registration.

sleep 1 is an explicit wait for launchd's processing. The bootout command sends launchd a request to unload the label, but launchd completes that work asynchronously. Call bootstrap immediately and launchd may decide it's "still registered" and fail.

Hidden design intent in guard.plist

Looking again at the guard's own plist, four keys are set explicitly.

<key>RunAtLoad</key>
<false/>
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
Enter fullscreen mode Exit fullscreen mode

RunAtLoad: false: don't run immediately at bootstrap time (e.g. login). Set to true, the guard would run on every startup. The fixed StartInterval: 600 is sufficient, so the extra run is skipped.

LowPriorityIO: true and Nice: 10: lower I/O priority and add +10 to CPU nice. The guard is a lightweight process that runs once every 10 minutes for a few dozen milliseconds. If it interfered with the I/O or browser rendering of outreach-ig, which is actually sending the sales DMs, that would defeat the purpose. The monitoring process never outranks the monitored one. These settings express that relationship correctly.


Where I Got Stuck

The -e flag: the watchdog was quietly dying on the first agent

The first version was written with set -euo pipefail. Run manually, it checked all four SPECS entries. Run via launchd, no log entries ever appeared past the first one.

Looking at ~/.claude/logs/outreach-schedule-guard.log, nothing followed the outreach-ig processing log. .err.log was empty. Nothing in launchd's journal either. It wasn't "the process didn't start." It was "started and stopped partway."

I found the cause when I manually checked the exit code of launchctl bootout.

launchctl bootout "gui/$(id -u)/com.lily.outreach-th" 2>/dev/null
echo $?
Enter fullscreen mode Exit fullscreen mode

Output: 36 (error). outreach-th wasn't registered with launchd at that moment (already booted out in the previous cycle), so bootout returned non-zero and -e terminated the script on the spot.

Two fixes. Change to set -uo pipefail, and add 2>/dev/null to bootout. That alone got all four processed in order. "Stop on error" looks safe, but for this use case it was the setting that killed the watchdog.

A space in the heredoc: fatal under launchd

actual="$(python3 - "$plist" <<'PY'
import plistlib, sys
...
PY
)"
Enter fullscreen mode Exit fullscreen mode

This worked perfectly in my local terminal, but run via launchd the script process never died and just stayed alive. Nothing in the logs. The guard process kept consuming a trickle of CPU.

I confirmed it was alive with ps aux | grep outreach-schedule-guard, dug in with the strace equivalent, and found it blocking inside the plist step. The cause: a trailing space after the heredoc end marker PY.

actual="$(python3 - "$plist" <<'PY '   # ← 末尾スペース
Enter fullscreen mode Exit fullscreen mode

(The comment marks the trailing space.) zsh tolerates this, but the /bin/bash that launchd invokes keeps waiting for 'PY ' (space included) as the terminator. A line reading PY never arrives on the input stream, so the Python process hangs waiting on stdin. With no timeout configured, when the guard tries to start on the next 10-minute cycle the previous process is still alive, and you get a double run.

The fix was just removing the trailing space, but the symptom combination of "no logs plus a process that won't die" made diagnosis slow. When you write automation scripts assuming zsh and run them under launchd (bash), you have to visually check heredoc end markers for stray whitespace.

The isinstance trap: an infinite restore loop on single-entry plists

When plistlib reads StartCalendarInterval, multiple entries come back as list[dict]. But a single entry comes back as a bare dict.

In the early version, written without knowing this, creating a test plist with only one StartCalendarInterval entry threw a TypeError.

TypeError: 'dict' object is not iterable
Enter fullscreen mode Exit fullscreen mode

The Python exception is caught on the guard side and actual becomes the string ERR. ERR never matches any expected value, so every cycle judges "tamper detected" → create backup → overwrite with plistlib.dump → plutil -lint → bootout → bootstrap → log "restored and reloaded," forever. The plist contents are actually correct, yet the guard keeps frantically writing "restored."

rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]   # この1行が防波堤
print(','.join(f"{r.get('Hour')}:{r.get('Minute')}" for r in rows))
Enter fullscreen mode Exit fullscreen mode

(The comment reads: this one line is the breakwater.) All production plists have multiple entries, so it never reproduced in production. I only hit it when hand-building a simple test plist, and realized it was a landmine buried where "only the test environment steps on it." plistlib's behavior is documented, but writing "code that only anticipates the multi-entry case" is extremely natural, so you need the habit of checking types before using an API or absorbing them with isinstance.

stat syntax: completely different on macOS and Linux

mtime=$(stat -f '%Sm' -t '%F %T' "$plist")
Enter fullscreen mode Exit fullscreen mode

This is macOS (BSD-style stat) syntax. The Linux equivalent is stat -c '%y' "$plist". When I ran unit tests in a Docker container, this line failed with illegal option -- f and I realized "this doesn't run in some container."

That said, this script is launchd-only, and launchd exists only on macOS, so Linux compatibility has no meaning here. Zero real harm, but it's a snag during development or testing on Linux.

To unpack -f '%Sm' -t '%F %T': -f specifies a format string, %S means "display the time using the -t format," and m is "last modification time (mtime)." The %F %T passed to -t is strftime format, so %F = 2026-08-23 and %T = 13:07:37. If you're used to GNU's stat -c, -f means something entirely different and it's confusing. When writing macOS-specific stat, it's faster to run man stat first or try it in a macOS terminal before writing.

Pitfalls

Beyond the four snags above (-e flag, heredoc space, isinstance, stat syntax), unexpected walls keep appearing once you actually build this. Here's an exhaustive list so you don't burn the same hours.

  • Mistaking the first argument of launchctl bootstrap for a domain
    In bootstrap "gui/$(id -u)" "$plist", gui/501 means "GUI session 501." Confuse it with user/$(id -u) (background session) or system/ (root only) and you can get a case where nothing errors and nothing silently starts either. For your own user agents, always gui/$(id -u).

  • id -u returns empty instead of a number
    Rarely, when launchd starts the script from outside a session, environment variables get stripped and id -u returns an empty string. You end up with a doubled slash like bootout "gui//com.lily.outreach-ig" and bootout fails silently. It's safer to write : "${UID:=$(id -u)}" at the top of the script as a fallback.

  • Backup files pile up without limit
    Every restore generates a com.lily.outreach-ig.plist.bak-guard-20260823-120412. If incidents recur intermittently, ~/Library/LaunchAgents/ fills with hundreds of backups. Add one line at the end of the guard script, find "$LA" -name '*.bak-guard-*' -mtime +7 -delete, and backups older than 7 days are removed automatically.

  • The plist errors when the log file doesn't exist
    If the directory of the path given to StandardOutPath doesn't exist, launchd refuses to bootstrap the plist at all. In an environment where ~/.claude/logs/ doesn't exist on first launch, the guard itself never starts. mkdir -p "$(dirname "$LOG")" at the top of the script is mandatory, but the directories pointed to by the plist's StandardOutPath/StandardErrorPath must be created in advance as well.

  • RunAtLoad: false delays the first check by 10 minutes
    guard.plist's RunAtLoad is false. This avoids an unnecessary run right after login, but the flip side is that after a Mac reboot, the first check runs 10 minutes later. If you reboot right after an incident, that 10-minute window is blind. During incident response, get in the habit of manually starting it with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.lily.outreach-schedule-guard.plist before doing anything else.

  • Cases where plutil -lint passes but launchd rejects
    plutil -lint validates XML syntax, but launchd's key specification is a separate matter. For example, put only a Hour key in a StartCalendarInterval entry and omit Minute, and plutil -lint passes while launchd ignores the agent. When writing via plistlib, both keys must always be included, as in {'Hour': int(h), 'Minute': minute}.

  • Double launch when the guard runs longer than 600 seconds
    StartInterval: 600 doesn't mean launchd starts the process every 600 seconds "regardless of whether the previous run finished." In practice, the next interval counts from when the previous run completes. However, if heavy forensic work or accumulated sleep 1 calls slow the whole script down, the "finish → immediate start" cycle can effectively jam. Four agents × restore × sleep 1 takes up to about 4 seconds, which is normally no problem.

  • Nobody detects it when the guard's own plist is rewritten
    This is the design's "last guardian problem." If StartInterval in com.lily.outreach-schedule-guard.plist is rewritten, the guard can't detect that on its own. Countermeasures: include "com.lily.outreach-schedule-guard:*:*" in the SPECS array so the guard's own plist is compared too, or set up a separate cron that checks from outside with launchctl print gui/$(id -u)/com.lily.outreach-schedule-guard | grep interval.

  • The python3 path varies by Homebrew environment
    Even with export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" declared at the top of the script, /opt/homebrew may not exist depending on Apple Silicon vs Intel Mac (Intel uses /usr/local). I support both by adding /usr/local/bin to guard.plist's EnvironmentVariables.

  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
Enter fullscreen mode Exit fullscreen mode

The slight difference between the plist PATH and the script PATH is intentional. The plist side is self-contained as the launch environment, and the script side serves as a runtime override.

  • grep -iE "python|node|bash" matches the guard itself
    When the forensic ps collection searches for bash and python, the guard script itself (/bin/bash outreach-schedule-guard.sh) is a hit. No real harm, but you'll be surprised to find yourself in the "suspects" list when analyzing logs. You can add grep -v "outreach-schedule-guard" to the filter to exclude yourself.

  • StartInterval doesn't count time while the Mac is powered off
    launchd's StartInterval counts time while the system is "asleep," but not while it's fully shut down. If the machine was powered off for a long trip, the first check after power-on is 600 seconds later. This can't be changed, so before a long absence it's worth manually running the guard once and checking the log.


Best Practices

Based on the real code and the real incident, here are the principles to hold onto when writing this kind of "guardian script for automation."

1. Declare expected values in a single array
When "label:minute:hour-list" fits on one line like the SPECS array, adding or changing monitored targets is just an edit to SPECS. If monitoring logic and expected values are scattered, changing one creates inconsistency with the other.

2. Know that the -e flag doesn't coexist with for loops
set -euo pipefail is a handy safety device, but in a script where commands returning non-zero are mixed into a loop, it kills the watchdog. Keep -uo pipefail, drop -e, and attach 2>/dev/null or || true individually to lines where errors are expected. That's more robust.

3. Split logs into three files
Separate the monitoring log the script writes deliberately (.log), launchd's captured stdout (.out.log), and bash's unexpected errors (.err.log). If tail -f outreach-schedule-guard.err.log is empty, you know instantly there are zero shell-level problems. Mix them into one file and you pay that judgment cost every time.

4. Always validate with plutil -lint before restoring
plistlib.dump() generates valid XML, but the chance of a byte sequence being corrupted by an interruption mid-write isn't zero. launchctl bootstrap on a broken plist puts launchd into an undefined state. Always insert the step of reloading only what passes plutil -lint.

5. Put sleep 1 between bootout and bootstrap
launchctl bootout completes asynchronously inside launchd. Call bootstrap right away and it fails as "still registered." One second is enough, but skip it and you get hard-to-reproduce failures depending on the environment.

6. Preserve evidence with a backup before overwriting
The order matters: cp the tampered plist to a timestamped backup, then restore. If the file proving "what it was before" disappears after the restore, it's useless for root-cause investigation later. Pair this with a rule that auto-deletes backups after 7 days.

7. Leave a process list in the forensics
On tamper detection, log python, node, bash, launchctl, and plutil processes with ps -Ao pid,lstart,comm. Even if it doesn't directly identify the culprit, a process list for the time window is grounds for forming hypotheses. In the 2026-08-23 incident, the forensic log showed that a Playwright driver started at 12:50:32 (PID 82665) overlapped with the modification time.

8. Absorb plistlib's type wobble with isinstance
StartCalendarInterval returns a dict for one entry and list[dict] for several. Fail to handle this Python standard library behavior and a simple test plist throws a TypeError, causing the guard to false-positive into an infinite restore loop.

rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
Enter fullscreen mode Exit fullscreen mode

These two lines are the breakwater that keeps this from becoming a production outage.

9. Manage the guard with its own launchd agent
Make the script depend on cron or manual runs and monitoring stops the moment cron dies or you forget to run it. Registering the guard with launchd via com.lily.outreach-schedule-guard.plist lets the OS layer auto-recover from Mac reboots, crashes, and user mistakes.

10. Set the monitoring process's priority below the monitored one
LowPriorityIO: true, Nice: 10, and ProcessType: Background in guard.plist make explicit that the guard is a lightweight process running once every 10 minutes, while ensuring it doesn't interfere with the I/O of the real sales DM process. Setups where the monitoring script runs at higher priority than the production script, defeating the purpose, are surprisingly common.

11. Declare PATH in both the script and the plist
launchd doesn't read your login shell's ~/.zshrc. Write it not only in the script's export PATH=... but also in the plist's EnvironmentVariables > PATH, so python3, plutil, and launchctl are reliably found through either launch path.

12. Skip unnecessary launches with RunAtLoad: false, and understand its limitation
The guard doesn't need to run at every login, so set false. But this setting is inseparable from the fact that "the first check after login is delayed 10 minutes." If you want to verify the schedule manually right after a reboot, kick it manually with launchctl.

13. Watch for trailing spaces after bash heredoc end markers
In zsh, a heredoc works fine even with <<'PY ' (space included), but the /bin/bash that launchd launches keeps waiting for PY as the terminator and the script blocks forever. The symptom shows up as "no logs plus a process that won't die," which takes time to diagnose. Always put the heredoc end marker on its own line and confirm there's no trailing space.

14. Consolidate monitored plist labels in the SPECS array so changes are one line
When adding a new outreach lane (e.g. outreach-tw), the only thing to do is add one line to the SPECS array. A structure that requires adding configuration across multiple files inevitably leads to omissions and inconsistencies. Choosing a "change SPECS and everything follows" design from the start makes long-term operation easy.

15. Note explicitly that the stat options are macOS-specific
stat -f '%Sm' -t '%F %T' is BSD-style macOS-only syntax. Try to test just this line on Linux and it fails with illegal option -- f. Since this script is launchd-only, there's no real harm, but it's a source of confusion when writing unit tests in Docker containers or CI environments. A one-line comment wherever a macOS-specific command is used prevents anyone picking up the code from getting stuck.


Summary

The 2026-08-23 incident, where the launchd plists for my sales DMs were rewritten to a late-night schedule, drove home a reality: "keeping automation running is easier than building a mechanism that detects breakage within 10 minutes and reverts it."

The design philosophy of outreach-schedule-guard.sh in one sentence: "Assume things will break, and have the OS run a watchdog while the human sleeps." A 73-line bash script packs into one file precise plist comparison via python3+plistlib, evidence preservation in forensic logs, atomic restore through plutil -lint and launchctl reload, and a "watchdog for the watchdog" structure where the guard itself is protected by launchd.

The foundation of ¥1.2M a month is an accumulation of unglamorous hardening like this. You end up spending more time making existing automation hard to break than writing flashy new automation scripts. I'm now convinced that's the essence of scaling solo development: separating "building the environment" from "doing the work."


The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day playbook 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)