Tsukutta

🪤macOSでClaude Codeの自動化を24/7回すと踏むlaunchdの罠5つ

zenn.dev これまでの記事で、スキルを自動生成する仕組みやコンテキスト監査を紹介してきました。これらは毎晩決まった時刻に自動で走らせてこそ価値が出ます。僕の環境では今 launchd ジョブが 20本動いています(スキル生成、コンテキスト監査、毎朝のブリーフ生成、Vault…

Lily2026年6月16日公開
About 13 min read6

*zenn.dev*

これまでの記事で、スキルを自動生成する仕組みコンテキスト監査を紹介してきました。これらは毎晩決まった時刻に自動で走らせてこそ価値が出ます。僕の環境では今 launchd ジョブが 20本動いています(スキル生成、コンテキスト監査、毎朝のブリーフ生成、Vault取り込み等)。

ただ、macOSで定期実行を組むのは想像以上に罠が多い。この記事は、実際に踏んで直した5つの罠と、検証済みの回避策です。

罠1:cron は現代のmacOSでは死んでいる

crontab -e で登録したのに一度も走らない。これが最初の罠です。modern macOS(Sequoia以降)では cron daemon が実質動いておらず、ジョブが無言でスキップされます。エラーすら出ません。

死活確認はこう。

text
log show --predicate 'process == "cron"' --last 7d

これが 0件なら cron は動いていません。素直に launchd へ移行します。launchd のジョブはこういう plist になります。

text
<key>Label</key><string>com.you.skill-harvest</string>
<key>ProgramArguments</key>
<array>
  <string>/bin/bash</string>
  <string>/Users/you/.claude/scripts/skill-harvest.sh</string>
</array>
<key>StandardOutPath</key><string>/Users/you/.claude/logs/skill-harvest.log</string>
<key>StandardErrorPath</key><string>/Users/you/.claude/logs/skill-harvest.log</string>

ロード・即時実行はこう。

text
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.skill-harvest.plist
launchctl kickstart gui/$(id -u)/com.you.skill-harvest   # 強制実行で動作確認

ポイント
launchctl list / launchctl load は legacy API です。modernは launchctl print / bootstrap / kickstart。新旧を混ぜると混乱します。

罠2:*/5 は StartCalendarInterval に直接書けない

「5分おき」をcron感覚で書こうとすると詰まります。StartCalendarInterval は特定時刻の指定で、*/5 のような周期構文は使えません。

  • 周期実行 → StartInterval(秒数。5分なら 300)
  • 毎時N分など特定時刻の複数指定 → StartCalendarInterval を配列で並べる

あと細かいですが効く罠:

  • Label は com.you.name 形式。dotを含まないlabelはロード拒否されます。
  • ProgramArguments は配列必須(string単体は弾かれる)。
  • StandardOutPath / StandardErrorPath を絶対パスで明示しないと出力は /dev/null に消えます

罠3:node: command not found ―― GUI起動の最小PATH問題

ここが一番ハマりました。Claude Codeのフック(PostToolUse 等)が毎回 /bin/sh: node: command not found で失敗する。ターミナルでは普通に node が通るのに、です。

原因:GUIから起動したアプリがspawnする子プロセスの /bin/sh -c は、ログインシェルのプロファイル(.zshrc)を読まず、最小PATH(/usr/bin:/bin:/usr/sbin:/sbin)しか持ちません。nvmやHomebrewにある node が見えないのです。launchdジョブも同じ理由でコケます。

修正は ~/.claude/settings.json のトップレベルに env.PATH を足し、子プロセスに継承させること。

text
"env": {
  "PATH": "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/you/.local/bin"
}

ポイントは /opt/homebrew/bin を必ず含めること。nvmのバージョン付きパス(.../node/v24.x/bin)だけをハードコードすると、Nodeをメジャー更新した瞬間にstale化して再び not found になります。/opt/homebrew/bin/node をfallbackとして入れておけば壊れません。

再現と検証はこう。

text
# 旧最小PATHで再現(not found が出る)
env -i HOME="$HOME" /bin/sh -c 'PATH="/usr/bin:/bin"; node --version'
# 新PATHで解決(バージョンが出る)
env -i HOME="$HOME" /bin/sh -c 'PATH="/opt/homebrew/bin:/usr/bin:/bin"; node --version'

罠4:macOSには timeout が無い

スクリプトを timeout 60 some-command で囲っているのに timeout: command not found。macOSはGNU coreutilsの timeout を標準搭載していません。多くの記事やルールが timeout 前提で書かれているので、コピペすると不発になります。

text
brew install coreutils   # gtimeout が入る

スクリプト側は両対応にしておくと移植性が上がります。

text
TIMEOUT_CMD="timeout 60"
command -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"
$TIMEOUT_CMD some-command

ちなみにbashのバージョン問題も同根です。macOS標準のbashは3.2で、$EPOCHREALTIME(µs精度の時刻)など5系の機能がありません。計測系スクリプトを書くなら shebang を #!/opt/homebrew/bin/bash にして5系を明示します。

罠5:exit 78 (EX_CONFIG) のクラッシュループ

最後が一番厄介でした。あるジョブが KeepAlive で無限にクラッシュループし、6日間で約15,000回起動を繰り返していた実例があります。プロセスは一切立たず、ログも残らない。launchctl print gui/$(id -u)/&lt;label&gt; を見ると last exit code = 78: EX_CONFIG、runs が数秒おきに増え続けていました。

78はアプリのexitではなく、launchdが合成する「サービスを初期化できなかった」=spawn段階の失敗です。ログが一切成長していない(mtimeが凍結)のが裏付け。手動でスクリプトを叩くと正常起動するなら、プログラム自体は健全で、launchd配下でだけ死んでいます。

健常なジョブとplistを差分比較して、効いた原因が3つありました。

  1. ログパスがTCC保護領域(~/Documents, ~/Desktop 等)にある → ~/.claude/logs/ や ~/Library/Logs/ へ変更(これが最有力)
  1. shebang #!/usr/bin/env bash が、plistのPATHでunsignedなHomebrew bashに解決される → ProgramArguments を ["/bin/bash", "script.sh"] にしてApple署名インタプリタを明示
  1. WorkingDirectory 未指定 → launchd既定cwdは /。アプリが相対パスでデータを書くと意図しない場所(再起動で消える /tmp 等)に書き込み、データを失う

修復手順は順序が重要です。

text
# 1. ループを止める
launchctl bootout gui/$(id -u)/<label>
# 2. ポートを掴んだ孤児プロセスを「所有者を確認してから」kill
lsof -nP -iTCP:<port> -sTCP:LISTEN
# 3. plistを直して再投入
launchctl bootstrap gui/$(id -u) <plist>
# 4. 検証
launchctl print gui/$(id -u)/<label> | grep -E 'state =|runs =|last exit'
⚠️ 注意
KeepAlive=true は spawn失敗を直しません。ThrottleInterval(30秒程度)を入れないと、数秒間隔の無限ループで runs が数万に積み上がります。

最後にもう一つの地雷。plistを調べるとき plutil -extract ... -o &lt;file&gt; の引数を間違えると、元のplistを出力で上書き破壊します(実際にあるジョブのplistが76バイトのJSON断片になりました)。調査系の plutil は必ず -o -(stdout)か別ファイルに出すこと。

まとめ

自動化は「組んだら終わり」ではなく、死活確認まで含めて初めて完成です。launchctl print でログのmtimeが想定どおり進んでいるか、定期的に見る習慣をつけると、無言で死んでいるジョブに早く気づけます。

ここまでの3記事で、スキルが育ち・コンテキストが軽く・自動化が24時間回る環境ができました。次はこれらを毎朝のブリーフとしてまとめる仕組みを書く予定です。

この記事が良かったら

「チップをリクエスト」で著者にチップの受け取り設定をお願いできます

シェア