{"slug": "how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared", "title": "How My Own Script Killed Instagram Sessions Every Night: pkill and a Shared Chrome Profile", "summary": "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.", "body_md": "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.\n\nHere'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**.\n\nWhen 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**.\"\n\nIn 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`\n\nmonitors whether each one actually produced output that day, based on artifact logs.\n\nBut before I built that monitoring, I have a track record of **not noticing for two weeks**. The header comment of `sns-output-watchdog.sh`\n\nsays this:\n\n```\n# content-watchdog.sh は article/note/maker/series/ameba だけを見ており、\n# X/IG/TikTok/Threads の投稿・返信は誰も監視していなかった。その結果\n# IG投稿は7/29から、TikTok投稿は7/28から止まったまま2週間気づかれなかった。\n```\n\nIf 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.\"\n\nEven 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`\n\nfinished 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 `終了 (`\n\nappeared,\" so it judged both circuit-break and need-login as \"healthy.\"\n\n```\n# 🔴 従来は '終了 (' の出現回数だけを数えていたため、circuit-break でも need-login でも\n#    「健全」と判定していた。2026-08-08 実測で ig-1 は12run中8回、tt-1/tt-2 は6割が\n#    いいね0件のまま exit 0 で終わっており、2週間誰も気づけなかった。\n```\n\nLearning from that, I added `count_dead_runs()`\n\nto detect runs with zero output, and `sum_likes_today()`\n\nto 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.\n\nTo lay out the problem, here's the structure as a diagram.\n\n```\nscent-media リポジトリ                social-autolike リポジトリ\n──────────────────────────            ──────────────────────────────────\nscripts/ensure_chrome.sh              config/accounts.json\n  └ CDP :9223 へ接続試行                └ \"reuseProfile\": \".profiles/chrome-ig\"\n    応答なし → pkill 実行\n         │                            th-scent ジョブ（Playwright）\n         │                              └ .profiles/chrome-ig を掴んで起動\n         │                                ※ CDP 9223 は一切開かない\n         ↓                                         ↓\n         └──────────────┬───────────────────────────┘\n                        ↓\n             .profiles/chrome-ig  ← 両者が同じプロファイルを参照\n             Cookie SQLite 強制破壊\n             instagram.com の sessionid → 0行\n```\n\n`ensure_chrome.sh`\n\nin `scent-media`\n\nchecks, 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:\n\n```\npkill -f \"user-data-dir=$PROFILE\"\n```\n\n`$PROFILE`\n\nis `.profiles/chrome-ig`\n\n. **The problem is that the th-scent job in social-autolike shared that same profile via a reuseProfile setting.**\n\n`th-scent`\n\nlaunches Chrome through Playwright, but it never opens CDP port 9223. From `ensure_chrome.sh`\n\n'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**.\n\n| Time | Job | Duration |\n|---|---|---|\n| 5:18 / 13:18 / 21:18 | th-scent autolike | up to 40 min |\n| 6:56 / 14:56 / 22:56 | th-scent unfollow | up to 40 min |\n| 7:20 | scent-media daily-generate | — |\n| 19:00 / 21:00 / 23:00 | scent-media daily-post (IG carousel post) | — |\n\nAt 22:56, `th-scent`\n\nunfollow grabs `.profiles/chrome-ig`\n\n; at 23:00, `daily-post`\n\ncalls `ensure_chrome.sh`\n\n. 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.\n\n`pkill -f \"user-data-dir=...\"`\n\nsends 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.\n\nOn next launch, Chrome **recreates an empty Cookie DB**. Here are the measured values:\n\nThe\n\n`cookies`\n\ntable shrank to 3 rows total across all hosts, and`instagram.com`\n\nhad 0 cookies. The IG`sessionid`\n\nwas gone.\n\nOn 2026-08-24, all three slots — 19:00, 21:00, and 23:00 — posted nothing.\n\nThe IG post check in `sns-output-watchdog.sh`\n\nlooks like this:\n\n```\ncheck \"ig-autopost\" \\\n  \"$(count_today \"$CL_LOGS/sns-ig-autopost.retry.log\" \"$TODAY_JST\" 'OK posted')\" \\\n  1 \"IGカルーセル投稿\" \\\n  \"$CL_LOGS/sns-ig-autopost.retry.log\" \"$TODAY_JST\"\n```\n\nIf `OK posted`\n\ndoesn't appear even once in the day's log, the lane goes into `FAILED`\n\nand `🚨 SNS当日未出力: ig-autopost`\n\nfires to Discord. That alert *was* firing. **But the text of the alert says \"posting failed,\" not \"session destroyed by pkill.\"**\n\nThe session checker correctly reports \"there is no IG sessionid.\" `need-login`\n\nshows 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.\n\n```\nwatchdog: 🚨 ig-autopost 未出力\n    ↓\nsession-liveness: sessionid が無い（正しい報告）\n    ↓\n人間: GUI 再ログインを実行\n    ↓\n翌日 22:56: th-scent がプロファイルを掴む\n    ↓\n翌日 23:00: ensure_chrome.sh → pkill → Cookie 消滅\n    ↓\nwatchdog: 🚨 ig-autopost 未出力（同じ報告が出る）\n```\n\nBecause **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\n\n`reuseProfile`\n\nsetting written in `social-autolike`\n\n's `config/accounts.json`\n\nappears nowhere in `scent-media`\n\n's code.The fix comes down to one thing: **eliminate pkill entirely and replace it with waiting and skipping**.\n\n```\n# 修正後の ensure_chrome.sh（概要）\nif pgrep -f \"user-data-dir=$PROFILE\" > /dev/null; then\n  # 他プロセスが掴んでいる → 10秒間隔・最大420秒ポーリング\n  for i in $(seq 1 42); do\n    sleep 10\n    pgrep -f \"user-data-dir=$PROFILE\" > /dev/null || break\n  done\n  if pgrep -f \"user-data-dir=$PROFILE\" > /dev/null; then\n    echo \"他ジョブが使用中(pid=$(pgrep -f \"user-data-dir=$PROFILE\"))。Cookie破壊を避けるため起動を見送る\" >&2\n    exit 2   # 「今スロット見送り」の専用コード\n  fi\nfi\n# ここまで来たら誰も掴んでいない → SingletonLock等の掃除 → 起動処理へ\n```\n\nThe caller, `daily_post.sh`\n\n, receives `exit 2`\n\nin a separate branch and treats it as \"skip this slot, retry at the next one,\" exiting with `exit 0`\n\n. For verification I ran two `bash -n`\n\nsyntax checks, confirmed `grep -c pkill`\n\nreturned 0, and confirmed the `exit 2`\n\npath really exists at line 57, then bundled it into commit `15307d7`\n\n.\n\nWhy I didn't fold `exit 2`\n\ninto \"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.\n\n`count_today()`\n\nand `has_today()`\n\nThe 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`\n\nI handle this with two independent functions.\n\n```\n# 当日分のログ行だけに成功マーカーがあるか数える。\ncount_today() {\n  local file=\"$1\" daymark=\"$2\" marker=\"$3\"\n  [ -f \"$file\" ] || { echo 0; return; }\n  /usr/bin/awk -v day=\"$daymark\" -v mark=\"$marker\" '\n    index($0, day) { seen = 1 }\n    seen && index($0, mark) { n++ }\n    END { print n + 0 }\n  ' \"$file\"\n}\n\n# そのログに「当日を示す行」自体があるか。\nhas_today() {\n  local file=\"$1\" daymark=\"$2\"\n  [ -f \"$file\" ] || return 1\n  /usr/bin/grep -qF \"$daymark\" \"$file\"\n}\n```\n\n`count_today()`\n\nuses awk's `seen`\n\nflag so that \"only lines after the day marker appears\" are considered. A simple `grep -c marker`\n\nwould 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.\"\n\n`has_today()`\n\nmatters because of the branching inside `check()`\n\n.\n\n```\ncheck() {\n  local lane=\"$1\" count=\"$2\" min=\"$3\" note=\"$4\" file=\"${5:-}\" daymark=\"${6:-}\"\n  # ...\n  if [ \"$count\" -ge \"$min\" ]; then\n    log \"ok lane=$lane count=$count\"\n    return\n  fi\n  if [ -n \"$file\" ] && ! has_today \"$file\" \"$daymark\"; then\n    UNKNOWN=\"${UNKNOWN:+$UNKNOWN,}$lane\"\n    log \"UNKNOWN lane=$lane 当日行なし\"\n    return\n  fi\n  FAILED=\"${FAILED:+$FAILED,}$lane\"\n}\n```\n\nWhen the count falls below min, if `has_today()`\n\nreturns 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.\n\n`count_dead_runs()`\n\n— Catching Runs That Finished but Produced Nothing\n\n```\ncount_dead_runs() {\n  local file=\"$1\" day=\"$2\"\n  [ -f \"$file\" ] || { echo \"\"; return; }\n  /usr/bin/grep -a \"^$day\" \"$file\" 2>/dev/null \\\n    | /usr/bin/grep -E '終了 \\((need-login|circuit-break|error|rate-limit)\\)' \\\n    | /usr/bin/grep -c 'いいね:0' || true\n}\n```\n\nThere's a reason this is a two-stage grep. The first regex, `終了 \\((need-login|circuit-break|error|rate-limit)\\)`\n\n, narrows to \"lines with a harmful termination reason,\" and the second, `grep -c 'いいね:0'`\n\n, narrows to \"and zero output.\" If you counted a run that terminated early on `rate-limit`\n\nbut 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.\"\n\nThis function is used in the outcome-based monitoring loop.\n\n```\nfor lane in x-1 ig-1 ig-2 ig-3 ig-sug th-1 th-2 tt-1 tt-2 tt-3; do\n  lf=\"$SA_LOGS/$lane.log\"\n  [ -f \"$lf\" ] || continue\n  likes=$(sum_likes_today \"$lf\" \"$TODAY_UTC\")\n  dead=$(count_dead_runs \"$lf\" \"$TODAY_UTC\")\n  if [ -n \"$likes\" ] && [ \"$likes\" = \"0\" ]; then\n    FAILED=\"${FAILED:+$FAILED,}$lane-likes0\"\n    log \"UNHEALTHY lane=$lane 本日のいいね合計=0 (成果ゼロrun=${dead:-?}回)\"\n  elif [ -n \"$dead\" ] && [ \"${dead:-0}\" -ge 3 ]; then\n    FAILED=\"${FAILED:+$FAILED,}$lane-dead${dead}\"\n    log \"UNHEALTHY lane=$lane 成果ゼロrunが${dead}回 (いいね合計=${likes})\"\n  else\n    log \"OK lane=$lane いいね合計=${likes:-?} 成果ゼロrun=${dead:-0}回\"\n  fi\ndone\n```\n\nThe branch differs between `likes`\n\nbeing an empty string (the file itself doesn't exist) and being `\"0\"`\n\n. 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.\"\n\n`sum_likes_today()`\n\n```\nsum_likes_today() {\n  local file=\"$1\" day=\"$2\"\n  [ -f \"$file\" ] || { echo \"\"; return; }\n  /usr/bin/grep -a \"^$day\" \"$file\" 2>/dev/null \\\n    | /usr/bin/grep -oE 'いいね:[0-9]+' \\\n    | /usr/bin/grep -oE '[0-9]+' \\\n    | /usr/bin/awk '{s+=$1} END {print s+0}'\n}\n```\n\nLog lines start with the `2026-08-24 ...`\n\nformat, so stage one narrows to today's lines with `\"^$day\"`\n\n. Stage two extracts marker-prefixed numbers with `grep -oE 'いいね:[0-9]+'`\n\n, 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`\n\n— pulling with just `[0-9]+`\n\nwould also pick up the `3`\n\nfrom `フォロー:3`\n\n.\n\nThe `フォロー`\n\ntotal uses the same structure in a separate function, `sum_follows_today()`\n\n. 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.\n\nHere's the core of the fix in real code. Before the fix, it tried to solve everything in one line.\n\n```\n# 修正前：CDP 9223 が応答しない → 無条件で殺す\npkill -f \"user-data-dir=$PROFILE\"\n```\n\nAfter the fix, it's three stages.\n\n```\n# 修正後\nif pgrep -f \"user-data-dir=$PROFILE\" > /dev/null; then\n  # 他プロセスが掴んでいる → 10秒間隔・最大420秒ポーリング\n  for i in $(seq 1 42); do\n    sleep 10\n    pgrep -f \"user-data-dir=$PROFILE\" > /dev/null || break\n  done\n  if pgrep -f \"user-data-dir=$PROFILE\" > /dev/null; then\n    echo \"他ジョブが使用中(pid=$(pgrep -f \"user-data-dir=$PROFILE\"))。Cookie破壊を避けるため起動を見送る\" >&2\n    exit 2\n  fi\nfi\n# ここまで来たら誰も掴んでいない → SingletonLock 等の掃除 → 起動処理へ\n```\n\nThe reason I didn't fold `exit 2`\n\ninto \"error\" (`exit 1`\n\n) 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`\n\n, 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`\n\n, receive it in an independent branch.\n\nIt also matters that the `SingletonLock`\n\ncleanup now happens *only when nobody is confirmed to be holding the profile*. Before the fix, it unconditionally ran `rm -f SingletonLock SingletonSocket SingletonCookie`\n\nright 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.\n\nMy first watchdog implementation pointed the IG post check at the wrong file.\n\n```\n# 🔴 誤り：err.log は 2026-08-09 で更新が止まっている\ncheck \"ig-autopost\" \\\n  \"$(count_today \"$CL_LOGS/sns-ig-autopost.err.log\" \"$TODAY_JST\" 'OK posted')\" ...\n```\n\nIn reality, post success/failure logs go to `retry.log`\n\n, and `err.log`\n\nhad stopped updating after 2026-08-09. `has_today()`\n\nkept returning false, and every morning `⚠️ SNS監視が判定不能: ig-autopost（当日行がログに無い）`\n\narrived in Discord.\n\nJudged 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`\n\n. The comment is still in the code:\n\n```\n# 成果は err.log ではなく retry.log に出る。err.log は 2026-08-09 で更新が止まっており、\n# ここを見ている限り毎日 UNKNOWN 誤報になる(実際は当日3本投稿できていた)。\ncheck \"ig-autopost\" \\\n  \"$(count_today \"$CL_LOGS/sns-ig-autopost.retry.log\" \"$TODAY_JST\" 'OK posted')\" ...\n```\n\nA 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.\n\nTikTok's log inherited the format launchd emits, so its date format differs from the other lanes.\n\n```\n# X・IG・Threads: TODAY_UTC = \"2026-08-24\"\n# TikTok だけ: TODAY_HUMAN = \"Mon Aug 24\" （%e で日を空白詰め → \"Mon Aug  8\"）\nTODAY_HUMAN=\"$(date '+%a %b %e')\"\n```\n\n`%e`\n\npads the day with a space, so August 8th becomes `Aug 8`\n\n(two spaces). Grepping TikTok's log with `\"^$TODAY_UTC\"`\n\nnever matched the date format at all, so it always returned 0.\n\n```\ntt_ok=$(count_today \"$CL_LOGS/tiktok-bokuwalily.log\" \"$TODAY_HUMAN\" 'run end (exit 0)')\ntt_maybe=$(count_today \"$CL_LOGS/tiktok-bokuwalily.log\" \"$TODAY_HUMAN\" 'run end (exit 2)')\ncheck \"tt-autopost\" \"$((tt_ok + tt_maybe))\" 1 \\\n  \"TikTok投稿(exit0=$tt_ok exit2=$tt_maybe)\" \"$CL_LOGS/tiktok-bokuwalily.log\" \"$TODAY_HUMAN\"\n```\n\nThe fix is just splitting out a separate variable that uses `TODAY_HUMAN`\n\n, 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.\n\n`count_undecidable_today()`\n\n, which I added on 2026-08-22, was born from this lesson.\n\n```\ncount_undecidable_today() {\n  local file=\"$1\" day=\"$2\"\n  [ -f \"$file\" ] || { echo \"0 0\"; return; }\n  /usr/bin/awk -v day=\"$day\" '\n    index($0, day) && index($0, \"follow成功\") { success++ }\n    index($0, day) && index($0, \"follow判定不能\") { undecidable++ }\n    END { print success + 0, undecidable + 0 }\n  ' \"$file\"\n}\n```\n\nThe 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判定不能`\n\nand moves on. Likes are working fine, so the `sum_likes_today()`\n\ncheck passes. From the outside, \"ig-1 is healthy again today.\"\n\nThe actual symptom was `2255 uncollected entries piled up in ff-ig-2`\n\n. 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`\n\nAND `follow判定不能>=20`\n\nas a dedicated detection condition.\n\n```\nfor lane in ff-ig-1 ff-ig-2 ff-ig-3; do\n  # ...\n  if [ \"$follow_success\" -eq 0 ] && [ \"$undecidable\" -ge 20 ]; then\n    FAILED=\"${FAILED:+$FAILED,}$lane-blocked\"\n    log \"UNHEALTHY lane=$lane follow成功=0 判定不能=$undecidable -> friendship APIが絞られている疑い\"\n  fi\ndone\n```\n\nThe `source-followers`\n\nlane 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.\n\n```\n# 2026-08-22 追加前は、このレーンの成果は一度も監視されていなかった。\n# 実害: ig-sug が 23:14/02:13/11:16/14:16 と4回連続で\n# 「coverage 0/17 -> 全ソース取得失敗 -> exit 1」になり日次20件で止まっていたのに、\n# 誰にも報告されなかった（同時刻に ig-3 も14時間ゼロ成果）。\n# 実体は private API の 429。\n```\n\n`ig-sug`\n\nemitted `全ソース取得失敗`\n\nin 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()`\n\non the main lane's `ig-sug.log`\n\nwas working normally on its own, the watchdog summary said `OK`\n\n.\n\nThe monitoring I added watches `source-followers-*.log`\n\nin an independent loop.\n\n```\nfor 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\n  sf=\"$SA_LOGS/source-followers-$lane.log\"\n  [ -f \"$sf\" ] || continue\n  sf_dead=$(count_today \"$sf\" \"$TODAY_UTC\" '全ソース取得失敗')\n  if [ \"$sf_dead\" -ge 2 ]; then\n    FAILED=\"${FAILED:+$FAILED,}$lane-srcfollow-blocked\"\n    log \"UNHEALTHY lane=$lane-srcfollow 全ソース取得失敗=${sf_dead}回 -> APIスロットリング疑い\"\n  fi\ndone\n```\n\nI use `sf_dead >= 2`\n\nas 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.\"\n\nThis was the nastiest failure of all. The symptoms were clear. No `OK posted`\n\nat all in IG's `sns-ig-autopost.retry.log`\n\n. `🚨 SNS当日未出力: ig-autopost`\n\narriving in Discord from the watchdog. The session checker correctly reporting `sessionid がない（need-login）`\n\n.\n\nI 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.\"\n\nIn reality, `ensure_chrome.sh`\n\nwas pkill-ing the profile used by `social-autolike`\n\n's `th-scent`\n\njob every night. `th-scent`\n\nlaunches Chrome with Playwright but never opens CDP port 9223. From `ensure_chrome.sh`\n\n'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.\n\nI found the cause when I lined up the log timestamps side by side.\n\n```\n22:56  th-scent unfollow start (.profiles/chrome-ig を掴む)\n23:00  daily-post → ensure_chrome.sh → CDP 9223 応答なし\n23:00  pkill -f user-data-dir=.profiles/chrome-ig\n23:00  Cookie SQLite 破壊 → 空の DB 作り直し\n23:00  daily-post: need-login → 投稿ゼロ\n```\n\nBecause \"the side doing the breaking (`ensure_chrome.sh`\n\n/ scent-media)\" and \"the side reporting the breakage (`retry.log`\n\n/ the session checker)\" live in different repositories, reading only one of them will never connect the dots. The setting `\"reuseProfile\": \".profiles/chrome-ig\"`\n\nin `social-autolike`\n\n's `config/accounts.json`\n\nappears nowhere in `scent-media`\n\n's code.\n\n**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`\n\n.\n\nHere 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.\n\n**Unfollow monitoring didn't exist at all.** Unfollow lanes like `ig-1.unfollow.log`\n\n/ `ig-2.unfollow.log`\n\n/ `tt-1.unfollow.log`\n\nweren't in the monitoring loop at all until 2026-08-17. The damage shows up in measured numbers: `ig-1`\n\nhad 712 unfollows uncollected for over 72 hours, `ig-2`\n\npiled up to 2255, and `tt-1`\n\nwas unable to unfollow 11 times in a row — while the watchdog returned `result=healthy`\n\nevery 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.\n\n**\"Zero like attempts\" is a different failure from \"zero like results.\"** `th-2`\n\nwas launching daily with `likeBudget=400`\n\n, but the log contained not a single line with the string `live like`\n\n. It was only attempting follows and ending on `circuit-break`\n\n. From `sum_likes_today()`\n\n'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`\n\n:\n\n```\n# 「起動した」記録があるのに「いいね試行」が0回 = セレクタ不一致/アクション制限の疑い\nfor 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\n  lf=\"$SA_LOGS/$lane.log\"\n  [ -f \"$lf\" ] || continue\n  started=$(count_today \"$lf\" \"$TODAY_UTC\" '開始 [')\n  attempts=$(count_today \"$lf\" \"$TODAY_UTC\" 'live like')\n  if [ \"$started\" -ge 1 ] && [ \"$attempts\" -eq 0 ]; then\n    FAILED=\"${FAILED:+$FAILED,}$lane-noattempt\"\n    log \"UNHEALTHY lane=$lane いいね試行が0回(セレクタ不一致/アクション制限の疑い) 起動=${started}回\"\n  fi\ndone\n```\n\n**Mixing FAILED and UNKNOWN destroys trust in your alerts.** If you run a lane that only launches three times a week through `check()`\n\nevery 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`\n\ndaily. 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()`\n\nand routes lanes with no lines to `UNKNOWN`\n\n, my false-positive rate dropped by what felt like 90%+.\n\n**Mixing UTC and JST creates a bug that only breaks at certain times of day.** The gap between `TODAY_JST`\n\nand `TODAY_UTC`\n\nis 9 hours. If a job that runs between midnight and 9 AM Japan time aggregates with `TODAY_UTC`\n\n, the string `2026-08-24`\n\nin 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`\n\n:\n\n```\nTODAY_JST=\"$(date '+%Y-%m-%d')\"\nTODAY_UTC=\"$(date -u '+%Y-%m-%d')\"\nTODAY_HUMAN=\"$(date '+%a %b %e')\"  # launchdが出す形式。日は空白詰めなので %e\n```\n\nUsing 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.\n\n**Discord notifications can silently fail to send.** The `notify()`\n\nfunction does `[ -f \"$DISCORD\" ] || return 0`\n\n, so if `discord_tool.py`\n\ndoesn'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`\n\nbut 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.\n\n**Nobody notices when a referenced log file goes stale.** `has_today()`\n\nchecks \"is there a line for today?\" but doesn't guarantee \"has this file been updated recently?\" Right after a job stops, `has_today()`\n\nalso 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`\n\nof referenced logs, your monitoring falls into \"continuing to judge today on stale evidence.\"\n\n**Shared configuration across multiple repositories is \"written only in the config file.\"** Even though `social-autolike/config/accounts.json`\n\nsays `\"reuseProfile\": \".profiles/chrome-ig\"`\n\n, the code in `scent-media/scripts/ensure_chrome.sh`\n\ncontains no mention whatsoever that this profile is also used by another repo. In day-to-day work you look at `git log`\n\n/ `git diff`\n\nseparately 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`\n\n: \"**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.\n\n**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`\n\nbut 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()`\n\nnarrows 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.\n\nHere are the design principles I actually hit and fixed, in reproducible form.\n\n\"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\"`\n\n, and don't depend on whether an interface responds.\n\n`pkill -f <pattern>`\n\ntakes 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.\"\n\n```\n# 10秒間隔・最大420秒ポーリング\nfor i in $(seq 1 42); do\n  sleep 10\n  pgrep -f \"user-data-dir=$PROFILE\" > /dev/null || break\ndone\nif pgrep -f \"user-data-dir=$PROFILE\" > /dev/null; then\n  echo \"他ジョブが使用中。Cookie破壊を避けるため起動を見送る\" >&2\n  exit 2   # 「今スロットは見送り」\nfi\n```\n\nIf you fold it into `exit 1`\n\n(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`\n\nas \"skipped = normal.\" Assign a dedicated code to states not worth alerting on, and have the caller handle it in an independent branch.\n\n`exit 0`\n\nonly 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`\n\n.\n\n```\ncheck() {\n  # ...\n  if [ \"$count\" -ge \"$min\" ]; then return; fi        # 健全\n  if ! has_today \"$file\" \"$daymark\"; then\n    UNKNOWN=\"${UNKNOWN:+$UNKNOWN,}$lane\"; return      # 判定不能\n  fi\n  FAILED=\"${FAILED:+$FAILED,}$lane\"                   # 異常\n}\n```\n\nWhen 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.\n\nMonitoring precision improves in three stages.\n\n| Stage | What's aggregated | Failure it can detect |\n|---|---|---|\n| Stage 1 | Number of completed runs | Whether the job is crashing |\n| Stage 2 | Total actual like count | Running but producing nothing |\n| Stage 3 | Number of like attempts | Not even attempting |\n\nUntil I added stage 3, `th-2`\n\n's selector mismatch had been invisible for over two weeks.\n\nPosting, 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`\n\ncurrently 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.\"\n\nWhen you add a new lane, it's easy to forget to add it to the monitoring loop. `source-followers-*.log`\n\nwasn'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**.\n\nIf you're using three variants — `date '+%Y-%m-%d'`\n\n, `date -u '+%Y-%m-%d'`\n\n, and `date '+%a %b %e'`\n\n— confirm first which format each log emits, then map the variables accordingly. Timestamps emitted by launchd can be in `%e`\n\n(space-padded) format. `Aug 8`\n\nand `Aug 8`\n\ndo not match under grep. **Putting the meaning \"this one's format differs from the others\" into the variable name**, as with `TODAY_HUMAN`\n\n, makes it easier for a later reader to notice.\n\n\"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`\n\nknew that \"this profile is also used by `social-autolike`\n\n's `th-scent`\n\nvia `reuseProfile`\n\n,\" 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.\n\nA 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`\n\ncookies 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.\n\nEven when the watchdog log records `result=unhealthy`\n\n, there are cases where nothing arrives in Discord. The `notify()`\n\nfunction silently `return 0`\n\ns if the path to `discord_tool.py`\n\ndoesn'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.\n\nThere are situations where the binary `exit 0 = success / exit 1 = failure`\n\nisn'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`\n\nto \"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.\n\nCompressed into one sentence, this incident is: \"**my own automation was destroying the persistent state of my own other automation, every day**.\"\n\nThe breaking side (`ensure_chrome.sh`\n\n/ scent-media) and the reporting side (`sns-ig-autopost.retry.log`\n\n/ the session checker) lived in different repositories, and each was working correctly. `ensure_chrome.sh`\n\nwas 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.\"\n\nThis 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`\n\nagainst the launchd start times side by side, and confirmed that the 22:56 `th-scent unfollow`\n\nstart and the 23:00 `ensure_chrome.sh`\n\nexecution 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`\n\nis now the criterion for my design decisions going forward.\n\nThe core of the fix is deleting one line of pkill. Commit `15307d7`\n\n, which removed `pkill -f \"user-data-dir=$PROFILE\"`\n\nand replaced it with 10-second-interval polling up to 420 seconds plus `exit 2`\n\n(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.\n\n`sns-output-watchdog.sh`\n\nis 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()`\n\n/ `has_today()`\n\n/ `count_dead_runs()`\n\n/ `sum_likes_today()`\n\n/ `count_undecidable_today()`\n\n— another invisible \"silent failure\" surfaced.\n\nIf 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**.\n\nAutomation 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.\n\nIf you're running multiple automations on one machine: do you actually know which of them share a resource?\n\nI've written up the full picture of the system, the ¥1.2M/month breakdown, and the 30-day process in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared", "canonical_source": "https://dev.to/bokuwalily/how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared-chrome-profile-3df5", "published_at": "2026-08-30 05:00:06+00:00", "updated_at": "2026-08-30 05:22:26.485543+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Instagram", "Chrome", "Playwright", "launchd", "Claude Code"], "alternates": {"html": "https://wpnews.pro/news/how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared", "markdown": "https://wpnews.pro/news/how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared.md", "text": "https://wpnews.pro/news/how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared.txt", "jsonld": "https://wpnews.pro/news/how-my-own-script-killed-instagram-sessions-every-night-pkill-and-a-shared.jsonld"}}