🔍 LaunchAgentsのスケジュールが無断変更されたのを検知して自動復旧する
会社都合で解雇されて手取りがゼロになった日から、半年でClaude Code自律環境を組み上げ、今は月商120万を安定させています。その数字を支えているのは「深夜でも休日でも止まらない自動営業」です。止まらないはずだった——2026年8月23日の早朝まで、そう信じていました。
なぜこの仕組みが効くのか
「環境を作る」と「作業をする」は別物です
個人開発で収益を伸ばしたいとき、多くの人がいきなり「作業の効率化」から入ります。もっと速くDMを送る、もっと多くのリストを処理する、もっと返信率の高い文面を書く——確かにどれも大事です。でも月60万を超えたあたりから、作業の速度より環境の堅牢さがボトルネックになりました。
私のアウトリーチ(営業DM)は4本のLaunchAgentで完全自動化されています。
| エージェント | 1日の送信回数 | 起動タイミング |
|---|---|---|
| com.lily.outreach-ig | 8回 | 8:20〜22:20(2時間おき) |
| com.lily.outreach-th | 7回 | 9:38〜21:38(2時間おき) |
| com.lily.followers-outreach | 8回 | 8:35〜22:35(2時間おき) |
| com.lily.outreach-yt | 6回 | 10:52〜20:52(2時間おき) |
合計29回/日、何もしなくてもMacが起動するたびに自動で動きます。これが正常稼働していれば、私はコンテンツ制作に集中できます。
問題は、この設定ファイル(plist)が外部から書き換えられた、という実インシデントが発生したことです。
2026-08-23の実インシデント
スクリプト冒頭のコメントに書いてある通りです。
# 2026-08-23 12:04 に outreach-ig が 8回/日 -> 3回/日(2:31/10:31/18:31)、
# outreach-th が 7回/日 -> 3回/日 に一斉に書き換えられ、
# 深夜帯に営業DMを送る設定になっていた。
8回が3回に削られただけでなく、時刻も「2:31」という深夜帯を含む設定に変わっていました。深夜にDMを送り続けるアカウントは、プラットフォームのスパム判定にかかります。最悪アカウント凍結——そうなれば積み上げてきた営業リストもフォロワーもリセットです。
犯人は特定できていません。当時インストールしていた何かのスクリプト、それともシステムアップデートの副作用なのか。でも重要なのは犯人を特定することではなく、変更を即座に検知して戻す仕組みを作ることです。
「気づいたら直す」はもう卒業です。人間が気づく前に、機械が直す。
スケジュールが壊れると何が起きるか、定量的に
outreach-igの正常スケジュールは8回/日です。それが3回になると、1日あたりの接触機会が62.5%減ります。私のDM→商談転換率を加味すると、スケジュールが半日壊れたまま放置されただけで、月換算で数十件の商談機会が飛びます。
さらに深夜帯(2:31)送信が検知されたとき、アカウントにフラグが立つリスクは「なんとなく悪そう」ではなく、各プラットフォームのAPI利用規約で明文化されている実リスクです。意図しない深夜送信が続けば、短期間でアカウントのレピュテーションスコアが落ちます。
だから私はこの問題を「後で直す」ではなく**「10分以内に自動で戻す」**という設計で解決することにしました。
全体の流れ
アーキテクチャ全体図
┌───────────────────────────────────────────────────────────┐
│ 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│ │
│ └─────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
番犬(guard)が番犬自身のplist(com.lily.outreach-schedule-guard.plist)によって動かされている。「番犬を監視する番犬」まで含めてlauncdに任せることで、スクリプト単体の停止・再起動・クラッシュ耐性をOSレイヤーに委譲しています。
SPECS配列——期待値の定義
outreach-schedule-guard.shの核心は、監視対象をラベル:分:時間リストの文字列配列で宣言している点です。
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"
)
com.lily.outreach-ig:20:8,10,12,14,16,18,20,22 の読み方は「outreach-igのStartCalendarIntervalは、毎時20分・Hour=8,10,12,14,16,18,20,22の8エントリであるべき」です。新しい営業レーンを追加したければ、このSPECS配列に1行足すだけで監視対象が増えます。
実値の抽出——plistlibで直接パース
XMLのplistファイルからStartCalendarIntervalを読むのに、/usr/libexec/PlistBuddyやxmlパースを使わず、Pythonの標準ライブラリplistlibを選んだのは理由があります。
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
)"
plistlib.load()はバイナリplistもXMLも両方扱えます。StartCalendarIntervalが単一エントリのときはdict、複数エントリのときはlistで返るため、isinstance(rows, dict)で吸収しています。出力は8:20,10:20,12:20,...のようなカンマ区切り文字列です。
期待値は同じフォーマットでbash側で生成します。
expected=""
IFS=',' read -r -a harr <<< "$hours"
for h in "${harr[@]}"; do expected="${expected}${expected:+,}${h}:${minute}"; done
${expected:+,}は「expectedが空でなければカンマを付ける」という慣用句です。8:20,10:20,...が生成され、actualと文字列比較するだけで差異検知が完結します。
差異検知から復元まで——4ステップの実装
[ "$actual" = "$expected" ] && continue
一致すればcontinueでスキップ。差異があれば次のブロックが走ります。
ステップ1:forensicログ
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
stat -f '%Sm'でplistの最終変更日時を記録します。psコマンドでpython・node・bash・launchctl・plutilを含むプロセスを最大25件ログに残します。犯人特定には至らなくても、タイムスタンプとプロセス一覧があれば後日の調査に使えます。
ステップ2:バックアップ
cp "$plist" "$plist.bak-guard-$(date +%Y%m%d-%H%M%S)"
書き換えられた状態のplistをcom.lily.outreach-ig.plist.bak-guard-20260823-120412のような名前で保存します。「戻した」後に元の状態を再現できるよう証拠保全します。
ステップ3: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
LabelやProgramArguments、EnvironmentVariablesなど他のキーは一切触らず、StartCalendarIntervalだけを差し替えます。plistlib.dumpで書き出すため、Apple公式形式に準拠したXMLが生成されます。
ステップ4:バリデーションと再ロード
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
plutil -lintでXMLとして正しいか確認してからlaunchctl bootout→bootstrapします。bootoutはすでに起動していないエージェントに対しても安全に呼べるので、ラベルが登録されているかどうかの事前チェックは不要です。bootstrapが失敗した場合は🔴を付けてログに残し、ここだけ「手動確認が要る」と明示します——自動で解決できない事態を隠蔽しない設計です。
番犬自身の定周期実行——guard.plist
<key>StartInterval</key>
<integer>600</integer>
com.lily.outreach-schedule-guard.plistのStartIntervalは600秒=10分です。launchdのStartIntervalはシステムスリープ中も時刻をカウントし、復帰時に「本来実行すべきだった回数」を検知して起動します。つまりMacがスリープしていた3時間の間に書き換えが起きても、復帰後10分以内には自動復元が走ります。
ログ出力先は~/.claude/logs/outreach-schedule-guard.out.logとoutreach-schedule-guard.err.logの2ファイルに分離しています。stdoutに通常ログ、stderrにbashの予期しないエラーを分けることで、tail -fでリアルタイム監視するときにノイズが混在しません。
実装の詳細
set -uo pipefail から -e を外した理由
スクリプト1行目は地味に重要な判断を含んでいます。
set -uo pipefail
-e(エラー時即終了)が入っていません。これは意図的です。-euo pipefailにすると、launchctl bootoutが非ゼロを返した瞬間にスクリプト全体が死にます。
bootoutの仕様として「指定ラベルがlaunchdに登録されていなければエラーを返す」という挙動があります。4本のエージェントをforループで順番にチェックする構造上、1本目のbootoutが失敗した時点で残り3本の監視が全てスキップされる——それでは番犬として機能しません。-u(未定義変数の参照でエラー)とpipefail(パイプ途中の失敗を検知)は残しつつ、-eだけ外す判断をしています。あわせてbootoutの呼び出しには2>/dev/nullを付けてあり、「登録されていない」エラーメッセージがstderrに流れるのを黙らせています。
launchctl bootout "gui/$(id -u)/$label" 2>/dev/null
launchd の PATH 問題と明示宣言
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
launchdで起動するスクリプトは、ログインシェルの~/.zshrcや~/.zprofileを一切継承しません。ターミナルでいくらPATHを育てていても、launchd経由の実行環境には届かない。python3の実体が見えなければplistlibを使う全ての処理が沈黙します。
guard.plistにも同じPATHをEnvironmentVariablesブロックで定義しています。
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
一方、監視対象のoutreach-ig.plistはもう少し長い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>
BROWSER_SLOT_TIMEOUT_SEC(セッション最大45分)やnvmのNode.jsパスがある——outreach-igはPlaywrightでブラウザを操作するので必要です。guardはpython3・stat・launchctl・plutilだけ使うため、HomebrewとシステムPATHで十分です。監視される側と監視する側で、PATHの「必要最小限」が異なります。
log() 関数と stdout/stderr の分離
log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
echoではなくprintfを使っているのは、echoがシェル実装によって-eフラグ等を解釈するため。date '+%F %T'は2026-08-23 13:07:37形式のタイムスタンプを生成します。
$LOGはスクリプト内で定義した独自のファイルパス(~/.claude/logs/outreach-schedule-guard.log)に直接appendします。guard.plistが定義するStandardOutPathとStandardErrorPathとは別です。
<key>StandardOutPath</key>
<string>~/.claude/logs/outreach-schedule-guard.out.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/outreach-schedule-guard.err.log</string>
結果として3本のログファイルが使い分けられます。**.logはguardが意図的に記録した監視結果。.out.logはスクリプトのstdout(printfやechoがそのまま流れる)。.err.log**はbashが吐いた予期しないエラー。tail -f ~/.claude/logs/outreach-schedule-guard.err.logが空であれば「bashレベルの異常はゼロ」と瞬時に判断できます。監視ログとシェルエラーが同じファイルに混在していると、どちらのノイズかをパースして判断する手間が増えます。
forensicログ——インシデント当日の実出力
2026-08-23のインシデント時、outreach-thについてguardが残したログをそのまま引用します。
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: 復元して再読込した
いくつか読み取れる事実があります。
mtime=2026-08-23 13:07:37——guardの実行タイムスタンプと同秒です。plistが書き換えられた直後にguardが走ったか、もしくは10分ポーリングの直前に書き換えが行われたかのどちらかです。10分以内に確実に検知できていることが数字で確認できます。
psのPlaywrightプロセス(PID 82665)——12:50:32起動のPlaywrightドライバが残っていました。plistの変更時刻と重なる帯域です。確定的な証拠ではありませんが、「何かのPlaywright実行中にplistが変わった」という仮説の根拠になります。
13:07:38に「復元して再読込した」——検知から復元完了まで1秒以内。launchdへの再登録まで含めた全処理がリアルタイムで完結しています。
plutil -lint と sleep 1 の必然性
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
plutil -lintはApple公式のplist検証ツールです。plistlib.dump()は基本的に正しいXMLを生成しますが、ディスク書き込み中の割り込みや予期しないファイルシステムエラーで中途半端なバイト列が書かれることはゼロではありません。壊れたplistをbootstrapするとlaunchdが予期しない状態になりうるため、Pythonで書いた直後に必ずAppleの検証を通してから再登録します。
sleep 1はlaunchdの処理を待つための明示的な待機です。bootoutコマンドはlaunchdに「このラベルを解除してくれ」とリクエストを送りますが、launchdの内部処理が完了するのは非同期です。間を置かずにbootstrapを呼ぶと、launchdが「まだ登録中だ」と判断して失敗することがあります。
guard.plist の隠れた設計意図
guard自身のplistを改めて見ると、明示的に設定されているキーが4つあります。
<key>RunAtLoad</key>
<false/>
<key>LowPriorityIO</key>
<true/>
<key>Nice</key>
<integer>10</integer>
<key>ProcessType</key>
<string>Background</string>
RunAtLoad: false——launchdへのbootstrap時(ログイン時など)に即実行しない設定です。trueにすると起動のたびにguardが走ります。StartInterval: 600の定周期で十分なので、余分な起動は省いています。
LowPriorityIO: true と Nice: 10——I/O優先度を下げ、CPU優先度をniceで+10します。guardは10分に1回、数十ミリ秒だけ動く軽いプロセスです。それが実際に営業DMを送っているoutreach-igのI/Oやブラウザレンダリングを妨害したら本末転倒です。監視するプロセスが監視される側より優先度を張らない——この設定が両者の関係を正しく表しています。
私が詰まった話
-e フラグ:1本目で番犬が静かに死んでいた
最初のバージョンはset -euo pipefailで書きました。手動実行では4本全てのSPECSをチェックします。なのにlaunchd経由で実行すると、なぜか1本目以降のログエントリがまったく現れない。
~/.claude/logs/outreach-schedule-guard.logを見ると、outreach-igの処理ログの後に何も続いていない。.err.logも空。launchdのjournalにもエラーなし。「プロセスが起動していない」ではなく「起動して途中で終わった」という状態でした。
原因に気づいたのはlaunchctl bootoutの終了コードを手動で確認したときです。
launchctl bootout "gui/$(id -u)/com.lily.outreach-th" 2>/dev/null
echo $?
出力:36(エラー)。outreach-thがその時点でlaunchdに登録されていなかったため(前の実行サイクルでbootout済みの状態)、bootoutが非ゼロを返し、-eフラグがスクリプトを即座に終了させていました。
修正は2点。set -uo pipefailに変更し、bootoutに2>/dev/nullを追加する。それだけで4本全てが順番に処理されるようになりました。「エラーで止まる」が安全に見えて、この用途では番犬を殺す設定だったという話です。
heredoc のスペース:launchd では致命的だった
actual="$(python3 - "$plist" <<'PY'
import plistlib, sys
...
PY
)"
手元のターミナルでは完全に動くのに、launchd経由で実行するとスクリプトプロセスが死なずにずっと生きている状態になる問題がありました。ログには何も出ない。guardのプロセスがずっとCPUをわずかに使い続けている。
ps aux | grep outreach-schedule-guardで生きていることを確認し、strace相当で調べると……plistの中でblockしていました。原因はheredocのエンドマーカーPYの末尾に半角スペースが入っていたことです。
actual="$(python3 - "$plist" <<'PY ' # ← 末尾スペース
zshはこれを許容しますが、launchdが呼び出す/bin/bashは'PY '(スペース込み)を終端マーカーとして待ち続けます。入力ストリームにPY という行が永遠に来ないので、Pythonプロセスがstdinを待ちながら固まった状態になります。タイムアウト設定がないため、guardが次の10分サイクルで起動しようとすると前のプロセスがまだ生きていて、二重実行の形になります。
修正は末尾スペースを除去するだけでしたが、症状が「ログが出ない+プロセスが死なない」という組み合わせだったので原因特定に時間がかかりました。自動化スクリプトをzsh前提で書いてlaunchd(bash)で実行するときは、heredocのエンドマーカーに余分な空白がないか目視確認が必要です。
isinstance の罠:1エントリのplistで無限復元ループ
plistlibでStartCalendarIntervalを読むとき、エントリが複数あればlist[dict]が返ります。ところがエントリが1つだけならdictが直接返ります。
これを知らずに実装した初期バージョンでは、テスト用にStartCalendarIntervalを1エントリだけ持つplistを作ったときにTypeErrorが飛びました。
TypeError: 'dict' object is not iterable
Pythonの例外はguardスクリプト側でキャッチされ、actualにERRという文字列が入ります。ERRはどのexpectedとも一致しないので、毎回「書き換え検知」と判定→バックアップ生成→plistlib.dumpで上書き→plutil-lint→bootout→bootstrap→ログ「復元して再読込した」が無限に繰り返されます。plistの中身は実は正しいのに、guardが狂ったように「復元した」と書き続ける状態です。
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))
本番のplistはすべて複数エントリなので本番環境では再現しませんでした。テスト用に単純なplistを手で作ったときに踏んで、「テスト環境でしか踏まない地雷」として埋まっていたことに気づきました。plistlibのこの挙動はドキュメントに書いてありますが、「複数エントリのケースしか想定しないコード」を書くのは非常に自然なので、APIを使う前に型を確認するかisinstanceで吸収する習慣が要ります。
stat の書式:macOSとLinuxで完全に別物
mtime=$(stat -f '%Sm' -t '%F %T' "$plist")
これはmacOS(BSD系のstat)の構文です。Linuxの同等処理はstat -c '%y' "$plist"になります。Dockerコンテナでユニットテストを回したとき、この行でillegal option -- fが出て「どこかのコンテナで動かない」と気づきました。
ただし、このスクリプトはmacOSのlaunchd専用です。launchdはmacOSにしか存在しないので、Linux互換性を確保する意味がありません。実害はゼロですが、Linux環境での開発・テスト中にハマるポイントです。
-f '%Sm' -t '%F %T'の読み方を整理しておきます。-fはフォーマット文字列を指定するオプション、%Sは「時刻を-tのフォーマットで表示する」、mは「最終変更時刻(mtime)」です。-tで渡す%F %Tはstrftime形式で、%F=2026-08-23、%T=13:07:37になります。GNUのstat -cに慣れていると-fの意味が全く違うので混乱します。macOS固有のstatを書くときは、最初にman statを引くか、手元のmacOSターミナルで試してから書く方が早いです。
つまずきポイント
前段で取り上げた4つのハマりどころ(-eフラグ・heredocスペース・isinstance・stat書式)以外にも、実際に手を動かすと想定外の壁が立て続けに現れます。同じ時間を溶かさないよう、網羅的にまとめます。
-
launchctl bootstrapの第一引数をドメインと勘違いするbootstrap "gui/$(id -u)" "$plist"のgui/501は「GUIセッション501番」という意味です。user/$(id -u)(バックグラウンドセッション)やsystem/(root専用)と混同するとエラーにならずにsilentで何も起動しないケースがあります。自分のユーザーエージェントには必ずgui/$(id -u)。 -
id -uが数値を返さず空になる 稀ですが、launchdがセッション外からスクリプトを起動した際に環境変数が剥がれ、id -uが空文字になることがあります。bootout "gui//com.lily.outreach-ig"のようにスラッシュが二重になり、bootoutが黙って失敗します。スクリプト冒頭で: "${UID:=$(id -u)}"と書いてフォールバックを確保する方が安全です。 -
バックアップファイルが際限なく溜まる 復元のたびに
com.lily.outreach-ig.plist.bak-guard-20260823-120412が生成されます。インシデントが断続的に続くと~/Library/LaunchAgents/が数百件のバックアップで埋まります。guardスクリプトの最後にfind "$LA" -name '*.bak-guard-*' -mtime +7 -deleteを1行追加するだけで7日超えのバックアップが自動削除されます。 -
ログファイルが存在しないとplistがエラーを吐く
StandardOutPathに指定したパスのディレクトリが存在しないと、launchdはplistのbootstrap自体を拒否します。~/.claude/logs/ディレクトリが初回起動時に存在しない環境では、guardそのものが立ち上がりません。スクリプト冒頭のmkdir -p "$(dirname "$LOG")"は必須ですが、plist側のStandardOutPath/StandardErrorPathが指すディレクトリも同様に事前に作る必要があります。 -
RunAtLoad: falseで最初の監視が10分遅れる guard.plistの
RunAtLoadはfalseにしています。これはログイン直後の不要な実行を避けるためですが、裏を返すとMacを再起動してから最初の監視が走るのは10分後です。インシデント直後に再起動した場合、その10分の窓が空白になります。インシデント対応中はlaunchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.lily.outreach-schedule-guard.plistで手動起動してから作業する習慣を持つと安心です。 -
plutil -lintが通ってもlaunchdが拒否するケースplutil -lintはXML構文の正しさを検証しますが、launchdのキー仕様は別です。たとえばStartCalendarIntervalのエントリにHourキーだけ入れてMinuteを省くとplutil -lintは通るのにlaunchdがエージェントを無視します。plistlibで書き出すときは{'Hour': int(h), 'Minute': minute}のように両方のキーを必ず含める必要があります。 -
guardの実行時間が600秒を超えると二重起動する
StartInterval: 600はlaunchdが「前の実行が終わっているかどうかに関わらず」600秒ごとにプロセスを起動するわけではありません。実際には前の実行が完了してから次のIntervalをカウントします。ただし、重いforensic処理やsleep 1の積み重ねでスクリプト全体が遅くなると、「終了→即起動」のサイクルが実質的に詰まることがあります。4エージェント × 復元処理 ×sleep 1で最大4秒程度かかりますが、通常は問題ありません。 -
guard自身のplistが書き換えられたとき誰も検知しない これは設計上の「最後の番人問題」です。
com.lily.outreach-schedule-guard.plistのStartIntervalが書き換えられても、guardはそれを自分では検知できません。対策として、SPECS配列に"com.lily.outreach-schedule-guard:*:*"を含めてguard自身のplistも比較対象に入れるか、別途launchctl print gui/$(id -u)/com.lily.outreach-schedule-guard | grep intervalを外部から確認するcronを仕込む方法があります。 -
python3のパスがHomebrew環境によって変わる スクリプト冒頭でexport PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"と宣言していても、Apple SiliconとIntel Macでは/opt/homebrewの実体が存在しないケースがあります(Intel環境では/usr/local)。guard.plistのEnvironmentVariablesに/usr/local/binを追加する形で両対応にしています。<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>plist側とスクリプト側でPATHが微妙に異なるのは意図的で、plist側は起動環境として完結させ、スクリプト側は実行時の上書き用として使い分けています。
-
grep -iE "python|node|bash"がguard自身にマッチする forensicのps収集でbashとpythonを検索すると、guardスクリプト自身(/bin/bash outreach-schedule-guard.sh)もヒットします。実害はありませんが、「犯人候補」のリストに自分が混じるので、ログを解析するときに驚きます。フィルタにgrep -v "outreach-schedule-guard"を加えて自分を除外してもよいです。 -
StartIntervalはMac電源オフ中の時間を加算しない launchdのStartIntervalはシステムが「スリープ」しているときの時間は加算しますが、完全にシャットダウンしている間は加算しません。長期の持ち出しで電源を切っていた場合、電源投入後のファーストチェックは次の600秒後です。この仕様は変えられないので、長期外出前は手動でguardを一度実行してログを確認する習慣が有効です。
ベストプラクティス
実コードと実インシデントを踏まえて、この種の「自動化の守護スクリプト」を書くときに押さえるべき原則を整理します。
1. 期待値は単一の配列で宣言する
SPECS配列のように「ラベル:分:時間リスト」を1行で完結させると、監視対象の追加・変更がSPECSの編集だけで完結します。監視ロジックと期待値が分散していると、どちらかを変更したときに不整合が生まれます。
2. -eフラグはforループと共存できないと心得る
set -euo pipefailは便利な安全装置ですが、ループ内で非ゼロを返すコマンドが混在するスクリプトでは番犬を殺します。-uo pipefailを残して-eを外し、エラーが想定される行には個別に2>/dev/nullや|| trueを添える方が堅牢です。
3. ログを3ファイルに分離する
スクリプトが意図的に書く監視ログ(.log)・stdoutがそのまま流れるlaunchdのアウトプット(.out.log)・bashの予期しないエラー(.err.log)の3つを分けると、tail -f outreach-schedule-guard.err.logが空であれば「shellレベルの異常ゼロ」と瞬時に判断できます。1ファイルに混在させると判断コストが毎回かかります。
4. 復元前に必ずplutil -lintで検証する
plistlib.dump()は正しいXMLを生成しますが、ディスク書き込み中の割り込みでバイト列が壊れる可能性がゼロではありません。壊れたplistをlaunchctl bootstrapするとlaunchdが不定な状態になります。plutil -lintを通過したものだけをreloadする手順を必ず挟んでください。
5. bootoutとbootstrapの間にsleep 1を入れる
launchctl bootoutの処理はlaunchd内部で非同期に完了します。間を置かずbootstrapを呼ぶと「まだ登録中」として失敗します。1秒で足りますが、省くと環境によって再現しにくい失敗が起きます。
6. バックアップで証拠保全してから上書きする
書き換えられた状態のplistをcpでタイムスタンプ付きバックアップしてから復元する順序が重要です。復元後に「元がどうだったか」を証明するファイルが消えてしまうと、後日の原因調査に使えません。バックアップは7日後に自動削除するルールを合わせて設定します。
7. forensicにプロセス一覧を残す
書き換え検知時にps -Ao pid,lstart,commでpython・node・bash・launchctl・plutilのプロセスをログに記録します。犯人特定に直結しなくても、タイムスタンプ帯のプロセス一覧は仮説を立てる根拠になります。2026-08-23のインシデントでは12:50:32起動のPlaywrightドライバ(PID 82665)が書き換え時刻帯に重なっていることをこのforensicログから読み取れました。
8. isinstanceでplistlibの型揺れを吸収する
StartCalendarIntervalはエントリ数が1のときdict、複数のときlist[dict]を返します。このPython標準ライブラリの挙動に対応しないと、テスト用の単純なplistでTypeErrorが発生し、guardが誤検知→無限復元ループに入ります。
rows = d.get('StartCalendarInterval') or []
if isinstance(rows, dict): rows = [rows]
この2行が本番障害にならない防波堤です。
9. guardをguard自身のlaunchdエージェントで管理する
スクリプトをcronや手動実行に依存させると、cronが止まったり手動実行を忘れたりした時点で監視が止まります。com.lily.outreach-schedule-guard.plistでguardをlaunchdに登録することで、Mac再起動・クラッシュ・ユーザーの操作ミスに対してOSレイヤーが自動復帰させます。
10. 監視するプロセスの優先度を被監視より下げる
guard.plistのLowPriorityIO: true・Nice: 10・ProcessType: Backgroundは、guardが10分に1回しか動かない軽量プロセスであることを明示しつつ、本体の営業DMプロセスのI/Oを妨害しないための設定です。監視スクリプトが本番スクリプトより高優先度で動いて本末転倒になる構成は意外に多いです。
11. PATHをスクリプトとplist双方に宣言する
launchdはログインシェルの~/.zshrcを読みません。スクリプト冒頭のexport PATH=...だけでなく、plist側のEnvironmentVariables > PATHにも記述することで、どちらの起動経路でもpython3・plutil・launchctlが確実に見つかります。
12. RunAtLoad: falseで不要な起動を省き、RunAtLoadの制限を理解する
毎回ログイン時にguardが走る必要はないためfalseにします。ただし、この設定は「ログイン後のファーストチェックが10分遅れる」という仕様と裏表です。再起動直後にスケジュールを手動確認したい場合はlaunchctlで手動kickします。
13. bash heredocのエンドマーカー末尾スペースに注意する
zshでは<<'PY '(スペース込み)でもheredocが正常に機能しますが、launchdが起動する/bin/bashではPY を終端マーカーとして待ち続け、スクリプトが無限にブロックします。症状が「ログが出ない+プロセスが死なない」という組み合わせで現れるため診断に時間がかかります。heredocのエンドマーカーは必ず単独行にし末尾スペースが入らないよう確認してください。
14. 監視対象plistのラベルをSPECS配列に集約して増減を1行で完結させる
新しい営業レーン(例:outreach-tw)を追加するとき、やることはSPECS配列への1行追加だけです。複数ファイルをまたいで設定を追加する構造にすると、追加漏れや不整合が必ず起きます。「SPECSを変えればすべてが追従する」設計を最初から選ぶと長期運用が楽になります。
15. statのオプションはmacOS固有であることを明記する
stat -f '%Sm' -t '%F %T'はBSD系macOS専用の構文です。この行だけをLinux環境でテストしようとするとillegal option -- fで失敗します。このスクリプトはmacOSのlaunchd専用なので実害はありませんが、Dockerコンテナや CI環境でユニットテストを書くときに混乱のもとになります。macOS固有コマンドを使う箇所にはコメントを1行入れておくと引き継ぎで詰まりません。
まとめ
2026-08-23に営業DMのlaunchd plistが深夜帯スケジュールに書き換えられたインシデントは、「自動化は動かし続けるより、壊れたことを10分以内に検知して戻す仕組みの方が難しい」という現実を叩き込んでくれました。
今回紹介したoutreach-schedule-guard.shの設計思想を一言で言うと、**「壊れることを前提に、人間が寝ている間もOSに番犬を走らせる」**です。73行のbashスクリプトが、python3+plistlibによる正確なplist比較・forensicログへの証拠保全・plutil -lintとlaunchctl reloadによる原子的な復元・そしてguard自身をlaunchdで守る「番犬の番犬」構造を1ファイルに収めています。
月商120万の土台はこういう地味な堅牢化の積み重ねです。派手な自動化スクリプトを書くより、既に動いている自動化を壊れにくくする時間の方が長くなります。それが「環境を作る」と「作業する」を切り離す、個人開発量産の本質だと今は確信しています。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!