🔇 SNS自動投稿が2週間止まっていても誰も気づかなかった話
月商120万の自動化環境が、SNS投稿レーンだけ14日間完全に死んでいました。
しかもシステムは毎朝「healthy」と報告し続けていました。エラーは一切出ていない。スクリプトのexit codeは0。watchdogは全部グリーン。なのにInstagramは7月29日から、TikTokは7月28日から、一本も投稿が出ていなかった。
気づいたのは別の用事でログを掘ったときでした。
なぜこの仕組みが効くのか
自動化に慣れてくると、こういう障害に特有の不気味さがわかってきます。派手に落ちるシステムはすぐ直せます。問題は静かに死んでいるシステムです。
今回の根本原因は、監視の「構造的死角」でした。
既存の content-watchdog.sh は、記事レーン(note / maker / series / ameba)だけを監視対象としていました。SNS投稿レーン(X / Instagram / TikTok / Threads)は、設計上どこにも含まれていなかった。コードのコメントには正直に書いてあります。
# content-watchdog.sh は article/note/maker/series/ameba だけを見ており、
# X/IG/TikTok/Threads の投稿・返信は誰も監視していなかった。その結果
# IG投稿は7/29から、TikTok投稿は7/28から止まったまま2週間気づかれなかった。
これはコードのバグではありません。実装した時点ではSNS投稿レーンをwatchdogに入れることを想定していなかった——つまり設計の死角です。監視しているつもりが、監視の網にそもそも入っていなかった。
exit codeを信用しない理由
もう一つの落とし穴は、exit codeによる健全性判断です。
SNS投稿の自動化スクリプトは、投稿が成功しても失敗しても exit 0 で終わることがあります。たとえばInstagramのrate-limitに引っかかった場合、スクリプトは「今日のクォータを使い切った」と判断して正常終了します。TikTokのエミュレータが「すでに投稿済み」と返した場合も exit 2 として正常扱いします。
つまり「プロセスが死んでいないこと」=「投稿が出ていること」ではない。
実際に sns-output-watchdog.sh のコードを見ると、この教訓が設計に直結しています。
# 🔴 従来は '終了 (' の出現回数だけを数えていたため、circuit-break でも need-login でも
# 「健全」と判定していた。2026-08-08 実測で ig-1 は12run中8回、tt-1/tt-2 は6割が
# いいね0件のまま exit 0 で終わっており、2週間誰も気づけなかった。
12回走って8回は0件で終わっている。それでも全部 exit 0 なので、従来の監視では「12回完走=健全」と判定していました。
成果物の有無で判定する
新しい監視の発想は単純です。「プロセスが完走したか」ではなく、「当日のログに成功マーカーが存在するか」で生死を判定する。
投稿スクリプトが成功したとき、ログには特定の文字列が残ります。たとえばIG投稿なら OK posted、X自動ツイートなら 投稿成功、TikTokなら run end (exit 0) または run end (exit 2)。これらのマーカーが当日の日付と共にログに存在するかどうかを数える——それだけです。
この発想は、副業自動化を半年以上運用してきた体験から来ています。「スクリプトが動いているか」より「アウトプットが出ているか」の方が、ビジネス的な意味での健全性に直結しています。月商に影響するのはコードが走ることではなく、投稿が出ることだからです。
「判定不能」と「異常」を区別する
監視設計でもう一つ重要なのは、「0件だから異常」と「そもそも判定できない」の区別です。
たとえば、ある日にTikTokスクリプトが一度も起動しなかった場合、ログに当日の日付行は存在しません。この状態を「0件=異常」と判定すると、インターネット接続が一時切れた日や、launchdが起動タイミングをスキップした日に毎回アラートが飛びます。アラートが毎日飛ぶようになると、人間はアラートを信用しなくなります——これは監視が機能しなくなる典型的なパターンです。
コードでは has_today() 関数がこの区別を担います。
has_today() {
local file="$1" daymark="$2"
[ -f "$file" ] || return 1
/usr/bin/grep -qF "$daymark" "$file"
}
ログに当日行がそもそもなければ UNKNOWN(判定不能)として分類し、UNHEALTHY(異常)とは別のアラートで上げます。「壊れている」のか「監視が効いていない」のかを混ぜると、両方の信頼性が下がります。
全体の流れ
システムの全体像はこうなっています。
launchd(毎日23:30 JST)
└─ sns-output-watchdog.sh
│
├─ 投稿レーンの確認
│ ├─ X自動ツイート autopost.log マーカー: "投稿成功"
│ ├─ X自動リプライ autoreply.log マーカー: "完了: 返信"
│ ├─ X outbound outbound.log マーカー: "完了"
│ ├─ Xいいね x-1.log マーカー: "終了 ("
│ ├─ IGカルーセル投稿 sns-ig-autopost.err.log マーカー: "OK posted"
│ ├─ Threads自動投稿 persona-autopost-th-1.log マーカー: "投稿成功"
│ └─ TikTok投稿 tiktok-bokuwalily.log マーカー: "run end (exit 0/2)"
│
├─ 成果ベースのチェック(いいね数)
│ └─ x-1 / ig-1〜3 / th-1〜2 / tt-1〜3 の各レーン
│ ├─ 当日のいいね合計を集計
│ └─ 0件 or 成果ゼロrun≥3回 → UNHEALTHY
│
└─ 結果を Discord に通知
├─ 異常あり → 🚨 UNHEALTHY
├─ 判定不能 → ⚠️ UNKNOWN
└─ 全正常 → healthy(無通知)
launchdの設定は com.lily.sns-output-watchdog.plist で管理しています。StartCalendarInterval で毎日23:30 JSTに起動し、RunAtLoad: false にしているのは、plistをロードした瞬間に誤発火させないためです。
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key>
<integer>23</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
</array>
<key>RunAtLoad</key>
<false/>
LowPriorityIO と Nice 10 を設定しているのは、深夜帯にディスクI/Oを大量に消費するバッチ処理と競合しないようにするためです。
タイムスタンプの罠
実装の中で最も見落としやすい落とし穴が、ログのタイムスタンプ形式の混在です。
今回の環境では、ログの日付表記が3種類混在しています。
TODAY_JST="$(date '+%Y-%m-%d')"
# → "2026-08-14" IGオートポストのログ形式
TODAY_UTC="$(date -u '+%Y-%m-%d')"
# → "2026-08-13" social-autolikeのログ形式(UTC記録)
TODAY_HUMAN="$(date '+%a %b %e')"
# → "Fri Aug 14" TikTokログ(launchdのdate出力形式)
JSTとUTCで最大1日ずれます。深夜0〜9時の間はJSTで「8月14日」でも、UTCでは「8月13日」です。この期間に監視スクリプトが走ると、UTC形式のログに対して TODAY_JST で検索すると「当日行なし → UNKNOWN」と誤判定します。
TikTokのケースはさらに特殊です。%e は日付を空白詰めで出力します。8日は Aug 8(スペース2つ)になります。%d だと 08 で出力されるため、ログと一致しません。
# TikTok: exit 0 と exit 2(投稿済み扱い)を成功とみなす。
tt_ok=$(count_today "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN" 'run end (exit 0)')
tt_maybe=$(count_today "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN" 'run end (exit 2)')
check "tt-autopost" "$((tt_ok + tt_maybe))" 1 "TikTok投稿(exit0=$tt_ok exit2=$tt_maybe)" \
"$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN"
「当日のログを読む」という一見単純な操作が、実はタイムゾーンとフォーマットという2軸の問題を内包しています。
成果ゼロ検出の仕組み
投稿レーン(X/IG/TikTok/Threads)とは別に、いいね・フォローレーンも監視しています。こちらは「runが完走したか」ではなく「実際にいいねが何件できたか」で判定します。
sum_likes_today() {
local file="$1" day="$2"
[ -f "$file" ] || { echo ""; return; }
/usr/bin/grep -a "^$day" "$file" 2>/dev/null \
| /usr/bin/grep -oE 'いいね:[0-9]+' \
| /usr/bin/grep -oE '[0-9]+' \
| /usr/bin/awk '{s+=$1} END {print s+0}'
}
当日行だけを grep で絞り込み、いいね:N 形式の数値を合算します。合計が0なら、そのレーンは「完走しているが成果なし」として UNHEALTHY に分類されます。
count_dead_runs() {
local file="$1" day="$2"
/usr/bin/grep -a "^$day" "$file" 2>/dev/null \
| /usr/bin/grep -E '終了 \((need-login|circuit-break|error|rate-limit)\)' \
| /usr/bin/grep -c 'いいね:0' || true
}
need-login(認証切れ)・circuit-break(過負荷回避)・error・rate-limit という終了理由かつ いいね:0 の組み合わせが1日に3回以上あれば、健全に見えるrunが実は機能不全を起こしている、と判定します。
実測で ig-1 レーンは12 run中8回がこのパターンでした。exit codeだけ見ていた従来の監視では「12回完走=健全」と誤判定し続けていた——それが2週間の沈黙につながりました。
count_today の設計
count_today() には微妙な設計判断が入っています。
count_today() {
local file="$1" daymark="$2" marker="$3"
[ -f "$file" ] || { echo 0; return; }
/usr/bin/awk -v day="$daymark" -v mark="$marker" '
index($0, day) { seen = 1 }
seen && index($0, mark) { n++ }
END { print n + 0 }
' "$file"
}
grep ではなく awk を使っているのは理由があります。ログは追記型で、昨日以前の行も残っています。grep で単純にマーカーを数えると、昨日以前の成功も今日の成功としてカウントしてしまいます。
awk の処理は「当日マークを初めて見た行以降だけをカウント対象にする」というロジックです。seen フラグが立った後にしかマーカーをカウントしない。これにより、前日以前の成功実績を今日の成功として誤算定することを防いでいます。
実装の詳細
check() が判定を一本化する
前半で紹介した count_today() や has_today() は部品です。それを束ねて「OK / UNKNOWN / UNHEALTHY」に振り分けるのが check() です。
check() {
local lane="$1" count="$2" min="$3" note="$4" file="${5:-}" daymark="${6:-}"
REPORT="$REPORT
$lane: $count 件 (最低 $min) $note"
if [ "$count" -ge "$min" ]; then
log "ok lane=$lane count=$count"
return
fi
if [ -n "$file" ] && ! has_today "$file" "$daymark"; then
UNKNOWN="${UNKNOWN:+$UNKNOWN,}$lane"
log "UNKNOWN lane=$lane 当日行なし file=$file (日付未出力かその日未起動)"
return
fi
FAILED="${FAILED:+$FAILED,}$lane"
log "UNHEALTHY lane=$lane count=$count min=$min"
}
引数は6つ:レーン名・件数・最低件数・注記・ログパス・当日マーク。
判定の順序が重要です。 件数が足りている(count -ge min)なら即 return、それ以外の場合だけ「なぜ0なのか」を調べます。「ログに当日行がない(has_today が false)」なら UNKNOWN、ログはあるのに0なら UNHEALTHY。この順序を逆にすると、ログが存在しないことを異常と誤判定してしまいます。
UNKNOWN="${UNKNOWN:+$UNKNOWN,}$lane" という構文も意図的です。${変数:+値} は変数が非空のときだけ展開されます。FAILEDが ""(空)のとき追加すると ,x-autopost(先頭カンマ)になってしまうのを防いでいます。
実際の呼び出しはこうなっています。
# Instagram: カルーセル自動投稿。
check "ig-autopost" \
"$(count_today "$CL_LOGS/sns-ig-autopost.err.log" "$TODAY_JST" 'OK posted')" \
1 "IGカルーセル投稿" \
"$CL_LOGS/sns-ig-autopost.err.log" "$TODAY_JST"
5番目と6番目の引数はログパスと当日マークのペアです。count_today() に渡したものと同じ値を check() にも渡す。これにより check() 内の has_today() が同じマーク文字列で「当日行があるか」を確認できます。
set -uo pipefail にした理由
スクリプト冒頭は set -uo pipefail です。set -e ではありません。
set -uo pipefail
-u:未定義変数を参照するとエラー。-o pipefail:パイプライン内のいずれかのコマンドが非0を返したらパイプライン全体を失敗扱い。-e(任意のコマンドが失敗でスクリプト終了)は入れていません。
理由は grep の挙動にあります。grep -c はマッチ件数をカウントするコマンドで、マッチが0件のとき exit 1 を返します。set -e があると、grep -c が0件ヒットした瞬間にスクリプト全体が死にます。
実際の count_dead_runs() のパイプ末尾を見てください。
count_dead_runs() {
local file="$1" day="$2"
[ -f "$file" ] || { echo ""; return; }
/usr/bin/grep -a "^$day" "$file" 2>/dev/null \
| /usr/bin/grep -E '終了 \((need-login|circuit-break|error|rate-limit)\)' \
| /usr/bin/grep -c 'いいね:0' || true
}
末尾の || true が grep -c の exit 1 を吸収しています。pipefail 環境でもこれで回避できます。もし || true を書き忘れると、成果ゼロ runが1件も存在しない健全な日ほどスクリプトが途中で死ぬという逆説的なバグが生まれます。
未定義変数への対処は ${5:-} や ${dead:-0} で行っています。check() の引数5・6はオプショナルで省略できるように :- で空文字デフォルトを設定しています。
launchd は bash の PATH を知らない
plist の EnvironmentVariables セクションを見てください。
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>
launchd から起動されるプロセスはログインシェルを経由しません。.zshrc も .bash_profile も読まれません。つまり nvm でインストールした node も、homebrew でインストールしたツールも、デフォルトでは PATH に含まれません。
だからスクリプト内で使うすべてのコマンドを絶対パスで書いています。
/usr/bin/grep -qF "$daymark" "$file"
/usr/bin/awk -v day="$daymark" -v mark="$marker" '...' "$file"
/usr/bin/python3 "$DISCORD" post alerts "$1"
grep を素のまま grep と書くと、homebrew 版の grep が /opt/homebrew/bin/grep にある環境では正常動作するのに launchd 経由では「コマンドが見つからない」でサイレントに失敗します。スクリプトは走っているのに何も出力されないという、一番デバッグしにくい壊れ方をします。
stdout と stderr を同一ファイルに集める
<key>StandardOutPath</key>
<string>~/.claude/logs/sns-output-watchdog.launchd.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/sns-output-watchdog.launchd.log</string>
stdout と stderr を同じファイルに向けているのは意図的です。
スクリプト自身は log() 関数で ~/.claude/logs/sns-output-watchdog.log に書き込みます。launchd の出力ファイルはそれとは別で、launchd 自身が起動に失敗したとき(パス不正・権限エラー・シェル構文エラーなど)のエラーメッセージが stderr に出ます。これを stderr だけ別ファイルにすると「どちらを見ればいいか」で迷うことになります。同一ファイルに統合しておけば、何かあったときに1ファイルだけ見れば全貌がわかります。
notify() は失敗を許容する
notify() {
[ -f "$DISCORD" ] || return 0
/usr/bin/python3 "$DISCORD" post alerts "$1" >/dev/null 2>&1 \
|| log "discord通知に失敗(ネットワーク?)"
}
2点の設計判断が入っています。
ひとつ目: $DISCORD ファイルが存在しない場合は return 0 で黙って終わります。watchdog スクリプトを別のマシンや CI 環境でも使いまわしたいとき、Discord ツールがなくてもスクリプト自体は壊れません。
ふたつ目: 通知に失敗してもスクリプトを落としません。|| log "..." でログだけ残して継続します。watchdog が「Discord が応答しないから自分も死ぬ」では本末転倒です。深夜にネットワークが一時切れた場合、Discord 通知は届かなくても翌朝ログを見れば結果がわかります。
成果ベースのチェックで「空かどうか」を先に見る
for lane in x-1 ig-1 ig-2 ig-3 th-1 th-2 tt-1 tt-2 tt-3; do
lf="$SA_LOGS/$lane.log"
[ -f "$lf" ] || continue
likes=$(sum_likes_today "$lf" "$TODAY_UTC")
dead=$(count_dead_runs "$lf" "$TODAY_UTC")
if [ -n "$likes" ] && [ "$likes" = "0" ]; then
FAILED="${FAILED:+$FAILED,}$lane-likes0"
log "UNHEALTHY lane=$lane 本日のいいね合計=0 (成果ゼロrun=${dead:-?}回)"
elif [ -n "$dead" ] && [ "${dead:-0}" -ge 3 ]; then
FAILED="${FAILED:+$FAILED,}$lane-dead${dead}"
log "UNHEALTHY lane=$lane 成果ゼロrunが${dead}回 (いいね合計=${likes})"
else
log "OK lane=$lane いいね合計=${likes:-?} 成果ゼロrun=${dead:-0}回"
fi
done
if [ -n "$likes" ] && [ "$likes" = "0" ] という二段階チェックに注目してください。
sum_likes_today() はログファイルが存在しない場合に空文字 "" を返します。
sum_likes_today() {
local file="$1" day="$2"
[ -f "$file" ] || { echo ""; return; }
...
}
空文字のまま [ "$likes" = "0" ] を評価すると「空文字 ≠ 0」で偽、つまり「いいね件数はゼロではない → OK」と誤判定します。-n "$likes" を先に評価することで「そもそもログが存在しない → スキップ」と「ログはあるが0件 → UNHEALTHY」を分けています。
こういう細かい条件の積み重ねが「99%の日は正しく動くが、特殊な日だけ誤報が出る」を防ぎます。
私が詰まった話
設計のきれいな部分だけ見せるのは正直じゃないので、実際に詰まった失敗を書きます。症状→原因→直し方の順で。
詰まり①:翌朝、watchdog が完全無音になった
症状。 count_dead_runs() を追加した翌朝、Discord に何も来ない。ログファイルを開くと ===== sns-output-watchdog start ===== の行はある。でもその数行後でログが途切れている。result=healthy も result=unhealthy も出ていない。
原因。 grep -c 'いいね:0' がマッチ0件のとき exit 1 を返す仕様でした。パイプラインの末尾に書いていたので pipefail がパイプ全体を失敗扱いにして、関数が異常終了。呼び出し元のスクリプトがそこで死んでいた。
最初のコードは末尾に || true がなかった:
# 壊れていたバージョン
| /usr/bin/grep -c 'いいね:0'
直し方。 || true を追加するだけです。
| /usr/bin/grep -c 'いいね:0' || true
ただしデバッグに30分かかりました。症状が「完全無音」なので、最初は「launchd が起動していない」「Discord ツールが壊れた」と疑っていました。watchdog スクリプト自身がクラッシュしているとは考えなかった。
教訓。 watchdog が無音のとき、まず result= というキーワードをログで検索します。このマーカーが末尾にあれば正常終了です。なければスクリプトが途中で死んでいます。
tail -50 ~/.claude/logs/sns-output-watchdog.log | grep -E 'result=|start'
詰まり②:TikTokレーンが毎日 UNKNOWN になる
症状。 導入翌日から tt-autopost が毎日 UNKNOWN で返ってくる。実際の TikTok アカウントを確認すると投稿は出ている。スクリプトのバグかと思ってコードを読み返しても問題が見つからない。
原因。 %e と %d の混同です。
最初の実装は日付フォーマットを %d で書いていました。
# 最初の(壊れていた)実装
TODAY_HUMAN="$(date '+%a %b %d')"
# 8月8日 → "Fri Aug 08"
TikTok のログファイルを head -10 で確認すると、launchd が出力する日付は %e 形式(日を空白詰め)でした。
Fri Aug 8 06:15:22 JST 2026
"Fri Aug 8"(スペース2つ)と "Fri Aug 08" は文字列として一致しません。has_today() が常に false を返すので、毎日 UNKNOWN と判定し続けていた。
直し方。 フォーマットを %e に変更。
TODAY_HUMAN="$(date '+%a %b %e')"
# 8月8日 → "Fri Aug 8"(スペース2つ)
教訓。 grep の検索文字列を決める前に、必ず実際のログの先頭行を head -5 で確認する。想定フォーマットで書くと、ログ出力プログラムのフォーマットと一致しないことが普通にあります。
詰まり③:plist をロードしたら昼間に通知が飛んだ
症状。 設定を変更して launchctl unload && launchctl load を実行したのが昼の13時。直後に Discord に通知が届いた。23:30 に走るはずなのになぜ?
原因。 RunAtLoad の設定を true にしていたためです。
<key>RunAtLoad</key>
<true/>
launchd の RunAtLoad: true は「ジョブをロードした瞬間にも1回実行する」という意味です。開発中に「ロードしてすぐ動作確認したい」と設定したまま、プロダクションの plist に残っていました。
昼間に実行されると当然ログに当日行が少なく、複数のレーンが UNKNOWN 扱いになります。監視対象のシステムは正常なのに、watchdog が誤った UNKNOWN 通知を出し続ける。
直し方。 RunAtLoad: false に変更。手動での動作確認は launchctl start コマンドで行います。
launchctl start com.lily.sns-output-watchdog
RunAtLoad の有無は「本番 plist として使うときのチェックリスト項目」として意識しておく必要があります。ほぼ必ず false でよい。
詰まり④:前日の成功実績を今日の成功としてカウントしていた
症状。 count_today() の最初のバージョンは grep で書いていました。
# 壊れていた最初の実装
count_today_broken() {
local file="$1" daymark="$2" marker="$3"
[ -f "$file" ] || { echo 0; return; }
/usr/bin/grep -c "$marker" "$file"
}
この実装で2週間運用したところ、ある日IGのカルーセル投稿が実際には1本も出ていないのに watchdog が「OK」と返した。
原因。 ログは追記型で、昨日の成功も前日の成功もすべて同じファイルに積み重なっています。単純に grep -c "$marker" すると、ファイル全体でマーカーが何回出現したかを数えます。昨日投稿できていれば、今日投稿できていなくても「1件以上 → OK」と判定します。
直し方。 awk に書き替えて、「当日マークが出現した行以降だけをカウント対象にする」ロジックを入れました。
count_today() {
local file="$1" daymark="$2" marker="$3"
[ -f "$file" ] || { echo 0; return; }
/usr/bin/awk -v day="$daymark" -v mark="$marker" '
index($0, day) { seen = 1 }
seen && index($0, mark) { n++ }
END { print n + 0 }
' "$file"
}
seen フラグが立った後にしかマーカーをカウントしない。当日行(日付文字列を含む行)が出てきて初めて seen = 1 になります。
教訓。 「監視スクリプトが healthy と言っている」を信用しすぎないこと。watchdog 自身が正しくカウントしているかどうかを、実際のログと突き合わせて確認するタイミングが必要です。私は実装から2週間後に、別の用事でログを掘ったときに初めて気づきました。
詰まり⑤:監視が「健全」と言い続けた2週間の構造
これは今回の障害の根本で、前述の個別バグとは少し性質が違います。
content-watchdog.sh は毎日 23:30 に走り、ログに result=healthy を残していました。Discord への通知も「異常なし」のまま。だから「システムは正常」だと思っていた。
問題は監視対象の取り違えです。content-watchdog.sh は記事レーン(note / maker / series / ameba)の生存確認をするスクリプトで、SNS 投稿レーンはスコープ外でした。コードにも正直に書いてあります。
# content-watchdog.sh は article/note/maker/series/ameba だけを見ており、
# X/IG/TikTok/Threads の投稿・返信は誰も監視していなかった。
「watchdog が healthy と言っている」と「監視網が全レーンをカバーしている」は別の命題です。でも毎日 healthy の通知が来ていると、人間はいつの間にか混同します。
直し方は2つ。
ひとつは sns-output-watchdog.sh を新たに作って SNS レーンを監視対象に加えたこと(今回の本題)。
もうひとつは Discord の通知メッセージに監視対象レーンの一覧を常に含めるようにしたこと。
REPORT="$REPORT
$lane: $count 件 (最低 $min) $note"
REPORT 変数にすべてのレーンの確認結果が積み重なっていき、Discord 通知に全部含まれます。「今日は x-autopost / x-autoreply / x-outbound / x-like / ig-autopost / th-autopost / tt-autopost を確認した」という情報が通知に出ます。監視スクリプトが何を見て、何を見ていないかが人間にわかります。
自動化システムの健全性報告で大事なのは「異常なし」と言うことではなく、「何を確認して、異常なしと判断した」を示すことだと、2週間の沈黙から学びました。
つまずきポイント
前半と中段で紹介した5つの詰まり(watchdog無音・TikTok UNKNOWN・RunAtLoad誤発火・前日成功の誤算定・構造的死角)以外にも、同じ設計を実装・運用していく中でまだ踏みやすい落とし穴があります。箇条書きで網羅します。
- plist の
~はlaunchdで展開されない
StandardOutPath に ~/ で書くと、launchdはそれをリテラル文字列として解釈してホームを展開しません。ログファイルが生成されず、ls ~/.claude/logs/ を確認しても当然ファイルは存在しない状態になります。実際の com.lily.sns-output-watchdog.plist の書き方を見てください。
<key>StandardOutPath</key>
<string>~/.claude/logs/sns-output-watchdog.launchd.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/sns-output-watchdog.launchd.log</string>
ホームの略記 ~ は一切使っていません。ProgramArguments のスクリプトパスも EnvironmentVariables の PATH も、全部フルパスです。plist 内で ~ を使っていい場所はないと覚えてください。
- 3種類のタイムスタンプ形式が1スクリプト内に共存する
sns-output-watchdog.sh の先頭を読むと、TODAY_ 変数が3種類あります。
TODAY_JST="$(date '+%Y-%m-%d')" # → "2026-08-14" IGオートポスト系
TODAY_UTC="$(date -u '+%Y-%m-%d')" # → "2026-08-13" social-autolike全般
TODAY_HUMAN="$(date '+%a %b %e')" # → "Fri Aug 14" TikTokログ(launchd形式)
ログを監視対象に追加するたびに「このツールはどの形式でタイムスタンプを出力するか」を head -5 ログファイル で必ず目視確認する必要があります。JST固定のスクリプトにUTC形式のログを渡すと、深夜0〜9時の間は1日ずれて UNKNOWN が続きます。新しいレーンを追加するときに最も高頻度で踏む落とし穴です。
bash -u環境では変数の初期化忘れがクラッシュを引き起こす
set -uo pipefail の -u は「未定義変数を参照するとエラー」です。スクリプト冒頭の3行、
FAILED=""
UNKNOWN=""
REPORT=""
これを書き忘れると、最初の check() が呼ばれた時点で ${UNKNOWN:+$UNKNOWN,}$lane の展開がエラーになります。症状はまたも「watchdog が完全無音」です。grep -c || true の忘れと症状が同じなので、デバッグ時に混乱します。変数の空宣言を書いた後 bash -n スクリプト名 で構文チェックを通してから plist をロードするのが確実です。
- TikTok の
exit 2を失敗扱いにしてしまう
TikTokのエミュレータ経由投稿スクリプトは「すでに本日分を投稿済み」と判断した場合に exit 2 で終わります。これは正常系です。スクリプトでは run end (exit 0) と run end (exit 2) の両方を成功としてカウントしています。
tt_ok=$(count_today "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN" 'run end (exit 0)')
tt_maybe=$(count_today "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN" 'run end (exit 2)')
check "tt-autopost" "$((tt_ok + tt_maybe))" 1 "TikTok投稿(exit0=$tt_ok exit2=$tt_maybe)" \
"$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN"
exit 2 を知らずに exit 0 だけをマーカーにすると、TikTokが「投稿済み」と正しく判断した日ほど UNHEALTHY が出ます。ツールの exit code のセマンティクスはログを読んで確認する必要があります。
- ログディレクトリが2箇所ある
スクリプトには SA_LOGS(social-autolikeプロジェクトのlogs/)と CL_LOGS(~/.claude/logs/)の2つのベースディレクトリがあります。X・Threads・TikTokのいいねレーンは SA_LOGS 下、IG投稿・TikTok投稿は CL_LOGS 下に書かれます。新しいレーンを追加するとき、どちらのディレクトリを使っているかをツールのコードで確認しないと、パスが合わずに毎日 UNKNOWN になります。
REPORT変数が肥大化してDiscordの文字制限に引っかかる
check() が呼ばれるたびに REPORT に1行ずつ追記します。x-autopost・x-autoreply・x-outbound・x-like・ig-autopost・th-autopost・tt-autopostの7レーン分に加え、いいね系9レーン分の結果も入ります。UNHEALTHY時の通知に $REPORT を全部付与すると、Discordの2000字制限を超えて末尾が切れます。「なぜこのレーンが赤いのか」が通知から読み取れないという本末転倒な状態になるので、REPORT は要約形式か別ファイル参照にする検討が必要です。
bashの構文エラーはwatchdog独自ログには出ない
スクリプトに構文エラーがあると bash が起動直後に終了します。watchdog が内部で log() 関数を呼ぶより前に死んでいるので、~/.claude/logs/sns-output-watchdog.log には何も残りません。エラーの出力先は launchd のログ(~/.claude/logs/sns-output-watchdog.launchd.log)だけです。watchdog が無音のときの診断順序は「まず launchd ログ → 次に watchdog 独自ログ」です。
tail -20 ~/.claude/logs/sns-output-watchdog.launchd.log
syntax error near unexpected token や command not found が出ていれば、スクリプト本体か PATH 設定の問題です。
- healthy のとき通知がこないので新レーンの追加が確認できない
watchdog は異常があるときだけ Discord に通知する設計です。全レーンが正常な日はログに result=healthy が残るだけで通知は飛びません。新しいレーンを check() に追加した翌日、「通知が来ない → healthy」と安心したいところですが、「そのレーンが追加できているか」は直接確認できません。
launchctl start com.lily.sns-output-watchdog
tail -f ~/.claude/logs/sns-output-watchdog.log
手動で起動してリアルタイムにログを見るのが唯一の確認手段です。ok lane=新レーン名 count=N の行が出ていれば監視網に入っています。
- 成果ゼロrunの閾値「3回」は根拠を持って設定する
count_dead_runs() の結果が 3 以上のとき UNHEALTHY と判定しています。1回や2回で即アラートにしない理由は、ネットワーク一時断やセッション再認証などで単発の need-login が出ることが実測で頻繁にあるからです。2026-08-08の実測では ig-1 は12回中8回が成果ゼロrunでした。この数字を根拠に「3回以上なら構造的な問題」という閾値を設定しています。閾値を低くしすぎると毎日アラートが飛んでアラート疲れが起き、監視が機能しなくなります。閾値を高くしすぎると本物の障害を見逃します。最初は3回から始めて、実環境での偽陽性率を見ながら調整するのが現実的です。
sum_likes_todayの空文字と"0"の区別を忘れると誤判定が起きる
ログファイルが存在しない場合、sum_likes_today() は echo "" で空文字を返します。空文字のまま [ "$likes" = "0" ] を評価すると「空文字 ≠ 0」で偽——つまり「ゼロではない → OK」という誤判定になります。「ログが存在しない」と「ログはあるが0件」を分けるために [ -n "$likes" ] && [ "$likes" = "0" ] の二段階チェックが必要で、順序を逆にしても壊れます。この構造は p2 で詳しく触れましたが、「空文字とゼロを混ぜると必ず誤判定が出る」という原則として監視スクリプト全般に適用できます。
ベストプラクティス
2週間の沈黙と実装で得た教訓を、他の自動化システムにもそのまま使えるルールに整理します。
1. 「プロセスが完走したか」ではなく「成果物が当日ログに存在するか」で判定する
exit code は正常でも投稿は出ていない。この実例が証明した通り、プロセスレベルの健全性とビジネスアウトプットの健全性は別物です。監視の判定基準は必ず「当日のアウトプット」に置いてください。
2. UNKNOWN(判定不能)と UNHEALTHY(異常)を別コードで分ける
if [ -n "$UNKNOWN" ]; then
notify "⚠️ SNS監視が判定不能: ${UNKNOWN}..."
exit 2
fi
両者を同じアラートに混ぜると、「壊れている」のか「監視が効いていない」のかが分からなくなります。異なる exit code と異なる絵文字で区別することで、受け取り側の対処も変わります。
3. 監視対象のレーン一覧を通知に必ず含める
REPORT 変数に全レーンの確認結果を積んで通知します。「何を確認して healthy と言っているか」が見えない監視は、人間が「全部大丈夫」と誤読する温床になります。
4. タイムスタンプ形式は実際のログをheadで確認してから決める
head -5 ~/.claude/logs/sns-ig-autopost.err.log
head -5 ~/dev/social-autolike/logs/autopost.log
head -5 ~/.claude/logs/tiktok-bokuwalily.log
「JST/UTC/HUMAN」の3種類があることを知っていても、新しいログを追加するたびに実物を確認する習慣をつけてください。想定と違う形式を使っていることが普通にあります。
5. grep ではなく awk で当日行以降に絞ってカウントする
ログは追記型です。grep -c "マーカー" はファイル全体を検索するので前日以前の成功も数えます。awk で seen フラグを使い、当日の日付行が出現した後だけをカウント対象にしてください。
6. pipefail 環境の grep -c 末尾には || true を必ず付ける
/usr/bin/grep -c 'いいね:0' || true
grep -c はマッチ0件のとき exit 1 を返します。pipefail 環境ではこれがパイプ全体の失敗として扱われ、スクリプトが途中終了します。「健全な日ほどスクリプトが死ぬ」という逆説的バグを防ぐには || true だけで十分です。
7. launchd から起動するスクリプトのコマンドはすべて絶対パスで書く
/usr/bin/grep -qF "$daymark" "$file"
/usr/bin/awk -v day="$daymark" ...
/usr/bin/python3 "$DISCORD" post alerts "$1"
launchd は .zshrc も .bash_profile も読みません。PATH に homebrew や nvm が含まれない状態で起動されます。素のコマンド名を書いてローカルで動いても、launchd 経由ではサイレントに失敗します。
8. plist のパスはフルパスで書く(~ 表記不可)
StandardOutPath・StandardErrorPath・ProgramArguments すべてに ~ を使わないでください。launchd はホームを展開しません。
9. RunAtLoad は本番 plist では false にする
動作確認は launchctl start ラベル名 で手動実行します。true にすると unload/load のたびに即時実行され、当日行が少ない状態で監視が走り UNKNOWN が多発します。
10. notify() は失敗しても watchdog を止めない
notify() {
[ -f "$DISCORD" ] || return 0
/usr/bin/python3 "$DISCORD" post alerts "$1" >/dev/null 2>&1 \
|| log "discord通知に失敗(ネットワーク?)"
}
深夜のネットワーク断やDiscordのダウンで通知が飛ばなくても、watchdog スクリプト自体は継続させます。結果は必ずログに残ります。
11. 変数は set -u 環境でも安全な形で宣言する
FAILED=""
UNKNOWN=""
REPORT=""
スクリプト先頭でこの3変数を空文字で初期化してください。${UNKNOWN:+$UNKNOWN,} 構文は変数が宣言されていることを前提にしています。
12. 新レーンを追加したら必ず launchctl start で手動実行して確認する
watchdog は healthy のとき通知を送りません。新しいレーンが監視網に入ったかどうかは、手動実行後に ok lane=新レーン名 がログに出るかで確認します。
13. bash -n でスクリプトを事前に構文チェックしてから plist をロードする
bash -n ~/.claude/scripts/sns-output-watchdog.sh && echo "syntax OK"
構文エラーがあると watchdog は launchd ログだけに記録を残してサイレント終了します。事前に構文チェックを通す習慣をつけることで、plist ロード後に「無音」で悩む時間を節約できます。
14. 閾値は偽陽性率を見ながら調整する
count_dead_runs() の閾値「3回」は、実環境での単発障害(ネットワーク断・セッション切れ)を吸収できる値として設定しています。監視を導入してから最初の2週間は閾値を3に設定し、毎日のアラート発生率を観察して調整してください。毎日アラートが飛ぶなら閾値を上げる。障害を見逃す頻度が高いなら閾値を下げる。数字を決める前に実測データが必要です。
15. 監視スクリプトの監視対象を定期的に棚卸しする
今回の障害の根本は「content-watchdog.sh が健全と言い続けた」ことではなく、「SNS投稿レーンがどのwatchdogの監視対象にも入っていなかった」ことでした。自動化システムに新しいレーンを追加するたびに「このレーンはどのwatchdogが見ているか」を確認するチェックリストを持つことを強くすすめます。月1回のレビューでも、2週間の沈黙は防げました。
まとめ
SNS投稿が14日間止まっていた障害から作ったのが sns-output-watchdog.sh と com.lily.sns-output-watchdog.plist です。この2ファイルが体現していることは1行で言えます——「システムが動いていること」と「アウトプットが出ていること」は別の命題です。
exit code で健全性を判定すると、「正常終了したが成果なし」を延々とスルーします。当日ログに成功マーカーが存在するかで判定することで初めて、ビジネス的な意味での「動いている」を検出できます。
タイムスタンプの混在(JST/UTC/HUMAN形式)、UNKNOWN と UNHEALTHY の分離、空文字とゼロの区別、grep -c と pipefail の衝突——どれも「実際にログを見た」経験から来ています。コードのコメントに 2026-08-08 実測で ig-1 は12run中8回が0件 と書いてある数字は測定値です。これらの詳細がなければ、同じ落とし穴を何度でも踏みます。
もうひとつ大事なことを書いておきます。「watchdog が healthy と言っている」は「すべてのレーンが監視されている」を意味しません。監視スクリプトは自分が監視対象に含めているレーンしか見ません。新しい自動化を追加するたびに監視網の更新が必要で、この更新を怠ると今回のような「健全に見える障害」が起きます。
自動化で稼げるようになった分だけ、その自動化を監視する仕組みへの投資も必要です。120万の売上のうちSNS流入が関係する部分をざっくり計算すると、14日の停止はおそらく8〜10万円分の機会損失に相当します。監視スクリプトを1本書くコストより、放置コストの方がはるかに高い。この記事を読んだ今日、自分のシステムのどのレーンが「誰も監視していない」かを確認することをすすめます。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!