# How My Own Script Killed Instagram Sessions Every Night: pkill and a Shared Chrome Profile

> Source: <https://dev.to/bokuwalily/how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared-chrome-profile-3df5>
> Published: 2026-08-30 05:00:06+00:00

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**.

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.

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."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.

`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

`cookies`

table shrank to 3 rows total across all hosts, and`instagram.com`

had 0 cookies. The IG`sessionid`

was gone.

On 2026-08-24, all three slots — 19:00, 21:00, and 23:00 — posted nothing.

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 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.

`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."

`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.

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.

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.

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.

`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
```

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."

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`

.

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.log`

weren't in the monitoring loop at all until 2026-08-17. The damage shows up in measured numbers: `ig-1`

had 712 unfollows uncollected for over 72 hours, `ig-2`

piled up to 2255, and `tt-1`

was unable to unfollow 11 times in a row — while the watchdog returned `result=healthy`

every 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-2`

was launching daily with `likeBudget=400`

, but the log contained not a single line with the string `live like`

. It was only attempting follows and ending on `circuit-break`

. From `sum_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 of `sns-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 into `FAILED`

daily. 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?" with `has_today()`

and routes lanes with no lines to `UNKNOWN`

, 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_JST`

and `TODAY_UTC`

is 9 hours. If a job that runs between midnight and 9 AM Japan time aggregates with `TODAY_UTC`

, the string `2026-08-24`

in 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 of `sns-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 if `discord_tool.py`

doesn't exist, it exits successfully without notifying. You can end up in a state where the watchdog log correctly records `result=unhealthy lanes=ig-1-likes0`

but 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 the `mtime`

of 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.json`

says `"reuseProfile": ".profiles/chrome-ig"`

, the code in `scent-media/scripts/ensure_chrome.sh`

contains no mention whatsoever that this profile is also used by another repo. In day-to-day work you look at `git log`

/ `git diff`

separately for each, so collisions keep happening with no visible point of contact. I wrote this lesson into the learning note `learning/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-limit`

but still managed 2 likes as a "dead run," you'll get an alert every night from mild nighttime rate limiting. That's exactly why `count_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.

Here are the design principles I actually hit and fixed, in reproducible form.

"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.

`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."

```
# 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.

`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`

.

```
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.

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.

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."

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**.

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.

"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.

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.

Even when the watchdog log records `result=unhealthy`

, there are cases where nothing arrives in Discord. The `notify()`

function silently `return 0`

s 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.

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.

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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
