5 Days of Silent Failures: A launchd Locale Bug That Was Hiding in 5 Repos A developer spent five days with a silent failure in an automated article-posting pipeline, caused by a locale bug in launchd that only manifested when scripts were run manually. The bug crashed the failure-notification line itself, so no alerts were sent. The developer, who built an autonomous Claude Code setup generating ¥1.2M/month, emphasizes that systems begin rotting the moment they are built and often fail without telling anyone. Everyone has met the bug that only shows up in production. This one was its exact mirror image: it passed every single scheduled run and died only when I typed the command myself. That inversion is why it stayed invisible for five days — five days in which my posting pipeline was down and my phone stayed completely quiet. Some context on why that pipeline matters to me: I went from ¥100k/month as a university student to ¥600k/month juggling gigs, got laid off and dropped back to zero, then spent six months building an autonomous Claude Code setup. Revenue is now ¥1.2M/month, and the foundation under all of it is a system that grows articles while I sleep. This is the story of that system failing quietly — failing without telling anyone. Most automation write-ups end at "build the system and life gets easier." The biggest thing I learned in the last six months is the opposite. A system starts rotting the moment you build it, and it will not tell you that it is rotting. article-daily-stock.sh ~/.claude/scripts/article-daily-stock.sh runs from launchd in two slots — 8:00 and 10:35 every morning — generates one article, and stocks it in ~/content/article/ . The core of the design is written in a comment. 設計の肝: - Zennデプロイ deploy-next が詰まっても、ここは止まらない。生成の成否は 「content/article にストックが書けたか」だけで判定する。 Pushing to Zenn and generating stock are decoupled. Even if the deploy jams, the generation buffer keeps stacking up. That "separate generation from publishing" split is what supports a stable output of 30 articles a month — or so it was supposed to. The problem wasn't that this script exited successfully. The problem was that the failure notification died along with the failure. What's the nastiest class of bug in software development? "It breaks only in production and never reproduces locally" — the demon everyone meets at least once. What I ran into this time was the perfect flip side of that. launchd does not set LANG. In other words, scripts under launchd run in the C locale. When you open a terminal and run the script by hand, the shell inherits LANG=ja JP.UTF-8 . That difference produces a failure pattern that is exactly backwards from normal. ja JP.UTF-8 → instant death with exit 127The scheduled run passes every day, so nobody suspects it. It only dies when you run it manually, so you shrug it off as "I must have invoked it wrong." And this time — the place where it died was the failure-notification line itself. When a post to Threads failed, the script that was supposed to fire a Discord notification daily post.sh in scent-media crashed on the notification line, and the very fact that it had failed got swallowed. For five days posting was stopped, and nothing reached my phone. 投稿が失敗した ↓ 失敗通知スクリプトが起動 ↓ 通知行でクラッシュ(exit 127) ↓ アラートが出ない ↓ 5日間誰も気づかない By its nature, a bug in notification code is "only ever hit when something fails" — so while things are succeeding, it is undetectable, always. Any script that writes logs and notifications in Japanese — which is to say nearly every piece of automation I write — was structurally capable of stepping on this mine. To understand where the bug lives, let's first walk the whole shape of article-daily-stock.sh . The script is 528 lines, but the skeleton splits into 12 phases. 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 │ └─────────────────────────────┘ Because this pipeline drives itself twice every morning, the article buffer keeps growing without me touching a keyboard. Look at what happens in the very first phase. 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" The launchd plist com.shun.article-daily does not read shell profiles. It starts in an environment where .zshrc and .bash profile may as well not exist. So the nvm path and the Homebrew path have to be filled in manually. The same root cause is why daily generate.sh in scent-media — discovered in the same window — had been wiped out by claude: No such file or directory : .local/bin was not on PATH . Scripts under launchd cannot assume environment variables. The same applies to the locale. LANG=ja JP.UTF-8 is set by the terminal. launchd does not set it. So you land in the C locale. That fact — "the terminal and launchd run in different environments" — is the direct reason this bug went undiscovered for five days. 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 keeps the MacBook from sleeping, and the CAFFEINATED environment variable tells the script whether it has already re-launched itself via exec . If the 10:35 slot fires while the 8:00 slot is still generating an article, and the pid in lockdir is alive kill -0 , it backs out immediately with exit 0 . If a stale lock is left behind, it does rm -rf and re-acquires — riding this decision on the atomicity of mkdir is what prevents races. audit repair — the self-repair that runs every slot Even on days where article generation is skipped SKIP GEN=1 , audit repair always runs. 監査+自己修復を先に走らせる(audit / 本日生成済みは ここで完結) audit repair if "$MODE" = "audit" || "$SKIP GEN" = "1" ; then log "===== article-daily done audit$ "$SKIP GEN" = "1" && echo '+gen-skipped' =====" exit 0 fi Inside audit repair it sweeps every file in ~/content/article/articles/ and runs body-quality checks at least 1200 bytes, frontmatter title present, no forbidden words plus a thumbnail width check at least 2000px via sips -g pixelWidth . If a thumbnail is missing, it regenerates it on the spot with gen note thumbs.py ; if the body is broken, it digs the meta out of done-queue and pushes it back to the head of topic-queue — up to two times. 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 } This article ok function is the gatekeeper for "stub detection." It explicitly rejects the string "request timed out" that the Claude API emits on timeout, and placeholder-ish text like "TODO: 本文". Because it runs every slot, a state where yesterday's generation was actually broken is guaranteed to be detected the next morning. When the topic queue ~/zenn-articles/.topic-queue.json empties out, claude itself plans the next topic and adds it to the queue. REPLENISH PROMPT=$ cat <