cd /news/developer-tools/how-my-own-script-killed-instagram-s… · home topics developer-tools article
[ARTICLE · art-115544] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer's own automation script was killing Instagram login sessions nightly, disrupting a revenue-generating account. The issue stemmed from two separate repositories sharing the same Chrome profile, where one script's health check force-killed Chrome via pkill, destroying the session cookies. The developer added monitoring to detect zero-output runs and total like counts to address the problem.

read28 min views2 publishedAug 30, 2026

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:

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

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, andinstagram.com

had 0 cookies. The IGsessionid

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.

if pgrep -f "user-data-dir=$PROFILE" > /dev/null; then
  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

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.

pkill -f "user-data-dir=$PROFILE"

After the fix, it's three stages.

if pgrep -f "user-data-dir=$PROFILE" > /dev/null; then
  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

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.

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:

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.

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.

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

:

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

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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート

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

Follow along: Portfolio · X · GitHub*

── more in #developer-tools 4 stories · sorted by recency
── more on @instagram 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-my-own-script-ki…] indexed:0 read:28min 2026-08-30 ·