この記事が良かったら
「チップをリクエスト」で著者にチップの受け取り設定をお願いできます

― サイレント失敗を可視化する
この記事が良かったら
「チップをリクエスト」で著者にチップの受け取り設定をお願いできます
前回の autopilot 記事で launchd から Claude を無人で走らせる構成を紹介しました。今回はその裏側 ―― 無人ジョブが黙って止まっていた事実を、誰も気づかないまま放置してしまう問題とその対処の話です。
無人自動化の本当の怖さはバグではなく、サイレント失敗です。launchd のジョブが exit 78 でクラッシュしていても、hooks のスクリプトが実行権限を失っていても、agentmemory がポートを開いたまま worker 不在の「半生状態」で動いていても、表面上は何も起きていないように見えます。この状態が6日間続いて誰にも気づかれなかった実例が `automation-health.sh` のコメントに残っています。
自律ループを組むと監視対象が増えます。私の構成だけでも:
これらが全部正常かどうかを手で確認する気にはなれません。かといって個別に `launchctl list` を叩いても「全体像」が見えない。
必要なのは 全自動化をまとめて点検して、GREEN / WARN / FAIL の3値で教えてくれる1コマンドです。
`~/.claude/scripts/automation-health.sh` のコアはシンプルです。
fail=0; warn=0
ok() { printf " ${GRN}✓${RST} %s\n" "$1"; }
wn() { printf " ${YEL}⚠${RST} %s\n" "$1"; warn=$((warn+1)); }
ng() { printf " ${RED}✗${RST} %s\n" "$1"; fail=$((fail+1)); }チェック項目を `ok` / `wn` / `ng` のいずれかで評価していき、最後にこう判定します。
if [ "$fail" -gt 0 ]; then
printf " ${RED}✗ FAIL${RST} red=%d warn=%d\n\n" "$fail" "$warn"; exit 1
elif [ "$warn" -gt 0 ]; then
printf " ${YEL}⚠ WARN${RST} warn=%d (致命的問題なし)\n\n" "$warn"; exit 0
else
printf " ${GRN}✓ ALL GREEN${RST} 全自動化が正常稼働\n\n"; exit 0
fiexit code を分けることで、`daily-brief.sh` や CI から `||` で後続処理を繋げられます。
スクリプトは9節に分かれています。実コードから主要な判定を抜粋します。
for job in com.shun.skill-harvest com.shun.skill-curate; do
line=$(launchctl list 2>/dev/null | grep -E "\b${job}\b")
if [ -z "$line" ]; then
ng "$job: 未ロード (launchctl load し直しが必要)"
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列目が last exit code です。`-` は「まだ起動していない(正常)」、0 は成功、それ以外は失敗です。
hooks=(pre_git_guard.sh pre_secrets_check.sh pre_env_guard.sh \
post_audit_log.sh post_format.sh post_tsc_check.sh \
stop_notify.sh user_prompt_submit.sh)
for h in "${hooks[@]}"; do
f="$CLAUDE/hooks/$h"
if [ ! -f "$f" ]; then ng "$h: 不在"
elif [ ! -x "$f" ]; then wn "$h: 実行権限なし (chmod +x 推奨)"
else ok "$h"
fi
donesettings.json に hook を書いても、実体スクリプトが `chmod +x` されていないと silently skip されます。これを WARN で検出します。
hlog="$CLAUDE/skills/auto/.harvest.log"
a=$(age_h "$hlog") # (now - mtime) / 3600 を返すヘルパ
if [ "$a" -ge 0 ] && [ "$a" -le 48 ]; then
ok "最終稼働 ${a}h前: ${last##*] }"
else
wn "最終稼働 ${a}h前 (>48h: cron停止の疑い): ${last##*] }"
fi
nskills=$(find "$CLAUDE/skills/auto" -maxdepth 2 -name SKILL.md | wc -l | tr -d ' ')
ok "生成済 auto-skill: ${nskills} 個"ログの mtime が 48h 以上古ければ WARN。「ログがある」と「最近走った」は別の話なので mtime で見ます。
cnt=$(find "$CONV" -name '*.md' ! -name 'INDEX.md' | wc -l | tr -d ' ')
ok "会話ログ ${cnt} 件 / 最新 ${la}h前"
ia=$(age_h "$idx")
if [ "$ia" -le 24 ]; then ok "INDEX.md 鮮度 ${ia}h前"
else wn "INDEX.md が ${ia}h前 (extract が更新していない可能性)"; fiif [ -f "$hot" ] && grep -q "recent:start auto-updated" "$hot"; then
ha=$(age_h "$hot"); ok "hot.md 自動更新マーカー有 / ${ha}h前"
else wn "hot.md の自動更新マーカーが見つからない"; fi
# index.md カバレッジ: 実ファイル数 vs 記載ページ数
real=$(find "$VAULT" -name '*.md' -not -path '*/.*' | wc -l | tr -d ' ')
stated=$(grep -oE '総ページ数:[0-9]+' "$vidx" | grep -oE '[0-9]+' | head -1)
if [ "$stated" = "$real" ]; then ok "index.md カバレッジ一致 (${real}p)"
else wn "index.md 記載 ${stated:-?}p ≠ 実 ${real}p"; fifor f in now.md recent.md archive.md; do
[ -f "$REMEMBER/$f" ] && ok "$f 存在" || wn "$f 不在 (必須層)"
done
dup=$(grep -vE '^\s*$|^##|^#' "$nowf" | 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`now.md` は consolidate が遅れると同じ要約が繰り返し追記されます。3回以上の重複を WARN で早期検知します。
cdir_mb=$(( ${cdir_total_mb:-0} - ${disabled_mb:-0} ))
if [ "${cdir_mb:-0}" -lt 2000 ]; then ok "~/.claude 実効 ${cdir_mb}MB"
elif [ "${cdir_mb:-0}" -lt 5000 ]; then wn "~/.claude 実効 ${cdir_mb}MB (>2GB: 大きめ)"
else ng "~/.claude 実効 ${cdir_mb}MB (>5GB: 異常肥大)"; fi
orphan=$(find "$CLAUDE" -maxdepth 1 -name 'security_warnings_state_*.json' -mtime +7 | wc -l | tr -d ' ')
if [ "${orphan:-0}" -ge 5 ]; then
wn "security_warnings_state_*.json が ${orphan} 個 (>7日前: backups/ へ退避推奨)"
fireversible cache(`.disabled-cache`)は実効容量から除外して判定します。
declare -a CRON_JOBS=(
"weekly cleanup-misc:$CLAUDE/logs/cleanup-misc.log:192"
"weekly env-audit:$CLAUDE/logs/env-audit-latest.md:192"
"monthly plugin-purge:$CLAUDE/logs/plugin-purge.log:744"
"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"
)
for entry in "${CRON_JOBS[@]}"; do
IFS=":" read -r name path budget <<< "$entry"
a=$(age_h "$path")
if [ "$a" -le "$budget" ]; then ok "$name: ${a}h前 (budget ${budget}h)"
else wn "$name: ${a}h前 (budget ${budget}h 超過 — launchd 配送失敗の可能性)"; fi
done週次ジョブなら 192h(8日)、月次なら 744h(31日)を budget として、それを超えたら WARN です。
agentmemory サーバのチェックだけ、判定が2段階になっています。
am_line=$(launchctl list 2>/dev/null | awk '$3=="com.shun.agentmemory"{print $1" "$2}')
am_pid=${am_line%% *}; am_exit=${am_line##* }
if [ -z "$am_line" ]; then
wn "com.shun.agentmemory が launchd に未ロード"
elif [ "$am_pid" = "-" ] && [ "$am_exit" != "0" ]; then
ng "com.shun.agentmemory 停止中 (last exit=$am_exit) — クラッシュループの可能性"
else
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
fiコメントに理由が書いてあります。
launchd の exit 78 クラッシュループが6日間誰にも気づかれず、さらに「ポートは開くが worker 不在で全API 404」の半生状態は死活監視では見えない。
launchd の状態チェックだけでは「プロセスがいるが実際には動いていない」ケースを検出できません。`curl` で HTTP 200 を確認する理由はここにあります。
launchd の PID チェックと HTTP ヘルスエンドポイントの両方を見ないと、「起動しているように見えて全 API が 404」という半生状態を見逃します。MCP サーバや API サーバを launchd で動かすなら、必ず `/health` エンドポイントを叩く一手間を足してください。
見落としがちな項目が9番目の二重実行チェックです。
cron_sh=$(crontab -l 2>/dev/null | grep -vE '^[[:space:]]*#' | \
grep -oE '/[^ ]+\.sh' | xargs -n1 basename | sort -u)
launchd_sh=$(grep -hoE '/[^<> ]+\.sh' \
~/Library/LaunchAgents/com.shun.*.plist | xargs -n1 basename | sort -u)
dup=$(comm -12 <(printf '%s\n' "$cron_sh") <(printf '%s\n' "$launchd_sh") | \
grep -vE '^[[:space:]]*$')
if [ -n "$dup" ]; then
ng "cron と launchd の両方に登録され二重実行されるスクリプト: $(printf '%s' "$dup" | tr '\n' ' ')"
fiコメントには「2026-06-01: cron→launchd 移行で cron を消し忘れて両方に登録=毎サイクル二重実行が発生」とあります。移行後に古い crontab を消し忘れたパターンです。
`daily-brief.sh` は毎朝 8:00 に launchd から起動し、冒頭の環境ヘルスに1行だけ組み込みます。
echo "## 🏥 環境ヘルス"
run_to 60 ~/.claude/scripts/automation-health.sh 2>&1 | tail -3 | head -1`tail -3 | head -1` は最終サマリ行(`✓ ALL GREEN` / `⚠ WARN` / `✗ FAIL` の行)だけを切り出しています。
さらに `daily-brief.sh` 側では、ライブ疎通プローブで RED を検出したときだけ `## 🚨 要対応` セクションが現れます。
RED_FLAGS=""
flag_red() { RED_FLAGS="${RED_FLAGS}\n- ❌ $1"; }
if [ -n "$RED_FLAGS" ]; then
echo "## 🚨 要対応(ライブ疎通でRED)"
printf '%b\n' "$RED_FLAGS"
fi問題がないときこのセクションは存在しません。**「失敗中だけ現れる節」**として設計されています。brief は `~/Desktop/Daily Brief/today-brief-YYYYMMDD.md` に書き出されるので、デスクトップを開いたときに RED が目に入る形になります。
`automation-health.sh` の exit 1 を使えば、launchd のラッパーで同様のパターンを追加できます。
bash ~/.claude/scripts/automation-health.sh || touch ~/Desktop/AUTOMATION_FAILED.md
bash ~/.claude/scripts/automation-health.sh && rm -f ~/Desktop/AUTOMATION_FAILED.md失敗中だけファイルが存在し、直ったら消える ―― ダッシュボードを見なくてもデスクトップが状態を教えてくれます。
次回は、このヘルスチェックと autopilot が生成した改善ログを統合して、週次で何が変わったかをまとめる ―― 週次レポート自動生成の仕組みを書く予定です。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
◼︎作ったアプリは **ポートフォリオ** にまとめています📱
◼︎新着・開発の裏側は X **@bokuwalily** で発信しています🐦
◼︎OSS: **github.com/bokuwalily**🐙
皆さんの ❤️ やシェアが励みになります!