{"slug": "two-weeks-of-silent-failure-idempotent-scheduling-and-self-healing-for-a-launchd", "title": "Two Weeks of Silent Failure: Idempotent Scheduling and Self-Healing for a launchd + Claude Code Pipeline", "summary": "A developer has built an autonomous affiliate article generation pipeline using Claude Code and launchd that publishes up to five articles daily to Hatena Blog, with an idempotent scheduling design that self-heals from failures. The system, which generates revenue while the developer sleeps, uses a daily.sh script that runs at morning, noon, and night, converging on the target article count regardless of partial failures. The developer reports that this autonomous environment contributes to a monthly revenue of 1.2 million yen, with the system designed to break if humans intervene.", "body_md": "Every morning at 5, launchd wakes up, and by the time I do, articles I never wrote are already live.\n\nIn Part 1, I covered the basic design of a Claude Code–centered article generator and the skeleton of how `generate.sh`\n\nproduces a single draft. This time I'm publishing the entire back half — the part that actually keeps the system running: how to reliably embed monetization links, how to bail out when the Rakuten API fails, an idempotent design that converges on exactly 5 articles a day no matter how many times you run it, and the self-healing loop built on `audit-heal.sh`\n\n.\n\nBack when I was a university student making 100,000 yen a month writing content as a side gig, my income equation was simple: hours × rate. The reason I got it up to 600,000 yen a month was that I stacked jobs and pushed my working hours to the limit — not because I had built a system.\n\nWhen I was laid off, my income went to zero instantly. A side business that sells time collapses the moment there's no one to sell it to. That's when it hit me: **building an environment that earns** and **doing the work that earns** are two completely different activities.\n\nAfter six months of assembling autonomous environments with Claude Code, most of my current 1.2M yen monthly revenue accumulates during hours when I'm not working. This affiliate factory is one of its core pieces.\n\nThe biggest bottleneck in earning from affiliate articles is the act of writing. An 8,000–10,000 character review takes at least 3–4 hours including research. Keeping that up 3–5 times a day is physically impossible unless it's your full-time job.\n\nMost writers optimize in the direction of \"how do I write faster?\" Build templates, hand research to AI, use voice input. All effective, and all with a visible ceiling — because there are only so many hours in a day.\n\nChange the approach at the root and the question changes. Not \"how do I write faster?\" but \"how do I stop having to write at all?\" This system is my head-on answer to that question.\n\nIn one sentence: **launchd hits daily.sh in the morning, at noon, and at night, and generates and publishes only as many articles as today is still short.**\n\nThe point isn't that **I don't have to do anything**. More precisely, it's that the design is such that **the system breaks if I get involved**. Manually adding or deleting files breaks idempotency. Trust launchd's schedule to run on its own, and keep human hands off. That resignation is what makes the system stable.\n\nAt 5 a.m. launchd hits `daily.sh`\n\n. Articles get generated and posted to Hatena Blog, an audit log is written, and if something's wrong a warning lands in the macOS Notification Center. I wake up at 7 and just check that notification. That check takes under three minutes.\n\n**Idempotent** means \"running it any number of times produces the same result.\" It's spelled out right in `daily.sh`\n\n's comments.\n\n```\n# 1日複数回実行される自己回復ジョブ。「今日まだ公開できていない本数」だけを\n# 生成→公開する冪等設計。朝が使用量制限等で空振りしても、昼/夜の再実行が\n# 自動で残りを埋めるため、何度走らせても1日ちょうど TARGET 本で収束する。\n```\n\nThat's not just a comment — it's the core of the design. Even if the morning run hits a Claude Code rate limit and generates zero articles, the noon run tops up the shortfall. If noon fails too, night covers it. Whenever and however many times it runs, by the end of the day it converges on the target count.\n\nWithout this design, every failed morning run would end with \"today was a bust.\" With it, partial failures get absorbed by the system automatically.\n\nThe heart of calling Claude inside `generate.sh`\n\nis this one line (line 272 of the actual script).\n\n```\nRESP=$(timeout \"$GEN_TIMEOUT\" \"$CLAUDE\" -p \"$PROMPT\" --allowedTools WebSearch \\\n  --model sonnet --permission-mode auto </dev/null 2>/dev/null)\n```\n\n`</dev/null`\n\ncloses stdin, `--permission-mode auto`\n\nbypasses the permission prompt for WebSearch, and `timeout \"$GEN_TIMEOUT\"`\n\nforce-kills a hang. The comment reads: \"`</dev/null`\n\nrequired: without it, `claude -p`\n\nwaits 3 seconds on stdin before proceeding (happens every time under launchd, where there's no tty).\" A gotcha specific to the launchd environment.\n\n`GEN_TIMEOUT`\n\ncan be overridden by an environment variable, but the default is `1200`\n\nseconds (20 minutes). Since it generates an 8,000–10,000 character article plus WebSearch on three competing products, without that much headroom the process gets killed mid-article, yielding an empty response and the worst outcome: zero articles published. It looks expensive, but compared to the per-article revenue it's nothing.\n\n```\nlaunchd (朝・昼・夜、1日複数回)\n  │\n  ▼\ndaily.sh\n  │ NEED = TARGET - 本日公開済 - ドラフト残  ← 冪等計算\n  │ NEED=0 なら生成スキップ\n  │\n  ├─ [NEED > 0] generate.sh × NEED 本\n  │     │\n  │     ├─ Claude Code claude -p (timeout 1200s, 最大3回リトライ)\n  │     │   └─ WebSearch で製品調査 + 競合3製品比較\n  │     │\n  │     ├─ resp_is_valid() バリデーション\n  │     │   └─ PRODUCT:行なし / エラー文含む / 400文字未満 → 失敗扱い\n  │     │\n  │     ├─ rakuten_affiliate_url() ← 楽天APIで商品リンク取得\n  │     │   ├─ 資格情報あり → API検索 (resolve: 末尾語削り戦略)\n  │     │   │   ├─ 命中 + ブランド一致 → affiliateUrl 取得\n  │     │   │   ├─ 400 \"keyword is not valid\" → 語を削って再挑戦\n  │     │   │   └─ 全滅 → hgc 検索リンクfallback (報酬乗る)\n  │     │   └─ 資格情報なし → 素の検索URL (報酬ゼロ注意)\n  │     │\n  │     ├─ postprocess_body(): 素リンク・プレースホルダー → アフィリリンク全差替\n  │     │\n  │     └─ ~/Desktop/アフィリ記事/<YYYYMMDD_HHMMSS>.md\n  │\n  ├─ post-to-hatena.sh --publish --all\n  │     ├─ posted-hatena.log でスキップ判定 (冪等)\n  │     ├─ 壊れ記事 (不明な商品 / Request timed out) スキップ\n  │     ├─ blogsync post --title \"$title\" bokuwalily.hatenablog.com\n  │     └─ 公開済み → published/ にアーカイブ移動\n  │\n  └─ audit-heal.sh\n        ├─ 壊れ記事を Desktop キューから削除\n        ├─ published/ の全記事を hb.afl.rakuten.co.jp 含有チェック\n        ├─ 公開数 < TARGET → ⚠ 未達警告\n        └─ 問題あり → osascript macOS通知 + logs/audit-YYYY-MM-DD.log\n```\n\nThe heart of `daily.sh`\n\nis a NEED calculation of fewer than 10 lines. Here's the actual code (lines 16–23).\n\n```\n# 今日すでに公開できた本数（published/ の本日プレフィックス）\nPUB_TODAY=$(find \"$ARCHIVE\" -maxdepth 1 -name \"${TODAY}_*.md\" 2>/dev/null | wc -l | tr -d ' ')\n# Desktop直下に残っている未公開ドラフト（持ち越し＋前段で作ったが未投稿の分）\nDRAFTS=$(find \"$OUT\" -maxdepth 1 -name '*.md' 2>/dev/null | wc -l | tr -d ' ')\n# 目標到達に必要な新規生成本数 = 目標 − 本日公開済 − 手元ドラフト\nNEED=$((TARGET - PUB_TODAY - DRAFTS))\n[ \"$NEED\" -lt 0 ] && NEED=0\n\necho \"[daily] $TODAY $(date '+%H:%M')  本日公開済: ${PUB_TODAY}本 / ドラフト: ${DRAFTS}本 / 目標: ${TARGET}本 → 生成: ${NEED}本\"\n```\n\n`TARGET`\n\nis hardcoded as `TARGET=5`\n\nat the top of the script (in `audit-heal.sh`\n\nit's `${AFFILIATE_FACTORY_TARGET:-3}`\n\n— a design where the default is 3 and it can be changed externally via an environment variable).\n\nWhat matters is that \"remaining drafts\" is included in the NEED calculation. If an article was generated in a previous run but failed to post and is still sitting on the Desktop, the next run doesn't generate more — it only retries posting. That separates \"generation cost (Claude API consumption)\" from \"posting retries.\"\n\nThe generation loop in `generate.sh`\n\n(lines 269–276) allows up to three retries per article.\n\n```\nfor attempt in 1 2 3; do\n  RESP=$(timeout \"$GEN_TIMEOUT\" \"$CLAUDE\" -p \"$PROMPT\" --allowedTools WebSearch \\\n    --model sonnet --permission-mode auto </dev/null 2>/dev/null)\n  if resp_is_valid \"$RESP\"; then break; fi\n  echo \"[generate] 生成失敗(試行${attempt}/3)。再試行します…\" >&2\n  RESP=\"\"\ndone\n```\n\nThe judgment logic in `resp_is_valid()`\n\n(lines 251–257) is equally concrete.\n\n```\nresp_is_valid() {\n  local r=\"$1\"\n  [ -z \"$r\" ] && return 1\n  # PRODUCT行が無い／タイムアウト等のエラー文／極端に短い応答は失敗扱い\n  printf '%s' \"$r\" | grep -q '^PRODUCT:' || return 1\n  printf '%s' \"$r\" | grep -qiE 'request timed out|error:|rate limit|usage limit' && return 1\n  [ \"$(printf '%s' \"$r\" | wc -c | tr -d ' ')\" -lt 400 ] && return 1\n  return 0\n}\n```\n\nIf all three attempts fail, `generate.sh`\n\nexits with `exit 1`\n\nwithout writing a broken article (lines 280–282). Writing out a file in a broken state pollutes the queue and creates cleanup work for `audit-heal.sh`\n\ndownstream. This check heads off that cost.\n\nThe prompt strictly mandates the first-line output format for the product name: `PRODUCT: <正式商品名>`\n\n. That single line is what makes product-name extraction (line 285) and body extraction (lines 287–289) reliable. Let the model produce ambiguous output and every downstream parse falls apart, so enforcing the output format is mandatory.\n\nThe biggest gotcha with the Rakuten API is the `400 Bad Request: \"keyword is not valid\"`\n\nerror. Standalone tokens inside a product name — the \"Z\" or \"Ultra\" in \"Narwal Freo Z Ultra\" — get rejected by Rakuten's search engine.\n\nThe fix is the \"trim one trailing word at a time and retry\" strategy in the `resolve()`\n\nfunction (lines 152–179).\n\n``` python\ndef resolve():\n    words = product.split()\n    brand = words[0].lower() if words else \"\"\n    tried = set()\n    for n in range(len(words), 0, -1):\n        keyword = \" \".join(words[:n]).strip()\n        if not keyword or keyword in tried:\n            continue\n        tried.add(keyword)\n        try:\n            result = fetch(keyword)\n        except Exception as exc:\n            print(f\"[generate] 楽天API検索に失敗({keyword}): {exc}\", file=sys.stderr)\n            return None\n        if result:\n            if not brand or brand in (result[\"name\"] + \" \" + result[\"url\"]).lower():\n                return result[\"url\"]\n            # ブランド不一致=別商品に化けた。\n            print(f\"[generate] 候補がブランド不一致({keyword}→{result['name'][:30]})。検索リンクへ。\", file=sys.stderr)\n            return None\n        time.sleep(1.0)\n    return None\n```\n\nThe flow is \"Narwal Freo Z Ultra\" → \"Narwal Freo Z\" (400) → \"Narwal Freo\" (hit). But trim too far and \"Narwal\" alone might pull in some entirely different, highly-reviewed product. So it verifies **whether the brand name (the first word) appears in the returned product name or URL** via `brand in (result[\"name\"] + \" \" + result[\"url\"]).lower()`\n\n, and if it doesn't match, it decides \"this turned into a different product\" and stops trimming further.\n\n429 (rate limit) is retried up to twice with `time.sleep(1.5)`\n\nin between (inside `fetch()`\n\n, lines 130–148).\n\nWhen an individual product link simply can't be obtained, `resolve()`\n\nreturns `None`\n\n. What comes next (lines 182–190) is the essential fallback.\n\n```\naffiliate_url = resolve()\nif not affiliate_url:\n    # 個別商品が取れない時は、アフィリ計測付き検索リンク(hgc)にフォールバック=必ず報酬が乗る。\n    search_url_enc = quote(search_url, safe=\"\")\n    affiliate_url = (\n        f\"https://hb.afl.rakuten.co.jp/hgc/{affiliate_id}/?pc={search_url_enc}&m={search_url_enc}\"\n    )\n    print(f\"[generate] 商品個別リンクを取得できず検索リンクにフォールバック: {product}\", file=sys.stderr)\nprint(affiliate_url)\n```\n\n`hb.afl.rakuten.co.jp/hgc/`\n\nis Rakuten Affiliate's search link with affiliate tracking. It points not to an individual product but to \"the results page for searching Rakuten Ichiba for this product name\" — but **the commission still applies**.\n\nIf any of the environment variables `RAKUTEN_APPLICATION_ID`\n\n/ `RAKUTEN_ACCESS_KEY`\n\n/ `RAKUTEN_AFFILIATE_ID`\n\nis empty, it skips the API call entirely and falls back to a bare search URL (`https://search.rakuten.co.jp/search/mall/…`\n\n) (lines 83–87). In that state, commissions are zero. Running in production without noticing a misconfigured `.env`\n\nis the classic cause of zero affiliate revenue.\n\nArticle bodies generated by Claude Code sometimes contain bare Rakuten search URLs. Even when the prompt instructs it to insert affiliate links, the model sometimes writes non-affiliate URLs. Publishing that as-is means zero commission.\n\n`postprocess_body()`\n\n(lines 201–246) cures this in post-processing.\n\n```\n# 2) claudeが本文に書いた実リンク [楽天で「…」を探す](任意URL) を、正しいアフィリリンクに丸ごと差し替える。\n#    （これをやらないと claude が書いた非アフィリの検索URLがそのまま残る＝報酬ゼロになる）\nrakuten_md_link_re = re.compile(r\"\\[楽天で「[^」]*」を探す\\]\\([^)]*\\)\")\nbody = rakuten_md_link_re.sub(lambda m: link, body)\n# 3) 念のため、楽天ドメインを指す素のmarkdownリンクも差し替える\nrakuten_any_re = re.compile(r\"\\[[^\\]]+\\]\\((?:https?:)?//[^)]*rakuten\\.co\\.jp[^)]*\\)\")\nbody = rakuten_any_re.sub(lambda m: link, body)\n```\n\nThere's also placeholder handling (lines 219–224). Claude sometimes writes placeholders like `（▼楽天で「〇〇」を検索してリンクを貼る）`\n\n, and those get detected and replaced by regex too.\n\nWith this three-stage replacement logic, no matter how the link was written, it ultimately converges on the correct affiliate URL. That's what \"reliably embedding the monetization link\" actually consists of.\n\nThe `--all`\n\nmode of `post-to-hatena.sh`\n\nuses `posted-hatena.log`\n\nfor skip detection (lines 40–61). It records the full path of every posted file in the log, so even if the same file is still around on the next run, it won't be double-posted.\n\n```\nfor f in \"$OUT\"/*.md; do\n  [ -e \"$f\" ] || continue\n  if /usr/bin/grep -qxF \"$f\" \"$POSTED_LOG\"; then continue; fi\n  # 生成失敗の残骸は投稿しない\n  if /usr/bin/grep -qE '不明な商品|Request timed out' \"$f\"; then\n    echo \"[hatena] スキップ(生成失敗の残骸): $f\" >&2; continue\n  fi\n  if post_one \"$f\"; then\n    echo \"$f\" >> \"$POSTED_LOG\"; found=$((found+1))\n    [ -z \"$DRAFT_FLAG\" ] && mv \"$f\" \"$ARCHIVE/\" && echo \"[hatena] アーカイブへ移動: $(basename \"$f\")\"\n  fi\ndone\n```\n\nOnly when run with the `--publish`\n\nflag does it move posted files into the `published/`\n\ndirectory. Taking them off the Desktop queue increases \"today's count in `published/`\n\n\", so `PUB_TODAY`\n\nis counted correctly on the next `daily.sh`\n\nrun. This archive move is a gear in the idempotent design.\n\nBecause `daily.sh`\n\ninvokes it as `post-to-hatena.sh --publish --all`\n\n(line 36), publishing and archiving happen together automatically.\n\nThe final step is `audit-heal.sh`\n\n. It performs three roles in order.\n\n**1. Cleaning up broken articles** (lines 23–29)\n\n```\nfor f in \"$OUT\"/*.md; do\n  [ -e \"$f\" ] || continue\n  if /usr/bin/grep -qE '不明な商品|Request timed out' \"$f\"; then\n    log \"  [掃除] 壊れ記事を削除: $(basename \"$f\")\"\n    rm -f \"$f\"\n  fi\ndone\n```\n\nThe design already avoids writing broken articles by rejecting them in `resp_is_valid()`\n\n, but this is a safety net for cases where past runs or manual intervention slipped something into the queue. Files containing \"不明な商品\" or \"Request timed out\" are deleted without question.\n\n**2. Checking for affiliate links** (lines 36–44)\n\n```\nfor f in \"$ARCHIVE/${TODAY}\"_*.md; do\n  published=$((published+1))\n  title=\"$(sed -n 's/^# //p' \"$f\" | head -1 | cut -c1-30)\"\n  if /usr/bin/grep -q 'hb.afl.rakuten.co.jp' \"$f\"; then\n    link=\"✓アフィリ\"\n  else\n    link=\"✗非アフィリ\"; bad_link=$((bad_link+1)); problems=$((problems+1))\n  fi\n  log \"    ${link} | ${title}\"\ndone\n```\n\nIt checks whether every published article contains `hb.afl.rakuten.co.jp`\n\n. If `postprocess_body()`\n\nis working correctly, all of them come back `✓アフィリ`\n\n; if the replacement failed for some reason, this is where it gets caught.\n\n**3. Target check and macOS notification** (lines 52–60)\n\n```\nif [ \"$published\" -lt \"$TARGET\" ]; then\n  log \"  ⚠ 公開が目標未達（生成 or 公開が失敗した可能性）\"\n  problems=$((problems+1))\nfi\n\nif [ \"$problems\" -gt 0 ]; then\n  log \"  ❌ 監査NG: 要確認 (${problems}件)\"\n  notify \"監査NG: 公開${published}/${TARGET}本・非アフィリ${bad_link}本。logs/audit-${TODAY}.log を確認\"\n  exit 1\nelse\n  log \"  ✅ 監査OK: ${published}本すべてアフィリリンク付きで公開\"\n  exit 0\nfi\n```\n\n`notify()`\n\npushes to the macOS Notification Center via `osascript -e \"display notification...\"`\n\n. Log files remain at `logs/audit-YYYY-MM-DD.log`\n\n, so the time and content of any problem can be traced after the fact.\n\nWhen this audit ends with `exit 1`\n\n, `daily.sh`\n\nalso prints \"⚠ 監査NG\" to the console (line 40). Looking at launchd's execution logs tells you what happened on which run.\n\nNext time I'll write about the failures I ran into while actually standing this thing up (a recurrence of zero commissions caused by a vanished `.env`\n\n, a corrupted launchd plist, and the self-healing watchdog destroying files on its own), plus design guidelines for not repeating them.\n\nThe first thing an article factory runs into is duplicate content. launchd running daily means telling Claude, every day, \"write one article about a new product in the genre you chose.\" Do nothing about it and you get the hell of 30 robot vacuum articles all about \"Roborock S8 Pro Ultra.\"\n\nThe fix is `posted-products.log`\n\n(the actual path is `$AFFILIATE_FACTORY_LOG`\n\n) plus the mechanism that embeds it into the prompt. Lines 8–19 of `generate.sh`\n\nare that implementation.\n\n```\nLOG=\"${AFFILIATE_FACTORY_LOG:-$DIR/posted-products.log}\"\n# ...\ntouch \"$LOG\"\n\n# 既出商品（重複回避用）\nEXCL=$(paste -sd '、' \"$LOG\" 2>/dev/null)\n[ -z \"$EXCL\" ] && EXCL=\"（まだ無し）\"\n```\n\nThis `EXCL`\n\nvariable is injected as `$EXCL`\n\ninto the prompt's `# 除外（これらの製品は今回選ばない）`\n\nsection. Every time an article is generated, one product name is appended to the end of the file (line 298), so once 100 articles have accumulated, 100 products line up in the exclusion list.\n\n```\n[ \"$PRODUCT\" != \"不明な商品\" ] && echo \"$PRODUCT\" >> \"$LOG\"\n```\n\n**The key is this single point: constraints on the model are communicated via the prompt.** Trying to do duplicate checking in code requires string matching to absorb title variations and brand differences, and the exception handling balloons. Just \"hand it the list of previously chosen product names and tell it not to pick those,\" and Claude avoids them sensibly. The philosophy on display here: aggressively hand the model the work the model can handle.\n\nLine 12 of `generate.sh`\n\nis written like this, in one line.\n\n```\n[ -f \"$DIR/.env\" ] && set -a && . \"$DIR/.env\" && set +a\n```\n\n`set -a`\n\nis a mode that auto-exports every variable defined afterward; `set +a`\n\nturns it off. Simply `source`\n\n-ing `.env`\n\ncan, depending on bash behavior, fail to pass variables down to subprocesses. Since the Rakuten API credentials (`RAKUTEN_APPLICATION_ID`\n\n, etc.) need to reach a Python3 subshell, they're loaded with export enabled via `set -a`\n\n.\n\nIf `.env`\n\ndoesn't exist, nothing happens. It's the standard setup — don't add `.env`\n\nto git, commit only `.env.example`\n\n— but **\"the script doesn't die when .env is missing\" is surprisingly important**. launchd also fires at system startup, so if\n\n`.env`\n\nis absent, it runs with empty environment variables. In that case `RAKUTEN_APPLICATION_ID`\n\nis undefined, and the condition at lines 83–86 of `generate.sh`\n\nfalls back to `rakuten_search_url()`\n\n— meaning it keeps writing articles with \"bare links that earn nothing.\" The script doesn't die, but revenue is zero: the worst kind of silent failure. I actually did exactly this, as described in \"Where I got stuck\" below.The comment at lines 260–263 of `generate.sh`\n\npreserves the traces of my trial and error.\n\n```\n# フル記事生成の所要時間。従来(2500字・検索数回)で約260sだったが、本文を8000〜10000字＋\n# 競合3製品の追加WebSearchに増やしたため生成が伸びる。余裕を持って実測の数倍を確保する。\n# 短いと長文の途中でkillされ空応答→0本公開になるため、ここはケチらない。\nGEN_TIMEOUT=\"${AFFILIATE_FACTORY_GEN_TIMEOUT:-1200}\"\n```\n\nI started out running with `GEN_TIMEOUT=300`\n\n. Simple generation finishes in 4–5 minutes including a few WebSearches. But the moment I added the instruction \"also WebSearch three competing products and confirm real specs before writing,\" the average stretched to 12–15 minutes. `timeout 300`\n\nkills the process at 300 seconds, so Claude got force-terminated mid-article and `RESP`\n\ncame back empty. `resp_is_valid()`\n\nrejects the empty response, three retries, all fail, `exit 1`\n\n— that was the true identity of \"not a single article was generated today.\"\n\n`1200`\n\nseconds (20 minutes) is about 1.5× the measured time. It's overridable via `AFFILIATE_FACTORY_GEN_TIMEOUT`\n\nso that if I shorten the prompt in the future I can adjust without touching code. \"Default value in an environment variable, overridable from outside if needed\" is a pattern applied consistently across the whole script (`AFFILIATE_FACTORY_OUT`\n\n, `AFFILIATE_FACTORY_LOG`\n\n, and `AFFILIATE_FACTORY_TARGET`\n\nhave the same structure).\n\nThe Claude API bills on every call. Hitting the real API every time you test a logic change is inefficient in both cost and time. That's why `AFFILIATE_FACTORY_TEST_RESPONSE`\n\nis wired in at lines 265–266 of `generate.sh`\n\n.\n\n```\nif [ -n \"${AFFILIATE_FACTORY_TEST_RESPONSE:-}\" ]; then\n  RESP=\"$AFFILIATE_FACTORY_TEST_RESPONSE\"\nelse\n  for attempt in 1 2 3; do\n    RESP=$(timeout \"$GEN_TIMEOUT\" \"$CLAUDE\" -p \"$PROMPT\" ...)\n```\n\nPass a dummy response in this environment variable and run the script, and you can exercise everything from `resp_is_valid()`\n\n→ product-name extraction → `postprocess_body()`\n\n→ file write, without calling the API. For example:\n\n```\nexport AFFILIATE_FACTORY_TEST_RESPONSE='PRODUCT: テスト掃除機 X100\n# 【2025年】テスト掃除機 X100 全スペック解説\n\n> ※本記事はアフィリエイトプログラムを利用しています。\n\n[:contents]\n\n## この記事でわかること\nテスト記事です。'\n\nbash generate.sh 1\n```\n\nThis lets you verify in one shot whether the Rakuten link was replaced correctly, whether the file was created with a timestamp in its name, and whether the product name was appended to `posted-products.log`\n\n. It goes through the exact production code path, so unlike unit tests, it's a check of actual behavior.\n\n`[:contents]`\n\nand blank lines after the disclaimer blockquote\nAt the end of `postprocess_body()`\n\n, the final output assembly order is hardcoded (`generate.sh`\n\nline 240).\n\n```\nout = [title, \"\", disclaimer, \"\", contents]\n```\n\nNotice the two `\"\"`\n\nentries. There's a blank line after the title, and another after `disclaimer`\n\n(the disclaimer blockquote), before `[:contents]`\n\n(the table of contents).\n\nAt first I wrote it packed together as `[title, disclaimer, contents]`\n\n. With that, the table of contents mysteriously failed to display on Hatena Blog, or got sucked into the disclaimer blockquote and wrecked the layout. Investigating, it turned out Hatena's Markdown processor sometimes treats `[:contents]`\n\nas a continuation of the blockquote when it comes immediately after a blockquote line (a line starting with `>`\n\n) without a blank line — a quirk of its spec.\n\nInserting one blank line lets the parser decide \"the blockquote ended here,\" and `[:contents]`\n\ngets interpreted as a standalone table-of-contents directive. This is specific to Hatena Markdown. It doesn't happen with ordinary Markdown renderers, note, or Zenn.\n\n`post-to-hatena.sh`\n\nhas one guard near the top of the script (lines 19–24).\n\n```\nCFG=\"$HOME/.config/blogsync/config.yaml\"\nif [ ! -f \"$CFG\" ] || /usr/bin/grep -q 'REPLACE_' \"$CFG\"; then\n  echo \"[hatena] スキップ: $CFG が未設定です（はてなID/APIキー未入力）。\" >&2\n  exit 0\nfi\n```\n\nIf the string `REPLACE_`\n\nis still present in `blogsync`\n\n's config file (i.e., the template was never filled in), it quietly `exit 0`\n\ns and does nothing. Called from `daily.sh`\n\n, it's treated as \"0 posts.\" Without this, the script would run with the config forgotten, `blogsync`\n\nwould throw an error, and all of `daily.sh`\n\ncould halt.\n\nThe other important piece is the title separation inside `post_one()`\n\n(lines 26–38).\n\n```\ntitle=\"$(sed -n 's/^# //p' \"$file\" | head -1)\"\n[ -z \"$title\" ] && title=\"$(basename \"$file\" .md)\"\nbody=\"$(awk 'NR==1 && /^# /{next} {print}' \"$file\")\"\n```\n\nIt extracts the `# タイトル`\n\non the first line of the Markdown file, passes it to blogsync's `--title`\n\nargument, and posts the body with that first line removed. Without this, the heading goes into the article body as an \"H1 title,\" duplicating the Hatena Blog article title and the in-article H1. That's terrible both for SEO and visually, so stripping the H1 out of the body and passing it to `--title`\n\nis the correct design.\n\n`.env`\n\n— my second time\nI first noticed the problem when I opened the Rakuten Affiliate dashboard after a week away. The records showed 5 articles published every day, yet the commission graph hadn't moved at all.\n\nGoing back through `audit-heal.sh`\n\n's logs, they were lined with `✅ 監査OK`\n\n. It's supposed to be checking for `hb.afl.rakuten.co.jp`\n\n, so why the OKs? Running `grep hb.afl.rakuten.co.jp`\n\ndirectly on the articles under `published/`\n\nreturned **zero matches**.\n\nRunning `generate.sh`\n\nmanually, this log scrolled across the console:\n\n```\n[generate] 商品個別リンクを取得できず検索リンクにフォールバック: Panasonic NA-LX129B\n```\n\nThat log wasn't coming from \"we couldn't get the individual product, so fall back to an hgc link\" — it was coming from a much earlier branch, before the Rakuten API was even called. When `RAKUTEN_APPLICATION_ID`\n\nis an empty string, lines 83–86 return a bare search URL before entering the Python code.\n\n```\nif [ -z \"${RAKUTEN_APPLICATION_ID:-}\" ] || [ -z \"${RAKUTEN_ACCESS_KEY:-}\" ] || [ -z \"${RAKUTEN_AFFILIATE_ID:-}\" ]; then\n  rakuten_search_url \"$product\"\n  return\nfi\n```\n\n`rakuten_search_url()`\n\nreturns `search.rakuten.co.jp`\n\n(zero commission). `postprocess_body()`\n\nembeds that as the affiliate link, so a link does exist. But since it isn't `hb.afl.rakuten.co.jp`\n\n, **it slips right past audit-heal.sh's check and gets published without ever being flagged as non-affiliate**.\n\nThe cause was a vanished `.env`\n\n. A separate automation script (a self-healing watchdog) was operating on the same directory, and in the incident described below it had overwritten and wiped `affiliate-factory/.env`\n\n. Since it's gitignored, `git checkout`\n\ncan't restore it either.\n\nThe fix had two parts.\n\n**① Fix the audit's detection logic**: I changed `audit-heal.sh`\n\n's content check so that it not only looks for `hb.afl.rakuten.co.jp`\n\nbut also explicitly flags `search.rakuten.co.jp`\n\nas \"non-affiliate.\" If a bare search URL raises `✗非アフィリ`\n\nthe moment it appears, `problems > 0`\n\n→ macOS notification, and I notice immediately.\n\n**② Put .env out of self-healing's reach**: scripts must never rewrite files containing secrets. I narrowed the self-healing watchdog's scope to \"generation logs and draft files only,\" and explicitly excluded\n\n`.env`\n\nand the config files.It was my second time doing this, so at that point the anger was directed at myself. Files holding secrets should have been designed in from the start as \"sanctuaries automation cannot reach.\"\n\nThis one is a horror story.\n\nIn another project I had a script I called the \"self-healing watchdog.\" When a script terminates abnormally or its output goes wrong, it rewrites a predetermined set of files to repair them.\n\nRight after I patched that watchdog in commit `fd77c12 fix(self-repair)`\n\n, the `affiliate-factory`\n\ndirectory started breaking. Specifically:\n\n`.env`\n\nwas overwritten to 0 bytes`post-to-hatena.sh`\n\nwas replaced with different content (a previous version of the code)Every file had the same `mtime`\n\n, so it was obvious that \"something rewrote them all at once.\"\n\nThe cause was a classic shell trap: in a routine where the watchdog wrote output to stdout while simultaneously manipulating files, **the order of shell variable expansion and redirection didn't line up, so the redirect target was opened before the target file was determined, wiping the contents**. As a result, files at unintended paths got overwritten.\n\nThe fix was to change all of the watchdog's write operations to the \"write to a temp file, then swap atomically with `mv`\n\n\" pattern.\n\n```\n# NG: リダイレクトがファイルを開いた時点でTARGET_FILEが空になり得る\nsome_command > \"$TARGET_FILE\"\n\n# OK: tmpに書いてからmvで原子置換\nsome_command > \"$TARGET_FILE.tmp\" && mv \"$TARGET_FILE.tmp\" \"$TARGET_FILE\"\n```\n\nI also made \"which files the self-healing script is allowed to rewrite\" an explicit whitelist, with guards so it doesn't touch anything else. **Self-healing — that \"kind\" feature — becomes the most vicious destroyer there is without scope limits.**\n\nWhen the incident above corrupted the launchd plist, I didn't notice right away. Since the Mac was just cycling through sleep/wake and the launchd job never gets re-registered, nothing appears in the logs even when the plist is broken. Articles stopped appearing, but running `bash daily.sh`\n\nmanually worked — and that state continued for two weeks.\n\nI noticed when I ran `launchctl list | grep affiliate`\n\nand the job wasn't in the list.\n\n```\n# ジョブが登録されているか確認\nlaunchctl list | grep affiliate\n# → 出力なし（登録されていない）\n\n# plist の文法チェック\nplutil ~/Library/LaunchAgents/com.affiliate-factory.daily.plist\n# → com.affiliate-factory.daily.plist: Unexpected character < at line 3\n\n# 修復：アンロードしてplistを直してリロード\nlaunchctl unload ~/Library/LaunchAgents/com.affiliate-factory.daily.plist 2>/dev/null || true\n# plistを正しい内容に書き直す\nlaunchctl load ~/Library/LaunchAgents/com.affiliate-factory.daily.plist\n```\n\nTo prevent recurrence, I added the following check at the end of `audit-heal.sh`\n\n.\n\n```\n# launchdジョブが生きているか確認（停止中なら警告だけ出す）\nif ! launchctl list 2>/dev/null | grep -q 'affiliate-factory'; then\n  log \"  ⚠ launchdジョブが未登録。plistを確認してください\"\n  problems=$((problems+1))\nfi\n```\n\nBy checking job registration in the morning audit too, a \"not running\" state is now guaranteed to be detected by the next morning.\n\n**One footnote on launchd plists**: commands written in a plist must be absolute paths. `/bin/bash`\n\nis fine; `bash`\n\nis not. Also, the `PATH`\n\nenvironment variable only has about `/usr/bin:/bin`\n\nin it, so commands installed via `nvm`\n\nor binaries in `~/.local/bin/`\n\nrequire full path specification. That's why this system specifies claude's full path in the `CLAUDE`\n\nvariable in `generate.sh`\n\n(line 9).\n\n```\nCLAUDE=\"${CLAUDE:-~/.local/bin/claude}\"\n```\n\nDesigning the path to be overridable by an environment variable means you can adapt without touching code if claude's install location changes.\n\nWhat all three failures share is the structure of \"automation breaking itself.\" The greatest irony was that `audit-heal.sh`\n\n, which was designed on the assumption that things break, was itself broken without noticing that it was broken. In the next section I'll pull together the design guidelines derived from these failures and the shortest route to standing this system up from scratch.\n\nIn the middle section of the previous part, I dissected the three big failures — the vanished `.env`\n\n, the runaway self-healing, and the corrupted launchd plist. In this final section, I'll list the smaller stumbling blocks I hit in actual operation and organize the design guidelines drawn from them into best practices.\n\nIn addition to the three big failures above, here are the points you're guaranteed to hit at least once when you actually run this code. They're listed as bullets, but every one is either something I actually did or a design pitfall I noticed later.\n\n**① The TARGET value differs between daily.sh and audit-heal.sh**\n\n`daily.sh`\n\nline 10 hardcodes `TARGET=5`\n\n. Meanwhile `audit-heal.sh`\n\nline 11 is `TARGET=\"${AFFILIATE_FACTORY_TARGET:-3}\"`\n\n, defaulting to 3 when the environment variable is unset. Run both scripts in this state and daily.sh aims to generate and post 5 per day, while audit-heal.sh judges 3 published articles as \"OK.\" You get an inversion where publishing 5 doesn't trigger \"⚠ 公開が目標未達,\" and a day where only 3 got published still passes the audit. daily.sh should also have gone through an environment variable as `TARGET=\"${AFFILIATE_FACTORY_TARGET:-5}\"`\n\n. Write one line of `AFFILIATE_FACTORY_TARGET=5`\n\nin `.env`\n\nand all scripts are unified.\n\n**② posted-products.log bloating inflates the prompt**\n\n`EXCL=$(paste -sd '、' \"$LOG\")`\n\nat line 18 of `generate.sh`\n\njoins every log entry with \"、\" and embeds it in the prompt. Five per day for a year is 1,825 entries. At an average of 20 characters per product name, that's about 37,000 characters total. It fits in Claude Sonnet's context, but the whole prompt reaches 50,000–60,000 tokens and the cost per generation starts climbing in stages from around month six. Archive old entries once a quarter, and add a monthly cron that keeps only the most recent 200 with `tail -n 200 \"$LOG\" > \"${LOG}.tmp\" && mv \"${LOG}.tmp\" \"$LOG\"`\n\n.\n\n**③ resp_is_valid trips on \"Error:\" inside the article body**\n\nThe check at line 255 of `generate.sh`\n\nis `grep -qiE 'request timed out|error:|rate limit|usage limit'`\n\n. If a correctly generated article body contains a sentence like \"if this error (Error: E10) code appears, check the charge,\" `resp_is_valid`\n\njudges it a failure and `exit 1`\n\ns after three retries. Reviews of premium home appliances often include explanations of error codes, and I actually hit this with robot vacuum and drum washer reviews. I recommend restricting the check to just the first few lines, as in `grep -qiE '^(request timed out|error:|rate limit)' <(printf '%s\\n' \"$r\" | head -5)`\n\n, or anchoring at the start of the word to exclude natural occurrences of \"Error:\" in the body.\n\n**④ launchd overlapping runs double your generation cost**\n\nIf `StartCalendarInterval`\n\nis configured with three times — 5 a.m., noon, and 8 p.m. — and the morning run is 20 minutes in when the noon interval fires, two `daily.sh`\n\nprocesses run in parallel. One computes `NEED=3`\n\nand starts generating 3, and the other computes `NEED=3`\n\nat the same time and runs 3 as well. Even if `PUB_TODAY`\n\nends up at 6, the idempotent design absorbs it as \"excess = 0 more,\" so the published count is fine. But the Claude API call cost is incurred twice. Adding a lock like `flock -n /tmp/affiliate-factory.lock -c \"bash ${0}\"`\n\nat the top of `daily.sh`\n\nprevents overlapping runs.\n\n**⑤ --model sonnet is hardcoded, so you can't switch models**\n\nLine 272 of `generate.sh`\n\nis fixed at `--model sonnet`\n\n. Even when you want to switch to Haiku to cut costs, you have to edit the code directly and commit again. Change it to `GEN_MODEL=\"${AFFILIATE_FACTORY_MODEL:-sonnet}\"`\n\nand `--model \"$GEN_MODEL\"`\n\n, and one line in `.env`\n\nswitches it from the next run. That enables workflows like temporarily bumping up to Opus 4.8 on days when a genre needs deep research, then going back to sonnet during high-volume periods.\n\n**⑥ A dangerous half-configured state when only RAKUTEN_AFFILIATE_ID is empty**\n\nLines 83–86 of `generate.sh`\n\nare a branch that returns a bare search URL \"if any of the three environment variables is empty.\" Behavior is consistent if all three are set or all three are unset, but if `RAKUTEN_APPLICATION_ID`\n\nand `RAKUTEN_ACCESS_KEY`\n\nare set and only `RAKUTEN_AFFILIATE_ID`\n\nis empty, it enters the Python code, hits the API, and expands an empty `affiliate_id`\n\ninto the hgc fallback link at line 187 (producing a double slash, `/hgc//`\n\n). That link doesn't register with Rakuten's affiliate tracking, so commissions are zero. I should have included a guard from the beginning that stops with `exit 1`\n\nat startup if even one of the three variables is empty.\n\n**⑦ The brand-match check is weak against katakana brands**\n\n`brand in (result[\"name\"] + \" \" + result[\"url\"]).lower()`\n\nat line 172 of `generate.sh`\n\njudges a match by whether the lowercased first word of the product name appears somewhere in the API response. \"Dyson\" → the URL slug matches a store name containing `dyson`\n\n, so it mostly works. But for a product like \"Balmuda,\" if the Rakuten store's registered name is the katakana 「バルミューダ」, the URL slug won't contain `balmuda`\n\nand it's misjudged as a mismatch. In that case, no individual product link is obtained and it falls through to the hgc fallback. The commission still applies, but CVR is lower than with a direct link to the individual product. You need a table converting katakana brand names to romaji, or a per-brand exception list.\n\n**⑧ Missing a version change in the Rakuten API endpoint**\n\n`API = \"https://openapi.rakuten.co.jp/ichibams/api/IchibaItem/Search/20260401?\"`\n\nat line 103 of `generate.sh`\n\nis the April 2026 updated endpoint. Code using the old endpoint (`app.rakuten.co.jp`\n\n) returns 403 from April 2026 onward. Miss the announcement of a Rakuten Affiliate API version bump and every query returns zero results, so every article keeps getting published with the hgc fallback. The pattern is that you notice after several days of \"articles are being published, but not one has an individual product link.\" Schedule a check — via cron or by hand — every six months to confirm the current endpoint on Rakuten's developer portal.\n\n**⑨ blogsync's path doesn't resolve in the launchd environment**\n\n`BLOGSYNC=\"${BLOGSYNC:-$HOME/.local/bin/blogsync}\"`\n\nat line 13 of `post-to-hatena.sh`\n\nlooks for `~/.local/bin/blogsync`\n\nwhen the environment variable is unset. If installed via Homebrew it lives at `/opt/homebrew/bin/blogsync`\n\n. launchd's PATH only has about `/usr/bin:/bin`\n\n, so neither path resolves. `blogsync: command not found`\n\nappears and every post fails, but the `--all`\n\nloop continues and the script itself ends with `exit 0`\n\n. The log just says \"投稿: 0本,\" which makes it hard to notice as an error. Write `BLOGSYNC=/opt/homebrew/bin/blogsync`\n\nas a full path in the launchd plist's `EnvironmentVariables`\n\n, or specify the `BLOGSYNC`\n\nenvironment variable explicitly in `.env`\n\n.\n\n**⑩ Forget shopt -s nullglob and the loop runs once with \"zero files\"**\n\nLine 34 of `audit-heal.sh`\n\nsets `shopt -s nullglob`\n\nso an empty glob returns an empty array before entering the for loop at line 35, then restores it with `shopt -u nullglob`\n\nat line 44. Forget this `nullglob`\n\nand, even with no files for the day in `published/`\n\n, the shell runs the loop once with the literal string `\"$ARCHIVE/2026-06-23_*.md\"`\n\n, and `[ -e \"$f\" ]`\n\nfails and skips it. The result is that it correctly reaches \"⚠ 公開が目標未達\" with `published=0`\n\n, but the trace of the loop having run remains in the log and causes confusion. Make it a habit to always set `shopt`\n\nin pairs around for loops that use globs.\n\n**⑪ macOS Focus mode swallows osascript notifications**\n\n`notify()`\n\nat line 16 of `audit-heal.sh`\n\nsends a macOS notification via `osascript -e \"display notification ...\"`\n\n. If Focus on macOS 15+ is set to \"Sleep,\" the notification isn't shown as a banner and only accumulates in Notification Center. I thought I was checking it at 7 a.m., but I wouldn't see it unless I opened Notification Center. I recommend setting `StandardOutPath`\n\n/ `StandardErrorPath`\n\nin the plist to write logs to files, or having a secondary channel like a Slack Webhook or LINE Notify.\n\n**⑫ StartCalendarInterval is skipped while macOS is asleep**\n\nlaunchd's `StartCalendarInterval`\n\ndoesn't fire the job when macOS is asleep past the scheduled time. There are cases where a missed run fires at login after wake, but it depends on timing. A few days a month, \"it was supposed to run at 5 a.m. but didn't run until 10.\" The idempotent design means the noon run compensates and articles converge to 3–5 a day, but the timing shifts. The root fix is either running a MacBook in clamshell mode (lid closed, display connected) permanently on, or migrating to an always-on environment like a Mac mini or a VPS.\n\nThese are the design guidelines drawn from a year of operation and several incidents. If you're building a new system, design with these in mind from the start.\n\n**1. Treat secret files as sanctuaries outside automation's reach from day one**\n\nAutomation scripts must never rewrite `.env`\n\nor `~/.config/blogsync/config.yaml`\n\n. From the beginning, design an explicit whitelist of files that self-healing, watchdog, and backup scripts are allowed to rewrite, and don't touch anything else. The cause of \"it was working, then commissions suddenly went to zero\" is almost always the loss of a secret. Secrets kept out of version control by gitignore can't be restored with `git checkout`\n\nonce a script accident wipes them.\n\n**2. Share TARGET across all scripts via a single environment variable**\n\nWrite `AFFILIATE_FACTORY_TARGET=5`\n\nin `.env`\n\nand unify every script to read `TARGET=\"${AFFILIATE_FACTORY_TARGET:-5}\"`\n\n. Divergences like daily.sh's hardcoded `TARGET=5`\n\nand audit-heal.sh's default of `3`\n\nkeep producing audit misjudgments. The correct state is one where changing the number in one line of `.env`\n\npropagates everywhere.\n\n**3. grep for affiliate links every morning to eradicate silent zero-revenue**\n\nIn addition to `grep -q 'hb.afl.rakuten.co.jp' \"$f\"`\n\nin `audit-heal.sh`\n\n, add logic that explicitly detects bare URLs as \"non-affiliate\" via `grep -q 'search.rakuten.co.jp' \"$f\"`\n\n. Detecting only the absence of `hb.afl`\n\nmeans that when `.env`\n\nvanishes and `search.rakuten.co.jp`\n\n(zero commission) slips in, it's still judged OK. Design it so a macOS notification arrives the moment a bare URL appears and you'll notice within a day.\n\n**4. Set GEN_TIMEOUT to at least 1.5× the measured time. Don't be cheap here**\n\nAverage generation time for 8,000–10,000 characters plus WebSearch on three competitors is 12–15 minutes. `GEN_TIMEOUT=1200`\n\n(20 minutes) is that with 1.5× headroom built in. Shorten the timeout and you lock in the chain \"killed mid-generation → empty response → 3 failures → 0 articles.\" The loss from publishing zero is reliably larger than the generation cost. When you change to a new prompt design, run it manually a few times and re-measure the elapsed time.\n\n**5. Don't remove the enforced output format (the PRODUCT: line)**\n\nBoth `resp_is_valid()`\n\nand product-name extraction depend on the `^PRODUCT:`\n\nline. Remove that one enforced output line while rewriting the prompt and all the parsing collapses. Changes to the output format require verifying the impact across the whole script. If you do change it, dry-run with `AFFILIATE_FACTORY_TEST_RESPONSE`\n\n(below) before going to production.\n\n**6. Always build in a dry run with AFFILIATE_FACTORY_TEST_RESPONSE**\n\nThis is the mock-response mechanism at lines 265–266 of `generate.sh`\n\n. Every time you change the prompt, modify `postprocess_body`\n\n, or add link-replacement logic, you can run the full path at zero API cost.\n\n```\nexport AFFILIATE_FACTORY_TEST_RESPONSE='PRODUCT: テスト掃除機 X100\n# 【2026年】テスト掃除機 X100 全スペック解説｜競合3機種比較\n\n> ※本記事はアフィリエイトプログラム（楽天アフィリエイト等）を利用しています。\n\n[:contents]\n\n## この記事でわかること'\nbash generate.sh 1\n```\n\nThe command above exercises the whole path: Rakuten link replacement, file output, and appending to `posted-products.log`\n\n. It prevents the mistake of running it for the first time under production launchd and locking in \"zero articles the next morning.\"\n\n**7. Limit self-healing scope strictly with a whitelist, and write only via atomic replacement**\n\nExplicitly enumerate the files self-healing may rewrite. Every write operation should use exactly one pattern: \"temp file → atomic swap with `mv`\n\n.\" Never use `some_command > \"$TARGET_FILE\"`\n\n. Use `some_command > \"$TARGET_FILE.tmp\" && mv \"$TARGET_FILE.tmp\" \"$TARGET_FILE\"`\n\n. When the order of shell variable expansion and redirection doesn't line up, a file at an unintended path gets overwritten. That's exactly what destroyed `.env`\n\nand `post-to-hatena.sh`\n\nall at once.\n\n**8. Build launchd job liveness checks into the audit**\n\n```\n# audit-heal.sh 末尾に追加\nif ! launchctl list 2>/dev/null | grep -q 'affiliate-factory'; then\n  log \"  ⚠ launchdジョブが未登録。plistを確認してください\"\n  problems=$((problems+1))\nfi\n```\n\nAdd this check to `audit-heal.sh`\n\nand you'll always detect a corrupted plist and unregistered job by the next morning. It's the minimum guard against a repeat of the failure I didn't notice for two weeks.\n\n**9. Syntax-check launchd plists with plutil and keep them under version control**\n\n```\nplutil ~/Library/LaunchAgents/com.affiliate-factory.daily.plist\n# → com.affiliate-factory.daily.plist: OK\n```\n\nplists are XML. A missing closing tag or stray characters around a `<key>`\n\nbreaks them. `plutil`\n\nreports syntax errors with line numbers. Fix the procedure: every time you change the code, run it through `plutil`\n\n, then reload with `launchctl unload && launchctl load`\n\n. Take plists out of `.gitignore`\n\nand version them. Whether or not you can restore from git makes an hour's difference in recovery time from the moment you notice.\n\n**10. Specify blogsync's path as a full path via the BLOGSYNC environment variable, and write it in the launchd plist too**\n\nWrite `BLOGSYNC=/opt/homebrew/bin/blogsync`\n\n(or `~/.local/bin/blogsync`\n\n) explicitly in launchd's `EnvironmentVariables`\n\n. Put the output of `which blogsync`\n\nstraight into the plist. Depend on how `PATH`\n\nhappens to be set and you'll suddenly hit `command not found`\n\non a macOS upgrade or a Homebrew prefix change (Intel→Apple Silicon migration).\n\n**11. Make --model an environment variable so you can switch models without redeploying**\n\nChange `--model sonnet`\n\nat line 272 of `generate.sh`\n\nto `--model \"${AFFILIATE_FACTORY_MODEL:-sonnet}\"`\n\n. When you want to cut costs, just write `AFFILIATE_FACTORY_MODEL=claude-haiku-4-5-20251001`\n\nin `.env`\n\nand it switches from the next run. Temporarily bumping only certain genres (high-value, technical) up to Opus 4.8 also becomes possible without touching code.\n\n**12. Rotate posted-products.log monthly to manage prompt cost**\n\n```\n# 月次 cron に追加\ntail -n 200 \"$LOG\" > \"${LOG}.tmp\" && mv \"${LOG}.tmp\" \"$LOG\"\n```\n\nKeeping just the most recent 200 entries gives plenty of exclusion effect and cuts token cost to under 1/9. Raising token costs in the pursuit of monthly revenue is backwards. Log rotation is part of cost management for a monetization system.\n\n**13. Don't rely on notifications alone — check logs/audit-YYYY-MM-DD.log weekly**\n\nEvery morning's full audit results remain in `audit-heal.sh`\n\n's `AUDIT_LOG=\"$DIR/logs/audit-${TODAY}.log\"`\n\n. Even on a day when the macOS notification was skipped by Focus mode, the log tells you how many articles were published. Once a week, run `grep '✅\\|❌' logs/audit-*.log | tail -14`\n\nto check the OK/NG rate for the last two weeks. If there are days with consecutive NGs, that day's log lets you trace the cause.\n\nPart 1 covered the system's skeleton (launchd → daily.sh → generate.sh → post-to-hatena.sh → audit-heal.sh), and this time I published the full back half — the machinery that reliably books revenue.\n\nEvaluating this system along two axes, design and failure:\n\n**On design, the core is the two pillars of idempotency and self-healing.** Because the idempotent design converges on TARGET articles per day no matter how many times it runs, a morning failure is automatically covered at noon. Because audit-heal.sh runs every morning, detects anomalies, and tells the human via macOS notification, the lag before I notice a problem stays within a day. The time I spend checking is under three minutes each morning.\n\n**On failure, the lesson is to build the risk of \"automation breaking itself\" into the design from the start.** The vanished `.env`\n\n, the runaway self-healing, the corrupted launchd plist — all of them were cases of \"automation that was supposed to be running\" quietly breaking beneath the surface. The state persists undetected unless you look at the logs, and by the time you notice, commissions are at zero or two weeks of publishing opportunity are gone. Design on the assumption that things break, and have a mechanism that guarantees you detect the breakage by the next morning — that's the lifeline of long-term operation.\n\nMost of my 1.2M yen in monthly revenue accumulates while I sleep. This setup is one of its core pieces, and the result of a six-month investment in \"building an environment.\" The decisive difference from a side business that sells time isn't reducing writing time to zero — it's building a state where machines keep running automatically during the hours when I can't create value.\n\nNext time I'll write about the design of the whole Claude Code autonomous environment that includes this affiliate factory — resource management when running multiple factories in parallel, model routing, and monthly cost management.\n\nI've written up the full picture, the breakdown of the 1.2M yen/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/two-weeks-of-silent-failure-idempotent-scheduling-and-self-healing-for-a-launchd", "canonical_source": "https://dev.to/bokuwalily/two-weeks-of-silent-failure-idempotent-scheduling-and-self-healing-for-a-launchd-claude-code-3gb5", "published_at": "2026-08-15 05:00:06+00:00", "updated_at": "2026-08-15 05:41:08.858019+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "ai-products", "developer-tools"], "entities": ["Claude Code", "launchd", "Hatena Blog", "Rakuten API"], "alternates": {"html": "https://wpnews.pro/news/two-weeks-of-silent-failure-idempotent-scheduling-and-self-healing-for-a-launchd", "markdown": "https://wpnews.pro/news/two-weeks-of-silent-failure-idempotent-scheduling-and-self-healing-for-a-launchd.md", "text": "https://wpnews.pro/news/two-weeks-of-silent-failure-idempotent-scheduling-and-self-healing-for-a-launchd.txt", "jsonld": "https://wpnews.pro/news/two-weeks-of-silent-failure-idempotent-scheduling-and-self-healing-for-a-launchd.jsonld"}}