🔁 🔁 本番より手元で死ぬ——launchdのロケール逆転バグが5リポジトリに潜んでいた — リーダー×
🔁

🔁 本番より手元で死ぬ——launchdのロケール逆転バグが5リポジトリに潜んでいた

#automation#claudecode#副業2026-08-15 · 約31

月10万の大学生が掛け持ちで月60万まで積み上げ、会社都合で解雇されて0に戻り、半年でClaude Code自律環境を建てて今は月商120万——その土台になったのは「寝ている間に記事が生えてくる」仕組みだ。ただしその仕組みが5日間、失敗しても誰にも知らせずに黙って落ち続けていたという話をする。

なぜこの仕組みが効くのか

自動化の話をすると、たいていは「仕組みさえ作れば楽になる」という結論で終わる。だが私がこの半年でいちばん学んだのは、その逆だ。仕組みは作った瞬間から腐り始め、しかも腐っていることを教えてくれない。

article-daily-stock.sh~/.claude/scripts/article-daily-stock.sh)は毎朝8時と10時35分の2スロット、launchdから起動して記事を1本生成し ~/content/article/ にストックするスクリプトだ。設計の核心はコメントに書いてある。

# 設計の肝:
#   - Zennデプロイ(deploy-next)が詰まっても、ここは止まらない。生成の成否は
#     「content/article にストックが書けたか」だけで判定する。

Zennへのpushとストック生成を切り離してある。デプロイが詰まっても生成バッファは積み上がる、という設計だ。この「生成と公開の分離」が結果として月30本の安定出力を支えている——はずだった。

問題は、このスクリプトが正常終了していたことではない。失敗したときの通知が道連れで死んでいたことだ。

「手で叩いたときだけ死ぬ」という逆転

ソフトウェア開発において最も厄介なバグの型は何か。「本番だけ落ちてローカルでは再現しない」——誰もが一度は踏む鬼だ。しかし今回遭遇したのはその完全な裏面だった。

launchdはLANGを設定しない。 つまり、launchd配下のスクリプトはCロケールで動く。ターミナルを開いてスクリプトを手で叩くと、シェルはLANG=ja_JP.UTF-8を引き継ぐ。この差が、通常とは真逆の障害パターンを生む。

  • 毎朝8時の定時実行(Cロケール)→ 何事もなく通過
  • 手でデバッグのために叩く(ja_JP.UTF-8)→ exit 127 で即死

定時実行は毎日通っている。だから誰も疑わない。手で叩いたときだけ死ぬから「叩き方を間違えた」で流される。そして今回は——死ぬ場所が失敗通知の行そのものだった。

Threadsへの投稿が失敗したとき、Discordへ通知を飛ばすはずのスクリプト(scent-mediadaily_post.sh)が通知行でクラッシュし、失敗したという情報自体が握り潰された。5日間、投稿は止まっていたが私のスマートフォンには何も届かなかった。

投稿が失敗した
  ↓ 失敗通知スクリプトが起動
  ↓ 通知行でクラッシュ(exit 127)
  ↓ アラートが出ない
  ↓ 5日間誰も気づかない

通知系のバグは「失敗したときにしか踏まれない」という性質上、成功している間は絶対に検出できない。日本語でログや通知を書くスクリプト——つまり私が書くほぼ全ての自動化——は構造的にこの地雷を踏む可能性を持っていた。

全体の流れ

バグの在りかを理解するために、まずarticle-daily-stock.shの全体像を追う。スクリプトは528行あるが、処理の骨格は12のフェーズに分けられる。

launchd (com.shun.article-daily)
  8:00 JST ─────────────────────────────────────┐
  10:35 JST (catch-up) ────────────────────────┤
                                                 ↓
                               ┌─────────────────────────────┐
                               │  Phase 0: 本日生成済みチェック  │
                               │  $DONE_MARKER が存在 → SKIP_GEN=1 │
                               └──────────────┬──────────────┘
                                              ↓
                               ┌─────────────────────────────┐
                               │  Phase 1: 環境補完            │
                               │  PATH += nvm / homebrew      │
                               │  caffeinate -i -s            │
                               │  二重実行ロック (lockdir)      │
                               └──────────────┬──────────────┘
                                              ↓
                               ┌─────────────────────────────┐
                               │  audit_repair()              │
                               │  全ストックの監査 + 自己修復   │
                               │  サムネ欠損 → 自動再生成       │
                               │  本文破損 → QUEUEへ再投入     │
                               └──────────────┬──────────────┘
                                              ↓
                              SKIP_GEN=1? ────→ exit 0 (audit only)
                                              ↓ No
                               ┌─────────────────────────────┐
                               │  Phase 2: 予算チェック        │
                               │  token-budget-advisor.sh     │
                               │  🔴 critical → exit 0       │
                               └──────────────┬──────────────┘
                                              ↓
                               ┌─────────────────────────────┐
                               │  Phase 3: キュー空なら自動立案  │
                               │  claude -p でネタ1本を生成    │
                               │  slug重複チェック → QUEUE追加 │
                               └──────────────┬──────────────┘
                                              ↓
                               ┌─────────────────────────────┐
                               │  Phase 4-5: 記事執筆          │
                               │  QUEUE先頭のtopicを取得      │
                               │  claude -p --max-turns 40    │
                               │  実ファイルをRead/Grepして引用 │
                               └──────────────┬──────────────┘
                                              ↓
                               ┌─────────────────────────────┐
                               │  Phase 6: 多段検証           │
                               │  frontmatter.title ≤ 70字   │
                               │  本文 ≥ 1200 bytes           │
                               │  禁止語 (タイムアウト等) スキャン│
                               │  秘密スキャン + 実パス正規化  │
                               └──────────────┬──────────────┘
                                              ↓
                               ┌─────────────────────────────┐
                               │  Phase 7-8: ストック化       │
                               │  ~/content/article/articles/ │
                               │  gen_note_thumbs.py          │
                               │  ~/content/article/thumbnails│
                               └──────────────┬──────────────┘
                                              ↓
                               ┌─────────────────────────────┐
                               │  Phase 9-12: 後処理          │
                               │  coverage.json upsert        │
                               │  QUEUE pop → done queue      │
                               │  DONE_MARKER touch           │
                               │  git push (best-effort)      │
                               └─────────────────────────────┘

このパイプラインが毎朝2スロット自走することで、私がキーボードに触れなくても記事のバッファが積み上がっていく。

環境補完——launchdの薄い実行環境

最初のフェーズで行っている処理に注目してほしい。

# launchd 最小PATH補完(node/git/jq/claude/python3/chrome を通す)
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"

launchdのPlist(com.shun.article-daily)はシェルのプロファイルを読まない。.zshrc.bash_profileも存在しないも同然の環境で起動する。だからnvmのパスも、Homebrewのパスも、手動で補完しなければならない。同じ窓で発覚したscent-mediadaily_generate.shが「claude: No such file or directory」で全滅していたのも同じ原因だ——.local/binPATHに無かった。

launchd配下のスクリプトは環境変数を前提にできない。 これはロケールについても同じだ。LANG=ja_JP.UTF-8はターミナルが設定する。launchdは設定しない。だからCロケールになる。この「ターミナルとlaunchdで実行環境が違う」という事実が、今回のバグが5日間発見されなかった直接の理由だ。

二重実行ロックとcaffeinate

# 実行中スリープ防止(バッテリ凍結時は次スロットが拾う)
if [ -z "${CAFFEINATED:-}" ]; then
  exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi

# 二重実行ロック
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

caffeinate -i -sでMacBookのスリープを抑止し、CAFFEINATED環境変数で自分自身がexecで再起動されたかどうかを判定する。8時スロットが記事生成中に10時35分スロットが起動しても、lockdirpidがalive(kill -0)であれば即exit 0で退く。staleなロックが残った場合はrm -rfしてから取り直す——この判定をmkdirのアトミック性に乗せることで競合を防いでいる。

audit_repair()——毎スロット必ず走る自己修復

記事生成をスキップする日(SKIP_GEN=1)でもaudit_repair()だけは必ず走る。

# 監査+自己修復を先に走らせる(audit / 本日生成済みは ここで完結)
audit_repair
if [ "$MODE" = "audit" ] || [ "$SKIP_GEN" = "1" ]; then
  log "===== article-daily done(audit$([ "$SKIP_GEN" = "1" ] && echo '+gen-skipped')) ====="
  exit 0
fi

audit_repair()の中では ~/content/article/articles/ の全ファイルを舐めて、本文の品質チェック(1200 bytes以上・frontmatter title存在・禁止語不在)とサムネの幅チェック(sips -g pixelWidthで2000px以上)を実施する。サムネが欠損していればgen_note_thumbs.pyで即再生成し、本文が壊れていればdone-queueからmetaを掘り起こしてtopic-queueの先頭に再投入する——最大2回まで。

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
  frontmatter_title_ok "$f" || return 1
  grep -qiE 'request timed out|不明な商品|TODO: *本文|\(生成失敗\)' "$f" && return 1
  return 0
}

このarticle_ok()関数が「スタブ検出」の番人だ。Claude APIがタイムアウトしたときに吐く「request timed out」という文字列や、placeholder的な「TODO: 本文」を明示的に弾く。毎スロット走ることで、昨日の生成が実は壊れていた——という状態を翌朝には必ず検知する。

キューが空なら実作業からネタを自動立案する

ネタキュー(~/zenn-articles/.topic-queue.json)が空になると、claude自身が次のネタを立案してキューに足す。

REPLENISH_PROMPT=$(cat <<EOF
あなたは Lily の「Claude Code環境」技術シリーズの編集者。次に書く記事ネタを1本だけ立案しろ。
ネタは「舜が実際にやった自動化・環境構築・Claude Code運用の工夫」から選ぶ。捏造禁止=必ず実在するファイルやスクリプトを根拠にする。
...
EOF
)
TOPIC_JSON=$(run_to 600 "$CLAUDE" -p "$REPLENISH_PROMPT" \
  --model "${ARTICLE_MODEL:-sonnet}" --effort high \
  --allowedTools "Read,Grep,Glob,Bash" --max-turns 20 ...)

モデルにはRead,Grep,Glob,Bashだけを許可し、実在するファイルを根拠にネタを選ばせる。出力は厳格なJSONスキーマ(slugtitlesourcesthumb_titleの存在チェック付き)で受け取り、slugの重複判定まで機械的に行う。slug重複なら即exit 0して次スロット再試行——捏造と重複をAPIレベルで封じる設計だ。

記事執筆から公開まで

キューからtopicを取り出したら、別のclaude -pセッションを--max-turns 40で起動して実際の記事を書かせる。

run_to 1500 "$CLAUDE" -p "$PROMPT" \
  --model "${ARTICLE_MODEL:-sonnet}" --effort high \
  --output-format text --allowedTools "Read,Grep,Glob,Write,Bash" --max-turns 40 >> "$LOG" 2>&1

タイムアウトは1500秒(25分)。失敗してもlogに残るだけでexit 0——article_ok()が失敗を検知して次スロットで再試行させるため、ここでは止めない。

生成後のパイプラインが念入りだ。

  1. frontmatter_title_chars()でtitle文字数を取得(Python3のinline heredoc)→ 70字超なら破棄
  2. article_ok()で本文品質チェック → NGなら破棄
  3. sedpublished: falseを強制セット
  4. lily-footer.pyでフッター注入
  5. sed -E 's#/Users/[A-Za-z0-9._-]+/#~/#g'で実ホームパスを~/に正規化
  6. 秘密スキャン(AWSキー正規表現、api_key =パターン等)→ 検出したら即破棄

最後に~/content/article/articles/$NO-$SLUG.mdにストックし、gen_note_thumbs.pyでサムネを生成、coverage.jsonにupsertしてからQUEUEをpopしてdone-queueに移す。gitへのpushはbest-effort——失敗しても「生成成功」は揺るがない。DONE_MARKERgit pushより前にtouchしてあるため、push失敗で翌スロットが再起動しても重複生成は起きない。


このパイプラインは設計の段階から「失敗するものとして」作られている。タイムアウト、生成品質不足、サムネ破損、ネットワーク断——それぞれに自己修復の経路が用意されており、監査は毎スロット走る。私がキーボードから離れていても、何かが壊れていれば翌朝には~/content/article/_NEEDS-FIX.txtが立ち、macOSの通知が飛んでくる。

飛んでくるはずだった。

実装の詳細

"$VAR(全角)" がロケールで死ぬ理由

まず現象を手元で確認する。~/Documents/claude-obsidian/wiki/learning/locale-dependent-shell-bugs.mdに記録されている再現コードがそのまま使える。

# 死ぬ(修正前の形)
LC_ALL=ja_JP.UTF-8 bash -c 'set -u; ID=test; echo "✅ 完了: $ID(要確認)"'
#=> bash: ID(要確認): unbound variable  (exit 127)

# 通る(修正後)
LC_ALL=ja_JP.UTF-8 bash -c 'set -u; ID=test; echo "✅ 完了: ${ID}(要確認)"'
#=> ✅ 完了: test(要確認)  (exit 0)

$ID(要確認)と書くと、ja_JP.UTF-8ロケール下のbashは変数名の終端を判定するときに全角文字も変数名の構成要素として読もうとする。はASCIIの(ではなくU+FF08(FULLWIDTH LEFT PARENTHESIS)だ。set -uが有効であれば「ID(要確認)という変数は存在しない」として即座にexit 127で死ぬ。

Cロケール(LC_ALL=C)では全角文字が変数名の一部として解釈されないため、$IDで展開が止まって正常に動く。launchdはLANGを設定しないのでCロケール——だから定時実行では通り、手で叩くと死ぬという逆転が生まれる。

危険な文字はだけではない。などUTF-8の全角文字全般が同じ罠になりうる。日本語でログや通知を書く自動化スクリプトはすべてこの地雷を踏む可能性がある。 ASCII文字が続くぶんには問題にならない——つまり英語だけで書いたスクリプトはこのバグに遭遇しない。日本語で丁寧に書けば書くほど踏む確率が上がる、という皮肉な性質だ。

article-daily-stock.sh の防御線——修正後の形

article-daily-stock.sh~/.claude/scripts/article-daily-stock.sh)の中を見ると、今のコードにはすでに波括弧が付いている。

# notify() の呼び出し例(修正後の形、スクリプトの検証フェーズより)
notify "title長すぎ: ${SLUG}(再試行)"
notify "記事生成が不完全: ${SLUG}(再試行)"

これが修正前は$SLUG(再試行)と書かれていた。が全角なので、ターミナルから手で走らせるとSLUG(再試行)という未定義変数エラーになり、set -uo pipefailがあれば即死する。定時実行(Cロケール)では正常に通るため、何日経っても発見されない。

今回見逃しがちだったのは通知行の危険性だ。article_ok()audit_repair()のような本体の処理には気を使う。でも失敗したときに呼ばれるnotifyの引数——「${SLUG}(再試行)」という文字列——は、成功時には到達しない経路にある。テストで成功経路だけ確認しても永遠に踏まれない。

もう一つ、article-daily-stock.shのPhase 1にあるclaudeバイナリの検出コードは同じ文脈の防御線だ。

# claude 解決(3段フォールバック)
CLAUDE="${CLAUDE_BIN:-$(command -v claude 2>/dev/null)}"
[ -z "$CLAUDE" ] && [ -x "$HOME/.local/bin/claude" ] && CLAUDE="$HOME/.local/bin/claude"
[ -z "$CLAUDE" ] && CLAUDE=$(ls -t "$HOME"/.nvm/versions/node/*/bin/claude 2>/dev/null | head -1)
[ -x "$CLAUDE" ] || { log "ABORT: claude binary not found"; notify "claude binaryが無い"; exit 0; }

command -v claude~/.local/bin/claude→nvmのbin以下→それでも見つからなければABORT、という3段フォールバックになっている。launchdのPlistはシェルプロファイルを読まないから、~/.local/binがPATHに存在しない。このことを知った上で書かなければ、「claude: command not found」で毎朝サイレントに全滅するスクリプトが出来上がる。

検出は rg 1行で済む

バグのパターンは明快だ。$VARの直後に非ASCII文字が続いていて、かつまだ${VAR}の形になっていないもの」 を全部拾えばよい。

rg -n --no-heading -g '*.sh' -g '!node_modules' \
  '\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]' ~/dev ~/.claude/scripts ~/bin | rg -v '\$\{'

2段パイプになっているのには理由がある。1段目の正規表現\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]は「$で始まり英数字アンダースコアが続き、その直後に非ASCII文字が来るもの」をすべてマッチさせる。これは$VAR${VAR}も両方ヒットする。2段目のrg -v '\$\{'で「すでに波括弧で囲まれた${VAR}形」を除外する。修正済みのものをノイズとして拾わず、修正が必要なものだけが残る。

今回これを実行したとき、5リポジトリ・8ファイル・12箇所が出てきた。一人でスクリプトを量産していると、癖はコピペで横展開される。「このリポジトリにはもう無い」という過去の判断は腐る。対象リストはハードコードしない——検出は常に現在のコードに対して行う。

直し方——波括弧を1個足すだけ

# Before(危険な形)
notify "記事生成が不完全: $SLUG(再試行)"
echo "処理済み: $ID(${DATE})"

# After(安全な形)
notify "記事生成が不完全: ${SLUG}(再試行)"
echo "処理済み: ${ID}(${DATE})"

変えるのは$VAR${VAR}にする、それだけだ。ログメッセージの日本語文言は1文字も変えない。 波括弧があれば、bashは{}の内側だけを変数名として解釈し、直後の全角文字に惑わされなくなる。

ただし注意点がある。article-daily-stock.shの中にはPythonのインラインスクリプトが複数あり、いずれもシングルクォートのヒアドキュメントで書かれている。

frontmatter_title_chars() {
  python3 - "$1" <<'PY'
import sys
# ...(Pythonコード)
PY
}

<<'PY'(シングルクォートあり)の中はシェルが展開しないため、その中の$はPythonが解釈する。修正対象はあくまでシェルが展開する文脈に限られる——ヒアドキュメントの引用符を確認してから手を入れる。

修正後は必ず実証してから閉じた。旧形がja_JP.UTF-8でexit 127になることを確認し、新形が同じ文言を出してexit 0になることを確認する。「直したつもり」で終わらせない。実際に走らせた結果が証拠だ。


私が詰まった話

症状①: 5日間、Discordに何も届かなかった

最初の異変は偶然気づいた。「最近Threadsのエンゲージメントが薄いな」——そう感じてダッシュボードを開いたら、5日前から投稿が止まっていた。

scent-mediaプロジェクトのdaily_post.sh(Threads自動投稿スクリプト)のログを開いた。投稿失敗のエラー行はある。しかしその後ろにあるはずの「Discord通知送信」のログ行がない。通知関数が呼ばれた形跡が一切なかった。

最初はDiscordのwebhook URL変更かレートリミットを疑った。しかしwebhookは手で叩くと普通に動く。次にスクリプト自体をターミナルから直接走らせた瞬間、エラーが出た。

bash: 投稿ID(2026-08-05): unbound variable

$変数名(が全角だった。Threadsへの投稿が失敗したとき、スクリプトはDiscordへ通知を送ろうとする——しかしその通知を組み立てる行自体がexit 127で死ぬため、「失敗した」という情報がどこにも届かなかった。

投稿が失敗する
  ↓ 失敗通知の処理に入る
  ↓ 通知文字列の組み立てで $VAR(全角)が出現 → exit 127
  ↓ 通知が送信されない
  ↓ ログへの書き込みも通知より後ろにあったため残らない
  ↓ 5日間誰も気づかない

「手で叩いたときだけ死ぬ」という事実が、逆に5日間の遅延を生んだ。 定時実行(Cロケール)は毎日通っているから疑わない。手で叩いて失敗したら「叩き方の問題」として流す。今回たまたまダッシュボードを開いて気づいたが、気づかなければ何週間でも続いていた可能性がある。

通知コードの恐ろしさはここにある。本体の処理が失敗したときにしか通る経路にない。成功経路を何度テストしても、通知コードの品質は一切保証されない。成功経路だけ何度確認しても、失敗経路の品質は一切保証されない——これはlocale-dependent-shell-bugs.mdにも[[silent-success-antipattern]]として記録した教訓だ。

症状②: daily_generate.sh が別の原因で全滅していた

同じscent-mediaプロジェクトの別スクリプト、daily_generate.sh(Claude APIを使ってコンテンツを生成するほう)を確認したら、こちらも動いていなかった。ただし原因はロケールではない。

~/.claude/scripts/daily_generate.sh: line 12: claude: command not found

claudeバイナリが見つからない。launchdのPlistはシェルプロファイルを読まないから、~/.local/binがPATHに存在しない。article-daily-stock.shではPhase 1で明示的に解決しているあの問題だ。

2つの異なるバグが同じタイミングで同じプロジェクトで顕在化した理由は同じだ。launchd配下のスクリプトは「ターミナルと実行環境が違う」という事実を、書いた本人が忘れていた。

ロケール差とPATH差——どちらも根っこは「launchdはターミナルのシェル環境を引き継がない」という一点だ。この事実を知識として持っていても、スクリプトを書く瞬間には忘れる。なぜなら手元でテストするとき、ターミナルから走らせると動く。動いた——でそれ以上追わない。launchdから走らせたときの環境を意識的に再現しなければ、テストは成功経路の確認にしかならない。

詰まりどころ①: git管理外と思い込んで進めた

横断修正を~/.claude/scripts/に当てるとき、この配下は「gitで管理していない雑多なスクリプト置き場」だと思い込んでいた。だから修正後に「commitは不要」と判断して先に進んだ。

ところが修正後に念のためgit statusを打ったら、changeが出てきた。

On branch main
Changes not staged for commit:
  modified:   article-daily-stock.sh
  modified:   token-budget-advisor.sh

~/.claude/scripts/はgitリポジトリだった。しかも2ファイルがtracked fileとして管理されていた。「git管理外のはず」という思い込みを確認せずに進めたことで、後から手戻りが発生した。

教訓は単純だ。作業前にgit statusを打つほうが早い。 前提を脳内で判定するより、コマンド1発で確認するほうが速くて確実だ。

修正後はgit add article-daily-stock.sh token-budget-advisor.shで2ファイルだけをステージングし、今回と無関係な差分を巻き込まないようにしてcommitした(2b65662)。5リポジトリのcommitハッシュは事後検証ノートに記録してある——lily-line-funnel6596c00autopilot56288fabrand-404f0d37f5metrics-hubf9b1a18だ。

詰まりどころ②: 1箇所だけコミットできなかった

12箇所を一掃したと言いながら、~/.claude/scripts/article-daily-stock.shの中の1箇所だけがコミットできない状態で残った。

その箇所は、スクリプトのまだコミットされていない変更ブロックの内部にあった。新機能を書きかけで止まっていた数十行の未コミット差分の中に、今回の修正対象が含まれていたのだ。

# 未コミット差分の内部(こんな形で存在していた)
# ... 新機能の実装途中 ...
log "処理スキップ: $SLUG(重複)"   # ← ここが修正対象
# ... 続く未コミット行 ...

選択肢は2つだった——(a)未コミットブロックごとcommitする、(b)その1行だけローカルで修正してコミットしない——だ。(a)は「今回と無関係な差分を巻き込む」ことになりcommitの意図が曖昧になる。(b)は「worktreeでは安全な形にした状態でコミットせず残す」ことになる。

「リポジトリ全体clean」という完了条件だけが未達と明記して(b)を選んだ。ローカルのworktreeでは全12箇所が${VAR}形になっており、launchdからの定時実行(Cロケール)でも手動実行(ja_JP.UTF-8)でも死なない状態で閉じた。残り1箇所は、未コミットブロックを整理してcommitするタイミングで一緒に閉じる——そう明記して次に進む。「完了条件の一部が未達」を隠さないことが、後から自分を助ける。


このバグを追うまで、「launchdとターミナルでロケールが違う」という事実は知っていた。しかし「だから通知行も守らなければいけない」には繋がっていなかった。知識と実装は別物だ。スクリプトを書くとき、成功時の処理には気を使う。失敗時の通知行は、どうせ動いていると思って流す——その思い込みが5日間のサイレントな死を招いた。

通知行が死ぬと「失敗したこと」が届かない。「届かない」は「成功した」と区別できない。 自動化において通知コードは本体のコードより堅固でなければならない。なぜなら通知コードが落ちると、本体が落ちたことすら教えてくれないからだ。

article-daily-stock.shの設計に「生成と公開の分離」があるように、通知系にも「通知そのものの失敗を見逃さない」という設計が要る。この一件の後、通知関数の呼び出し前には必ず${VAR}の形を確認する、という癖がついた。波括弧1個の差が、5日間の沈黙か当日の検知かを分ける。

つまずきポイント

実際に踏んだ地雷と、見落としがちな罠を列挙します。p1・p2で触れた症状の裏側にある「なぜそこで詰まるのか」の部分です。

① シングルクォートヒアドキュメントを「対象外」と意識していなかった

article-daily-stock.shにはfrontmatter_title_chars()のようにPythonをインラインで呼ぶ箇所が複数あります。

frontmatter_title_chars() {
  python3 - "$1" <<'PY'
import sys
# Pythonコード($はPythonが解釈する)
PY
}

<<'PY'(シングルクォートあり)の内側はシェルが展開しません。検出コマンドを走らせると$が含まれたPythonコードにもヒットしますが、ここは修正不要です。最初にこれを知らず「ここも波括弧を足すべきか」と迷いました。判断基準は「シングルクォートで閉じたヒアドキュメントの中か否か」の1点です。ダブルクォートや引用符なしのヒアドキュメント(<<PY)は展開されるので対象になります。

>/dev/null 2>&1 が通知失敗を隠す

スクリプトのnotify()は以下のように定義されています。

notify() { /usr/bin/osascript -e "display notification \"$1\" with title \"Article daily\"" >/dev/null 2>&1; }

stdoutとstderrを両方捨てているため、osascriptが失敗しても何も残りません。通知引数に全角文字の展開問題がある場合、この行自体がexit 127で死んでも2>&1で握りつぶされます。今回の5日間沈黙の直接原因はまさにここでした。修正後に気づいたことですが、通知失敗だけは$LOGに残す形にしておくべきでした。

DONE_MARKERのタイミングを間違えると重複生成が起きる

article-daily-stock.shではDONE_MARKERtouchをgit pushより前に行っています。

# 生成成功=この時点で当日doneを確定する
touch "$DONE_MARKER"

# ---- 12. Zennソースを push(best-effort)---------
# ...git push...

touch "$DONE_MARKER"  # pushの後にも念のため

git pushが失敗してもDONE_MARKERが立っているので10:35のcatch-upスロットはSKIP_GEN=1でauditのみで抜けます。DONE_MARKERをpushより後に置くと、push失敗→翌スロットで同じtopicを再生成、という二重生成が起きます。設計時に見落としやすいポイントです。

④ lockdirのstaleを考慮しないと自縄自縛になる

mkdirのアトミック性を使ったロック機構の中に、stale判定があります。

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

kill -0でプロセスがaliveかを確認し、いなければstaleとみなしてrm -rfしてから取り直します。この判定を入れなかった初期実装では、スクリプトがSIGKILLで強制終了された後(予算超過でタイムアウトするケースなど)にlockdirが残り続け、翌日から二度と動かなくなるという問題が出ました。

set -uo pipefailpipefail部分が予想外の場所で死ぬ

set -uo pipefailpipefailは「パイプの途中でどれか1つが非0を返したら全体が非0になる」という動作です。jq ... | grep -q ...のような組み合わせで、grepが「マッチなし」でexit 1を返す正常ケースでもスクリプトが死ぬことがあります。

# 危険な形
jq -r '.[].slug' "$QUEUE" | grep -qx "$NEW_SLUG"

# 安全な形
used_slugs | grep -qx "$NEW_SLUG"
# ↑ used_slugs()内でpipeのエラーを || true で吸収済み

article-daily-stock.shused_slugs()がこのパターンに対処しています。grep -qの「見つからない」はエラーではなく正常系なのに、pipefailだと違う意味になる。bash特有の落とし穴です。

claudeバイナリの検出を後回しにした

最初の実装ではclaudeコマンドが見つからない場合の処理を後回しにして先に進んでしまいました。launchdから起動する場合は~/.local/binがPATHにないため、実際には毎朝「claude: command not found」でサイレントに全滅していました。現在の3段フォールバックはその経験から生まれています。

CLAUDE="${CLAUDE_BIN:-$(command -v claude 2>/dev/null)}"
[ -z "$CLAUDE" ] && [ -x "$HOME/.local/bin/claude" ] && CLAUDE="$HOME/.local/bin/claude"
[ -z "$CLAUDE" ] && CLAUDE=$(ls -t "$HOME"/.nvm/versions/node/*/bin/claude 2>/dev/null | head -1)
[ -x "$CLAUDE" ] || { log "ABORT: claude binary not found"; notify "claude binaryが無い"; exit 0; }

この検出を入れた後、初めて「claude未発見」のABORTログが出て問題を把握できました。「動いているはず」は動いていない。

ALLOWED_OWNERチェックを忘れてforkにpushされた

git pushの直前にOWNERチェックを入れています。

OWNER=$(printf '%s' "$URL" | sed -nE 's#.*github\.com[:/]+([^/]+)/.*#\1#p')
if [ "$OWNER" = "$ALLOWED_OWNER" ]; then
  # pushする
fi

これを入れていなかった時期に、開発中にoriginを別リポジトリに向けていたまま自動実行が走り、関係ないリポジトリにコミットが積まれました。ALLOWED_OWNER=bokuwalilyを固定しておくことで、誤操作でoriginを変えても最悪の事態を防げます。

MIN_ARTICLE_BYTES=1200という判定値の根拠を知らずに疑った

article_ok()の本文チェックでstat -f%zが1200バイト未満なら破棄しています。最初「なぜ1200バイトなのか」と疑い、値を変えようとしましたが——これはバイト数です。UTF-8では日本語1文字が3バイトなので、1200バイト=約400文字。まともな技術記事の冒頭だけでも超えるはずの値として決めた下限です。stat -f%zはmacOS固有のオプション(Linux版はstat -c%s)なので、Linuxに移植する場合は書き換えが必要です。


ベストプラクティス

実際にこのバグを踏み、5リポジトリ・8ファイル・12箇所を一掃した経験から導いた実践的なルールです。

1. 変数展開の直後に全角文字を置くなら必ず${VAR}形式にする

# NG
notify "生成失敗: $SLUG(再試行)"
# OK
notify "生成失敗: ${SLUG}(再試行)"

波括弧1個の差です。ログメッセージの日本語文言は1文字も変えなくていい。

2. 横断検出コマンドを手元に持っておく

rg -n --no-heading -g '*.sh' -g '!node_modules' \
  '\$[A-Za-z_][A-Za-z0-9_]*[^\x00-\x7F]' ~/dev ~/.claude/scripts ~/bin | rg -v '\$\{'

「1箇所直した」で終わらせない。同じ癖はコピペで横展開される。今回も同じパターンが5リポジトリに散らばっていました。検出対象リストをハードコードしない——常に現在のコードに対して走らせます。

3. launchdの実行環境をターミナルで再現してテストする

# launchdと同じ環境を手元で再現
env -i HOME="$HOME" PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" \
  CAFFEINATED=1 /bin/bash ~/path/to/script.sh dry

env -iでほぼ空の環境から起動することで、launchd配下での挙動を手元で確認できます。「ターミナルで通る」は「launchdで通る」ではない。

4. launchd PlistにLANGを明示して環境を揃える

<key>EnvironmentVariables</key>
<dict>
  <key>LANG</key>
  <string>ja_JP.UTF-8</string>
</dict>

PlistにLANGを明示すると、定時実行でもja_JP.UTF-8で動きます。「ターミナルと同じ環境で動く」ことで、ロケール差に起因するバグが手元テストで検出できるようになります。今回はコードを修正する選択をしましたが、実行環境を揃える選択肢もあります。

5. PATH補完はset -uより前に、スクリプトの最初のフェーズで行う

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"

launchdのPlistはシェルプロファイルを読まない。nvm・Homebrew・~/.local/binを使うツールは全部「見えていないもの」として扱い、明示的に足す必要があります。

6. claudeバイナリは3段フォールバックで解決し、見つからなければABORTする

command -v~/.local/bin→nvmのbin以下、という順で探し、どこにもなければexit 0(次スロット再試行)。exit 1にすると通知まで吹き飛ぶことがあるのでexit 0にしてlogだけ残す、という設計にしています。

7. 通知コードを本体より堅固に書く

通知が落ちると「失敗したこと」が届かない——「届かない」は「成功した」と区別できません。article-daily-stock.shでこれを防ぐために採った設計は2つです。

  • 通知引数に全角文字が含まれる場合は必ず${VAR}形式
  • 通知よりも先に$LOGへ書く(logへの書き込みが残れば通知失敗は検知可能)
log "ABORT: title長すぎ(${TITLE_CHARS}字) → 破棄"
notify "title長すぎ: ${SLUG}(再試行)"  # 通知は後

8. set -uo pipefailは入れるが、|| trueの使い所を知る

pipefailのせいで「grepでマッチしなかった」という正常系がexit 1扱いになることがあります。|| trueはそのような「失敗ではない非0」を吸収するために使います。「全部|| true」は論外——set -uの価値が消える。使うのは「非0が正常」と確定している箇所だけです。

9. DONE_MARKERはgit pushより前にtouchする

touch "$DONE_MARKER"  # ← ここでマーカーを立てる
# 以降のpushはbest-effort
git push ...
touch "$DONE_MARKER"  # 念のため二重touch(冪等)

pushが失敗しても「生成成功」は揺るがない、という設計の要です。DONE_MARKERが立っていれば次スロットは生成をスキップするので、push失敗→重複生成は起きません。

10. audit_repair()を「生成をスキップする日」にも走らせる

audit_repair
if [ "$MODE" = "audit" ] || [ "$SKIP_GEN" = "1" ]; then
  exit 0  # 生成はスキップ、auditは毎日走る
fi

本日すでに生成済みの日でも、昨日以前のストックの品質は劣化しうる。サムネの幅チェック(sips -g pixelWidthで2000px以上確認)と本文のスタブ検出は毎スロット走らせ、問題があればQUEUEに再投入します。

11. gitで管理しているかどうかを思い込みで判断しない

git status  # 作業開始前に1発打つだけ

今回、~/.claude/scripts/が「git管理外の雑多な場所」という思い込みで進めて、後からgit statusを打ったらmodifiedが2件出てきました。思い込みを確認するより、コマンド1発で事実を確認する方が早いです。

12. 修正後は必ず実証してから閉じる

# 旧形がexit 127で死ぬことを確認
LC_ALL=ja_JP.UTF-8 bash -c 'set -u; SLUG=test; echo "生成失敗: $SLUG(再試行)"'
# => bash: SLUG(再試行): unbound variable

# 新形が同じ文言を正常に出すことを確認
LC_ALL=ja_JP.UTF-8 bash -c 'set -u; SLUG=test; echo "生成失敗: ${SLUG}(再試行)"'
# => 生成失敗: test(再試行)

「直したつもり」で終わらせない。locale-dependent-shell-bugs.mdにもこの実証コマンドが残してあります。差分が一行でも、旧形と新形の両方の挙動を目で見て閉じる習慣が、「直したのにまだ落ちる」の往復を防ぎます。

13. ネタ立案にも実在ファイルの根拠を要求する

article-daily-stock.shのネタ自動立案プロンプトには「捏造禁止=必ず実在するファイルやスクリプトを根拠にする」「sourcesに実在パスを2〜4個挙げる」という制約を入れ、出力のJSONでそれを検証しています。自動化記事が「架空の実装」にならないための設計です。自分が実際に動かしているコードから切り口を選ぶことで、記事の密度が根本的に変わります。


まとめ

このバグが5日間見つからなかった理由は、「手で叩いたときだけ死ぬ」という通常の逆転パターンと、「失敗時のパスにしか存在しないバグ」という2つの性質が重なったからです。

  • 定時実行はCロケールで毎日通る → 誰も疑わない
  • 手で叩くとja_JP.UTF-8で死ぬ → 「叩き方の問題」で流される
  • 死ぬ場所が失敗通知の行 → 失敗したこと自体が届かない

$SLUG(再試行)という変数展開の直後に全角が来る書き方は、日本語で丁寧にログを書けば書くほど踏む確率が上がります。英語だけで書いたスクリプトはこの問題に遭遇しません。皮肉な性質です。

修正自体はrg1行で12箇所を検出し、波括弧を1個足すだけで完了しました(2b656626596c0056288faf0d37f5f9b1a18の5コミット)。しかし今回本当に学んだのは「直し方」ではなく「通知コードは本体コードより堅固でなければならない」という原則です。

通知が落ちると、落ちたこと自体が届かない。

自動化の仕組みは作った瞬間から腐り始め、しかも腐っていることを教えてくれない——だからaudit_repair()が毎スロット走り、DONE_MARKERがgit pushより先に立ち、article_ok()が「スタブ検出」の番人として控えています。月30本の記事が自動生成され続ける土台は、1200バイト未満を弾く1行の関数と、波括弧1個の差で守られています。

仕組みは「正常に動いている間」は透明です。壊れたときに初めて見える。その瞬間に見えるかどうかが、月商120万の土台が安定するかどうかを分けています。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。 📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!