I built a fleet of 171 automation jobs to grow my business — and one of them was quietly murdering another one's login session every single night for days before I figured out why.
Here's the arc: I made ¥100k/month as a university student, stacked side jobs up to ¥600k/month, got laid off and went back to zero, then spent six months building an autonomous Claude Code environment that now does ¥1.2M/month in revenue. The Instagram account underpinning that revenue was being killed daily by a script I wrote myself.
Why This Setup Matters
When you're building up revenue as a solo developer, the first wall you hit isn't "not enough hands" — it's "I have no idea what's happening while I sleep."
In my current environment, 171 jobs launch automatically from launchd every day. Posting, liking, following, unfollowing, and DMs across X / Instagram / Threads / TikTok are all automated, and a Bash script called sns-output-watchdog.sh monitors whether each one actually produced output that day, based on artifact logs.
But before I built that monitoring, I have a track record of not noticing for two weeks. The header comment of sns-output-watchdog.sh says this:
# content-watchdog.sh は article/note/maker/series/ameba だけを見ており、
# X/IG/TikTok/Threads の投稿・返信は誰も監視していなかった。その結果
# IG投稿は7/29から、TikTok投稿は7/28から止まったまま2週間気づかれなかった。
If IG posting stops, new followers stop coming in, the flow into my official LINE account stops, and revenue growth eventually flattens. A single failed slot doesn't mean "I missed one post today" — it means "the entire funnel that was supposed to start there was dead."
Even after I set up monitoring, the next problem showed up: exit 0 and "something actually happened" are different things. In measurements on 2026-08-08, the IG like lane ig-1 finished with exit 0 and zero likes on 8 out of 12 daily runs. The watchdog at the time only counted "how many times the marker 終了 ( appeared," so it judged both circuit-break and need-login as "healthy."
# 🔴 従来は '終了 (' の出現回数だけを数えていたため、circuit-break でも need-login でも
# 「健全」と判定していた。2026-08-08 実測で ig-1 は12run中8回、tt-1/tt-2 は6割が
# いいね0件のまま exit 0 で終わっており、2週間誰も気づけなかった。
Learning from that, I added count_dead_runs() to detect runs with zero output, and sum_likes_today() to total the actual like count. But that still didn't solve the structural problem: the side doing the breaking and the side reporting the breakage lived in different repositories. That's the 2026-08-24 incident. If you run multiple automations on the same Mac, you're at risk of falling into exactly the same hole.
The Overall Picture
A Shared Resource Across Repositories
To lay out the problem, here's the structure as a diagram.
scent-media リポジトリ social-autolike リポジトリ
────────────────────────── ──────────────────────────────────
scripts/ensure_chrome.sh config/accounts.json
└ CDP :9223 へ接続試行 └ "reuseProfile": ".profiles/chrome-ig"
応答なし → pkill 実行
│ th-scent ジョブ(Playwright)
│ └ .profiles/chrome-ig を掴んで起動
│ ※ CDP 9223 は一切開かない
↓ ↓
└──────────────┬───────────────────────────┘
↓
.profiles/chrome-ig ← 両者が同じプロファイルを参照
Cookie SQLite 強制破壊
instagram.com の sessionid → 0行
ensure_chrome.sh in scent-media checks, before an IG post, whether Chrome is in a CDP-controllable state. The check is an attempted connection to port 9223. If there's no response, it decides "Chrome is dead" and force-kills it with this command:
pkill -f "user-data-dir=$PROFILE"
$PROFILE is .profiles/chrome-ig. The problem is that the th-scent job in social-autolike shared that same profile via a reuseProfile setting. th-scent launches Chrome through Playwright, but it never opens CDP port 9223. From ensure_chrome.sh's point of view, "the Chrome that th-scent is using" always looks like "9223 is closed = a dead process."
A Collision at the Same Time Every Day
This collision wasn't random — the launchd schedule made it happen deterministically at fixed times every day.
| Time | Job | Duration |
|---|---|---|
| 5:18 / 13:18 / 21:18 | th-scent autolike | up to 40 min |
| 6:56 / 14:56 / 22:56 | th-scent unfollow | up to 40 min |
| 7:20 | scent-media daily-generate | — |
| 19:00 / 21:00 / 23:00 | scent-media daily-post (IG carousel post) | — |
At 22:56, th-scent unfollow grabs .profiles/chrome-ig; at 23:00, daily-post calls ensure_chrome.sh. 9223 doesn't respond, so pkill runs. This repeated every night. The 19:00 and 21:00 slots overlap with th-scent autolike in the same way.
Destroying the Cookie SQLite
pkill -f "user-data-dir=..." sends SIGTERM and then SIGKILL. Chrome can't complete its shutdown routine and loses the chance to write the profile's Cookie SQLite back in a consistent state.
On next launch, Chrome recreates an empty Cookie DB. Here are the measured values:
The
cookiestable shrank to 3 rows total across all hosts, andinstagram.comhad 0 cookies. The IGsessionidwas gone.
On 2026-08-24, all three slots — 19:00, 21:00, and 23:00 — posted nothing.
Why the Watchdog Misdiagnosed It as "Logged Out"
The IG post check in sns-output-watchdog.sh looks like this:
check "ig-autopost" \
"$(count_today "$CL_LOGS/sns-ig-autopost.retry.log" "$TODAY_JST" 'OK posted')" \
1 "IGカルーセル投稿" \
"$CL_LOGS/sns-ig-autopost.retry.log" "$TODAY_JST"
If OK posted doesn't appear even once in the day's log, the lane goes into FAILED and 🚨 SNS当日未出力: ig-autopost fires to Discord. That alert was firing. But the text of the alert says "posting failed," not "session destroyed by pkill."
The session checker correctly reports "there is no IG sessionid." need-login shows up in the log. A human reads that and concludes "the login expired → let's log in again." The next day, at the same time, pkill runs again and the cookies vanish. This loop kept repeating.
watchdog: 🚨 ig-autopost 未出力
↓
session-liveness: sessionid が無い(正しい報告)
↓
人間: GUI 再ログインを実行
↓
翌日 22:56: th-scent がプロファイルを掴む
↓
翌日 23:00: ensure_chrome.sh → pkill → Cookie 消滅
↓
watchdog: 🚨 ig-autopost 未出力(同じ報告が出る)
Because the side doing the breaking (ensure_chrome.sh / scent-media) and the side reporting the breakage (retry.log / the session checker) live in different repositories, reading only one of them will never connect the dots. The reuseProfile setting written in social-autolike's config/accounts.json appears nowhere in scent-media's code.
The Skeleton of the Fix
The fix comes down to one thing: eliminate pkill entirely and replace it with waiting and skipping.
# 修正後の ensure_chrome.sh(概要)
if pgrep -f "user-data-dir=$PROFILE" > /dev/null; then
# 他プロセスが掴んでいる → 10秒間隔・最大420秒ポーリング
for i in $(seq 1 42); do
sleep 10
pgrep -f "user-data-dir=$PROFILE" > /dev/null || break
done
if pgrep -f "user-data-dir=$PROFILE" > /dev/null; then
echo "他ジョブが使用中(pid=$(pgrep -f "user-data-dir=$PROFILE"))。Cookie破壊を避けるため起動を見送る" >&2
exit 2 # 「今スロット見送り」の専用コード
fi
fi
# ここまで来たら誰も掴んでいない → SingletonLock等の掃除 → 起動処理へ
The caller, daily_post.sh, receives exit 2 in a separate branch and treats it as "skip this slot, retry at the next one," exiting with exit 0. For verification I ran two bash -n syntax checks, confirmed grep -c pkill returned 0, and confirmed the exit 2 path really exists at line 57, then bundled it into commit 15307d7.
Why I didn't fold exit 2 into "error," what the "3 slots per day" premise behind the skip design means, and how I reworked the watchdog so it won't have the same structural blind spot again — I break all of that down in the next part.
Implementation Details
Why You Must Never Mix count_today() and has_today()
The first thing you agonize over when writing a watchdog is distinguishing "zero count = failure" from "it just didn't run." In sns-output-watchdog.sh I handle this with two independent functions.
# 当日分のログ行だけに成功マーカーがあるか数える。
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"
}
# そのログに「当日を示す行」自体があるか。
has_today() {
local file="$1" daymark="$2"
[ -f "$file" ] || return 1
/usr/bin/grep -qF "$daymark" "$file"
}
count_today() uses awk's seen flag so that "only lines after the day marker appears" are considered. A simple grep -c marker would mix in success lines from previous days. The logs are designed not to rotate, so if this one-day offset breaks, you get "today judged healthy based on yesterday's post count."
has_today() matters because of the branching inside check().
check() {
local lane="$1" count="$2" min="$3" note="$4" file="${5:-}" daymark="${6:-}"
# ...
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 当日行なし"
return
fi
FAILED="${FAILED:+$FAILED,}$lane"
}
When the count falls below min, if has_today() returns false the lane goes into UNKNOWN rather than FAILED. If you conflate the two, a lane that only runs three times a week will emit UNHEALTHY every day — Monday, Wednesday, and Friday included. The moment alerts stop being trusted, your monitoring is finished.
count_dead_runs() — Catching Runs That Finished but Produced Nothing
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
}
There's a reason this is a two-stage grep. The first regex, 終了 \((need-login|circuit-break|error|rate-limit)\), narrows to "lines with a harmful termination reason," and the second, grep -c 'いいね:0', narrows to "and zero output." If you counted a run that terminated early on rate-limit but still managed a few likes as a "dead run," you'd get an alert every time a mild nighttime rate limit kicks in. The point is to AND the two conditions to isolate "genuinely accomplished nothing."
This function is used in the outcome-based monitoring loop.
for lane in x-1 ig-1 ig-2 ig-3 ig-sug 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
The branch differs between likes being an empty string (the file itself doesn't exist) and being "0". In the empty case it simply skips to the next lane rather than reporting UNKNOWN. That subtle distinction is what keeps "a lane not yet promoted to monitored status" from being confused with "a monitored lane that isn't running."
The Three-Stage Pipeline of sum_likes_today()
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}'
}
Log lines start with the 2026-08-24 ... format, so stage one narrows to today's lines with "^$day". Stage two extracts marker-prefixed numbers with grep -oE 'いいね:[0-9]+', and stage three strips down to the bare digits and sums them with awk. The reason for the intermediate grep is that log lines can contain multiple markers, like いいね:0 フォロー:3 — pulling with just [0-9]+ would also pick up the 3 from フォロー:3.
The フォロー total uses the same structure in a separate function, sum_follows_today(). Likes and follows are independent outcomes, and there are cases where likes are zero but follows are working, so they must always be judged separately.
ensure_chrome.sh — From Killing pkill to a Skip Design
Here's the core of the fix in real code. Before the fix, it tried to solve everything in one line.
# 修正前:CDP 9223 が応答しない → 無条件で殺す
pkill -f "user-data-dir=$PROFILE"
After the fix, it's three stages.
# 修正後
if pgrep -f "user-data-dir=$PROFILE" > /dev/null; then
# 他プロセスが掴んでいる → 10秒間隔・最大420秒ポーリング
for i in $(seq 1 42); do
sleep 10
pgrep -f "user-data-dir=$PROFILE" > /dev/null || break
done
if pgrep -f "user-data-dir=$PROFILE" > /dev/null; then
echo "他ジョブが使用中(pid=$(pgrep -f "user-data-dir=$PROFILE"))。Cookie破壊を避けるため起動を見送る" >&2
exit 2
fi
fi
# ここまで来たら誰も掴んでいない → SingletonLock 等の掃除 → 起動処理へ
The reason I didn't fold exit 2 into "error" (exit 1) rests on a design fact. IG carousel posting has three slots a day: 19:00, 21:00, and 23:00. Skipping one slot still leaves the next slot to post the same content. With exit 1, on the other hand, Discord alerts would keep firing every time the th-scent collision window comes around, and genuinely abnormal alerts would drown in the noise. "Skipped" is a third state that is neither failure nor success, and representing it with a dedicated exit code lets the caller, daily_post.sh, receive it in an independent branch.
It also matters that the SingletonLock cleanup now happens only when nobody is confirmed to be holding the profile. Before the fix, it unconditionally ran rm -f SingletonLock SingletonSocket SingletonCookie right after pkill. If you delete lock files while someone is using the profile, the process using it crashes and leaves the profile in a half-broken state.
Where I Got Stuck
"I Was Watching err.log" — Two Weeks of Daily False UNKNOWNs
My first watchdog implementation pointed the IG post check at the wrong file.
# 🔴 誤り:err.log は 2026-08-09 で更新が止まっている
check "ig-autopost" \
"$(count_today "$CL_LOGS/sns-ig-autopost.err.log" "$TODAY_JST" 'OK posted')" ...
In reality, post success/failure logs go to retry.log, and err.log had stopped updating after 2026-08-09. has_today() kept returning false, and every morning ⚠️ SNS監視が判定不能: ig-autopost(当日行がログに無い) arrived in Discord.
Judged by symptoms alone, it looks like "the ig-autopost log is broken." The log wasn't actually broken — I just had the filename wrong. It took me two days to find the cause. I only noticed after checking the file's mtime with ls -la. The comment is still in the code:
# 成果は err.log ではなく retry.log に出る。err.log は 2026-08-09 で更新が止まっており、
# ここを見ている限り毎日 UNKNOWN 誤報になる(実際は当日3本投稿できていた)。
check "ig-autopost" \
"$(count_today "$CL_LOGS/sns-ig-autopost.retry.log" "$TODAY_JST" 'OK posted')" ...
A monitoring script that reads log files can't notice "the file went stale = monitoring is dead" unless there's a mechanism that periodically checks the mtime of the files it reads. Receiving false alarms for two weeks is the same state as receiving no alerts at all.
Only TikTok Has a Different Date Format
TikTok's log inherited the format launchd emits, so its date format differs from the other lanes.
# X・IG・Threads: TODAY_UTC = "2026-08-24"
# TikTok だけ: TODAY_HUMAN = "Mon Aug 24" (%e で日を空白詰め → "Mon Aug 8")
TODAY_HUMAN="$(date '+%a %b %e')"
%e pads the day with a space, so August 8th becomes Aug 8 (two spaces). Grepping TikTok's log with "^$TODAY_UTC" never matched the date format at all, so it always returned 0.
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"
The fix is just splitting out a separate variable that uses TODAY_HUMAN, but the reason I got stuck finding the cause is that "since every other lane was working fine, the TikTok problem looked like a TikTok-side defect." Format inconsistencies inside a script are invisible unless you line them up against the working lanes and compare.
"Zero Follow Successes, 20 Undecidable" — The friendship API Throttle
count_undecidable_today(), which I added on 2026-08-22, was born from this lesson.
count_undecidable_today() {
local file="$1" day="$2"
[ -f "$file" ] || { echo "0 0"; return; }
/usr/bin/awk -v day="$day" '
index($0, day) && index($0, "follow成功") { success++ }
index($0, day) && index($0, "follow判定不能") { undecidable++ }
END { print success + 0, undecidable + 0 }
' "$file"
}
The friendship API is Instagram's private API, used to check follow relationships. When it gets throttled, "is this person following me back?" becomes undecidable, and the job records follow判定不能 and moves on. Likes are working fine, so the sum_likes_today() check passes. From the outside, "ig-1 is healthy again today."
The actual symptom was 2255 uncollected entries piled up in ff-ig-2. Since unfollows can't happen, the backlog grows, you approach the follow limit, and one day follows suddenly stop too. I added the combination of follow成功=0 AND follow判定不能>=20 as a dedicated detection condition.
for lane in ff-ig-1 ff-ig-2 ff-ig-3; do
# ...
if [ "$follow_success" -eq 0 ] && [ "$undecidable" -ge 20 ]; then
FAILED="${FAILED:+$FAILED,}$lane-blocked"
log "UNHEALTHY lane=$lane follow成功=0 判定不能=$undecidable -> friendship APIが絞られている疑い"
fi
done
"source-follow: All Sources Failed" — Four Times in a Row, Reported to Nobody
The source-followers lane is a subprocess that collects follower lists to find follow targets. Until 2026-08-22 it wasn't included in the monitoring loop at all.
# 2026-08-22 追加前は、このレーンの成果は一度も監視されていなかった。
# 実害: ig-sug が 23:14/02:13/11:16/14:16 と4回連続で
# 「coverage 0/17 -> 全ソース取得失敗 -> exit 1」になり日次20件で止まっていたのに、
# 誰にも報告されなかった(同時刻に ig-3 も14時間ゼロ成果)。
# 実体は private API の 429。
ig-sug emitted 全ソース取得失敗 in four slots: 11 PM, 2 AM, 11 AM, and 2 PM. It was falling over with exit 1 at zero coverage against 17 target accounts, but because sum_likes_today() on the main lane's ig-sug.log was working normally on its own, the watchdog summary said OK.
The monitoring I added watches source-followers-*.log in an independent loop.
for lane in ig-1 ig-2 ig-3 ig-nagi ig-sug th-1 th-2 th-3 th-nagi x-1 x-2 x-nagi x-reina; do
sf="$SA_LOGS/source-followers-$lane.log"
[ -f "$sf" ] || continue
sf_dead=$(count_today "$sf" "$TODAY_UTC" '全ソース取得失敗')
if [ "$sf_dead" -ge 2 ]; then
FAILED="${FAILED:+$FAILED,}$lane-srcfollow-blocked"
log "UNHEALTHY lane=$lane-srcfollow 全ソース取得失敗=${sf_dead}回 -> APIスロットリング疑い"
fi
done
I use sf_dead >= 2 as the threshold because a one-off failure (a temporary 429) self-recovers on the next run. Two consecutive failures let you conclude "the throttling is ongoing."
Deleting My Neighbor's Cookies Daily via pkill — A Structure Where the Symptom Can Only Look Like "Logged Out"
This was the nastiest failure of all. The symptoms were clear. No OK posted at all in IG's sns-ig-autopost.retry.log. 🚨 SNS当日未出力: ig-autopost arriving in Discord from the watchdog. The session checker correctly reporting sessionid がない(need-login).
I re-logged in via the GUI every time. It would be back the next morning. It would be gone again the next night. This cycle went on for several days, and I was starting to form the hypothesis that "maybe Instagram shortened its session lifetime."
In reality, ensure_chrome.sh was pkill-ing the profile used by social-autolike's th-scent job every night. th-scent launches Chrome with Playwright but never opens CDP port 9223. From ensure_chrome.sh's point of view, "9223 is closed = a dead process." pkill sends SIGTERM → SIGKILL, Chrome can't complete its shutdown routine, and it can't write the Cookie SQLite back in a consistent state. On the next launch, Chrome recreates an empty Cookie DB.
I found the cause when I lined up the log timestamps side by side.
22:56 th-scent unfollow start (.profiles/chrome-ig を掴む)
23:00 daily-post → ensure_chrome.sh → CDP 9223 応答なし
23:00 pkill -f user-data-dir=.profiles/chrome-ig
23:00 Cookie SQLite 破壊 → 空の DB 作り直し
23:00 daily-post: need-login → 投稿ゼロ
Because "the side doing the breaking (ensure_chrome.sh / scent-media)" and "the side reporting the breakage (retry.log / the session checker)" live in different repositories, reading only one of them will never connect the dots. The setting "reuseProfile": ".profiles/chrome-ig" in social-autolike's config/accounts.json appears nowhere in scent-media's code.
You must not write a presence check for a shared resource as "does the interface I expect respond?" — this incident was the first time I learned that lesson. 9223 being closed doesn't mean "dead"; it may mean "not launched in my particular style." Identify the owner with interface-independent means (pgrep / lock files), and allow kill only against processes you started yourself — that principle is what led to the pkill removal in commit 15307d7.
Pitfalls
Here are the traps I hit in an environment running multiple repositories on the same Mac. On top of the "wrong err.log filename," "TikTok date format difference," "friendship API throttling," "source-follow monitoring gap," and "Cookie destruction via pkill" detailed in the previous part, the same structural hole showed up in other forms too.
Unfollow monitoring didn't exist at all. Unfollow lanes like
ig-1.unfollow.log/ig-2.unfollow.log/tt-1.unfollow.logweren't in the monitoring loop at all until 2026-08-17. The damage shows up in measured numbers:ig-1had 712 unfollows uncollected for over 72 hours,ig-2piled up to 2255, andtt-1was unable to unfollow 11 times in a row — while the watchdog returnedresult=healthyevery day. When unfollows jam up, you approach the follow limit, and a few days later follows stop too. Because the direct symptom (follows stopped) is one step removed from the root cause (unfollows jammed first), digging out the cause takes time."Zero like attempts" is a different failure from "zero like results."
th-2was launching daily withlikeBudget=400, but the log contained not a single line with the stringlive like. It was only attempting follows and ending oncircuit-break. Fromsum_likes_today()'s point of view, this looks like one verdict: "total likes = 0." But "attempted and got blocked every time" and "never attempted at all" have completely different causes. The former is an account restriction; the latter is a selector mismatch or an action-flow bug. The detection loop that makes this distinction possible now lives at lines 244–259 ofsns-output-watchdog.sh:
# 「起動した」記録があるのに「いいね試行」が0回 = セレクタ不一致/アクション制限の疑い
for lane in x-1 x-4 x-5 ig-1 ig-2 ig-3 ig-sug th-1 th-2 tt-1 tt-2 tt-3; do
lf="$SA_LOGS/$lane.log"
[ -f "$lf" ] || continue
started=$(count_today "$lf" "$TODAY_UTC" '開始 [')
attempts=$(count_today "$lf" "$TODAY_UTC" 'live like')
if [ "$started" -ge 1 ] && [ "$attempts" -eq 0 ]; then
FAILED="${FAILED:+$FAILED,}$lane-noattempt"
log "UNHEALTHY lane=$lane いいね試行が0回(セレクタ不一致/アクション制限の疑い) 起動=${started}回"
fi
done
Mixing FAILED and UNKNOWN destroys trust in your alerts. If you run a lane that only launches three times a week through
check()every day, the days it doesn't launch have no log lines for that day, so it's treated as zero count and piled intoFAILEDdaily. When "🚨 SNS当日未出力: lane-X" arrives in Discord four days a week, a genuine outage alert one day gets skimmed past as "there it goes again." The moment alerts stop being trusted, your monitoring infrastructure is finished. After adding the branch that checks "does a log line for today exist?" withhas_today()and routes lanes with no lines toUNKNOWN, my false-positive rate dropped by what felt like 90%+.Mixing UTC and JST creates a bug that only breaks at certain times of day. The gap between
TODAY_JSTandTODAY_UTCis 9 hours. If a job that runs between midnight and 9 AM Japan time aggregates withTODAY_UTC, the string2026-08-24in the log matches the previous UTC date, and output completed overnight gets counted as "yesterday's success." From today's monitoring perspective it looks like zero, and a FAILED alert fires. Since it passes without issue when run during the day, it looks like "occasional false alarms" and the cause takes longer to find. I make this mixture explicit at the top ofsns-output-watchdog.sh:
TODAY_JST="$(date '+%Y-%m-%d')"
TODAY_UTC="$(date -u '+%Y-%m-%d')"
TODAY_HUMAN="$(date '+%a %b %e')" # launchdが出す形式。日は空白詰めなので %e
Using the three variables appropriately and matching each log to the one it actually emits resolved it, but every time I add a new lane it needs re-checking.
Discord notifications can silently fail to send. The
notify()function does[ -f "$DISCORD" ] || return 0, so ifdiscord_tool.pydoesn't exist, it exits successfully without notifying. You can end up in a state where the watchdog log correctly recordsresult=unhealthy lanes=ig-1-likes0but nothing arrives in Discord. I have a track record of the path being wrong on day one of deployment and notifications silently not going out. To verify that a monitoring script is actually raising alerts, you have to check on the receiving end (Discord's last-received timestamp). Looking only at the watchdog log, you'll never notice.Nobody notices when a referenced log file goes stale.
has_today()checks "is there a line for today?" but doesn't guarantee "has this file been updated recently?" Right after a job stops,has_today()also returns false and the lane becomes UNKNOWN — but the state where the file exists and has today's lines, i.e. "it ran once today, but every run after that has silently failed," is undetectable. Without a mechanism to periodically check themtimeof referenced logs, your monitoring falls into "continuing to judge today on stale evidence."Shared configuration across multiple repositories is "written only in the config file." Even though
social-autolike/config/accounts.jsonsays"reuseProfile": ".profiles/chrome-ig", the code inscent-media/scripts/ensure_chrome.shcontains no mention whatsoever that this profile is also used by another repo. In day-to-day work you look atgit log/git diffseparately for each, so collisions keep happening with no visible point of contact. I wrote this lesson into the learning notelearning/shared-resource-kill-corrupts-the-neighbor.md: "Write it in the code of the side being shared, not the side doing the sharing" — that's the only way to minimize discovery cost.Among completed runs, "runs that genuinely accomplished nothing" need to be counted separately from zero output. If you count a run that terminated early on
rate-limitbut still managed 2 likes as a "dead run," you'll get an alert every night from mild nighttime rate limiting. That's exactly whycount_dead_runs()narrows to "a harmful termination reason (need-login / circuit-break / error / rate-limit) AND いいね:0." Changing that AND to an OR alone would send the false-positive rate through the roof.
Best Practices
Here are the design principles I actually hit and fixed, in reproducible form.
1. Check the presence of a shared resource in an interface-independent way
"Does CDP port 9223 respond?" is not a check for "is Chrome running" — it's a check for "is a Chrome that was launched in my particular style running." A Chrome on the same profile launched by someone else exists without opening the port. Check process presence on a PID basis, like pgrep -f "user-data-dir=$PROFILE", and don't depend on whether an interface responds.
2. Allow kill only against processes you started yourself
pkill -f <pattern> takes down every process matching the pattern. It doesn't ask who started them. If there's even a 1% chance someone else started it, fall back to "wait and skip" instead of kill. The cost of killing the wrong process isn't "this run failed" — it's "every slot until recovery, plus human GUI work."
3. Return unacquirable resources with "bounded polling → a dedicated exit code"
# 10秒間隔・最大420秒ポーリング
for i in $(seq 1 42); do
sleep 10
pgrep -f "user-data-dir=$PROFILE" > /dev/null || break
done
if pgrep -f "user-data-dir=$PROFILE" > /dev/null; then
echo "他ジョブが使用中。Cookie破壊を避けるため起動を見送る" >&2
exit 2 # 「今スロットは見送り」
fi
If you fold it into exit 1 (error), Discord alerts keep firing on every slot collision and real outages get buried. Only when there's a design fact like "with 3 slots a day, one skip ≈ zero loss" can you treat exit 2 as "skipped = normal." Assign a dedicated code to states not worth alerting on, and have the caller handle it in an independent branch.
4. Monitor by outcome logs, not exit codes
exit 0 only means "it didn't crash." In measurements on 2026-08-08, ig-1 finished 8 of 12 runs — and tt-1 / tt-2 60% of theirs — with exit 0 and zero likes. During the period when monitoring only looked at exit codes, this state persisted for two weeks and nobody noticed. Judge whether output happened by aggregating actual like counts, follow counts, and post counts from the logs with awk.
5. Always branch between "zero count" and "no lines for today"
check() {
# ...
if [ "$count" -ge "$min" ]; then return; fi # 健全
if ! has_today "$file" "$daymark"; then
UNKNOWN="${UNKNOWN:+$UNKNOWN,}$lane"; return # 判定不能
fi
FAILED="${FAILED:+$FAILED,}$lane" # 異常
}
When a lane that only runs three times a week emits FAILED four days in a row, the alert becomes "there it goes again." Don't bundle "today was a non-run day" and "it ran today but produced zero" into the same alert.
6. Split zero-output, zero-attempt, and zero-completion into three tiers
Monitoring precision improves in three stages.
| Stage | What's aggregated | Failure it can detect |
|---|---|---|
| Stage 1 | Number of completed runs | Whether the job is crashing |
| Stage 2 | Total actual like count | Running but producing nothing |
| Stage 3 | Number of like attempts | Not even attempting |
Until I added stage 3, th-2's selector mismatch had been invisible for over two weeks.
7. Keep an independent monitoring loop per type of outcome
Posting, liking, following, unfollowing, and source collection are each different processes with different failure modes. Trying to watch them all in one loop mixes different "definitions of output" and produces misjudgments. sns-output-watchdog.sh currently has six independent loops: post checks, outcome-based like verification, follow verification, unfollow verification, source-follow verification, and zero-attempt detection. It looks redundant, but without that separation I'd never have found "2255 unfollows piled up in ig-2."
8. Periodically inventory whether all lanes are centrally managed
When you add a new lane, it's easy to forget to add it to the monitoring loop. source-followers-*.log wasn't included in monitoring until 2026-08-22, so ig-sug failing all-source collection four times in a row was reported to nobody. Make it routine to cross-check, once a month, the list of running jobs against the list of monitored lanes and confirm the number of unmonitored lanes is zero.
9. Align date formats, and when there's an exception, make it explicit in the variable name
If you're using three variants — date '+%Y-%m-%d', date -u '+%Y-%m-%d', and date '+%a %b %e' — confirm first which format each log emits, then map the variables accordingly. Timestamps emitted by launchd can be in %e (space-padded) format. Aug 8 and Aug 8 do not match under grep. Putting the meaning "this one's format differs from the others" into the variable name, as with TODAY_HUMAN, makes it easier for a later reader to notice.
10. Annotate cross-repository sharing in the code of the side being referenced
"Writing it in the config file of the referencing side" alone is invisible to whoever reads the code of the referenced side. If the implementer reading ensure_chrome.sh knew that "this profile is also used by social-autolike's th-scent via reuseProfile," they'd stop before writing pkill. Document "who uses it and for what" in a comment on the shared resource, placed inside the code of the side being referenced.
11. Design on the assumption that force-killing a process corrupts persistent state
A process that receives SIGKILL can't run its shutdown handlers. In Chrome's case, it exits without writing the Cookie SQLite back in a consistent state and recreates an empty DB on next launch. As a measured value, instagram.com cookies were confirmed to have shrunk to 0 rows. When force-killing a process that holds SQLite, caches, or session information, either prepare a recovery procedure in advance on the assumption that persistent state will be corrupted, or switch to a design that doesn't force-kill in the first place.
12. Verify that your notification infrastructure works from the receiving end
Even when the watchdog log records result=unhealthy, there are cases where nothing arrives in Discord. The notify() function silently return 0s if the path to discord_tool.py doesn't exist. The script only leaves a record of "attempted to notify." Looking at the watchdog log alone is not enough to confirm monitoring is functioning. You need to separately check the last-received timestamp in Discord, or periodically send a "test notification" and confirm connectivity from the receiving end.
13. Skipping is neither failure nor success — express the third state with a dedicated exit code
There are situations where the binary exit 0 = success / exit 1 = failure isn't enough. "The conditions weren't right for this slot, so I skipped it" is not a success, but it's not a failure either. By assigning exit 2 to "skip this slot" and having the caller keep an independent branch that treats it as "retry at the next slot, exit 0," you can convey state accurately without adding alert noise. The premise that makes this design work is the design fact that there are multiple slots per day. A skip design doesn't fit a job that only has one slot a day.
Summary
Compressed into one sentence, this incident is: "my own automation was destroying the persistent state of my own other automation, every day."
The breaking side (ensure_chrome.sh / scent-media) and the reporting side (sns-ig-autopost.retry.log / the session checker) lived in different repositories, and each was working correctly. ensure_chrome.sh was correctly detecting "a Chrome process where CDP 9223 doesn't respond." The session checker was correctly reporting "there is no IG sessionid." To a human, it can only look like "IG got logged out."
This is the nastiest structure a failure can have. "The report is correct, but the cause is somewhere else" — with this shape, no amount of chasing symptoms in detail will get you to the cause. Only when I lined up the outcome logs from sns-output-watchdog.sh against the launchd start times side by side, and confirmed that the 22:56 th-scent unfollow start and the 23:00 ensure_chrome.sh execution were 4 minutes apart, did the two repositories connect. The general form I recorded in the learning note learning/shared-resource-kill-corrupts-the-neighbor.md is now the criterion for my design decisions going forward.
The core of the fix is deleting one line of pkill. Commit 15307d7, which removed pkill -f "user-data-dir=$PROFILE" and replaced it with 10-second-interval polling up to 420 seconds plus exit 2 (skip), doesn't even add 10 lines of code. But without the context that "another repository is using the same profile," you'd never reach the decision to delete that one line.
sns-output-watchdog.sh is now a script of over 270 lines. X posts, likes, replies / IG posts, likes, follows, unfollows / two Threads accounts / TikTok / 13 source-followers lanes — each has a different definition of output and is judged daily by an independent aggregation function. The first version only looked at "exit code 0 = healthy." From there, each time a function was added — count_today() / has_today() / count_dead_runs() / sum_likes_today() / count_undecidable_today() — another invisible "silent failure" surfaced.
If you treat an SNS foundation as something you merely "build," then from the moment it breaks you're in a state of "stopped while appearing to run." The starting point was IG posting being stopped since 7/29 and not noticing for two weeks. What today's ¥1.2M/month foundation rests on isn't a mechanism for checking whether 171 jobs are running — it's one for checking daily whether those 171 jobs are actually producing output.
Automation is two processes as a set: "making it run" and "making it observable." If either is missing, you can't tell for yourself whether this month is going well or failing. And if you can't tell, you can't decide your next move.
If you're running multiple automations on one machine: do you actually know which of them share a resource?
I've written up the full picture of the system, the ¥1.2M/month breakdown, 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)