{"slug": "5-days-of-silent-failures-a-launchd-locale-bug-that-was-hiding-in-5-repos", "title": "5 Days of Silent Failures: A launchd Locale Bug That Was Hiding in 5 Repos", "summary": "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.", "body_md": "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.\n\nSome 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.**\n\nMost 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.**\n\n`article-daily-stock.sh`\n\n(`~/.claude/scripts/article-daily-stock.sh`\n\n) runs from launchd in two slots — 8:00 and 10:35 every morning — generates one article, and stocks it in `~/content/article/`\n\n. The core of the design is written in a comment.\n\n```\n# 設計の肝:\n#   - Zennデプロイ(deploy-next)が詰まっても、ここは止まらない。生成の成否は\n#     「content/article にストックが書けたか」だけで判定する。\n```\n\nPushing 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.**\n\nThe problem wasn't that this script exited successfully. **The problem was that the failure notification died along with the failure.**\n\nWhat'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.\n\n**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\n\n`LANG=ja_JP.UTF-8`\n\n. That difference produces a failure pattern that is exactly backwards from normal.`ja_JP.UTF-8`\n\n) → 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.**\n\nWhen a post to Threads failed, the script that was supposed to fire a Discord notification (`daily_post.sh`\n\nin `scent-media`\n\n) 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.\n\n```\n投稿が失敗した\n  ↓ 失敗通知スクリプトが起動\n  ↓ 通知行でクラッシュ（exit 127）\n  ↓ アラートが出ない\n  ↓ 5日間誰も気づかない\n```\n\nBy 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.\n\nTo understand where the bug lives, let's first walk the whole shape of `article-daily-stock.sh`\n\n. The script is 528 lines, but the skeleton splits into 12 phases.\n\n```\nlaunchd (com.shun.article-daily)\n  8:00 JST ─────────────────────────────────────┐\n  10:35 JST (catch-up) ────────────────────────┤\n                                                 ↓\n                               ┌─────────────────────────────┐\n                               │  Phase 0: 本日生成済みチェック  │\n                               │  $DONE_MARKER が存在 → SKIP_GEN=1 │\n                               └──────────────┬──────────────┘\n                                              ↓\n                               ┌─────────────────────────────┐\n                               │  Phase 1: 環境補完            │\n                               │  PATH += nvm / homebrew      │\n                               │  caffeinate -i -s            │\n                               │  二重実行ロック (lockdir)      │\n                               └──────────────┬──────────────┘\n                                              ↓\n                               ┌─────────────────────────────┐\n                               │  audit_repair()              │\n                               │  全ストックの監査 + 自己修復   │\n                               │  サムネ欠損 → 自動再生成       │\n                               │  本文破損 → QUEUEへ再投入     │\n                               └──────────────┬──────────────┘\n                                              ↓\n                              SKIP_GEN=1? ────→ exit 0 (audit only)\n                                              ↓ No\n                               ┌─────────────────────────────┐\n                               │  Phase 2: 予算チェック        │\n                               │  token-budget-advisor.sh     │\n                               │  🔴 critical → exit 0       │\n                               └──────────────┬──────────────┘\n                                              ↓\n                               ┌─────────────────────────────┐\n                               │  Phase 3: キュー空なら自動立案  │\n                               │  claude -p でネタ1本を生成    │\n                               │  slug重複チェック → QUEUE追加 │\n                               └──────────────┬──────────────┘\n                                              ↓\n                               ┌─────────────────────────────┐\n                               │  Phase 4-5: 記事執筆          │\n                               │  QUEUE先頭のtopicを取得      │\n                               │  claude -p --max-turns 40    │\n                               │  実ファイルをRead/Grepして引用 │\n                               └──────────────┬──────────────┘\n                                              ↓\n                               ┌─────────────────────────────┐\n                               │  Phase 6: 多段検証           │\n                               │  frontmatter.title ≤ 70字   │\n                               │  本文 ≥ 1200 bytes           │\n                               │  禁止語 (タイムアウト等) スキャン│\n                               │  秘密スキャン + 実パス正規化  │\n                               └──────────────┬──────────────┘\n                                              ↓\n                               ┌─────────────────────────────┐\n                               │  Phase 7-8: ストック化       │\n                               │  ~/content/article/articles/ │\n                               │  gen_note_thumbs.py          │\n                               │  ~/content/article/thumbnails│\n                               └──────────────┬──────────────┘\n                                              ↓\n                               ┌─────────────────────────────┐\n                               │  Phase 9-12: 後処理          │\n                               │  coverage.json upsert        │\n                               │  QUEUE pop → done queue      │\n                               │  DONE_MARKER touch           │\n                               │  git push (best-effort)      │\n                               └─────────────────────────────┘\n```\n\nBecause this pipeline drives itself twice every morning, the article buffer keeps growing without me touching a keyboard.\n\nLook at what happens in the very first phase.\n\n```\n# launchd 最小PATH補完（node/git/jq/claude/python3/chrome を通す）\nNODE_BIN=$(ls -d \"$HOME\"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH\"\n[ -n \"$NODE_BIN\" ] && export PATH=\"${NODE_BIN}:$PATH\"\n```\n\nThe launchd plist (`com.shun.article-daily`\n\n) does not read shell profiles. It starts in an environment where `.zshrc`\n\nand `.bash_profile`\n\nmay 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`\n\nin `scent-media`\n\n— discovered in the same window — had been wiped out by `claude: No such file or directory`\n\n: `.local/bin`\n\nwas not on `PATH`\n\n.\n\n**Scripts under launchd cannot assume environment variables.** The same applies to the locale. `LANG=ja_JP.UTF-8`\n\nis 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.\n\n`caffeinate`\n\n```\n# 実行中スリープ防止（バッテリ凍結時は次スロットが拾う）\nif [ -z \"${CAFFEINATED:-}\" ]; then\n  exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash \"$0\" \"$@\"\nfi\n\n# 二重実行ロック\nLOCKDIR=\"$HOME/.claude/locks/article-daily.lock\"\nif ! /bin/mkdir \"$LOCKDIR\" 2>/dev/null; then\n  oldpid=$(cat \"$LOCKDIR/pid\" 2>/dev/null || true)\n  if [ -n \"${oldpid:-}\" ] && kill -0 \"$oldpid\" 2>/dev/null; then\n    log \"別インスタンス実行中(pid=$oldpid) — skip\"; exit 0\n  fi\n  rm -rf \"$LOCKDIR\"; /bin/mkdir \"$LOCKDIR\" 2>/dev/null || exit 0\nfi\necho $$ > \"$LOCKDIR/pid\"\ntrap 'rm -rf \"$LOCKDIR\"' EXIT INT TERM\n```\n\n`caffeinate -i -s`\n\nkeeps the MacBook from sleeping, and the `CAFFEINATED`\n\nenvironment variable tells the script whether it has already re-launched itself via `exec`\n\n. If the 10:35 slot fires while the 8:00 slot is still generating an article, and the `pid`\n\nin `lockdir`\n\nis alive (`kill -0`\n\n), it backs out immediately with `exit 0`\n\n. If a stale lock is left behind, it does `rm -rf`\n\nand re-acquires — riding this decision on the atomicity of `mkdir`\n\nis what prevents races.\n\n`audit_repair()`\n\n— the self-repair that runs every slot\nEven on days where article generation is skipped (`SKIP_GEN=1`\n\n), `audit_repair()`\n\nalways runs.\n\n```\n# 監査＋自己修復を先に走らせる（audit / 本日生成済みは ここで完結）\naudit_repair\nif [ \"$MODE\" = \"audit\" ] || [ \"$SKIP_GEN\" = \"1\" ]; then\n  log \"===== article-daily done(audit$([ \"$SKIP_GEN\" = \"1\" ] && echo '+gen-skipped')) =====\"\n  exit 0\nfi\n```\n\nInside `audit_repair()`\n\nit sweeps every file in `~/content/article/articles/`\n\nand 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`\n\n). If a thumbnail is missing, it regenerates it on the spot with `gen_note_thumbs.py`\n\n; if the body is broken, it digs the meta out of `done-queue`\n\nand pushes it back to the head of `topic-queue`\n\n— up to two times.\n\n```\narticle_ok() {\n  local f=\"$1\"\n  [ -s \"$f\" ] || return 1\n  [ \"$(stat -f%z \"$f\" 2>/dev/null || echo 0)\" -ge \"$MIN_ARTICLE_BYTES\" ] || return 1\n  grep -qE '^title:' \"$f\" || return 1\n  frontmatter_title_ok \"$f\" || return 1\n  grep -qiE 'request timed out|不明な商品|TODO: *本文|\\(生成失敗\\)' \"$f\" && return 1\n  return 0\n}\n```\n\nThis `article_ok()`\n\nfunction 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.\n\nWhen the topic queue (`~/zenn-articles/.topic-queue.json`\n\n) empties out, claude itself plans the next topic and adds it to the queue.\n\n```\nREPLENISH_PROMPT=$(cat <<EOF\nあなたは Lily の「Claude Code環境」技術シリーズの編集者。次に書く記事ネタを1本だけ立案しろ。\nネタは「舜が実際にやった自動化・環境構築・Claude Code運用の工夫」から選ぶ。捏造禁止＝必ず実在するファイルやスクリプトを根拠にする。\n...\nEOF\n)\nTOPIC_JSON=$(run_to 600 \"$CLAUDE\" -p \"$REPLENISH_PROMPT\" \\\n  --model \"${ARTICLE_MODEL:-sonnet}\" --effort high \\\n  --allowedTools \"Read,Grep,Glob,Bash\" --max-turns 20 ...)\n```\n\nThe model is allowed only `Read,Grep,Glob,Bash`\n\n, forcing it to pick topics grounded in files that actually exist. The output is received under a strict JSON schema (with presence checks for `slug`\n\n, `title`\n\n, `sources`\n\n, `thumb_title`\n\n), and even slug-duplication is judged mechanically. On a duplicate slug it immediately does `exit 0`\n\nand retries in the next slot — a design that seals off fabrication and duplication at the API level.\n\nOnce a topic is pulled off the queue, a separate `claude -p`\n\nsession is launched with `--max-turns 40`\n\nto actually write the article.\n\n```\nrun_to 1500 \"$CLAUDE\" -p \"$PROMPT\" \\\n  --model \"${ARTICLE_MODEL:-sonnet}\" --effort high \\\n  --output-format text --allowedTools \"Read,Grep,Glob,Write,Bash\" --max-turns 40 >> \"$LOG\" 2>&1\n```\n\nThe timeout is 1500 seconds (25 minutes). On failure it just leaves a line in `log`\n\nand `exit 0`\n\ns — `article_ok()`\n\nwill detect the failure and force a retry in the next slot, so there is no reason to halt here.\n\nThe post-generation pipeline is meticulous.\n\n`frontmatter_title_chars()`\n\n(an inline Python3 heredoc) → discard if over 70 characters`article_ok()`\n\n→ discard if NG`published: false`\n\nwith `sed`\n\n`lily-footer.py`\n\n`~/`\n\nwith `sed -E 's#/Users/[A-Za-z0-9._-]+/#~/#g'`\n\n`api_key =`\n\npatterns, etc.) → discard immediately on a hitFinally it stocks the file at `~/content/article/articles/$NO-$SLUG.md`\n\n, generates the thumbnail with `gen_note_thumbs.py`\n\n, upserts into `coverage.json`\n\n, then pops the QUEUE and moves the entry to the done-queue. The git push is best-effort — if it fails, \"generation succeeded\" still stands. Because `DONE_MARKER`\n\nis `touch`\n\ned *before* `git push`\n\n, a push failure followed by the next slot restarting will not produce duplicate generation.\n\nThis pipeline was built from the design stage on the assumption that things fail. Timeouts, insufficient generation quality, broken thumbnails, network drops — each has a self-repair path, and the audit runs every slot. Even when I'm away from the keyboard, if something is broken, `~/content/article/_NEEDS-FIX.txt`\n\nwill be standing there the next morning and a macOS notification will fly in.\n\n**It was supposed to fly in.**\n\n`\"$VAR（全角）\"`\n\ndies depending on locale\nFirst, confirm the behavior locally. The reproduction code recorded in `~/Documents/claude-obsidian/wiki/learning/locale-dependent-shell-bugs.md`\n\ncan be used as-is.\n\n```\n# 死ぬ（修正前の形）\nLC_ALL=ja_JP.UTF-8 bash -c 'set -u; ID=test; echo \"✅ 完了: $ID（要確認）\"'\n#=> bash: ID（要確認）: unbound variable  (exit 127)\n\n# 通る（修正後）\nLC_ALL=ja_JP.UTF-8 bash -c 'set -u; ID=test; echo \"✅ 完了: ${ID}（要確認）\"'\n#=> ✅ 完了: test（要確認）  (exit 0)\n```\n\nWhen you write `$ID（要確認）`\n\n, bash under the `ja_JP.UTF-8`\n\nlocale tries to read full-width characters as part of the variable name when determining where the name ends. `（`\n\nis not ASCII `(`\n\nbut U+FF08 (FULLWIDTH LEFT PARENTHESIS). With `set -u`\n\nenabled, it decides \"there is no variable named `ID（要確認）`\n\n\" and dies immediately with exit 127.\n\nUnder the C locale (`LC_ALL=C`\n\n), full-width characters are not interpreted as part of a variable name, so expansion stops at `$ID`\n\nand everything works. launchd doesn't set LANG, so you get the C locale — hence the inversion where the scheduled run passes and the manual run dies.\n\n`（`\n\nisn't the only dangerous character. Full-width UTF-8 characters in general — `、`\n\n, `。`\n\n, `「`\n\n, `：`\n\n, `・`\n\n, `％`\n\n, `→`\n\nand friends — can all become the same trap. **Any automation script that writes logs or notifications in Japanese can step on this mine.** As long as ASCII characters follow, there's no problem — which means a script written entirely in English never encounters this bug. The more carefully you write in Japanese, the higher your odds of hitting it. An ironic property.\n\n`article-daily-stock.sh`\n\n's line of defense — the fixed form\nIf you look inside `article-daily-stock.sh`\n\n(`~/.claude/scripts/article-daily-stock.sh`\n\n), the current code already has braces.\n\n```\n# notify() の呼び出し例（修正後の形、スクリプトの検証フェーズより）\nnotify \"title長すぎ: ${SLUG}（再試行）\"\nnotify \"記事生成が不完全: ${SLUG}（再試行）\"\n```\n\nBefore the fix these read `$SLUG（再試行）`\n\n. Since `（`\n\nis full-width, running it by hand from the terminal produces an undefined-variable error for `SLUG（再試行）`\n\n, and with `set -uo pipefail`\n\nit dies instantly. Under the scheduled run (C locale) it passes normally, so no matter how many days go by, it is never found.\n\nWhat was easy to overlook this time is **the danger of the notification line**. You pay attention to core logic like `article_ok()`\n\nand `audit_repair()`\n\n. But the argument to `notify`\n\nthat's called on failure — the string `${SLUG}（再試行）`\n\n— sits on a path that is never reached on success. Testing only the success path means it is never hit, ever.\n\nOne more thing: the `claude`\n\nbinary detection code in Phase 1 of `article-daily-stock.sh`\n\nis a line of defense in the same vein.\n\n```\n# claude 解決（3段フォールバック）\nCLAUDE=\"${CLAUDE_BIN:-$(command -v claude 2>/dev/null)}\"\n[ -z \"$CLAUDE\" ] && [ -x \"$HOME/.local/bin/claude\" ] && CLAUDE=\"$HOME/.local/bin/claude\"\n[ -z \"$CLAUDE\" ] && CLAUDE=$(ls -t \"$HOME\"/.nvm/versions/node/*/bin/claude 2>/dev/null | head -1)\n[ -x \"$CLAUDE\" ] || { log \"ABORT: claude binary not found\"; notify \"claude binaryが無い\"; exit 0; }\n```\n\nIt's a three-stage fallback: `command -v claude`\n\n→ `~/.local/bin/claude`\n\n→ under nvm's bin → ABORT if still not found. Because the launchd plist doesn't read shell profiles, `~/.local/bin`\n\nisn't on PATH. Unless you write with that knowledge in hand, what you end up with is a script that silently gets wiped out every morning by `claude: command not found`\n\n.\n\n`rg`\n\nThe bug pattern is unambiguous. **Just collect everything where \"a non-ASCII character immediately follows $VAR, and it isn't already in ${VAR} form.\"**\n\n```\nrg -n --no-heading -g '*.sh' -g '!node_modules' \\\n  '\\$[A-Za-z_][A-Za-z0-9_]*[^\\x00-\\x7F]' ~/dev ~/.claude/scripts ~/bin | rg -v '\\$\\{'\n```\n\nThere's a reason it's a two-stage pipe. The first regex `\\$[A-Za-z_][A-Za-z0-9_]*[^\\x00-\\x7F]`\n\nmatches everything that \"starts with `$`\n\n, continues with alphanumerics and underscores, and is immediately followed by a non-ASCII character.\" That hits both `$VAR`\n\nand `${VAR}`\n\n. The second stage, `rg -v '\\$\\{'`\n\n, excludes the already-braced `${VAR}`\n\nform. **Already-fixed occurrences aren't picked up as noise, and only what needs fixing remains.**\n\nWhen I ran this, out came 5 repos, 8 files, 12 sites. When you mass-produce scripts solo, habits spread horizontally by copy-paste. Past judgments like \"this repo doesn't have any anymore\" go stale. **Don't hardcode the target list — always run detection against the current code.**\n\n```\n# Before（危険な形）\nnotify \"記事生成が不完全: $SLUG（再試行）\"\necho \"処理済み: $ID（${DATE}）\"\n\n# After（安全な形）\nnotify \"記事生成が不完全: ${SLUG}（再試行）\"\necho \"処理済み: ${ID}（${DATE}）\"\n```\n\nThe only change is `$VAR`\n\n→ `${VAR}`\n\n. **Not a single character of the Japanese log wording changes.** With braces, bash interprets only what's inside `{}`\n\nas the variable name and is no longer confused by the full-width character that follows.\n\nThere is one caveat, though. `article-daily-stock.sh`\n\ncontains several inline Python scripts, all written as single-quoted heredocs.\n\n``` python\nfrontmatter_title_chars() {\n  python3 - \"$1\" <<'PY'\nimport sys\n# ...（Pythonコード）\nPY\n}\n```\n\nInside `<<'PY'`\n\n(with single quotes) the shell does not expand anything, so the `$`\n\nin there is interpreted by Python. The scope of the fix is strictly **limited to contexts the shell expands** — check the heredoc's quoting before touching anything.\n\nAfter fixing, I always demonstrated it before closing out. Confirm that the old form exits 127 under `ja_JP.UTF-8`\n\n, and that the new form prints the same wording and exits 0. Don't stop at \"I think I fixed it.\" The result of actually running it is the evidence.\n\nThe first sign of trouble was noticed by accident. \"Threads engagement feels thin lately\" — I opened the dashboard on that hunch and found posting had been stopped for five days.\n\nI opened the log for `daily_post.sh`\n\n(the Threads auto-posting script) in the `scent-media`\n\nproject. The post-failure error lines were there. But the \"sending Discord notification\" log line that should have followed was not. There was no trace whatsoever of the notification function being called.\n\nAt first I suspected a changed Discord webhook URL or a rate limit. But the webhook worked fine when hit by hand. Then, the moment I ran the script itself directly from the terminal, the error appeared.\n\n```\nbash: 投稿ID（2026-08-05）: unbound variable\n```\n\nThe `（`\n\nin `$変数名（`\n\nwas full-width. When a Threads post failed, the script tried to send a notification to Discord — but the very line assembling that notification died with exit 127, so the information that it had failed reached nowhere.\n\n```\n投稿が失敗する\n  ↓ 失敗通知の処理に入る\n  ↓ 通知文字列の組み立てで $VAR（全角）が出現 → exit 127\n  ↓ 通知が送信されない\n  ↓ ログへの書き込みも通知より後ろにあったため残らない\n  ↓ 5日間誰も気づかない\n```\n\n**The very fact that \"it only dies when run by hand\" is what produced the five-day delay.** The scheduled run (C locale) passes every day, so you don't suspect it. If a manual run fails, you write it off as \"a problem with how I invoked it.\" This time I happened to open the dashboard and notice; if I hadn't, it could have continued for weeks.\n\nThat's where the horror of notification code lies. It sits on a path that's only reached when the core logic fails. No matter how many times you test the success path, the quality of the notification code is guaranteed by nothing at all. **No amount of confirming the success path guarantees anything about the quality of the failure path** — a lesson I also recorded in `locale-dependent-shell-bugs.md`\n\nas `[[silent-success-antipattern]]`\n\n.\n\n`daily_generate.sh`\n\nwas wiped out for a different reason\nWhen I checked another script in the same `scent-media`\n\nproject — `daily_generate.sh`\n\n, the one that generates content using the Claude API — it wasn't running either. But the cause wasn't the locale.\n\n```\n~/.claude/scripts/daily_generate.sh: line 12: claude: command not found\n```\n\nThe `claude`\n\nbinary was not found. Because the launchd plist doesn't read shell profiles, `~/.local/bin`\n\nisn't on PATH. It's the very problem that `article-daily-stock.sh`\n\nexplicitly solves in Phase 1.\n\nThe reason two different bugs surfaced in the same project at the same time is the same reason. **The person who wrote the scripts had forgotten that scripts under launchd \"run in a different environment than the terminal.\"**\n\nLocale differences and PATH differences — both come down to the single fact that \"launchd does not inherit the terminal's shell environment.\" Even when you hold that fact as knowledge, you forget it at the moment you write the script. Because when you test locally, running it from the terminal works. It worked — and you don't dig further. Unless you deliberately reproduce the environment as launchd sees it, your test is nothing more than a check of the success path.\n\nWhen applying the cross-cutting fix to `~/.claude/scripts/`\n\n, I had assumed this directory was \"a junk drawer of scripts not managed by git.\" So after the fix I decided \"no commit needed\" and moved on.\n\nThen, just in case, I ran `git status`\n\nafter the fix — and changes came out.\n\n```\nOn branch main\nChanges not staged for commit:\n  modified:   article-daily-stock.sh\n  modified:   token-budget-advisor.sh\n```\n\n`~/.claude/scripts/`\n\nwas a git repository. And two files were managed as tracked files. **Proceeding without verifying the assumption \"this shouldn't be under git\"** created rework after the fact.\n\nThe lesson is simple. **Running git status before you start is faster.** Verifying with one command beats deciding in your head — it's quicker and more reliable.\n\nAfter the fix, I staged only those two files with `git add article-daily-stock.sh token-budget-advisor.sh`\n\nso unrelated diffs wouldn't get swept in, and committed (`2b65662`\n\n). The commit hashes across all 5 repos are recorded in my post-verification notes — `lily-line-funnel`\n\nis `6596c00`\n\n, `autopilot`\n\nis `56288fa`\n\n, `brand-404`\n\nis `f0d37f5`\n\n, and `metrics-hub`\n\nis `f9b1a18`\n\n.\n\nEven while saying I'd swept all 12 sites, exactly one site inside `~/.claude/scripts/article-daily-stock.sh`\n\n**was left in a state that couldn't be committed.**\n\nThat site was inside a block of the script that hadn't been committed yet. The fix target was contained within a few dozen lines of uncommitted diff from a half-written new feature.\n\n```\n# 未コミット差分の内部（こんな形で存在していた）\n# ... 新機能の実装途中 ...\nlog \"処理スキップ: $SLUG（重複）\"   # ← ここが修正対象\n# ... 続く未コミット行 ...\n```\n\nThere were two options — (a) commit the whole uncommitted block, or (b) fix just that one line locally and not commit it. Option (a) would \"sweep in diffs unrelated to this work,\" making the commit's intent ambiguous. Option (b) means \"leaving it in a safe form in the worktree, uncommitted.\"\n\nI chose (b), explicitly noting that **the only unmet completion criterion is \"the repository as a whole is clean.\"** In the local worktree all 12 sites are in `${VAR}`\n\nform, and I closed it out in a state that won't die under either the scheduled launchd run (C locale) or a manual run (`ja_JP.UTF-8`\n\n). The remaining one site will be closed together with the commit that tidies up the uncommitted block — I noted that explicitly and moved on. Not hiding \"part of the completion criteria is unmet\" is what helps you later.\n\nUntil I chased this bug down, I already knew that \"launchd and the terminal have different locales.\" But it never connected to \"therefore the notification line must be protected too.\" Knowledge and implementation are different things. When you write a script, you're careful with the success path. The failure notification line, you wave off assuming it works — and that assumption caused five days of silent death.\n\n**When the notification line dies, \"it failed\" never arrives. And \"never arrived\" is indistinguishable from \"it succeeded.\"** In automation, notification code has to be more robust than the core code. Because when the notification code goes down, it doesn't even tell you the core went down.\n\nJust as `article-daily-stock.sh`\n\nhas \"separate generation from publishing\" in its design, the notification path needs a design of its own: \"don't let a failure of the notification itself slip by.\" After this incident, I picked up the habit of always checking for `${VAR}`\n\nform before a notification call. One pair of braces is the difference between five days of silence and same-day detection.\n\nHere are the mines I actually stepped on, plus the traps that are easy to miss. This is the \"why you get stuck there\" behind the symptoms covered above.\n\n**① I wasn't conscious that single-quoted heredocs are out of scope**\n\n`article-daily-stock.sh`\n\nhas several places that call Python inline, like `frontmatter_title_chars()`\n\n.\n\n``` python\nfrontmatter_title_chars() {\n  python3 - \"$1\" <<'PY'\nimport sys\n# Pythonコード（$はPythonが解釈する）\nPY\n}\n```\n\nThe inside of `<<'PY'`\n\n(with single quotes) is not expanded by the shell. Running the detection command will also hit Python code containing `$`\n\n, but no fix is needed there. Not knowing this at first, I hesitated over \"should I add braces here too?\" The single criterion is \"is it inside a single-quoted heredoc or not?\" Double-quoted or unquoted heredocs (`<<PY`\n\n) *are* expanded, so those are in scope.\n\n**② >/dev/null 2>&1 hides notification failures**\n\nThe script's `notify()`\n\nis defined like this.\n\n```\nnotify() { /usr/bin/osascript -e \"display notification \\\"$1\\\" with title \\\"Article daily\\\"\" >/dev/null 2>&1; }\n```\n\nBecause both stdout and stderr are thrown away, nothing remains even if `osascript`\n\nfails. If the notification argument has a full-width expansion problem, the line itself can die with exit 127 and `2>&1`\n\nswallows it. That is exactly the direct cause of this five-day silence. Something I realized after the fix: notification failures in particular should have been left in `$LOG`\n\n.\n\n**③ Get the DONE_MARKER timing wrong and you get duplicate generation**\n\nIn `article-daily-stock.sh`\n\n, the `touch`\n\nof `DONE_MARKER`\n\nhappens before the git push.\n\n```\n# 生成成功＝この時点で当日doneを確定する\ntouch \"$DONE_MARKER\"\n\n# ---- 12. Zennソースを push（best-effort）---------\n# ...git push...\n\ntouch \"$DONE_MARKER\"  # pushの後にも念のため\n```\n\nEven if `git push`\n\nfails, DONE_MARKER is up, so the 10:35 catch-up slot exits with `SKIP_GEN=1`\n\nafter audit only. Put DONE_MARKER after the push, and a push failure means the next slot regenerates the same topic — duplicate generation. It's an easy point to overlook at design time.\n\n**④ Ignore stale lockdirs and you tie your own hands**\n\nInside the lock mechanism built on `mkdir`\n\n's atomicity, there's a stale check.\n\n```\nif ! /bin/mkdir \"$LOCKDIR\" 2>/dev/null; then\n  oldpid=$(cat \"$LOCKDIR/pid\" 2>/dev/null || true)\n  if [ -n \"${oldpid:-}\" ] && kill -0 \"$oldpid\" 2>/dev/null; then\n    log \"別インスタンス実行中(pid=$oldpid) — skip\"; exit 0\n  fi\n  rm -rf \"$LOCKDIR\"; /bin/mkdir \"$LOCKDIR\" 2>/dev/null || exit 0\nfi\n```\n\n`kill -0`\n\nchecks whether the process is alive; if it isn't, the lock is treated as stale, `rm -rf`\n\n'd, and re-acquired. In an early implementation without this check, after the script was force-killed with SIGKILL (e.g. timing out on budget overrun), the lockdir stuck around and the script never ran again from the next day on.\n\n**⑤ The pipefail part of set -uo pipefail kills you in unexpected places**\n\nThe `pipefail`\n\nin `set -uo pipefail`\n\nmeans \"if any one command in the pipe returns non-zero, the whole thing is non-zero.\" With combinations like `jq ... | grep -q ...`\n\n, the script can die even in the normal case where grep returns exit 1 for \"no match.\"\n\n```\n# 危険な形\njq -r '.[].slug' \"$QUEUE\" | grep -qx \"$NEW_SLUG\"\n\n# 安全な形\nused_slugs | grep -qx \"$NEW_SLUG\"\n# ↑ used_slugs()内でpipeのエラーを || true で吸収済み\n```\n\n`used_slugs()`\n\nin `article-daily-stock.sh`\n\nhandles this pattern. \"Not found\" from `grep -q`\n\nisn't an error but the normal case — yet under `pipefail`\n\nit means something different. A bash-specific pitfall.\n\n**⑥ I put off detecting the claude binary**\n\nIn the first implementation, I deferred the handling for \"the `claude`\n\ncommand isn't found\" and moved on. When launched from launchd, `~/.local/bin`\n\nisn't on PATH, so in reality it was being silently wiped out every morning with `claude: command not found`\n\n. The current three-stage fallback was born from that experience.\n\n```\nCLAUDE=\"${CLAUDE_BIN:-$(command -v claude 2>/dev/null)}\"\n[ -z \"$CLAUDE\" ] && [ -x \"$HOME/.local/bin/claude\" ] && CLAUDE=\"$HOME/.local/bin/claude\"\n[ -z \"$CLAUDE\" ] && CLAUDE=$(ls -t \"$HOME\"/.nvm/versions/node/*/bin/claude 2>/dev/null | head -1)\n[ -x \"$CLAUDE\" ] || { log \"ABORT: claude binary not found\"; notify \"claude binaryが無い\"; exit 0; }\n```\n\nOnly after adding this detection did the \"claude not found\" ABORT log appear and let me grasp the problem. \"It should be running\" is not running.\n\n**⑦ I forgot the ALLOWED_OWNER check and pushed to a fork**\n\nAn `OWNER`\n\ncheck sits immediately before the git push.\n\n```\nOWNER=$(printf '%s' \"$URL\" | sed -nE 's#.*github\\.com[:/]+([^/]+)/.*#\\1#p')\nif [ \"$OWNER\" = \"$ALLOWED_OWNER\" ]; then\n  # pushする\nfi\n```\n\nDuring the period when I didn't have this, an automated run fired while origin was still pointed at a different repository during development, and commits piled up in an unrelated repo. Pinning `ALLOWED_OWNER=bokuwalily`\n\nprevents the worst case even if origin gets changed by mistake.\n\n**⑧ I doubted MIN_ARTICLE_BYTES=1200 without knowing where it came from**\n\nThe body check in `article_ok()`\n\ndiscards anything under 1200 bytes via `stat -f%z`\n\n. At first I doubted it — \"why 1200 bytes?\" — and tried to change the value. But these are bytes. In UTF-8 a Japanese character is 3 bytes, so 1200 bytes ≈ 400 characters. It's a floor set at a value that even the opening of a halfway decent technical article should exceed. `stat -f%z`\n\nis a macOS-specific option (the Linux version is `stat -c%s`\n\n), so porting to Linux requires a rewrite.\n\nPractical rules derived from actually stepping on this bug and sweeping 5 repos / 8 files / 12 sites.\n\n**1. If a full-width character follows a variable expansion, always use ${VAR} form**\n\n```\n# NG\nnotify \"生成失敗: $SLUG（再試行）\"\n# OK\nnotify \"生成失敗: ${SLUG}（再試行）\"\n```\n\nOne pair of braces. Not a single character of the Japanese log wording has to change.\n\n**2. Keep the cross-repo detection command on hand**\n\n```\nrg -n --no-heading -g '*.sh' -g '!node_modules' \\\n  '\\$[A-Za-z_][A-Za-z0-9_]*[^\\x00-\\x7F]' ~/dev ~/.claude/scripts ~/bin | rg -v '\\$\\{'\n```\n\nDon't stop at \"I fixed one.\" The same habit spreads horizontally by copy-paste. This time the same pattern was scattered across 5 repos. Don't hardcode the list of targets — always run it against the current code.\n\n**3. Reproduce launchd's runtime in the terminal and test there**\n\n```\n# launchdと同じ環境を手元で再現\nenv -i HOME=\"$HOME\" PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\" \\\n  CAFFEINATED=1 /bin/bash ~/path/to/script.sh dry\n```\n\nStarting from a nearly empty environment with `env -i`\n\nlets you check launchd-side behavior locally. \"It passes in the terminal\" is not \"it passes under launchd.\"\n\n**4. Set LANG explicitly in the launchd plist to align the environments**\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>LANG</key>\n  <string>ja_JP.UTF-8</string>\n</dict>\n```\n\nDeclaring LANG in the plist makes scheduled runs use `ja_JP.UTF-8`\n\ntoo. By \"running in the same environment as the terminal,\" locale-difference bugs become detectable in local testing. This time I chose to fix the code, but aligning the runtime environment is also an option.\n\n**5. Do PATH completion before set -u, in the script's first phase**\n\n```\nNODE_BIN=$(ls -d \"$HOME\"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)\nexport PATH=\"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH\"\n[ -n \"$NODE_BIN\" ] && export PATH=\"${NODE_BIN}:$PATH\"\n```\n\nThe launchd plist doesn't read shell profiles. Treat every tool that lives under nvm, Homebrew, or `~/.local/bin`\n\nas \"invisible\" and add them explicitly.\n\n**6. Resolve the claude binary with a three-stage fallback, and ABORT if not found**\n\nSearch in the order `command -v`\n\n→ `~/.local/bin`\n\n→ under nvm's bin, and if it's nowhere, `exit 0`\n\n(retry next slot). I use `exit 0`\n\nand leave only a log entry, because `exit 1`\n\ncan blow away the notification too.\n\n**7. Write notification code to be more robust than the core**\n\nIf the notification goes down, \"it failed\" never arrives — and \"never arrived\" is indistinguishable from \"it succeeded.\" Two design measures I took in `article-daily-stock.sh`\n\nto prevent this:\n\n`${VAR}`\n\nform`$LOG`\n\nbefore notifying (if the log write survives, a notification failure is detectable)\n\n```\nlog \"ABORT: title長すぎ(${TITLE_CHARS}字) → 破棄\"\nnotify \"title長すぎ: ${SLUG}（再試行）\"  # 通知は後\n```\n\n**8. Use set -uo pipefail, but know where || true belongs**\n\nBecause of `pipefail`\n\n, the normal case \"grep found no match\" can be treated as exit 1. `|| true`\n\nexists to absorb that kind of \"non-zero that isn't a failure.\" \"`|| true`\n\neverywhere\" is out of the question — it destroys the value of `set -u`\n\n. Use it only where \"non-zero is normal\" is established.\n\n**9. touch DONE_MARKER before the git push**\n\n```\ntouch \"$DONE_MARKER\"  # ← ここでマーカーを立てる\n# 以降のpushはbest-effort\ngit push ...\ntouch \"$DONE_MARKER\"  # 念のため二重touch（冪等）\n```\n\nThis is the linchpin of the design where \"generation succeeded\" holds even if the push fails. With DONE_MARKER up, the next slot skips generation, so a push failure can't cause duplicate generation.\n\n**10. Run audit_repair() even on days when generation is skipped**\n\n```\naudit_repair\nif [ \"$MODE\" = \"audit\" ] || [ \"$SKIP_GEN\" = \"1\" ]; then\n  exit 0  # 生成はスキップ、auditは毎日走る\nfi\n```\n\nEven on a day where today's article is already generated, the quality of stock from yesterday and earlier can degrade. Thumbnail width checks (confirming at least 2000px via `sips -g pixelWidth`\n\n) and body stub detection run every slot, and anything problematic gets pushed back into the QUEUE.\n\n**11. Don't decide whether something is under git by assumption**\n\n```\ngit status  # 作業開始前に1発打つだけ\n```\n\nThis time I proceeded on the assumption that `~/.claude/scripts/`\n\nwas \"a junk location outside git,\" then ran `git status`\n\nafterwards and got two `modified`\n\nentries. Confirming the fact with one command is faster than second-guessing an assumption.\n\n**12. Always demonstrate before closing out a fix**\n\n```\n# 旧形がexit 127で死ぬことを確認\nLC_ALL=ja_JP.UTF-8 bash -c 'set -u; SLUG=test; echo \"生成失敗: $SLUG（再試行）\"'\n# => bash: SLUG（再試行）: unbound variable\n\n# 新形が同じ文言を正常に出すことを確認\nLC_ALL=ja_JP.UTF-8 bash -c 'set -u; SLUG=test; echo \"生成失敗: ${SLUG}（再試行）\"'\n# => 生成失敗: test（再試行）\n```\n\nDon't stop at \"I think I fixed it.\" These demonstration commands are also kept in `locale-dependent-shell-bugs.md`\n\n. Even for a one-line diff, the habit of seeing both the old and new behavior with your own eyes prevents the \"I fixed it but it still breaks\" round trips.\n\n**13. Require real-file grounding for topic planning too**\n\nThe automatic topic-planning prompt in `article-daily-stock.sh`\n\nincludes constraints like \"no fabrication = always ground it in files or scripts that actually exist\" and \"list 2–4 real paths in `sources`\n\n,\" and the output JSON is validated against that. It's a design to keep automation articles from becoming \"fictional implementations.\" Choosing angles from code you actually run changes the density of an article fundamentally.\n\nThe reason this bug went unfound for five days is that two properties overlapped: the usual pattern inverted into \"it only dies when run by hand,\" and \"a bug that exists only on the failure path.\"\n\n`ja_JP.UTF-8`\n\n→ written off as \"a problem with how I invoked it\"Writing a full-width `（`\n\nimmediately after a variable expansion, as in `$SLUG（再試行）`\n\n, becomes more likely the more carefully you write your logs in Japanese. Scripts written only in English never meet this problem. An ironic property.\n\nThe fix itself was done by detecting 12 sites with one line of `rg`\n\nand adding one pair of braces (5 commits: `2b65662`\n\n, `6596c00`\n\n, `56288fa`\n\n, `f0d37f5`\n\n, `f9b1a18`\n\n). But what I really learned this time isn't \"how to fix it\" — it's the principle that **notification code must be more robust than core code.**\n\n**When the notification goes down, the fact that it went down never arrives.**\n\nAn automated system starts rotting the moment you build it, and it won't tell you it's rotting — which is why `audit_repair()`\n\nruns every slot, `DONE_MARKER`\n\ngoes up before the git push, and `article_ok()`\n\nstands by as the gatekeeper for stub detection. The foundation that keeps 30 articles a month generating automatically is protected by a one-line function that rejects anything under 1200 bytes, and by the difference of one pair of braces.\n\nA system is transparent *while* it's working. You only see it when it breaks. Whether you can see it at that moment is what decides whether the foundation under ¥1.2M/month in revenue stays stable.\n\nI've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/5-days-of-silent-failures-a-launchd-locale-bug-that-was-hiding-in-5-repos", "canonical_source": "https://dev.to/bokuwalily/5-days-of-silent-failures-a-launchd-locale-bug-that-was-hiding-in-5-repos-hk", "published_at": "2026-08-26 11:00:06+00:00", "updated_at": "2026-08-26 11:15:20.214866+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Claude Code", "launchd", "Zenn", "Threads", "Discord"], "alternates": {"html": "https://wpnews.pro/news/5-days-of-silent-failures-a-launchd-locale-bug-that-was-hiding-in-5-repos", "markdown": "https://wpnews.pro/news/5-days-of-silent-failures-a-launchd-locale-bug-that-was-hiding-in-5-repos.md", "text": "https://wpnews.pro/news/5-days-of-silent-failures-a-launchd-locale-bug-that-was-hiding-in-5-repos.txt", "jsonld": "https://wpnews.pro/news/5-days-of-silent-failures-a-launchd-locale-bug-that-was-hiding-in-5-repos.jsonld"}}