0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Posted at

月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-mediaのdaily_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-mediaのdaily_generate.shが「claude: No such file or directory」で党滅しおいたのも同じ原因だ——.local/binがPATHに無かった。

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分スロットが起動しおも、lockdirのpidが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スキヌマslug・title・sources・thumb_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. sedでpublished: 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_MARKERはgit 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-funnelが6596c00、autopilotが56288fa、brand-404がf0d37f5、metrics-hubがf9b1a18だ。

詰たりどころ②: 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_MARKERのtouchを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 pipefail のpipefail郚分が予想倖の堎所で死ぬ

set -uo pipefailのpipefailは「パむプの途䞭でどれか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.shのused_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個足すだけで完了したした2b65662・6596c00・56288fa・f0d37f5・f9b1a18の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サヌビスを量産しおいたす

  • 制䜜物・蚘事は bokuwalily.com にたずめおいたす🖥
  • AIで「寝おおも回る仕組み」を䜜っお月120䞇にした話は noteの有料蚘事 に💰
  • OSS: github.com/bokuwalily 🐙
  • 最新情報・お問い合わせは X @bokuwalily ぞ🌍

皆さんの ❀ やシェアが励みになりたす

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?