🩺 26本のlaunchdが今日も動いてるか、1コマンドで全断チェックする
会社都合で解雇されて収入が0になった日、最初に確認したのは「自分の自動化が全部生きているか」だった。答えは半分ノーだった。
なぜこの仕組みが効くのか
個人開発の自動化が20本を超えたあたりから、「サイレントデス」が日常になります。
人間の認知には上限があります。3本のlaunchdジョブなら毎朝目で確認できます。10本になると週1になります。26本になると——もう確認しなくなります。「たぶん動いてるだろう」という感覚で日々が過ぎていく。そして気づいたとき、止まってから3週間経っていたりします。
私が解雇直後に体験した現実はまさにこれでした。副業収入が月60万あった頃、自動化で支えていた仕組みが静かに死んでいました。会社員としての給料があったので誰も気づかなかった。月商0になって初めて「あのジョブ、いつから止まってたんだ?」が可視化されました。
launchdの壊れ方は静かです。
macOSのlaunchd(常駐デーモン管理のしくみ)は、スクリプトがクラッシュすると何も言わずに次の起動予定まで待ちます。exit 78を返していても、launchctl listを直接叩かない限り誰も知りません。私の環境でも2026年6月にcom.shun.agentmemoryが6日間クラッシュループしていたことがありました。ポートは開いていてもworkerが居ない「半生状態」——HTTPリクエストは全部404を返すのに、プロセスだけ存在している。死活監視のよくある実装(プロセス存在確認だけ)では完全に見落とします。
こういう経験が、automation-health.shを「チェックするだけ」でなく「見つけたら即座に直す」設計にした理由です。
作業ではなく「環境」の問題
自動化の失敗はタスクの失敗と違います。タスクが失敗すれば誰かが怒ります。でも環境が腐っていても、誰も怒りません。ゆっくりと、あなたの生産性が下がっていくだけです。
月商120万を支えているのは個別のスクリプト1本ではなく、それらが絡み合って動き続ける「環境」です。skill-harvestがauto-skillを生成し、会話ログをKnowledge Baseに蓄積し、Obsidian Vaultに同期し、agentmemoryがClaude間で記憶を共有する——この連鎖のどこか一本が止まると、数週間後に「なんか最近Claudeの提案が薄いな」という感覚になります。原因がわかるのはさらに後です。
automation-health.shが検査するのは、まさにこの連鎖の各リンクです。launchdの定期ジョブだけでなく、hookスクリプトの実行権限、skill-harvestのログ鮮度、会話ログの最終更新、Obsidian Vaultの自動更新マーカー、agentmemoryのHTTP疎通——全部を1コマンドで横断して、REDが1つでもあればexit 1で終了します。
[開発者] bash ~/.claude/scripts/automation-health.sh
↓
exit 0 → ALL GREEN / WARN
exit 1 → RED あり → StopHookが捕捉
このexit 1設計が重要です。Claudeのセッション終了時に発火するStopHookにこのスクリプトを組み込めば、「Claudeを閉じるたびに自動化の健康を確認する」が標準になります。cronに入れれば毎朝確認します。どちらも「確認しようと思う意志」に依存しない仕組みです。
全体の流れ
automation-health.shは9つのセクションを順番に検査します。どのセクションも同じ出力スタイルです:
✓ 緑 → 正常
⚠ 黄 → 警告(致命的ではないがケア必要)
✗ 赤 → 失敗(exit 1の原因になる)
全体の構造をアスキー図で示します:
bash automation-health.sh
│
├─ [1] launchd ジョブ (com.shun.* / com.lily.*)
│ 全plistをループ → launchctl list で照合
│ 未ロード? → launchctl bootstrap で即自動再投入
│ 再投入失敗 → ✗ RED
│ 前回 exit ≠ 0 → ✗ RED
│
├─ [2] hooks (8本のシェルスクリプト)
│ pre_git_guard / pre_secrets_check / pre_env_guard
│ post_audit_log / post_format / post_tsc_check
│ stop_notify / user_prompt_submit
│ 不在 → ✗ RED / 実行権限なし → ⚠ WARN
│
├─ [3] skill-harvest
│ .harvest.log の最終更新が48h以内か
│
├─ [4] 会話ログ (Stop hook → 長期記憶)
│ ~/Documents/my-knowledge-base/raw/conversations/
│ INDEX.md の鮮度が24h以内か
│
├─ [5] Obsidian Vault 連携
│ hot.md の自動更新マーカー存在確認
│ index.md のカバレッジ(実ファイル数と記載ページ数の一致)
│
├─ [5.5] agentmemory サーバ
│ launchctl 状態 AND http://localhost:3111/agentmemory/health → 200
│ どちらかが欠けていても ✗ RED
│
├─ [6] remember 記憶層
│ now.md / recent.md / archive.md の存在確認
│ now.md の重複バーストを検知(consolidate 遅延の兆候)
│
├─ [7] ディスク / 残骸
│ ~/.claude 実効サイズ(>5GB で ✗)
│ security_warnings_state_*.json の残骸数
│
├─ [8] 週次/月次バッチ (7本)
│ ログファイルの最終更新時刻 vs 許容時間予算
│
└─ [9] cron ↔ launchd 重複
同一スクリプトが両系統に登録されていないかを確認
(移行後の二重実行バグを防ぐ)
↓
fail > 0 → exit 1(RED あり)
warn > 0 → exit 0(致命的問題なし)
fail = 0, warn = 0 → exit 0(ALL GREEN)
セクション[1]が核心——自動自己修復
最も重要なのはセクション[1]の実装です。「チェックして報告するだけ」のスクリプトを書くことは簡単です。でも、それでは「REDを見て手動で直す」という人間の手間が残ります。
automation-health.shのlaunchdセクションは、未ロードのジョブを発見した瞬間に自動で再投入します:
# 全 com.shun.* / com.lily.* plist を監視。未ロードを見つけたら冪等に自動再bootstrap
# (これが無いと、ジョブがlaunchdから外れてもサイレントに発火しなくなる)
uid_num=$(id -u)
for plist in "$HOME_DIR"/Library/LaunchAgents/com.shun.*.plist \
"$HOME_DIR"/Library/LaunchAgents/com.lily.*.plist; do
[ -e "$plist" ] || continue
job=$(basename "$plist" .plist)
line=$(launchctl list 2>/dev/null | grep -E "\b${job}\b")
if [ -z "$line" ]; then
if launchctl bootstrap "gui/${uid_num}" "$plist" 2>/dev/null; then
ok "$job: 未ロード → 自動で再ロードした"
else
ng "$job: 未ロード・再ロード失敗 (手動 launchctl bootstrap 要)"
fi
else
exitc=$(echo "$line" | awk '{print $2}')
if [ "$exitc" = "0" ] || [ "$exitc" = "-" ]; then
ok "$job: ロード済 / last exit=$exitc"
else
ng "$job: last exit=$exitc (前回失敗)"
fi
fi
done
launchctl listの2列目がexit codeです。0は正常終了、-は「現在実行中またはまだ一度も起動していない」、それ以外の数値は前回の異常終了です。exit 78(設定エラー)やexit 1(スクリプト内エラー)を静かに返し続けているジョブを、この1ループで全部捕捉します。
未ロードの場合はlaunchctl bootstrap gui/${uid_num} <plist>で即座に再投入します。gui/${uid_num}というターゲット指定がポイントで、macOS 10.15以降の現行APIです(古いlaunchctl loadはdeprecatedです)。再投入自体が失敗したときだけng(RED)として記録し、次の判定に進みます。
セクション[5.5]——プロセス存在確認だけでは不十分な理由
agentmemoryの監視が「2段階」になっているのには理由があります。コード内のコメントにそのまま残してあります:
# 2026-06-11 監査の教訓: launchd の exit 78 クラッシュループが6日間誰にも気づかれず、
# さらに「ポートは開くが worker 不在で全API 404」の半生状態は死活監視では見えない。
# launchd 状態 + /agentmemory/health の HTTP 200 の両方を見る。
実際の確認ロジックはこうです:
am_code=$(curl -s -o /dev/null -w '%{http_code}' -m 3 \
http://localhost:3111/agentmemory/health 2>/dev/null || echo 000)
if [ "$am_code" = "200" ]; then ok "稼働中 (pid=$am_pid / health 200)"
elif [ "$am_code" = "000" ]; then ng "プロセスは居るが port 3111 無応答"
else ng "port 3111 は開くが /agentmemory/health=$am_code — worker 不在の半生状態"
fi
HTTPの応答コードまで確認することで、「プロセスは生きているがAPIは死んでいる」という半生状態を検出できます。同じ発想をagentmemory以外にも横展開できます——Webサーバ、AIモデルのAPI proxy、anything that serves HTTP。
セクション[8]——時刻ベースの死活確認
週次・月次バッチの死活は「最後にログが書かれた時刻」で判定します。7本のバッチジョブに対してそれぞれ「許容時間予算」を設定しています:
declare -a CRON_JOBS=(
"weekly cleanup-misc:~/.claude/logs/cleanup-misc.log:192" # 週次→8日許容
"weekly env-audit:~/.claude/logs/env-audit-latest.md:192"
"monthly plugin-purge:~/.claude/logs/plugin-purge.log:744" # 月次→31日
"weekly plugin-auto-disable:~/.claude/logs/plugin-auto-disable.log:192"
"weekly dotfiles-snapshot:~/.claude/logs/dotfiles-snapshot.log:192"
"weekly agents-index:~/.claude/logs/agents-index.log:192"
"daily plugin-usage:~/.claude/scripts/plugin-audit-latest.md:48" # 毎日→2日
)
週次バッチは192時間(8日)、月次は744時間(31日)、日次は48時間が許容値です。ログのmtimeが予算を超過していれば⚠ WARNとして報告します。ジョブが実際に発火した証拠はログへの書き込みだけなので、ログを見ることが一番確実です。
具体例として、com.shun.plugin-auto-disableのplistを見ると:
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key> <integer>6</integer>
<key>Minute</key> <integer>45</integer>
<key>Weekday</key> <integer>0</integer>
</dict>
毎週日曜日の06:45にplugin-auto-disable.sh applyを実行し、ログを~/.claude/logs/plugin-auto-disable.logに追記します。このplistがlaunchdに正しくロードされていても、スクリプト側がエラーで終了していればログは止まります。セクション[1]でロード状態を確認し、セクション[8]でログの鮮度を確認する——2層の確認があってはじめて「本当に動いている」と言えます。
実装の詳細
launchctl bootstrap を使う理由——「古いAPI」を踏まない
macOSのlaunchctlには新旧2つのAPIが存在します。古い方はlaunchctl load <plist>、新しい方はlaunchctl bootstrap <target> <plist>です。
Catalinaあたりからlaunchctl loadはdeprecatedになりました。実行しても動くことはありますが、エラーメッセージがログに出ず、再起動後に挙動が変わることがあります。automation-health.shのセクション[1]がbootstrapを使っているのはこのためです:
uid_num=$(id -u)
if launchctl bootstrap "gui/${uid_num}" "$plist" 2>/dev/null; then
ok "$job: 未ロード → 自動で再ロードした"
else
ng "$job: 未ロード・再ロード失敗 (手動 launchctl bootstrap 要)"
fi
gui/${uid_num}という文字列がターゲットです。id -uでログイン中ユーザーのUID(通常は501)を取得し、gui/501というターゲットに対してplistを投入します。このターゲット指定がないと、ログイン済みのGUIセッションで動くジョブ(Homebrew製ツールを呼ぶスクリプト等)が、デーモンとして登録されてしまい、PATHが全く違う環境で実行される問題が起きます。
2>/dev/nullを付ける理由も重要です。すでにロード済みのジョブにbootstrapを叩くとエラーが出ます。冪等に実行できるようにするため、エラー出力を捨てて戻り値だけ見る設計にしています。
plistの EnvironmentVariables が死活に関わる理由
com.shun.plugin-auto-disable.plistの先頭を見ると、PATHが明示的に書き込まれています:
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/…/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Users/…/.local/bin</string>
</dict>
launchdは.zshrcや.bashrcを読みません。素のlaunchd環境のPATHは/usr/bin:/bin:/usr/sbin:/sbinしかありません。nodeもbrewもclaudeも、全部見えません。
このPATH問題で2回やられました。最初はnode系のスクリプトが「command not found: node」で黙って死んでいた。2回目はHomebrew製のjqを呼ぶスクリプトが同じ理由でサイレント失敗していた。スクリプト本体に何も問題がなく、ターミナルで手動実行すると動く——この症状が最も診断しにくいです。
解決策はplistにPATHを書き込む一択です。~/.nvm/versions/node/v24.13.0/binのようにバージョン固定のパスになるのが難点ですが、暗黙PATHで黙って失敗するよりずっとましです。私はnodeのバージョンを変えたあとに1回ここを更新し忘れ、3日間ジョブが動いていなかったことがあります。それ以降、node系ジョブのplistには# nvm version: X.X.Xというコメントを付けてplist更新のきっかけにしています。
セクション[6]——now.mdの重複バースト検知
rememberプラグインのnow.mdは、会話ごとに1行ずつ要約が追記されます。定期的にHaikusがconsolidateコマンドでnow.md→recent.md→archive.mdに昇格させるのですが、この昇格処理が遅延するとnow.mdに同じ要約が何十行も溜まります。
スクリプトはこの「バースト」を検知します:
dup=$(grep -vE '^\s*$|^##|^#' "$nowf" 2>/dev/null \
| sort | uniq -c | sort -rn | head -1 | awk '{print $1}')
if [ "${dup:-0}" -ge 3 ]; then
wn "now.md に同一要約が ${dup} 回 (consolidate 遅延の兆候)"
else
ok "now.md 重複なし (consolidate 健全)"
fi
空行・見出し行を除去してからsort | uniq -cで出現回数を数え、最多のものを取り出す——3回以上同じ行があればWARNです。「ConsolidateはHaikusが非同期で回しているから問題ない」と思っていた時期がありましたが、Haikusへの委譲が何らかの理由で止まるとnow.mdが数百行になり、次のConsolidate時に「古い記憶」として誤って昇格してしまいます。実際に「2週間前の要約が今日の重要記憶に昇格している」という状態を1度経験してから、このチェックを追加しました。
セクション[9]——comm -12で二重登録を炙り出す
cron_sh=$(crontab -l 2>/dev/null | grep -vE '^[[:space:]]*#' \
| grep -oE '/[^ ]+\.sh' | xargs -n1 basename 2>/dev/null | sort -u)
launchd_sh=$(grep -hoE '/[^<> ]+\.sh' \
"$HOME"/Library/LaunchAgents/com.shun.*.plist 2>/dev/null \
| xargs -n1 basename 2>/dev/null | sort -u)
dup=$(comm -12 <(printf '%s\n' "$cron_sh") <(printf '%s\n' "$launchd_sh") \
| grep -vE '^[[:space:]]*$')
commコマンドは2つのソート済みリストを受け取り、「両方に共通する行(-12オプションで1列目・2列目を非表示にした残り)」を返します。cronとlaunchdの両方に登録されているスクリプト名の一覧が取れます。
スクリプトの絶対パスではなくbasename(ファイル名だけ)で比較しているのは、cronとlaunchdでパスが微妙に違うことがあるためです。~/scripts/foo.shと$HOME/scripts/foo.shは同じファイルですが文字列比較では別物になります。
セクション[7]——ディスクとゴミファイルの2段構え
ディスクチェックは「~/.claude全体のサイズ」と「プラグインのdisabledキャッシュ」を別々に計測します:
cdir_total_mb=$(du -sm "$CLAUDE" 2>/dev/null | awk '{print $1}')
disabled_mb=$(du -sm "$CLAUDE/plugins/.disabled-cache" 2>/dev/null | awk '{print $1}')
cdir_mb=$(( ${cdir_total_mb:-0} - ${disabled_mb:-0} ))
.disabled-cacheはplugin-auto-disable.shが一時退避したプラグインの可逆キャッシュです。最大で1.2GBになることがありますが、apply→restoreで戻せる「整理済みゴミ」なので実効サイズから除外します。これを除外しないと~/.claudeが常時WARNになり、ノイズで本当のWARNが埋もれます。
私が詰まった話
①「Obsidianのカバレッジが永久WARNになった」——隠しディレクトリの罠
セクション[5]のObsidian連携チェックに、こういうコードがあります:
real=$(find "$VAULT" -name '*.md' -not -path '*/.*' 2>/dev/null | wc -l | tr -d ' ')
stated=$(grep -oE '総ページ数:[0-9]+' "$vidx" 2>/dev/null \
| grep -oE '[0-9]+' | head -1)
if [ -n "$stated" ] && [ "$stated" = "$real" ]; then
ok "index.md カバレッジ一致 (${real}p)"
else
wn "index.md 記載 ${stated:-?}p ≠ 実 ${real}p (index.md の更新が必要)"
fi
-not -path '*/.*'が付いていますが、最初はこれがありませんでした。Obsidianの内部ファイルは.obsidian/ディレクトリに、私の自動バックアップ出力は.backup/に格納されています。どちらも隠しディレクトリ(先頭がドット)ですが、findはデフォルトでこれらも拾います。
index.mdには「総ページ数:47」と書いてある。でもfindは.backup/内の古いスナップショットも含めて63件見つける。index.mdを正しく更新しても絶対にstatedとrealが一致しない。
症状は「毎回WARNが出るがよくわからない」でした。原因に気づくまで4日かかりました。スクリプトのデバッグとしてfind "$VAULT" -name '*.md' | head -20を実行したとき、.backup/2026-06-10/some-page.mdという行を見て「ああ、そういうことか」となりました。隠しディレクトリを除外する-not -path '*/.*'の1フラグで解決しましたが、「チェックロジックが正しくてもデータのスコープが間違っていれば永久にWARNになる」という教訓です。
②「cron→launchd移行後、6週間二重実行が続いていた」
スクリプト内のコメントにそのまま残してあります:
# 2026-06-01: cron→launchd 移行で「cron を消し忘れて両方に登録=毎サイクル二重実行」
# が発生していたため、同一スクリプトが両系統に存在しないかを常時監視する。
agents-index.shをcronからlaunchdに移行しました。plistを書いてロードして動作確認——完璧に思えました。ただしcronの古いエントリを削除し忘れました。毎週日曜の同じ時間帯にagents-index.shが2回実行されていました。
6週間気づかなかった理由は「実害が見えにくかった」からです。agents-index.shはべき等な処理で、2回実行しても同じ結果になります。インデックスが壊れるわけでも、エラーログが出るわけでもない。でも毎週2回、余計なCPU時間とログ追記が走っていました。
comm -12によるセクション[9]のチェックを追加したのはこの経験からです。移行直後に実行すれば1分で気づけた問題が、チェックがなかったから6週間続きました。
③「env-auditのログパスが毎回変わって検知できなかった」
セクション[8]のCRON_JOBS配列に、こういう特別処理があります:
if [[ "$path" == *"env-audit-latest.md" ]]; then
real=$(ls -t "$CLAUDE/logs/"env-audit-*.md 2>/dev/null | head -1)
[ -n "$real" ] && path="$real"
fi
env-audit.shは実行のたびにenv-audit-2026-06-10.mdのように日付入りのファイルを生成します。固定パスを持たないため、env-audit-latest.mdという「プレースホルダパス」を配列に書いておき、実際にはls -tで最新ファイルを探す特別分岐を入れています。
最初は「どのファイルもないのでlog/出力なし → NG」と出続けました。CRON_JOBSの定義を見ると確かに存在しないパスを書いてある。「スクリプトが一度も動いていない」と判断しかけましたが、ls ~/.claude/logs/で実態を確認したらenv-audit-2026-06-11.mdが存在していました。
固定パスを想定して設計したチェックが、動的命名のスクリプトに対応できていなかった。スクリプトの出力先を固定にするか、チェック側が動的に探すか、どちらかに統一する必要があります。私は後者(チェック側で探す)を選びましたが、理想は「全ジョブが固定パスにログを書く」設計です。ただし既存スクリプトを全部書き直す工数は払えなかったので、特別分岐で当座の対応をしました。
④「agentmemoryが6日間クラッシュループしていた」——半生状態の発見
これがセクション[5.5]を2段階チェックにした直接の原因です。コメントに日付まで残してあります:
# 2026-06-11 監査の教訓: launchd の exit 78 クラッシュループが6日間誰にも気づかれず、
# さらに「ポートは開くが worker 不在で全API 404」の半生状態は死活監視では見えない。
# launchd 状態 + /agentmemory/health の HTTP 200 の両方を見る。
症状は「Claudeの記憶が共有されない」でした。会話Aで覚えたことが会話Bで出てこない。でもClaude自体は正常に動いている。agentmemoryプロセスはps auxで見えている(存在している)。ポート3111へのcurlは「接続拒否」ではなく「応答なし」でした。
launchctl list | grep agentmemoryを叩くと、PIDが-で終了コードが78。exit 78はlaunchd規約で「設定ファイルのエラー」を意味します。起動しようとしてはクラッシュし、また起動しようとしてはクラッシュする——これが6日間繰り返されていました。
プロセス存在確認だけの死活監視では絶対に捕捉できません。ps auxでプロセスが見えても「クラッシュループ中のゾンビ」であることがあります。正確に言うと、launchdは連続クラッシュを検知すると「スロットリング」に入り、次の再起動を数十秒後に遅らせます。その遅延時間内にプロセスチェックを打つと「いる」と出ます。
http://localhost:3111/agentmemory/healthへのcurlが真実を語ります:
am_code=$(curl -s -o /dev/null -w '%{http_code}' -m 3 \
http://localhost:3111/agentmemory/health 2>/dev/null || echo 000)
if [ "$am_code" = "200" ]; then ok "稼働中 (pid=$am_pid / health 200)"
elif [ "$am_code" = "000" ]; then ng "プロセスは居るが port 3111 無応答"
else ng "port 3111 は開くが /agentmemory/health=$am_code — worker 不在の半生状態"; fi
000は「接続自体が成立しない」(ポートが閉じている、タイムアウト)、404や500は「ポートは開いているがworkerが機能していない」。この3パターンを区別することで、「起動しているが機能していない」という半生状態を捕捉できます。
この経験以降、HTTPを提供するすべてのサービスにこの2段チェックを横展開しています。launchd状態確認とHTTPヘルスエンドポイントのcurl——この2つがセットで初めて「本当に動いている」と言えます。
⑤「now.mdが1200行になってConsolidateが詰まった」
4つ目とは別の症状として、ある時期から「Claudeの提案がなんとなく薄い」と感じました。具体的には「先週話したことを忘れている」「同じ説明を2回求められる」という体感です。
~/.remember/now.mdをwc -lで確認したら1247行でした。通常は30〜50行程度です。Consolidate処理が止まって、now.mdに1000件以上の要約が積み上がっていました。
原因はHaikusへの委譲タスクが失敗していたことです。Consolidateはバックグラウンドで非同期に動くため、失敗していても誰も気づきません。タスクキューにエラーがあり、新しいConsolidateが積み上がっていくだけで一切処理されていませんでした。
手動でclaude --model haiku -p "~/.remember/now.md を読んでconsolidateしてください"と実行して解消しましたが、検知が1週間遅れました。セクション[6]の重複バースト検知を入れたのはこの経験が直接のきっかけです。
重複行が3件以上あるというのは「同じ会話ループが何度も要約された」サインです。普通の会話では同じ要約が2回以上now.mdに書かれることはありません。3件というしきい値は「偶然の重複」と「Consolidate詰まり」を分けるために経験則で決めました。
これだけのチェックを積み上げてくると、「全部緑でALL GREENになる日」がいかに珍しいかがわかります。私の環境では毎朝だいたい1〜2個のWARNが出ます。それが「正常」です。WARNが1個もない日より、「検知できるWARNが毎日あり、それを処理できている状態」の方が自動化環境としては健全だと思っています。
何も言わないシステムを信頼するのは、コードが存在しないことを「バグがない証拠」と読むようなものです。
つまずきポイント
p2では「詰まった話」を5本ストーリー形式で書きました。ここでは「ストーリーにならなかった地味な罠」を網羅します。どれも実際にやらかしています。
-
plistのXML構文を1箇所間違えると
launchctl bootstrapが「Error: 125」しか言わない。原因は何もわからない。plutil -lint ~/Library/LaunchAgents/com.shun.xxx.plistを叩くと「Unexpected character '/' at line 18」のように具体的な行番号が出る。plistを編集したらplutil -lintを走らせる、をplistのセーブ前手順として体に刻む必要があります。 -
ProgramArguments内のリダイレクトとStandardOutPathの二重書き込み。com.shun.plugin-auto-disable.plistを見ると、ProgramArgumentsの中で>> ~/.claude/logs/plugin-auto-disable.log 2>&1と書いているのに、StandardOutPath/StandardErrorPathにも同じパスを書いています。これは意図的な設計で、launchd自体の起動失敗(plist読み込みエラー等)はProgramArguments内リダイレクトより前に起き、StandardErrorPathにしか記録されないためです。知らずに「どちらかを消す」とlaunchd起動失敗が無音になります。 -
macOSのスリープ中は
StartCalendarIntervalがスキップされる。com.shun.plugin-auto-disableは毎週日曜06:45に起動しますが、その時刻にMacが閉じていれば発火しません。Power Napが有効な環境では一部ジョブが起動しますが保証なし。スクリプトが発火したかどうかの唯一の証拠はログのmtimeだけです——これがautomation-health.shセクション[8]の「192時間以内にログが更新されているか」チェックが存在する理由です。「launchdに登録した=必ず実行される」は誤りです。 -
set -uをlaunchdから実行するスクリプトに入れると、.zshrc定義の環境変数の欠落でexit 1。ターミナルから手動実行すると正常に動くのに、launchdから呼ぶと即落ちる症状。launchd環境には$EDITORや$NVM_DIRは存在しません。対策は2択:plistのEnvironmentVariablesに全必要変数を列挙するか、launchd向けスクリプトではset -uを外して${VAR:-default}パターンで防御する。 -
plistを編集しても古い設定がlaunchd内に残り続ける。
automation-health.shセクション[1]の自動re-bootstrapは「未ロード」のときだけ機能します。既にロード済みで古い設定のまま動いているジョブは再起動してくれません。plistを変更したらlaunchctl bootout gui/$(id -u) <label>で一度外してからlaunchctl bootstrap gui/$(id -u) <plist>で再投入が必須です。この手順を知らずに「plistを書き換えたのに動きが変わらない」で1時間溶かしました。 -
ジョブラベル(
<key>Label</key>の文字列)とplistファイル名が不一致だと自動チェックが誤動作する。たとえばcom.shun.foo.plistに<string>com.shun.bar</string>と書くと、launchctl listにはcom.shun.barとして登録されます。automation-health.shはbasename "$plist" .plistでラベルを推定してgrepするので、不一致があると「未ロード→自動re-bootstrap→すでにロード済みのためエラー→RED」という誤判定が出ます。ラベルとファイル名は必ず一致させる。 -
クラッシュループ中のスロットリングで
ps auxの結果がタイミングによって変わる。launchdは連続クラッシュを検知するとThrottleInterval(デフォルト10秒)で再起動を遅延させます。遅延時間内にプロセスを確認すると「いない」、次の起動試行中に確認すると「いる」となり、ps aux | grep agentmemoryだけでは真実が見えません。launchctl list | grep com.shun.agentmemoryでPIDと終了コードを同時に確認する、が正解です。 -
age_hが返す-1を算術比較でtrueと読んでしまう。age_hはファイルが存在しない場合-1を返します。[ "$a" -le 48 ]という比較式は-1 -le 48をtrueと判定するので、「ログファイルなし=鮮度OK」という誤判定になります。実コードでは[ "$a" -ge 0 ] && [ "$a" -le 48 ]と「0以上かつX以下」の2段比較にしてこの罠を避けています。同じパターンをコピーするときは2段比較ごとコピーする。 -
CRON_JOBS配列のIFS区切り文字:をログパスが含む環境ではIFS=":"分割が崩れる。IFS=":" read -r name path budget <<< "$entry"で:区切りにしているので、仮にログパスに:が含まれると$pathと$budgetが意図しない位置で割れます。macOSのパスに:は通常入らないが、意識せずにコピペすると壊れます。 -
hookスクリプトに実行権限がないとClaude Codeが完全無音でスキップする。
settings.jsonでhookを設定してもchmod +xされていなければhookは実行されません。エラーもログも何も出ない。automation-health.shセクション[2]が[ ! -x "$f" ]でWARNを出すのはこれが理由です。新しいhookを追加したら必ずchmod +x、そしてautomation-health.shを走らせて緑になることを確認してから信頼する。 -
comm -12はソート済み入力が前提であり、unsortedだと重複を見落とす。セクション[9]のcron/launchd重複検知でcomm -12を使っていますが、これは2つの入力がsort -u済みであることを前提にします。cron_shやlaunchd_shを生成するパイプにsort -uを入れ忘れると、重複があっても検知されません。実コードでは各変数の最後に| sort -uを入れています。
ベストプラクティス
26本のlaunchdジョブを半年運用して、「これが最初からあれば6週間の無音障害を防げた」と確信できる設計指針を10本以上まとめます。
1. ジョブラベルとplistファイル名を必ず一致させる
<key>Label</key><string>com.shun.hoge</string>のラベルとplistファイル名com.shun.hoge.plistを一致させます。不一致はデバッグコストを数倍にします。launchctl list | grep com.shunで見えているラベルとls ~/Library/LaunchAgents/com.shun*.plistのリストが1対1で対応している状態を維持してください。
2. 全ジョブにEnvironmentVariablesでPATHを明示する
launchdは.zshrcも.bashrcも読みません。素のlaunchd環境のPATHは/usr/bin:/bin:/usr/sbin:/sbinのみです。nodeもbrewもclaudeもすべて見えない。nodeのバージョンを更新したあとはplistのPATHも更新を忘れずに。私はplist内に# nvm version: v24.13.0というコメントをProgramArgumentsの直前行に書き、バージョン変更の際のリマインダーにしています。
3. launchctl loadを捨ててlaunchctl bootstrap gui/$(id -u)を使う
launchctl loadはmacOS Catalina以降deprecated。動くこともありますが、ログに何も出ずに挙動が変わることがあります。launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.shun.xxx.plistが現行API。UID取得はid -u(数値だけ出る)で、gui/501のようなターゲットを組み立てます。
4. plist編集後は必ずplutil -lint→bootout→bootstrapの3ステップ
plutil -lint ~/Library/LaunchAgents/com.shun.xxx.plist
launchctl bootout "gui/$(id -u)" com.shun.xxx
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.shun.xxx.plist
この3ステップを手順として決める。plistの内容変更はlaunchdに自動反映されません。
5. シェルスクリプトに渡す引数はProgramArguments内で/bin/zsh -c "..."経由にする
>>や2>&1のシェルリダイレクトは、ProgramArgumentsに直接書いても機能しません。launchdはシェルを介さずにプログラムを実行するためです。/bin/zsh -c "command >> log 2>&1"のパターンで包む。com.shun.plugin-auto-disable.plistが実際にこのパターンを使っています。
6. チェックスクリプトは「報告する」だけでなく「見つけたら直す」設計にする
automation-health.shのセクション[1]が「未ロードを検知したら即launchctl bootstrapで再投入する」設計なのは、「REDを見て手動で直す」という人間の手間を省くためです。自動化のモニタリングスクリプトが人間の介入を前提にしていれば、それはモニタリングの責任放棄です。自動修復できるものは自動修復し、自動修復できないもの(再ロード自体が失敗)だけREDとして人間に通知する。
7. HTTPサービスはプロセス確認+ヘルスエンドポイントの2段確認を徹底する
ps aux | grep <process>でプロセスが存在しても、そのプロセスがクラッシュループ中や「ポートは開いているがworkerが不在の半生状態」であることがあります。
am_code=$(curl -s -o /dev/null -w '%{http_code}' -m 3 \
http://localhost:3111/agentmemory/health 2>/dev/null || echo 000)
このパターンはagentmemoryに限らず、HTTPを提供する全サービスに横展開できます。000=接続不能、2xx=正常、それ以外=「開いているが壊れている」の3段階で区別してください。
8. exit 1設計でcron/StopHookから機械的に拾えるようにする
automation-health.shはREDが1つでもexit 1で終わります。これがポイントで、
bash ~/.claude/scripts/automation-health.sh || say "自動化に問題があります"
のように後続の処理に連鎖できます。StopHookに組み込めばClaude Codeを閉じるたびに自動検査になります。cronに入れれば毎朝確認になります。「確認しようと思う意志」に依存しない設計の核心はexit 1です。
9. ジョブのログは固定パスに書き込む設計にする
automation-health.shセクション[8]での実際の苦労(env-audit-*.mdが動的命名でチェックできなかった)は、「ジョブが固定パスにログを書かない」ことが根本原因です。新しいバッチジョブを書くときは、ProgramArguments内で>> ~/.claude/logs/<jobname>.log 2>&1のように固定パスへのリダイレクトを標準パターンにする。固定パスへの書き込みがあれば、監視側がmtimeを取るだけで死活確認できます。
10. 週次・月次・日次の許容時間予算を種別ごとに設定する
ログのmtimeがbudget_hour以内か否かで死活を判定するとき、全ジョブに同じ閾値を適用するのは間違いです。日次バッチに192時間(8日)許容を設定すれば、7日間止まっていてもWARNが出ません。automation-health.shではdaily=48h / weekly=192h / monthly=744hと3種の予算を定義しています。
11. cronとlaunchdの移行後はcomm -12で重複チェックを即実行する
cronからlaunchdに移行したその日にautomation-health.shを走らせて、セクション[9]のREDがないことを確認する。確認しなければ、私のように6週間二重実行が続いても気づけません。「移行完了」の完了条件に「automation-health.shがGREEN」を含めてください。
12. now.mdの行数を週1回は目視する
wc -l ~/.remember/now.mdを週に一度叩くだけでConsolidateの詰まりを早期発見できます。正常時は30〜50行程度です。100行を超えていれば要注意、300行を超えていればConsolidateが止まっています。automation-health.shのセクション[6]が重複バースト検知(同一要約が3回以上)をしていますが、あくまで「症状が進んだ後の検知」です。行数確認の方が早い。
13. ALL GREENを「目標」にしない
毎朝1〜2個のWARNが出る状態が正常です。「WARNが出る=検知系が生きている」であり、「WARNが一切出ない日」の方がむしろ不自然です。目標は「REDをゼロに保つ」であり、「WARNをゼロに保つ」ではありません。WARNを全部消そうとすると、チェックのしきい値を甘くしてWARNを隠す方向に誘惑されます。何も言わないシステムを信頼するのは、コードが存在しないことを「バグがない証拠」と読む行為と同じです。
まとめ
解雇された日に「自動化の半分が止まっていた」と書きました。あのときautomation-health.shがあれば、止まったのが解雇の3週間前だとわかり、収入ゼロで「原因調査」をする3日間は消えていました。
月商120万を支えているのはどの1本のスクリプトでもなく、26本が絡み合って動き続ける「環境」です。その環境の健康を1コマンドで横断確認し、問題を自動修復し、exit 1でcron/StopHookに拾わせる——この設計の恩恵は自動化の本数が増えるほど大きくなります。
launchdは壊れても何も言いません。だから私たちが毎朝1回、機械に聞く仕組みを作るしかない。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!