📡 5本のコンテンツレーンを1つのウォッチドッグで守る
解雇から半年で月商120万になった理由を一言で言うなら、「詰まっても自分で気づいて直る環境を作ったから」です。
なぜこの仕組みが効くのか
コンテンツを自動生成する仕組みを作ったとき、最初に直面したのは「動いている」と思っていたら実は止まっていた問題でした。
launchdで毎朝スクリプトを起動しても、ネットワーク未接続のまま起動して無言で終了する。APIのレスポンスが返らずタイムアウトしても、ログは存在する。Claudeのトークン予算が枯渇していても、エラーメッセージは出力ファイルに流れ込んで本文がゼロバイトになる。こうした失敗はすべて「スクリプトは走った」という事実だけを残して消えます。
作業でなく環境を作るとはどういうことか。私の答えは「完了をファイルの実在で証明させる」という設計でした。スクリプトが何をログに書いたかではなく、~/.claude/logs/.article-daily-done-20260710 というファイルが存在するかどうかだけを真実として扱う。これがdone-markerパターンの本質です。
この発想を5本のコンテンツレーン全体に横展開したのが content-watchdog.sh です。1日に複数回呼ばれ、「全レーンのdone-markerを確認して、欠けているものだけ再起動する」という単純な仕事をします。単純だからこそ壊れません。
自動化が詰まる理由は技術的なものよりも運用的なものが多いです。「昨日は動いていたから今日も動く」という前提で設計すると、Wifiが繋がっていない朝、バッテリーが残り3%の深夜、予算が枯渇した月末に静かに止まります。ウォッチドッグが存在することで、私は「今日も動いているはず」という不安から解放されました。iOSアプリ10本を掛け持ちで管理しながら月商120万を維持できるのは、確認のための作業時間が限りなくゼロに近いからです。
全体の流れ
ウォッチドッグと各レーンスクリプトの関係を図で示します。
launchd (複数スロット)
│
└─→ content-watchdog.sh (sweep モード)
│
├─ acquire_lock() # mkdir 競合ロック
│ └─ ~/.claude/locks/content-watchdog.lockd/
│
├─ for lane in article note maker series ameba
│ │
│ ├─ done_lane() # done-marker / .done ファイルを確認
│ │
│ └─ [未完了なら] run_capped 1800 bash <script> apply
│ │
│ └─ 各レーンスクリプトが自前のdone-markerを立てる
│ 例: ~/.claude/logs/.article-daily-done-20260710
│
└─ [全レーン完了なら] send_heartbeat_once()
└─ Discord通知 + ~/.claude/logs/.content-watchdog-heartbeat-20260710
コードの実体を順に読んでいきます。
done-markerの確認ロジック
done_lane() 関数は5レーンそれぞれに異なる確認ロジックを持っています。
done_lane() {
local lane="$1"
local hit
case "$lane" in
article)
[ -f "$LOG_DIR/.article-daily-done-$TODAY" ]
;;
note)
hit="$(find "$LOG_DIR/note-daily" -maxdepth 1 -name "$TODAY-*.done" -print -quit 2>/dev/null || true)"
[ -n "$hit" ]
;;
maker)
hit="$(find "$LOG_DIR/maker-daily" -maxdepth 1 -name "$TODAY-*.done" -print -quit 2>/dev/null || true)"
[ -n "$hit" ]
;;
series)
[ -f "$LOG_DIR/.series-daily-done-$TODAY" ]
;;
ameba)
local ad td f
ad="$HOME_DIR/Desktop/Article/ameba"
td="$(date +%F)"
grep -rlq "created:.*$td" "$ad" 2>/dev/null && return 0
for f in "$ad"/*.md; do
[ -f "$f" ] || continue
[ "$(stat -f %Sm -t %F "$f" 2>/dev/null)" = "$td" ] && return 0
done
return 1
;;
esac
}
article と series は隠しファイル形式(.article-daily-done-20260710)の単一ファイルで管理します。note と maker は 20260710-*.done というパターンで複数ファイルが存在する可能性を許容する設計です。ameba だけがdone-markerを持たず、実成果物(.md ファイルの created: メタデータかmtime)を直接確認します。これはameba用のスクリプトがdone-markerの仕様を持っていないためのフォールバック実装で、「設計の外側にある既存スクリプトをウォッチドッグに組み込む際の現実的な妥協点」として機能しています。
done-markerはいつ立つか
article-daily-stock.sh の実装を見ると、done-markerが立つタイミングが明確です。
DONE_MARKER="$HOME/.claude/logs/.article-daily-done-${TODAY}"
スクリプトの冒頭でパスを確定させ、記事本文の生成・検証・ストック配置・秘密スキャンをすべて通過したあと、git pushの前にマーカーを立てます(コード453行目)。
# 生成成功=この時点で当日doneを確定する。以降のgit pushはbest-effort(失敗しても生成は成功扱い)
# なので、git失敗でマーカー未設定→翌スロットで重複生成、という事故を防ぐためここで先に立てる。
touch "$DONE_MARKER"
コメントに「git失敗でマーカー未設定→翌スロットで重複生成、という事故を防ぐため」と書いてあります。pushがベストエフォートである以上、pushの成否でdoneを判定すると重複生成が起きます。「生成と配信は独立した責務」という設計原則がここに表れています。
mkdirによる競合ロック
ウォッチドッグ自身が複数スロットから起動されることを想定し、排他制御に mkdir を使っています。
acquire_lock() {
if mkdir "$LOCKDIR" 2>/dev/null; then
printf '%s\n' "$$" > "$LOCKDIR/pid"
trap release_lock EXIT INT TERM
return 0
fi
local now mod age
now="$(date +%s)"
mod="$(stat -f %m "$LOCKDIR" 2>/dev/null || printf '%s\n' "$now")"
age=$((now - mod))
if [ "$age" -ge 1800 ]; then
log "lock stale age=${age}s; taking over"
rm -rf "$LOCKDIR"
if mkdir "$LOCKDIR" 2>/dev/null; then
printf '%s\n' "$$" > "$LOCKDIR/pid"
trap release_lock EXIT INT TERM
return 0
fi
fi
log "lock held; skip"
exit 0
}
mkdir はPOSIXレベルでアトミックな操作です。2つのプロセスが同時に mkdir を呼んでも、成功するのは1つだけです。flock不要、Linuxとmacの互換性問題なし、失敗したら即 exit 0 という単純さが強みです。
ロックが1800秒(30分)以上放置されている場合は「前のプロセスが異常終了してロックが残った」と判断して強制引き継ぎします。各レーンのスクリプト呼び出しにも同じ1800秒がタイムアウトとして使われているため、正常なウォッチドッグが完走した直後でなければ次のウォッチドッグが起動することはありません。
article-daily-stock.sh も独自に同じ方式のロックを持っています。
LOCKDIR="$HOME/.claude/locks/article-daily.lock"
if ! /bin/mkdir "$LOCKDIR" 2>/dev/null; then
oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || true)
if [ -n "${oldpid:-}" ] && kill -0 "$oldpid" 2>/dev/null; then
log "別インスタンス実行中(pid=$oldpid) — skip"; exit 0
fi
rm -rf "$LOCKDIR"; /bin/mkdir "$LOCKDIR" 2>/dev/null || exit 0
fi
echo $$ > "$LOCKDIR/pid"
trap 'rm -rf "$LOCKDIR"' EXIT INT TERM
こちらはPIDの生存確認を追加しています。前のプロセスが生きていれば本当にスキップし、死んでいればロックを強制解放して自分が走ります。ウォッチドッグのロックと各レーンスクリプトのロックが二重に存在することで、「ウォッチドッグが再起動を試みる」と「前の記事生成プロセスがまだ走っている」が干渉しません。
Discord通知とハートビートの設計
失敗は通知するが、成功は1日1回だけ通知するという設計が send_heartbeat_once() に込められています。
send_heartbeat_once() {
[ "$MODE" = "sweep" ] || return 0
if [ -f "$HEARTBEAT_MARKER" ]; then
log "heartbeat skip already_sent=1"
return 0
fi
notify alerts "💓 content-watchdog heartbeat: 全レーン当日生成済み"
touch "$HEARTBEAT_MARKER"
log "heartbeat sent"
}
ウォッチドッグは1日に何度も呼ばれます。全レーン正常なら最初の1回だけDiscordに 💓 を飛ばし、以降はマーカーで短絡します。対して失敗通知は毎回飛びます。
if [ -n "$FAILED_LANES" ]; then
log "result=unhealthy lanes=$FAILED_LANES"
if [ "$MODE" = "sweep" ]; then
notify alerts "🚨 content停止: $FAILED_LANES 当日未生成(自己修復不能)"
fi
自動修復を試みても(run_lane の中で再起動し、その後もう一度 done_lane() を確認する)なおdone-markerが立っていなければ、手動対応が必要なことをDiscordに知らせます。
run_lane の内部構造を見ると、「再起動→再確認」のサイクルが1関数に収まっています。
run_lane() {
local lane="$1"
local script rc
script="$(script_for "$lane")"
if done_lane "$lane"; then
log "lane=$lane status=healthy action=skip"
return 0
fi
if [ ! -f "$script" ]; then
log "lane=$lane status=missing script=$script"
return 1
fi
log "lane=$lane status=missing-done action=reinvoke script=$script timeout=1800s"
if [ "$lane" = "ameba" ]; then
run_capped 1800 bash "$script" >> "$LOG" 2>&1
else
run_capped 1800 bash "$script" apply >> "$LOG" 2>&1
fi
rc=$?
log "lane=$lane reinvoke_exit=$rc"
if done_lane "$lane"; then
log "lane=$lane status=healthy-after-reinvoke"
return 0
fi
log "lane=$lane status=failed-after-reinvoke"
return 1
}
done_lane で確認 → 問題なければ skip → あれば再起動 → 再度 done_lane で確認、という流れです。再起動の終了コード(rc)はログに残りますが、判断には使いません。「終了コード0でもdone-markerが無ければ失敗」という厳格さが、スクリプトが出力ファイルにエラーを吐いて正常終了するケースを捕捉します。
実装の詳細
生成物が「中身のある記事」かを3層で確認する
run_lane() がdone-markerを信頼基準にすることは前半で説明しました。では、done-markerを立てる前に何をチェックしているか。article-daily-stock.sh の検証層が答えです。
まず article_ok() がファイルの実在と中身を確認します。
MIN_ARTICLE_BYTES=1200
article_ok() {
local f="$1"
[ -s "$f" ] || return 1
[ "$(stat -f%z "$f" 2>/dev/null || echo 0)" -ge "$MIN_ARTICLE_BYTES" ] || return 1
grep -qE '^title:' "$f" || return 1
grep -qiE 'request timed out|不明な商品|TODO: *本文|\(生成失敗\)' "$f" && return 1
return 0
}
4つのガードが直列に並んでいます。ファイルが存在するか・1200バイト以上あるか・frontmatterにtitle行があるか・タイムアウト文言や生成失敗フレーズが混入していないかです。この最後の grep -qiE が後で詰まった話に直結するので覚えておいてください。
サムネの確認は別関数 thumb_ok() が担います。
thumb_ok() {
local f="$1" w; [ -s "$f" ] || return 1
w=$(sips -g pixelWidth "$f" 2>/dev/null | awk '/pixelWidth/{print $2}')
[ -n "$w" ] && [ "$w" -ge 2000 ] 2>/dev/null
}
sips はmacOS組み込みの画像ツールです。幅2000ピクセル以上でなければサムネとして認めません。ファイルが存在してもゼロバイトや破損PNGは弾けます。
この2つの関数を使って、毎回の起動で全ストックを再確認するのが audit_repair() です(312行の大関数ですが要点だけ引用します)。
audit_repair() {
for f in "$STOCK_ART"/[0-9][0-9]-*.md; do
if [ "$t_ok" = "missing" ] && [ "$a_ok" = "ok" ]; then
if regen_thumb "$slug" "$no" "$f"; then
t_ok=ok; fixed=$((fixed+1)); log "FIX: サムネ再生成 $no-$slug"
fi
fi
[ "$a_ok" = "broken" ] && broken+=("$no-$slug")
done
for nb in "${broken[@]:-}"; do
n=$(bump_attempt "$slug")
if [ "$n" -le "$MAX_ATTEMPTS" ]; then
jq --argjson t "$topic" '[$t] + (map(select(.slug != ($t.slug))))' "$QUEUE" > ...
log "REQUEUE: $slug 再生成キューへ投入(試行 $n/$MAX_ATTEMPTS)"
else
needhuman+=("$nb (再生成${n}回失敗)")
fi
done
}
MAX_ATTEMPTS=2 が定数で宣言されています(コード136行目)。サムネが壊れていれば自動再生成、本文が壊れていれば最大2回まで自動でキューに戻して再生成、3回目は人間に通知して止まります。「詰まったまま放置」されないための自己修復がここにあります。
audit_repair() は記事生成の前に毎回走ります(コード246行目)。生成済みフラグで本日の生成をスキップしても、監査だけは走り続けます。新規生成とストックの品質保証が独立したサイクルで動いているのが設計の肝です。
audit_repair
if [ "$MODE" = "audit" ] || [ "$SKIP_GEN" = "1" ]; then
log "===== article-daily done(audit$([ "$SKIP_GEN" = "1" ] && echo '+gen-skipped')) ====="
exit 0
fi
起動タイミングの罠を3つの仕掛けで潰す
launchdが毎朝スクリプトを呼んでも、起動直後の環境は思ったより不安定です。3つの仕掛けがあります。
1. caffeinate:Macがスリープしないようにする
if [ -z "${CAFFEINATED:-}" ]; then
exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi
exec で自分自身を caffeinate でラップして起動し直しています。環境変数 CAFFEINATED=1 を渡すことで二重execを防ぎます。-i がプロセスが生きている間スリープを防ぐフラグ、-s がシステムスリープの防止です。これがないと、深夜のバッテリー駆動時に記事生成の途中でMacが眠ります。
2. ネットワーク待ち:Wifi接続前にAPIを呼ばない
if [ "$MODE" != "audit" ]; then
for _ in $(seq 1 18); do
/usr/bin/nc -z -G 3 1.1.1.1 443 2>/dev/null && break; sleep 5
done
fi
nc で1.1.1.1のポート443に接続確認し、つながるまで最大18回(90秒)繰り返します。-G 3 は3秒のコネクトタイムアウトです。launchdはMacが起動してすぐにスクリプトを呼ぶことがあるため、Wifi接続が確立する前にClaudeのAPIを叩いて全滅するケースへの対処です。audit専用モードはClaudeを使わないのでスキップします。
3. 予算チェック:トークンが枯渇しているなら何もしない
BUDGET=$(~/.claude/scripts/token-budget-advisor.sh --short 2>/dev/null || echo "n/a")
log "budget: $BUDGET"
if [ "$MODE" != "audit" ] && echo "$BUDGET" | grep -qE '🔴|critical|cap-near'; then
log "ABORT: budget critical — 次スロットで再試行"; exit 0
fi
token-budget-advisor.sh がトークン消費状況を返し、🔴 や critical が含まれていたら何もせずに終了します。月末に予算が枯渇していても、次のスロット(翌月リセット後)でウォッチドッグが自動で再起動するので、手動で何かする必要はありません。
gtimeoutの有無を吸収するrun_cappedパターン
content-watchdog.sh の run_capped() は一見シンプルですが、環境差分を吸収する重要なラッパーです。
timeout_bin() {
if [ -x /opt/homebrew/bin/gtimeout ]; then
printf '%s\n' /opt/homebrew/bin/gtimeout
else
command -v gtimeout 2>/dev/null || true
fi
}
run_capped() {
local limit="$1"
shift
local tb
tb="$(timeout_bin)"
if [ -n "$tb" ]; then
"$tb" "$limit" "$@"
else
log "gtimeout unavailable; running without timeout: $*"
"$@"
fi
}
homebrewの絶対パスを先に確認し、なければ command -v でPATHを探します。両方なければタイムアウトなしで実行しつつログに残します。|| true でエラーにしない点がポイントで、「gtimeoutが無いせいでウォッチドッグ全体が落ちる」という事故を防いでいます。article-daily-stock.sh も同じ思想で run_to ヘルパを持っています。
TIMEOUT_BIN="/opt/homebrew/bin/gtimeout"
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN="/opt/homebrew/bin/timeout"
[ -x "$TIMEOUT_BIN" ] || TIMEOUT_BIN=""
run_to() { local s=$1; shift; if [ -n "$TIMEOUT_BIN" ]; then "$TIMEOUT_BIN" --kill-after=30 "$s" "$@"; else "$@"; fi; }
こちらは --kill-after=30 も追加していて、タイムアウト後にSIGKILLを送るまで30秒の猶予を持たせています。Claudeのプロセスは SIGTERM を受け取っても即死しないため、この二段構えが必要です。
キューが空になっても止まらない自動ネタ補充
記事のネタは ~/.topic-queue.json に格納されています。このキューが空になったとき、スクリプトは止まるのではなく claude -p でネタを1本自動立案してキューに追加します。
QLEN=$(jq 'length' "$QUEUE" 2>/dev/null || echo 0)
if [ "$QLEN" -eq 0 ]; then
log "queue 空 → 実作業からネタ自動立案"
# ...長いプロンプト組み立て...
TOPIC_JSON=$(run_to 600 "$CLAUDE" -p "$REPLENISH_PROMPT" \
--model "${ARTICLE_MODEL:-claude-sonnet-4-6}" --effort high \
--output-format text --allowedTools "Read,Grep,Glob,Bash" --max-turns 20 2>>"$LOG")
--max-turns 20 が重要です。ネタ立案のためにClaudeが実ファイルをReadしてから出力する往復ターン数の上限です。これがないと、Claudeが念入りに調べすぎてトークンを食い潰します。
立案されたネタが既存のslugと重複していないかも確認します。
if used_slugs | grep -qx "$NEW_SLUG"; then
log "ABORT: 立案slug '$NEW_SLUG' が既出 → 次スロット再試行"; exit 0
fi
used_slugs() は、キュー・完了済みリスト・既存記事ファイル・coverage.jsonの4ソースからslugを集めて重複チェックします。これがないと「全く同じネタの記事が量産される」という事態が起きます(実際に起きました。後述)。
私が詰まった話
「スクリプトは走った」のに記事が186バイトだった
最初の失敗はこれです。launchdのログには正常終了が記録されていました。ファイルも存在した。しかし中身を開くと以下の内容だけでした。
---
title: "Claude Codeを使った自動化"
emoji: "🤖"
type: "tech"
published: true
---
request timed out
Claude Code自律セッションが30秒のネットワークタイムアウトで切れ、そのエラーメッセージが出力ファイルに流れ込んでいました。スクリプトの終了コードは0。[ -s "$ART" ] の確認もパスする。この状態を「成功」として扱っていたため、翌朝のウォッチドッグが「done-markerあり、スキップ」と判断して記事は永遠に補修されませんでした。
直し方は article_ok() の追加です。grep -qiE 'request timed out' を検証条件に加え、このフレーズが本文に含まれていたら生成失敗として扱いファイルを削除します。done-markerも立てずに終了するので、次のスロットでウォッチドッグが再起動します。
教訓:終了コード0はスクリプトが走ったという証明であって、出力が正しいという証明ではない。中身を見ない限り、生成の成否は分かりません。
done-markerをgit pushの後に立てていた時代
当初の実装では、git pushが成功したことをdone-markerを立てる条件にしていました。論理的に聞こえます。「公開まで含めて完了」という考え方です。
しかし月末に詰まりました。GitHubのAPIレートリミットにひっかかり、pushが連続して失敗しました。するとウォッチドッグは毎スロット「done-markerなし、再起動」と判断し、同じ日に同じネタで3本の記事が生成されました。内容は微妙に違う。ファイル名は番号が連番でついている。混乱しました。
コードのコメントがその経緯を記録しています(453行目)。
# 生成成功=この時点で当日doneを確定する。以降のgit pushはbest-effort(失敗しても生成は成功扱い)
# なので、git失敗でマーカー未設定→翌スロットで重複生成、という事故を防ぐためここで先に立てる。
touch "$DONE_MARKER"
キューのpop・done-queueへの記録・マーカーの設置をすべてgit pushより前に済ませるようになりました。pushはあくまで「Zennリポへのベストエフォート配信」であり、記事の生成とストック配置とは独立した責務です。念のためgit pushの直後にも touch "$DONE_MARKER" を再度呼んでいます(468行目)。べき等な操作なのでコストはゼロです。
Macが深夜にスリープして記事生成が途中で死んでいた
MacBook Airをバッテリー駆動で使っている夜、午前2時に記事生成が走ってOSがスリープしました。launchdはプロセスを起動しましたが、ディスクI/Oが止まり、Claudeのセッションが中断され、記事は途中で終わりました。ファイルは存在する、サイズも500バイトある、でも本文が途中で切れている。article_ok() が「1200バイト未満」で弾くので翌日に再生成されましたが、それまで気づきませんでした。
caffeinate を追加した後は、記事生成の実行中はMacがスリープしなくなりました。-s フラグ(システムスリープ防止)があるので、ふたを閉じても眠りません。この仕掛けなしに「毎朝自動生成」は成立しませんでした。
amebaレーンだけdone-markerが存在しなかった
ameba用の generate.sh は私が書いたのではなく、既存のプロジェクトに後からウォッチドッグを繋いだものです。そのスクリプトにはdone-markerの仕組みがありませんでした。
他の4レーンと同じように .ameba-daily-done-20260710 を確認しようとしても、そんなファイルは永遠に存在しません。ウォッチドッグは毎回「未完了」と判断して再起動を繰り返しました。
仕方なく done_lane() のameba分岐だけ別の確認方法にしました。
ameba)
local ad td f
ad="$HOME_DIR/Desktop/Article/ameba"
td="$(date +%F)"
grep -rlq "created:.*$td" "$ad" 2>/dev/null && return 0
for f in "$ad"/*.md; do
[ -f "$f" ] || continue
[ "$(stat -f %Sm -t %F "$f" 2>/dev/null)" = "$td" ] && return 0
done
return 1
;;
当日の created: メタデータを持つmdファイルが存在するかをgrepで確認し、なければmtimeが今日のファイルがあるかをstatで確認します。2段階のフォールバックです。「設計の外側にある既存スクリプトをウォッチドッグに取り込む際の現実的な妥協点」とコードのコメントに書きましたが、これは実際にそうで、理想は generate.sh 側にdone-markerを追加することです。ただ既存スクリプトへの手を加えるコストと、フォールバックで動かすコストを比べてフォールバックを選びました。
同じネタで10本の記事が並んだ日
slug重複チェックを追加する前の話です。キューが空になり、自動ネタ補充が走りました。Claudeが出力したslugは claude-code-automation でした。これは既存のdone-queueにすでに存在していました。重複チェックがなかったので、キューに追加され、翌日また同じネタが生成されました。10日後に確認したら 01-claude-code-automation.md から 10-claude-code-automation.md まで並んでいました。内容は毎回微妙に違います。
used_slugs() 関数は、この事故の後に追加されました。4ソース(キュー・完了済み・既存ファイル・coverage.json)を統合することで、どのタイミングで重複チェックしても漏れがありません。
ロックが残骸になってその日の記事生成が全部止まった
プロセスが kill -9 で強制終了されると、trap が発火せずにロックディレクトリが残ります。これがあると翌スロットのウォッチドッグが「lock held; skip」と判断してすぐに終了します。
2日後にDiscordの 💓 が届かないことで気づきました。ログを確認したら「lock held; skip」が100行以上並んでいました。
acquire_lock() の1800秒stale検知(前半で引用したコード)は、この事故を受けて追加したものです。ロックが1800秒(30分)放置されていれば強制引き継ぎします。記事生成の最長タイムアウトも1800秒なので、正常なプロセスが走っていれば必ずその時間内にロックを解放します。stale判定された場合だけ引き継ぎが起きます。
この対処を入れてから、「Discordの 💓 が来ない日」はゼロになりました。ウォッチドッグが詰まってもDiscordに 🚨 が飛んでくるので、朝のコーヒーを飲みながら通知を確認するだけで全5レーンの健全性を把握できます。iOSアプリ10本の開発と掛け持ちしながら月商120万を維持できている理由の一つは、この「確認しなくていい設計」にあります。
つまずきポイント
前半と中段で「実際に詰まった話」を6本取り上げました。ここでは実装を見直す中で気づいた、より細かい落とし穴を箇条書きでまとめます。これらは「動いているように見えるのに、気づいたら壊れていた」系のものばかりです。
launchdはPATHを引き継がない
content-watchdog.sh の冒頭4行目にこう書いてあります。
export PATH="~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
これが無いと gtimeout も claude も jq も見つかりません。launchdで起動したプロセスの PATH は /usr/bin:/bin:/usr/sbin:/sbin だけです。ターミナルで動くのにlaunchdで動かないという症状の9割はこれです。article-daily-stock.sh 側は別のアプローチで解決しています。
NODE_BIN=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
[ -n "$NODE_BIN" ] && export PATH="${NODE_BIN}:$PATH"
sort -V でバージョン順にソートして最新のnvmバイナリを動的に発見します。claudeのバイナリもこの直後に3段フォールバックで解決しています(command -v → ~/.local/bin → nvm配下を ls -t で探索)。どれかで見つからなければ ABORT: claude binary not found と記録して exit 0 します。exit 1 でなく exit 0 なのは、launchdに「失敗」として記録させてリトライループに入られると困るからです。
note/makerレーンのdone-markerはglob検索
article と series はファイル名が固定の隠しファイル(.article-daily-done-20260710)ですが、note と maker は違います。
note)
hit="$(find "$LOG_DIR/note-daily" -maxdepth 1 -name "$TODAY-*.done" -print -quit 2>/dev/null || true)"
[ -n "$hit" ]
;;
20260710-*.done というワイルドカードパターンです。なぜかというと、noteと maker は1日に複数トピックが生成されることがあるため、ファイル名にトピックのslugが入ります。固定名のdone-markerだと「どのトピックが完了したか」が分からなくなります。-print -quit で1件見つかったらすぐ終了するのは、ファイル数が増えたときに find が全件走査するのを防ぐためです。
実装時にここを全レーン同一パターンで書いてしまうと、noteレーンのdone-markerが永遠に「見つからない」状態になります。レーンごとに確認ロジックが違うことを最初から把握しておく必要があります。
gtimeoutのSIGTERMで止まらないプロセスへの対処
Claudeのプロセスは SIGTERM を受け取っても即座に終了しません。会話の途中でトークンを処理している最中に SIGTERM が来ると、数秒から数十秒かけて処理を終えてから終了します。article-daily-stock.sh の run_to ヘルパーが --kill-after=30 を付けているのはそのためです。
run_to() { local s=$1; shift; if [ -n "$TIMEOUT_BIN" ]; then "$TIMEOUT_BIN" --kill-after=30 "$s" "$@"; else "$@"; fi; }
gtimeout の --kill-after=30 は「タイムアウト後にSIGTERMを送り、30秒待ってもまだ生きていればSIGKILLを送る」という2段構えです。これがないと、タイムアウト後のプロセスがゾンビのように残り続け、次のスロットでウォッチドッグが起動した際に二重実行ロックにひっかかります。
jqが見つからないと後続の全処理がサイレント失敗する
キューの処理・coverage.jsonの更新・used_slugs()の重複チェックはすべて jq を使います。article-daily-stock.sh には明示的なチェックがあります。
command -v jq >/dev/null 2>&1 || { log "ABORT: jq not found"; exit 0; }
これを入れる前は、jq が無い環境でスクリプトを走らせた際に jq: command not found がログの奥深くに埋まり、キューが正しく読めているように見えて実は空として扱われ、毎回ネタ自動補充が走るという現象が起きました。
ログが膨らみ続けてディスクを食う
ウォッチドッグは1日に複数回起動されます。記事生成のたびにClaudeの出力が全部ログに流れ込むため、何もしないと数日でログファイルが巨大になります。article-daily-stock.sh は5MBを超えた時点でローテートします(コード78行目)。
[ -f "$LOG" ] && [ "$(stat -f%z "$LOG" 2>/dev/null || echo 0)" -gt 5242880 ] && mv "$LOG" "$LOG.old"
content-watchdog.log 側にはローテートが実装されていないため、長期運用では手動で確認する必要があります。これは今の運用での未解決事項のひとつです。
「監査は毎回走るが生成は1日1回しか走らない」という分離
article-daily-stock.sh の起動直後に SKIP_GEN フラグが設定されます。
SKIP_GEN=0
[ "$MODE" = "apply" ] && [ -f "$DONE_MARKER" ] && SKIP_GEN=1
その後 audit_repair() が必ず実行されます。SKIP_GEN=1 でも監査は止まりません(コード246行目)。
audit_repair
if [ "$MODE" = "audit" ] || [ "$SKIP_GEN" = "1" ]; then
log "===== article-daily done(audit$([ "$SKIP_GEN" = "1" ] && echo '+gen-skipped')) ====="
exit 0
fi
つまりウォッチドッグが1日に複数回スクリプトを呼んでも、2回目以降は「既存ストックの監査・自己修復」だけを実行して終わります。生成の二重実行とストックの品質劣化が独立して制御されています。この分離を理解していないと「なぜウォッチドッグが毎回スクリプトを呼ぶのに生成は1回なのか」が分からなくなります。
ベストプラクティス
実際に壊して直した経験から、「最初からこうしておけばよかった」という設計判断を10個以上まとめます。
1. 完了の証明はファイルの実在にする
ログの内容・プロセスの終了コード・printfの出力は「スクリプトが走った事実」しか証明しません。~/.claude/logs/.article-daily-done-20260710 というファイルが存在するかどうかだけを真実として扱う設計にすると、どんな失敗パターンでも次のスロットで再起動されます。
2. 生成と配信は独立した責務にする
done-markerをgit pushの成否で判定すると、ネットワーク障害やAPIレートリミットが生成の失敗に見えてしまいます。article-daily-stock.sh の453行目のコメントが示すとおり、生成成功の時点でdone-markerを立て、pushはベストエフォートにします。touch "$DONE_MARKER" はpush後にも再度呼ばれますが(468行目)、べき等な操作なのでコストはゼロです。
3. 中身を検証してからdone-markerを立てる
article_ok() の4つのガードを通過しない限り、done-markerは立ちません。
article_ok() {
local f="$1"
[ -s "$f" ] || return 1
[ "$(stat -f%z "$f" 2>/dev/null || echo 0)" -ge "$MIN_ARTICLE_BYTES" ] || return 1
grep -qE '^title:' "$f" || return 1
grep -qiE 'request timed out|不明な商品|TODO: *本文|\(生成失敗\)' "$f" && return 1
return 0
}
1200バイト・frontmatterのtitle行・タイムアウト文言の不在という3点をクリアして初めて「記事として成立している」と判断します。終了コード0は何も証明しない、という原則の実装です。
4. 自動修復の試行回数に上限を設ける
MAX_ATTEMPTS=2
この定数は article-daily-stock.sh の136行目にあります。壊れた記事の再生成を2回まで自動で試み、3回目は needhuman リストに積んで通知します。無限に再生成を繰り返すと、キューが破損していたり、Claudeがある種の入力を処理できない場合にトークンを無駄に消費します。
5. 重複チェックは4ソースから
used_slugs() は4ソースを統合します。
used_slugs() {
{ jq -r '.[].slug' "$QUEUE" 2>/dev/null
jq -r '.[].slug' "$DONEQ" 2>/dev/null
ls "$ARTICLES" 2>/dev/null | sed 's/\.md$//'
jq -r '.[].slug' "$COVERAGE" 2>/dev/null
} | sort -u
}
キュー・完了済み・既存ファイル・coverage.jsonのどれかひとつでも欠けると、重複ネタが生成されます。「同じネタで10本並んだ」という事故が起きたのはcoverageの確認だけが抜けていたためです。
6. mkdirで競合ロックを取る
mkdir はPOSIXレベルでアトミックです。flock は Linux/macOS で挙動が微妙に異なりますが、mkdir の原子性はどちらでも保証されています。content-watchdog.sh のロックディレクトリ名が .lockd(末尾d)になっているのはdirectoryであることを明示するためです。
7. stale lockを1800秒で自動引き継ぎする
各レーンのタイムアウトが1800秒なので、正常なプロセスが走っていれば必ずその時間内にロックを解放します。
if [ "$age" -ge 1800 ]; then
log "lock stale age=${age}s; taking over"
rm -rf "$LOCKDIR"
...
この値は「何秒で引き継ぐか」と「レーンスクリプトのタイムアウト」を一致させることで、誤引き継ぎを防いでいます。数字に一貫性を持たせることがポイントです。
8. 終了コードでなくdone-markerで再起動の要否を判断する
run_lane() は再起動後の終了コード(rc)をログに残しますが、判断には使いません。
rc=$?
log "lane=$lane reinvoke_exit=$rc"
if done_lane "$lane"; then
log "lane=$lane status=healthy-after-reinvoke"
return 0
fi
終了コード0でもdone-markerが無ければ再起動扱い。終了コード1でもdone-markerがあれば成功扱い。スクリプトが出力ファイルにエラーを流し込んで正常終了するケース(タイムアウト文言混入)を正確に捕捉できます。
9. caffeinate execで深夜スリープを防ぐ
バッテリー駆動のMacで深夜に記事生成が走るとOSがスリープします。
if [ -z "${CAFFEINATED:-}" ]; then
exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi
exec で自分自身を caffeinate でラップして起動し直します。-s フラグはシステムスリープの防止で、ふたを閉じても眠りません。CAFFEINATED=1 を環境変数で渡して二重execを防ぎます。
10. ネットワーク待ちを必ず実装する
launchdはMac起動直後にプロセスを呼ぶことがあります。Wifi接続が確立する前にClaudeのAPIを叩くと全レーンが失敗します。
for _ in $(seq 1 18); do
/usr/bin/nc -z -G 3 1.1.1.1 443 2>/dev/null && break; sleep 5
done
最大18回×5秒=90秒待ちます。-G 3 でコネクトタイムアウトを3秒に抑えることで、Wifiがつながっていれば数秒で抜けます。audit モードはClaudeを使わないのでこのループをスキップします(コード87行目)。
11. 予算チェックをAPIを叩く前に済ませる
BUDGET=$(~/.claude/scripts/token-budget-advisor.sh --short 2>/dev/null || echo "n/a")
if echo "$BUDGET" | grep -qE '🔴|critical|cap-near'; then
log "ABORT: budget critical — 次スロットで再試行"; exit 0
fi
月末に予算が枯渇していても、翌月リセット後のスロットで自動的にウォッチドッグが再起動します。「月末に全部止まって手動で再開した」という体験をしてから追加した仕掛けです。
12. ハートビートは1日1回、失敗通知は毎回
成功をDiscordで毎回通知すると通知疲れが起きます。send_heartbeat_once() は HEARTBEAT_MARKER で初回だけに絞ります。失敗通知(🚨)は毎回飛ばします。
notify alerts "💓 content-watchdog heartbeat: 全レーン当日生成済み"
touch "$HEARTBEAT_MARKER"
対して失敗通知は if [ -n "$FAILED_LANES" ] で毎回条件を評価します。「静かな成功・うるさい失敗」という設計です。朝にDiscordを開いて 💓 があれば全レーン正常、🚨 があれば手動対応が必要だと分かります。
13. ameba等の既存スクリプトはdone-markerより実成果物を確認する
既存スクリプトに後からウォッチドッグを繋ぐ際、スクリプトにdone-markerを追加改修するコストが高い場合があります。ameba レーンのフォールバック実装(created: メタデータとmtimeの2段階確認)はその現実的な妥協点です。理想はスクリプト側にdone-markerを追加することですが、「動かす」と「完璧にする」のどちらを優先するかは状況次第です。
まとめ
この記事では content-watchdog.sh と article-daily-stock.sh の実コードを読みながら、「詰まっても自動で直る」設計の核心を確認してきました。
中心にあるのは3つのアイデアです。
完了はファイルの実在で証明する。 ログの文字列でも終了コードでもなく、~/.claude/logs/.article-daily-done-20260710 が存在するかどうかだけを真実として扱います。これにより、「スクリプトは走ったが中身がない」という失敗パターンを全部捕捉できます。
生成と配信は独立した責務にする。 git pushの成否をdone-markerの条件にすると、ネットワーク障害が生成の失敗に見えます。記事が ~/Desktop/Article に書けた時点を成功とし、配信はベストエフォートにします。月末のAPIレートリミットが当日分の記事生成に影響しなくなりました。
監査は毎回走らせ、生成は1日1回だけ走らせる。 audit_repair() は SKIP_GEN=1 でも実行されます。新規生成をスキップした日でも、既存ストックの品質確認と壊れた記事の再生成が静かに動き続けます。この分離がなければ、「生成済みフラグがある日に既存記事が壊れていても誰も気づかない」という穴が残ります。
解雇から半年でiOSアプリ10本を掛け持ちしながら月商120万を維持できているのは、この「確認しなくていい設計」があるからです。毎朝Discordに 💓 が来ることを確認するだけで、5本のコンテンツレーンが正常であることが分かります。ウォッチドッグが存在する前は、「今日も動いているはず」という不安を抱えながら手動でログを確認していました。その時間と不安コストがゼロになったことが、他の開発に集中できる余白を生んでいます。
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!