DEV Community

Lily
Lily

Posted on Originally published at dev.to

Our Social Posting Automation Was Dead for 14 Days and the Monitoring Never Noticed

A ¥1.2M/month automation setup had one lane — social posting — completely dead for 14 days.

And the whole time, the system reported "healthy" every morning. No errors anywhere. Script exit codes were 0. Every watchdog was green. Yet Instagram hadn't published a single post since July 29, and TikTok since July 28.

I only found out because I was digging through logs for something else.


Why This Approach Works

Once you've run automation for a while, you develop a specific dread about this class of failure. Systems that crash loudly are easy to fix. The problem is systems that die quietly.

The root cause here was a structural blind spot in the monitoring.

The existing content-watchdog.sh only monitored the article lanes (note / maker / series / ameba). The social posting lanes (X / Instagram / TikTok / Threads) were, by design, not included anywhere. The code comment says so plainly.

# content-watchdog.sh は article/note/maker/series/ameba だけを見ており、
# X/IG/TikTok/Threads の投稿・返信は誰も監視していなかった。その結果
# IG投稿は7/29から、TikTok投稿は7/28から止まったまま2週間気づかれなかった。
Enter fullscreen mode Exit fullscreen mode

This isn't a bug in the code. At the time it was written, putting the social posting lanes under the watchdog was never part of the plan — it's a design blind spot. I thought I was monitoring, but the thing that broke was never inside the net in the first place.

Why You Shouldn't Trust Exit Codes

The other trap is judging health by exit code.

Social posting automation scripts can finish with exit 0 whether the post succeeded or failed. If Instagram hits a rate limit, for example, the script decides "today's quota is used up" and exits normally. When the TikTok emulator reports "already posted," that's treated as normal too, with exit 2.

In other words: "the process didn't die" ≠ "a post went out."

Looking at the actual code of sns-output-watchdog.sh, this lesson is baked straight into the design.

# 🔴 従来は '終了 (' の出現回数だけを数えていたため、circuit-break でも need-login でも
#    「健全」と判定していた。2026-08-08 実測で ig-1 は12run中8回、tt-1/tt-2 は6割が
#    いいね0件のまま exit 0 で終わっており、2週間誰も気づけなかった。
Enter fullscreen mode Exit fullscreen mode

Eight out of twelve runs finished with zero results. All of them still exit 0, so the old monitoring scored it as "12 complete runs = healthy."

Judge by Whether the Output Exists

The idea behind the new monitoring is simple. Instead of "did the process run to completion," decide life-or-death by "does a success marker exist in today's log."

When a posting script succeeds, it leaves a specific string in the log. For IG posts it's OK posted, for the X auto-tweet it's 投稿成功, for TikTok it's run end (exit 0) or run end (exit 2). Count whether those markers appear in the log alongside today's date — that's all.

This idea comes from more than six months of running side-business automation. "Is output coming out" maps to health in the business sense far better than "is the script running." What affects monthly revenue isn't code executing; it's posts going out.

Separate "Can't Tell" from "Broken"

Another thing that matters in monitoring design is separating "zero, therefore broken" from "can't determine at all."

Say the TikTok script never launched at all on some day. Then there's no line with today's date in the log. If you classify that state as "zero = broken," you'll fire an alert every time the internet blips or launchd skips a start window. And once alerts fire daily, humans stop trusting them — that's the classic way monitoring stops functioning.

In the code, the has_today() function handles that distinction.

has_today() {
  local file="$1" daymark="$2"
  [ -f "$file" ] || return 1
  /usr/bin/grep -qF "$daymark" "$file"
}
Enter fullscreen mode Exit fullscreen mode

If the log has no line for today at all, it's classified as UNKNOWN (undeterminable) and raised through a different alert than UNHEALTHY (broken). Mixing up "it's broken" and "monitoring isn't working" degrades the reliability of both.


The Overall Flow

Here's the whole system.

launchd(毎日23:30 JST)
  └─ sns-output-watchdog.sh
       │
       ├─ 投稿レーンの確認
       │    ├─ X自動ツイート        autopost.log    マーカー: "投稿成功"
       │    ├─ X自動リプライ        autoreply.log   マーカー: "完了: 返信"
       │    ├─ X outbound          outbound.log    マーカー: "完了"
       │    ├─ Xいいね             x-1.log         マーカー: "終了 ("
       │    ├─ IGカルーセル投稿     sns-ig-autopost.err.log  マーカー: "OK posted"
       │    ├─ Threads自動投稿     persona-autopost-th-1.log マーカー: "投稿成功"
       │    └─ TikTok投稿         tiktok-bokuwalily.log    マーカー: "run end (exit 0/2)"
       │
       ├─ 成果ベースのチェック(いいね数)
       │    └─ x-1 / ig-1〜3 / th-1〜2 / tt-1〜3 の各レーン
       │         ├─ 当日のいいね合計を集計
       │         └─ 0件 or 成果ゼロrun≥3回 → UNHEALTHY
       │
       └─ 結果を Discord に通知
            ├─ 異常あり → 🚨 UNHEALTHY
            ├─ 判定不能 → ⚠️ UNKNOWN
            └─ 全正常  → healthy(無通知)
Enter fullscreen mode Exit fullscreen mode

The launchd configuration lives in com.lily.sns-output-watchdog.plist. StartCalendarInterval fires it daily at 23:30 JST, and RunAtLoad: false is set so it doesn't misfire the instant the plist is loaded.

<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Hour</key>
    <integer>23</integer>
    <key>Minute</key>
    <integer>30</integer>
  </dict>
</array>
<key>RunAtLoad</key>
<false/>
Enter fullscreen mode Exit fullscreen mode

LowPriorityIO and Nice 10 are set so it doesn't compete with the late-night batch jobs that chew through disk I/O.

The Timestamp Trap

The easiest pitfall to overlook in the implementation is mixed log timestamp formats.

In this environment, three different date notations coexist across logs.

TODAY_JST="$(date '+%Y-%m-%d')"
# → "2026-08-14"  IGオートポストのログ形式

TODAY_UTC="$(date -u '+%Y-%m-%d')"
# → "2026-08-13"  social-autolikeのログ形式(UTC記録)

TODAY_HUMAN="$(date '+%a %b %e')"
# → "Fri Aug 14"  TikTokログ(launchdのdate出力形式)
Enter fullscreen mode Exit fullscreen mode

JST and UTC can differ by up to a day. Between midnight and 9 a.m., it's "August 14" in JST but "August 13" in UTC. If the monitoring script runs during that window and searches a UTC-format log with TODAY_JST, it misjudges: "no line for today → UNKNOWN."

The TikTok case is even more peculiar. %e pads the day with a space. The 8th becomes Aug 8 (two spaces). %d outputs 08, which doesn't match the log.

# TikTok: exit 0 と exit 2(投稿済み扱い)を成功とみなす。
tt_ok=$(count_today "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN" 'run end (exit 0)')
tt_maybe=$(count_today "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN" 'run end (exit 2)')
check "tt-autopost" "$((tt_ok + tt_maybe))" 1 "TikTok投稿(exit0=$tt_ok exit2=$tt_maybe)" \
  "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN"
Enter fullscreen mode Exit fullscreen mode

"Read today's log" looks like a trivial operation, but it actually contains two axes of problems: timezone and format.

How Zero-Result Detection Works

Separately from the posting lanes (X/IG/TikTok/Threads), the like/follow lanes are monitored too. These are judged not by "did the run complete" but by "how many likes actually went through."

sum_likes_today() {
  local file="$1" day="$2"
  [ -f "$file" ] || { echo ""; return; }
  /usr/bin/grep -a "^$day" "$file" 2>/dev/null \
    | /usr/bin/grep -oE 'いいね:[0-9]+' \
    | /usr/bin/grep -oE '[0-9]+' \
    | /usr/bin/awk '{s+=$1} END {print s+0}'
}
Enter fullscreen mode Exit fullscreen mode

grep narrows to today's lines only, then the numbers in いいね:N form are summed. If the total is 0, that lane is classified as UNHEALTHY — "completed the run, but produced nothing."

count_dead_runs() {
  local file="$1" day="$2"
  /usr/bin/grep -a "^$day" "$file" 2>/dev/null \
    | /usr/bin/grep -E '終了 \((need-login|circuit-break|error|rate-limit)\)' \
    | /usr/bin/grep -c 'いいね:0' || true
}
Enter fullscreen mode Exit fullscreen mode

If the combination of an exit reason of need-login (expired auth), circuit-break (overload avoidance), error, or rate-limit together with いいね:0 occurs three or more times in a day, we judge that a healthy-looking run is in fact malfunctioning.

In the measured data, the ig-1 lane hit this pattern in 8 of 12 runs. The old monitoring, which only looked at exit codes, kept scoring it as "12 complete runs = healthy" — and that led to two weeks of silence.

The Design of count_today

count_today() contains a subtle design decision.

count_today() {
  local file="$1" daymark="$2" marker="$3"
  [ -f "$file" ] || { echo 0; return; }
  /usr/bin/awk -v day="$daymark" -v mark="$marker" '
    index($0, day) { seen = 1 }
    seen && index($0, mark) { n++ }
    END { print n + 0 }
  ' "$file"
}
Enter fullscreen mode Exit fullscreen mode

There's a reason it uses awk rather than grep. The logs are append-only, so lines from yesterday and earlier are still there. Counting markers with a plain grep would count yesterday's successes as today's.

The awk logic is: "only count lines from the first appearance of today's date marker onward." Markers are only counted after the seen flag is set. This prevents older successes from being miscounted as today's.

Implementation Details

check() Centralizes the Verdict

count_today() and has_today() introduced above are parts. check() is what binds them together and sorts things into "OK / UNKNOWN / UNHEALTHY."

check() {
  local lane="$1" count="$2" min="$3" note="$4" file="${5:-}" daymark="${6:-}"
  REPORT="$REPORT
  $lane: $count 件 (最低 $min) $note"
  if [ "$count" -ge "$min" ]; then
    log "ok lane=$lane count=$count"
    return
  fi
  if [ -n "$file" ] && ! has_today "$file" "$daymark"; then
    UNKNOWN="${UNKNOWN:+$UNKNOWN,}$lane"
    log "UNKNOWN lane=$lane 当日行なし file=$file (日付未出力かその日未起動)"
    return
  fi
  FAILED="${FAILED:+$FAILED,}$lane"
  log "UNHEALTHY lane=$lane count=$count min=$min"
}
Enter fullscreen mode Exit fullscreen mode

Six arguments: lane name, count, minimum count, note, log path, today's marker.

The order of evaluation matters. If the count is sufficient (count -ge min), return immediately; only otherwise do we investigate "why is it zero." If there's no line for today in the log (has_today is false), it's UNKNOWN; if the log is there but the count is 0, it's UNHEALTHY. Reverse this order and you'd misjudge the absence of a log as a failure.

The UNKNOWN="${UNKNOWN:+$UNKNOWN,}$lane" syntax is deliberate too. ${var:+value} expands only when the variable is non-empty. It prevents appending to an empty FAILED from producing ,x-autopost (a leading comma).

Here's what an actual call looks like.

# Instagram: カルーセル自動投稿。
check "ig-autopost" \
  "$(count_today "$CL_LOGS/sns-ig-autopost.err.log" "$TODAY_JST" 'OK posted')" \
  1 "IGカルーセル投稿" \
  "$CL_LOGS/sns-ig-autopost.err.log" "$TODAY_JST"
Enter fullscreen mode Exit fullscreen mode

The fifth and sixth arguments are the log-path / today-marker pair. You pass check() the same values you passed count_today(). That lets has_today() inside check() verify "is there a line for today" using the same marker string.

Why set -uo pipefail

The script starts with set -uo pipefail. Not set -e.

set -uo pipefail
Enter fullscreen mode Exit fullscreen mode

-u: referencing an undefined variable is an error. -o pipefail: if any command in a pipeline returns non-zero, the whole pipeline is treated as failed. -e (exit the script if any command fails) is not included.

The reason is grep's behavior. grep -c counts matches, and returns exit 1 when there are zero matches. With set -e, the entire script dies the moment grep -c finds nothing.

Look at the end of the actual count_dead_runs() pipeline.

count_dead_runs() {
  local file="$1" day="$2"
  [ -f "$file" ] || { echo ""; return; }
  /usr/bin/grep -a "^$day" "$file" 2>/dev/null \
    | /usr/bin/grep -E '終了 \((need-login|circuit-break|error|rate-limit)\)' \
    | /usr/bin/grep -c 'いいね:0' || true
}
Enter fullscreen mode Exit fullscreen mode

The trailing || true absorbs grep -c's exit 1. That works around it even under pipefail. Forget the || true and you get a paradoxical bug: the healthier the day (no zero-result runs at all), the more certain the script is to die partway through.

Undefined variables are handled with ${5:-} and ${dead:-0}. Arguments 5 and 6 of check() are optional, so :- gives them an empty-string default.

launchd Doesn't Know bash's PATH

Look at the EnvironmentVariables section of the plist.

<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>
Enter fullscreen mode Exit fullscreen mode

A process launched by launchd doesn't go through a login shell. Neither .zshrc nor .bash_profile is read. That means node installed via nvm and tools installed via homebrew are not on the PATH by default.

So every command used inside the script is written with an absolute path.

/usr/bin/grep -qF "$daymark" "$file"
/usr/bin/awk -v day="$daymark" -v mark="$marker" '...' "$file"
/usr/bin/python3 "$DISCORD" post alerts "$1"
Enter fullscreen mode Exit fullscreen mode

If you write bare grep, it works fine in an environment where homebrew's grep sits at /opt/homebrew/bin/grep, but under launchd it fails silently with "command not found." The script runs and produces nothing — the hardest failure mode to debug.

Send stdout and stderr to the Same File

<key>StandardOutPath</key>
<string>~/.claude/logs/sns-output-watchdog.launchd.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/sns-output-watchdog.launchd.log</string>
Enter fullscreen mode Exit fullscreen mode

Pointing stdout and stderr at the same file is intentional.

The script itself writes to ~/.claude/logs/sns-output-watchdog.log via the log() function. launchd's output file is separate from that: it captures error messages on stderr from launchd itself failing to start the job (bad path, permission errors, shell syntax errors, and so on). Splitting stderr into its own file just leaves you wondering "which one do I look at?" Merge them into one file and, when something happens, you see the whole picture from a single file.

notify() Tolerates Failure

notify() {
  [ -f "$DISCORD" ] || return 0
  /usr/bin/python3 "$DISCORD" post alerts "$1" >/dev/null 2>&1 \
    || log "discord通知に失敗(ネットワーク?)"
}
Enter fullscreen mode Exit fullscreen mode

Two design decisions here.

First: if the $DISCORD file doesn't exist, it quietly returns 0. If you want to reuse the watchdog script on another machine or in CI, the script itself doesn't break just because the Discord tool isn't there.

Second: a failed notification doesn't take the script down. || log "..." records it and continues. A watchdog that dies because Discord isn't responding defeats its own purpose. If the network drops briefly overnight, the Discord notification may not arrive, but the results are in the log the next morning.

In Result-Based Checks, Test for Empty First

for lane in x-1 ig-1 ig-2 ig-3 th-1 th-2 tt-1 tt-2 tt-3; do
  lf="$SA_LOGS/$lane.log"
  [ -f "$lf" ] || continue
  likes=$(sum_likes_today "$lf" "$TODAY_UTC")
  dead=$(count_dead_runs "$lf" "$TODAY_UTC")
  if [ -n "$likes" ] && [ "$likes" = "0" ]; then
    FAILED="${FAILED:+$FAILED,}$lane-likes0"
    log "UNHEALTHY lane=$lane 本日のいいね合計=0 (成果ゼロrun=${dead:-?}回)"
  elif [ -n "$dead" ] && [ "${dead:-0}" -ge 3 ]; then
    FAILED="${FAILED:+$FAILED,}$lane-dead${dead}"
    log "UNHEALTHY lane=$lane 成果ゼロrunが${dead}回 (いいね合計=${likes})"
  else
    log "OK lane=$lane いいね合計=${likes:-?} 成果ゼロrun=${dead:-0}回"
  fi
done
Enter fullscreen mode Exit fullscreen mode

Note the two-stage check if [ -n "$likes" ] && [ "$likes" = "0" ].

sum_likes_today() returns an empty string "" when the log file doesn't exist.

sum_likes_today() {
  local file="$1" day="$2"
  [ -f "$file" ] || { echo ""; return; }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Evaluating [ "$likes" = "0" ] against an empty string is false — "empty ≠ 0" — which misjudges it as "the like count isn't zero → OK." Evaluating -n "$likes" first separates "no log at all → skip" from "log exists but zero → UNHEALTHY."

Stacking up small conditions like this is what prevents "works correctly 99% of days, false-alarms only on the unusual ones."


Where I Got Stuck

Showing only the clean parts of the design would be dishonest, so here are the failures I actually hit. Symptom → cause → fix, in that order.

Stumble ①: The Next Morning, the Watchdog Went Completely Silent

Symptom. The morning after I added count_dead_runs(), nothing arrived on Discord. Opening the log file, the ===== sns-output-watchdog start ===== line was there. But the log cut off a few lines later. Neither result=healthy nor result=unhealthy appeared.

Cause. grep -c 'いいね:0' returns exit 1 when there are zero matches. Since it was at the end of the pipeline, pipefail marked the whole pipeline as failed, and the function terminated abnormally. The calling script died right there.

The first version had no || true at the end:

# 壊れていたバージョン
| /usr/bin/grep -c 'いいね:0'
Enter fullscreen mode Exit fullscreen mode

Fix. Just add || true.

| /usr/bin/grep -c 'いいね:0' || true
Enter fullscreen mode Exit fullscreen mode

Debugging it took 30 minutes, though. Because the symptom was "complete silence," I first suspected "launchd isn't starting it" and "the Discord tool broke." It didn't occur to me that the watchdog script itself was crashing.

Lesson. When the watchdog goes silent, search the log for the keyword result= first. If that marker is at the end, it exited normally. If not, the script died partway.

tail -50 ~/.claude/logs/sns-output-watchdog.log | grep -E 'result=|start'
Enter fullscreen mode Exit fullscreen mode

Stumble ②: The TikTok Lane Came Back UNKNOWN Every Day

Symptom. From the day after rollout, tt-autopost returned UNKNOWN every single day. Checking the actual TikTok account, the posts were going out. I reread the code assuming a script bug and found nothing wrong.

Cause. Confusing %e and %d.

The first implementation wrote the date format with %d.

# 最初の(壊れていた)実装
TODAY_HUMAN="$(date '+%a %b %d')"
# 8月8日 → "Fri Aug 08"
Enter fullscreen mode Exit fullscreen mode

Checking the TikTok log file with head -10, the date launchd emits was in %e format (day padded with a space).

Fri Aug  8 06:15:22 JST 2026
Enter fullscreen mode Exit fullscreen mode

"Fri Aug 8" (two spaces) and "Fri Aug 08" don't match as strings. has_today() always returned false, so it kept judging UNKNOWN every day.

Fix. Change the format to %e.

TODAY_HUMAN="$(date '+%a %b %e')"
# 8月8日 → "Fri Aug  8"(スペース2つ)
Enter fullscreen mode Exit fullscreen mode

Lesson. Before deciding on a grep search string, always check the actual first lines of the log with head -5. Writing it from the format you assume, only to find it doesn't match what the logging program emits, is entirely routine.

Stumble ③: Loading the plist Fired a Notification in the Middle of the Day

Symptom. I changed a setting and ran launchctl unload && launchctl load at 1 p.m. A Discord notification arrived immediately afterward. It's supposed to run at 23:30 — why?

Cause. RunAtLoad was set to true.

<!-- 誤った設定 -->
<key>RunAtLoad</key>
<true/>
Enter fullscreen mode Exit fullscreen mode

launchd's RunAtLoad: true means "also run the job once, the moment it's loaded." I'd set it during development to "load it and immediately verify it works," and it stayed in the production plist.

Run in the middle of the day, there are naturally few lines for today in the logs, so multiple lanes get treated as UNKNOWN. The monitored system is fine, but the watchdog keeps emitting false UNKNOWN notifications.

Fix. Change it to RunAtLoad: false. Manual verification is done with the launchctl start command.

launchctl start com.lily.sns-output-watchdog
Enter fullscreen mode Exit fullscreen mode

RunAtLoad needs to be a conscious checklist item for "when using this as a production plist." It should almost always be false.

Stumble ④: Yesterday's Successes Were Counted as Today's

Symptom. The first version of count_today() was written with grep.

# 壊れていた最初の実装
count_today_broken() {
  local file="$1" daymark="$2" marker="$3"
  [ -f "$file" ] || { echo 0; return; }
  /usr/bin/grep -c "$marker" "$file"
}
Enter fullscreen mode Exit fullscreen mode

After running this implementation for two weeks, there was a day where not a single IG carousel post actually went out and the watchdog still returned "OK."

Cause. The logs are append-only, so yesterday's successes and everything before pile up in the same file. A plain grep -c "$marker" counts how many times the marker appears in the entire file. If you posted yesterday, it judges "1 or more → OK" even if nothing posted today.

Fix. Rewrote it in awk with the logic "only count from the line where today's marker appeared onward."

count_today() {
  local file="$1" daymark="$2" marker="$3"
  [ -f "$file" ] || { echo 0; return; }
  /usr/bin/awk -v day="$daymark" -v mark="$marker" '
    index($0, day) { seen = 1 }
    seen && index($0, mark) { n++ }
    END { print n + 0 }
  ' "$file"
}
Enter fullscreen mode Exit fullscreen mode

Markers are only counted after the seen flag is set. seen = 1 only happens once a line for today (containing the date string) appears.

Lesson. Don't put too much faith in "the monitoring script says healthy." You need a point at which you verify, against the actual logs, that the watchdog itself is counting correctly. I only noticed two weeks after implementing it, while digging through logs for something unrelated.

Stumble ⑤: The Structure Behind Two Weeks of "Healthy"

This is the root of the whole incident, and it's a bit different in nature from the individual bugs above.

content-watchdog.sh ran daily at 23:30 and left result=healthy in the log. Discord notifications kept saying "no problems." So I believed "the system is fine."

The problem was a mismatch in what was being monitored. content-watchdog.sh is a script that checks the liveness of the article lanes (note / maker / series / ameba); the social posting lanes were out of scope. The code says so honestly.

# content-watchdog.sh は article/note/maker/series/ameba だけを見ており、
# X/IG/TikTok/Threads の投稿・返信は誰も監視していなかった。
Enter fullscreen mode Exit fullscreen mode

"The watchdog says healthy" and "the monitoring net covers every lane" are two different propositions. But when healthy notifications arrive every day, humans conflate them before they notice.

Two fixes.

One was creating sns-output-watchdog.sh and bringing the social lanes under monitoring (the main subject of this article).

The other was making the Discord notification message always include the list of monitored lanes.

REPORT="$REPORT
  $lane: $count 件 (最低 $min) $note"
Enter fullscreen mode Exit fullscreen mode

The REPORT variable accumulates the check results for every lane, and all of it goes into the Discord notification. The notification carries the information "today I checked x-autopost / x-autoreply / x-outbound / x-like / ig-autopost / th-autopost / tt-autopost." A human can see what the monitoring script is looking at, and what it isn't.

What matters in an automation system's health report isn't saying "no problems" — it's showing "here's what I checked, and that's the basis for no problems." That's what two weeks of silence taught me.

Gotchas

Beyond the five stumbles covered earlier (silent watchdog, TikTok UNKNOWN, RunAtLoad misfire, miscounting yesterday's successes, the structural blind spot), there are more pitfalls that are easy to step on while implementing and operating this same design. Here they are, exhaustively.

  • ~ in a plist is not expanded by launchd

If you write ~/ in StandardOutPath, launchd interprets it as a literal string and doesn't expand your home directory. The log file is never created, and checking ls ~/.claude/logs/ naturally shows no file. Look at how the actual com.lily.sns-output-watchdog.plist is written.

<key>StandardOutPath</key>
<string>~/.claude/logs/sns-output-watchdog.launchd.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/sns-output-watchdog.launchd.log</string>
Enter fullscreen mode Exit fullscreen mode

The home shorthand ~ isn't used at all. The script path in ProgramArguments and the PATH in EnvironmentVariables are all full paths too. Remember: there is no place in a plist where ~ is acceptable.

  • Three timestamp formats coexist inside one script

Reading the top of sns-output-watchdog.sh, there are three kinds of TODAY_ variables.

TODAY_JST="$(date '+%Y-%m-%d')"   # → "2026-08-14"  IGオートポスト系
TODAY_UTC="$(date -u '+%Y-%m-%d')"  # → "2026-08-13"  social-autolike全般
TODAY_HUMAN="$(date '+%a %b %e')"  # → "Fri Aug 14"  TikTokログ(launchd形式)
Enter fullscreen mode Exit fullscreen mode

Every time you add a log to the monitoring targets, you have to visually confirm with head -5 <logfile> which format that tool emits timestamps in. Feed a UTC-format log to a JST-fixed script and you get a one-day offset and continuous UNKNOWN between midnight and 9 a.m. This is the pitfall you'll hit most often when adding a new lane.

  • Under bash -u, forgetting to initialize a variable causes a crash

The -u in set -uo pipefail means "referencing an undefined variable is an error." These three lines at the top of the script,

FAILED=""
UNKNOWN=""
REPORT=""
Enter fullscreen mode Exit fullscreen mode

if you forget them, the expansion ${UNKNOWN:+$UNKNOWN,}$lane errors out the moment the first check() is called. The symptom is, once again, "the watchdog is completely silent." Since it matches the symptom of a forgotten grep -c || true, it's confusing during debugging. The reliable approach is to declare the variables empty, then pass a syntax check with bash -n <scriptname> before loading the plist.

  • Treating TikTok's exit 2 as a failure

The emulator-based TikTok posting script exits with exit 2 when it determines "today's post already went out." That's a normal-path outcome. The script counts both run end (exit 0) and run end (exit 2) as successes.

tt_ok=$(count_today "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN" 'run end (exit 0)')
tt_maybe=$(count_today "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN" 'run end (exit 2)')
check "tt-autopost" "$((tt_ok + tt_maybe))" 1 "TikTok投稿(exit0=$tt_ok exit2=$tt_maybe)" \
  "$CL_LOGS/tiktok-bokuwalily.log" "$TODAY_HUMAN"
Enter fullscreen mode Exit fullscreen mode

If you don't know about exit 2 and use only exit 0 as the marker, UNHEALTHY fires precisely on the days TikTok correctly determined "already posted." You have to read the logs to confirm the semantics of a tool's exit codes.

  • There are two log directories

The script has two base directories: SA_LOGS (the social-autolike project's logs/) and CL_LOGS (~/.claude/logs/). The X, Threads, and TikTok like lanes write under SA_LOGS; IG posting and TikTok posting write under CL_LOGS. When adding a new lane, if you don't check the tool's code for which directory it uses, the path won't match and you'll get UNKNOWN every day.

  • The REPORT variable bloats and hits Discord's character limit

Every time check() is called, one line is appended to REPORT. On top of the seven posting lanes — x-autopost, x-autoreply, x-outbound, x-like, ig-autopost, th-autopost, tt-autopost — the results for nine like lanes go in too. If you attach the whole $REPORT to an UNHEALTHY notification, it exceeds Discord's 2000-character limit and the tail gets cut off. That leaves you unable to read "why is this lane red" from the notification — self-defeating — so REPORT needs to be reconsidered as a summary format or a reference to a separate file.

  • bash syntax errors don't appear in the watchdog's own log

If the script has a syntax error, bash exits immediately after starting. It dies before the watchdog calls its internal log() function, so nothing is left in ~/.claude/logs/sns-output-watchdog.log. The only destination for the error is launchd's log (~/.claude/logs/sns-output-watchdog.launchd.log). The diagnostic order when the watchdog is silent is "launchd log first → then the watchdog's own log."

tail -20 ~/.claude/logs/sns-output-watchdog.launchd.log
Enter fullscreen mode Exit fullscreen mode

If you see syntax error near unexpected token or command not found, the problem is in the script body or the PATH configuration.

  • Since healthy sends no notification, you can't confirm a newly added lane

The watchdog is designed to notify Discord only when something is wrong. On days when every lane is fine, it just leaves result=healthy in the log and sends nothing. The day after you add a new lane to check(), you'd like to relax at "no notification → healthy," but you can't directly confirm "was the lane actually added."

launchctl start com.lily.sns-output-watchdog
tail -f ~/.claude/logs/sns-output-watchdog.log
Enter fullscreen mode Exit fullscreen mode

Starting it manually and watching the log in real time is the only way to verify. If a line ok lane=<new lane name> count=N appears, it's in the monitoring net.

  • Set the zero-result-run threshold of "3" with a basis

We judge UNHEALTHY when count_dead_runs() returns 3 or more. The reason we don't alert immediately at 1 or 2 is that measurements show one-off need-login events happen frequently due to brief network drops, session re-authentication, and the like. In the 2026-08-08 measurement, ig-1 had 8 zero-result runs out of 12. That number is the basis for the threshold "3 or more means a structural problem." Set the threshold too low and alerts fire daily, alert fatigue sets in, and monitoring stops functioning. Set it too high and you miss real failures. Starting at 3 and adjusting while watching the false-positive rate in the real environment is the practical approach.

  • Forgetting the distinction between empty string and "0" in sum_likes_today causes misjudgment

When the log file doesn't exist, sum_likes_today() returns an empty string via echo "". Evaluating [ "$likes" = "0" ] against the empty string is false — "empty ≠ 0" — i.e. a misjudgment of "not zero → OK." To separate "the log doesn't exist" from "the log exists but is zero," you need the two-stage check [ -n "$likes" ] && [ "$likes" = "0" ], and reversing the order breaks it too. This structure was covered in detail in p2, but as a principle — "mix up empty string and zero and you will get a misjudgment" — it applies to monitoring scripts generally.


Best Practices

Here are the lessons from two weeks of silence and the implementation, organized into rules you can apply directly to other automation systems.

1. Judge by "does the output exist in today's log," not "did the process complete"

The exit code is fine but no post went out. As this case proved, process-level health and business-output health are different things. Always anchor your monitoring criteria to "today's output."

2. Separate UNKNOWN (undeterminable) from UNHEALTHY (broken) with different codes

if [ -n "$UNKNOWN" ]; then
  notify "⚠️ SNS監視が判定不能: ${UNKNOWN}..."
  exit 2
fi
Enter fullscreen mode Exit fullscreen mode

Mixing both into the same alert makes it impossible to tell "it's broken" from "monitoring isn't working." Distinguishing them with different exit codes and different emoji also changes how the receiver responds.

3. Always include the list of monitored lanes in the notification

Accumulate the check results for every lane in the REPORT variable and send them. Monitoring where you can't see "what was checked to conclude healthy" is a breeding ground for humans misreading it as "everything's fine."

4. Decide timestamp formats only after checking the actual logs with head

head -5 ~/.claude/logs/sns-ig-autopost.err.log
head -5 ~/dev/social-autolike/logs/autopost.log
head -5 ~/.claude/logs/tiktok-bokuwalily.log
Enter fullscreen mode Exit fullscreen mode

Even knowing there are three kinds — JST/UTC/HUMAN — make it a habit to check the real thing every time you add a new log. Tools routinely use a format different from what you assume.

5. Count with awk scoped to today's lines onward, not grep

Logs are append-only. grep -c "marker" searches the whole file and counts successes from yesterday and earlier. Use a seen flag in awk and only count after today's date line appears.

6. Always append || true after grep -c in a pipefail environment

/usr/bin/grep -c 'いいね:0' || true
Enter fullscreen mode Exit fullscreen mode

grep -c returns exit 1 when there are zero matches. Under pipefail that's treated as a failure of the whole pipeline and the script exits partway. || true alone is enough to prevent the paradoxical bug of "the healthier the day, the more surely the script dies."

7. Write every command in a launchd-started script with an absolute path

/usr/bin/grep -qF "$daymark" "$file"
/usr/bin/awk -v day="$daymark" ...
/usr/bin/python3 "$DISCORD" post alerts "$1"
Enter fullscreen mode Exit fullscreen mode

launchd reads neither .zshrc nor .bash_profile. It starts the job with a PATH that doesn't include homebrew or nvm. Bare command names may work locally and fail silently under launchd.

8. Write plist paths as full paths (no ~ notation)

Don't use ~ in StandardOutPath, StandardErrorPath, or ProgramArguments. launchd does not expand the home directory.

9. Set RunAtLoad to false in production plists

Verify behavior manually with launchctl start <label>. With true, it runs immediately on every unload/load, and monitoring runs while there are few lines for today, producing a flood of UNKNOWNs.

10. notify() must not stop the watchdog when it fails

notify() {
  [ -f "$DISCORD" ] || return 0
  /usr/bin/python3 "$DISCORD" post alerts "$1" >/dev/null 2>&1 \
    || log "discord通知に失敗(ネットワーク?)"
}
Enter fullscreen mode Exit fullscreen mode

Even if a notification doesn't go out because of an overnight network drop or a Discord outage, let the watchdog script itself continue. The results always land in the log.

11. Declare variables in a form that's safe under set -u

FAILED=""
UNKNOWN=""
REPORT=""
Enter fullscreen mode Exit fullscreen mode

Initialize these three variables to empty strings at the top of the script. The ${UNKNOWN:+$UNKNOWN,} syntax assumes the variable has been declared.

12. After adding a new lane, always run it manually with launchctl start to confirm

The watchdog sends no notification when healthy. Whether a new lane made it into the monitoring net is confirmed by whether ok lane=<new lane name> appears in the log after a manual run.

13. Syntax-check the script with bash -n before loading the plist

bash -n ~/.claude/scripts/sns-output-watchdog.sh && echo "syntax OK"
Enter fullscreen mode Exit fullscreen mode

With a syntax error, the watchdog exits silently leaving a record only in the launchd log. Making a habit of passing a syntax check up front saves you the time spent puzzling over "silence" after loading the plist.

14. Tune thresholds while watching the false-positive rate

The threshold of "3" in count_dead_runs() is set as a value that absorbs one-off failures in the real environment (network drops, expired sessions). For the first two weeks after introducing monitoring, set the threshold to 3, observe the daily alert rate, and adjust. If alerts fire daily, raise it. If failures slip through often, lower it. You need measured data before you fix a number.

15. Periodically inventory what your monitoring scripts monitor

The root of this incident wasn't "content-watchdog.sh kept saying healthy" — it was "the social posting lanes weren't in any watchdog's scope." I strongly recommend keeping a checklist so that every time you add a new lane to an automation system, you confirm "which watchdog is watching this lane." Even a monthly review would have prevented the two weeks of silence.


Summary

What came out of a 14-day social posting outage is sns-output-watchdog.sh and com.lily.sns-output-watchdog.plist. What those two files embody fits in one line — "the system is running" and "output is coming out" are different propositions.

Judge health by exit code and you'll wave through "exited normally but produced nothing" indefinitely. Only by judging on whether a success marker exists in today's log can you detect "working" in the business sense.

Mixed timestamps (JST/UTC/HUMAN formats), separating UNKNOWN from UNHEALTHY, distinguishing empty string from zero, the collision between grep -c and pipefail — every one of these comes from the experience of actually looking at the logs. The number written in the code comment, 2026-08-08 実測で ig-1 は12run中8回が0件, is a measurement. Without these details, you'll step in the same holes over and over.

One more important thing. "The watchdog says healthy" does not mean "every lane is monitored." A monitoring script only sees the lanes it includes as targets. Every time you add new automation, the monitoring net needs updating, and neglecting that update produces exactly this kind of healthy-looking failure.

The more you earn from automation, the more you also need to invest in the machinery that monitors that automation. Roughly calculating the portion of ¥1.2M in revenue that involves social traffic, 14 days of downtime probably amounts to something like ¥80,000–100,000 in lost opportunity. The cost of leaving it unmonitored is far higher than the cost of writing one monitoring script. Today, having read this article, I'd suggest checking which lanes of your own system are ones "nobody is monitoring."


I've written up the full picture of the setup, the breakdown of the ¥1.2M/month, and the 30-day process in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)