{"slug": "your-cron-job-exits-0-and-does-nothing-reading-chrome-s-cookie-sqlite-to-know-if", "title": "Your Cron Job Exits 0 and Does Nothing: Reading Chrome's Cookie SQLite to Know If a Session Is Actually Alive", "summary": "A developer discovered that an Instagram automation job was silently failing for days because the session had expired but the job still returned exit 0. The fix involved reading Chrome's cookie SQLite database to check for the presence of the 'sessionid' cookie, which directly indicates login state, rather than relying on DOM structure. The developer built a script called 'profile-session-guard.sh' to proactively verify session liveness and stop the lane if expired.", "body_md": "An automation job can fail for days without making a single sound. Mine did: one Instagram lane ran twelve times a day, logged \"done\" every time, raised zero errors — and liked exactly nothing. This post is about that failure mode and how I fixed the detection, in a series where I share the holes I've actually fallen into while mass-producing personal projects.\n\nI went from ¥100k/month as a university student, to ¥600k/month juggling side gigs, to zero income after being laid off, and then rebuilt an autonomous environment with Claude Code — now at ¥1.2M/month in revenue.\n\nThis time, the story is: **\"the job ran every day, produced zero results, and nobody noticed.\"**\n\nThe scary thing about automation is that **when it breaks, it makes no noise.**\n\nThe job for my Instagram auto-like system (`social-autolike`\n\n) was working normally until the morning of 2026-08-09 — or at least it looked that way. The lane tied to the account `ig-2`\n\nwas scheduled to run 12 times a day. Each run logged \"complete.\" No errors reached the monitoring dashboard.\n\nThe reality: **likes = 0, follows = 0.** It had been doing absolutely nothing for days.\n\nThe cause was simple. The `ig-2`\n\naccount was logged out. Its Instagram session had expired. But the job, staring at a logout screen, kept returning `exit 0`\n\non the grounds that \"there were no targets left\" or \"no accounts matched the criteria.\"\n\nThe job did have login-detection code at the time. `needLogin()`\n\nin `social-autolike/src/run.js`\n\ndecided that login was required based on roughly these two conditions:\n\n`/login`\n\n`article`\n\n/ `[role=feed]`\n\n/ `video`\n\n/ `main`\n\n/ `[data-e2e]`\n\nexist**The problem is the second half of that second condition.**\n\nInstagram's logout screen has a `main`\n\nelement. It's still there today — an element placed as part of the page's semantic structure. Because the DOM check was built on the idea that \"zero `main`\n\nelements means login is needed,\" **opening the logout screen still found one main, and the check slipped through.**\n\nThe selector measures \"does this look like a login screen?\" But what I actually want to know is the authentication state itself: \"is this browser currently logged in?\" Those are two different things, and the former slips through the moment the UI's structure changes.\n\nChrome's session management is recorded in a SQLite database file at `Default/Network/Cookies`\n\n(or `Default/Cookies`\n\n, depending on the Chrome version). Instagram's login state is managed by a cookie named `sessionid`\n\n, and if that cookie exists for the `instagram.com`\n\ndomain, that profile is logged in.\n\nThe decisive difference from the DOM is that **this is not the appearance of the UI — it is the fact of authentication.** No matter how much Instagram redesigns things, no matter what elements they add to the logout screen, the presence of the `sessionid`\n\ncookie maps one-to-one to login state.\n\nMaking cookies your primary source is a shift from guessing to observing the fact.\n\nPlaywright, Puppeteer, browser-use, or something you wired together with Claude Code — whatever the shape, **any automation that accesses a web service using a Chrome or Chromium profile can have this exact problem.**\n\nWhen a job returns \"0 results, exit 0,\" how do you currently distinguish \"there genuinely were no targets\" from \"it was logged out and couldn't see anything\"?\n\nEven if the number on your monitoring graph is pinned at zero, it's hard to notice as long as no errors fire. By the time you do notice, days' worth of results are already gone. In my case, I couldn't even determine exactly how many days `ig-2`\n\nhad lost.\n\nThe `profile-session-guard.sh`\n\nI built this time is a script for answering that question. It reads Chrome's cookie DB directly to confirm up front whether the session is alive, and if it's expired, it notifies and stops the lane. Below is the full picture.\n\nWhat the script does breaks into four stages.\n\n```\n[設定ファイル3本]\n  accounts.json\n  ig-reply-accounts.json      ─→ [プロファイルdir一覧を解決]\n  post-accounts.json               + 重複を1件に集約\n  + 固定2件（別リポ）\n         ↓\n[各プロファイルdirでCookie DBを探す]\n  Default/Network/Cookies\n  Default/Cookies             ─→ [見つかったら mktemp でコピー]\n  Network/Cookies\n  Cookies\n         ↓\n[sqlite3 でCookieを検索]\n  platform別のSQLクエリ      ─→ [OK / EXPIRED / UNKNOWN を判定]\n  コピーは終了時に削除\n         ↓\n[結果を集計してstateファイルと照合]\n  切れ方が前回と同じ → 通知スキップ\n  新しい切れ          → Discord通知 + exit 1\n  全員OK              → exit 0\n```\n\n(Translation of the diagram: three config files → resolve the list of profile dirs, collapsing duplicates; then look for the cookie DB in each profile dir and copy it with `mktemp`\n\nwhen found; then query cookies with `sqlite3`\n\nusing a per-platform SQL query to decide OK / EXPIRED / UNKNOWN, deleting the copy on exit; then aggregate the results and compare against the state file — same breakage as last time → skip the notification, new breakage → Discord notification + `exit 1`\n\n, everyone OK → `exit 0`\n\n.)\n\nLet's walk through it in order.\n\nThe script's first job is to determine which profiles to inspect.\n\nThe earlier implementation hardcoded the list of profile dirs inside the script. As a result, **the very next day it falsely reported metrics-hub/profiles/tiktok and tiktok2 — old dirs not used by any actual job — as \"logged out.\"** The real TikTok lanes were using different dirs via the\n\n`reuseProfile`\n\nfield in `social-autolike/accounts.json`\n\n. A hardcoded list starts rotting the moment you write it.The current implementation resolves them from the config files the jobs actually reference.\n\n```\nload_accounts() {\n  local lane platform dir\n  if ! \"$JQ\" -r --arg root \"$ROOT\" \\\n    '.accounts[] | [.id, .platform, (.reuseProfile // ($root + \"/profiles/\" + .id))] | @tsv' \\\n    \"$ACCOUNTS_CONFIG\" >\"$SOURCE_FILE\"; then\n    log \"unknown resolver: accounts.json could not be read\"\n    return 1\n  fi\n\n  while IFS=$'\\t' read -r lane platform dir; do\n    add_candidate \"$dir\" \"$platform\" \"$lane\"\n  done <\"$SOURCE_FILE\"\n}\n```\n\nThe key is the `.reuseProfile // ($root + \"/profiles/\" + .id)`\n\npart of the `jq`\n\nquery. If `reuseProfile`\n\nis written in the config file, use that path; otherwise fall back to the default path `profiles/<id>`\n\n. This is the same resolution rule as `profileDir()`\n\nin the job's own `src/lib.js`\n\n. Because the config file is the source of truth, the dir the job references and the dir the script inspects stay in sync.\n\nThe same processing is applied to `ig-reply-accounts.json`\n\n(for the auto-reply lanes) and `post-accounts.json`\n\n(for the posting lanes), plus two fixed entries from a separate repo.\n\n```\nadd_candidate \"~/dev/bokuwalily-sns/profiles/ig-post\" \"instagram\" \"bokuwalily-sns:ig-post\"\nadd_candidate \"~/dev/brand-404/profiles/ig-o81\" \"instagram\" \"brand-404:ig-o81\"\n```\n\nThere are cases where multiple lanes reference the same profile dir (for example, `ig-1-live`\n\nis used by both `ig-autoreply:ig-1`\n\nand `ig-1`\n\n). Inspecting it twice is wasteful, so duplicates are removed by `canonical`\n\npath before entering the inspection loop. Lane names are not discarded, though — they're retained so the log and notification text can say *which lane will stop*.\n\n```\n[2026-08-09 07:10:05] resolved 22 unique existing profiles\n```\n\nThe hardcoded version included dirs that didn't even exist in the inspection targets, but `add_candidate`\n\nchecks `[ ! -d \"$dir\" ]`\n\nand skips, so dirs that haven't been created yet produce neither an inspection nor an error.\n\nThe `find_cookie_db`\n\nfunction runs against each profile dir.\n\n```\nfind_cookie_db() {\n  local dir=\"$1\" candidate\n  COOKIE_DB=\"\"\n  for candidate in \\\n    \"$dir/Default/Network/Cookies\" \\\n    \"$dir/Default/Cookies\" \\\n    \"$dir/Network/Cookies\" \\\n    \"$dir/Cookies\"; do\n    if [ -f \"$candidate\" ]; then\n      COOKIE_DB=\"$candidate\"\n      return 0\n    fi\n  done\n  return 1\n}\n```\n\nChrome places the cookie file differently depending on the version. `Default/Network/Cookies`\n\nis current, but older profiles and some Chromium-based browsers may have it at `Default/Cookies`\n\nor `Network/Cookies`\n\n. It tries four patterns and uses the first one it finds.\n\nOnce found, **always copy it with mktemp before reading.**\n\n```\nTEMP_COOKIE_DB=$(mktemp /tmp/profile-session-guard.cookies.XXXXXX)\nif [ -z \"$TEMP_COOKIE_DB\" ] || ! /bin/cp \"$COOKIE_DB\" \"$TEMP_COOKIE_DB\" 2>/dev/null; then\n  CHECK_DETAIL=\"Cookies DB copy failed\"\n  [ -n \"$TEMP_COOKIE_DB\" ] && /bin/rm -f \"$TEMP_COOKIE_DB\"\n  TEMP_COOKIE_DB=\"\"\n  return\nfi\n```\n\nIf Chrome is running and holding the cookie DB, trying to open it directly with `sqlite3`\n\nproduces a lock error. Taking a copy first lets you read it regardless of whether Chrome is running. The copy is reliably removed by the cleanup function registered via `trap cleanup EXIT`\n\n.\n\nThe cookie DB is SQLite, so we throw a `sqlite3`\n\nquery at the copy. What matters is that **the cookie name to check differs by platform.**\n\n```\ncookie_query() {\n  case \"$1\" in\n    x)\n      printf \"%s\" \"select count(*) from cookies where name='auth_token' and (host_key like '%x.com%' or host_key like '%twitter.com%');\"\n      ;;\n    instagram)\n      printf \"%s\" \"select count(*) from cookies where name='sessionid' and host_key like '%instagram.com%';\"\n      ;;\n    threads)\n      printf \"%s\" \"select count(*) from cookies where name='sessionid' and host_key like '%threads%';\"\n      ;;\n    tiktok)\n      printf \"%s\" \"select count(*) from cookies where name in ('sessionid','sessionid_ss','sid_tt') and host_key like '%tiktok%';\"\n      ;;\n  esac\n}\n```\n\n**The reason TikTok searches three cookie names with IN** is based on a fact discovered by measurement. TikTok's session management uses different cookie combinations depending on the environment and account, and the live account\n\n`tt-1`\n\nwas confirmed to actually hold all three. If I judged on `sessionid`\n\nalone, an account holding only `sessionid_ss`\n\nor `sid_tt`\n\nwould be misjudged as \"logged out.\" Since `count(*)`\n\ncomes back, `0`\n\nmeans EXPIRED and `1`\n\nor more means that platform is OK. When one profile covers multiple platforms (a composite profile like `instagram,threads`\n\n), it becomes EXPIRED the moment even one of them is broken.\n\n```\ncount=$(/usr/bin/sqlite3 \"$TEMP_COOKIE_DB\" \"$query\" 2>/dev/null)\nsqlite_rc=$?\nif [ \"$sqlite_rc\" -ne 0 ] || ! printf '%s' \"$count\" | /usr/bin/grep -Eq '^[0-9]+$'; then\n  unknown=1\n  CHECK_DETAIL=\"sqlite3 failed for $platform\"\nelif [ \"$count\" -eq 0 ]; then\n  CHECK_STATUS=\"EXPIRED\"\n  CHECK_DETAIL=\"required cookie missing for $platform\"\n  break\nfi\n```\n\nValidating both the `sqlite3`\n\nexit code and the output value handles cases where the DB is corrupted or returns an unexpected format. In those cases it's treated as UNKNOWN rather than EXPIRED, **avoiding false alarms.** Having 17 lanes shut down entirely does more damage than missing one lane's expiration.\n\nAfter inspecting all 22 profiles, it aggregates.\n\n```\nlog \"check complete ok=$ok expired=$expired unknown=$unknown\"\n```\n\nIf you fired a notification right here, **you'd get two notifications a day even while the same account stays broken.** Too many notifications bury the real emergency alerts — that's a separate problem I actually experienced, where an alert that should have arrived as `need-login`\n\nhad been dropped into the digest.\n\nSo the previous list of broken profiles is saved in a `state`\n\nfile, and it only notifies when the contents change.\n\n```\nprevious=\"\"\n[ -f \"$STATE_FILE\" ] && previous=$(/bin/cat \"$STATE_FILE\" 2>/dev/null)\n\nif [ -n \"$broken\" ]; then\n  if [ \"$broken\" = \"$previous\" ]; then\n    log \"login missing unchanged ($broken); notification skipped\"\n  else\n    body=\"🔑 ログイン切れ: $broken — 該当レーンは成果ゼロのまま exit 0 を返し続けます。再ログインが必要\"\n    if notify \"$body\"; then\n      log \"notified login missing ($broken)\"\n    fi\n  fi\n  write_state \"$broken\"\n  exit 1\nfi\n\nwrite_state \"\"\nexit 0\n```\n\nThe notification text includes `$broken`\n\n, but that isn't just an account name — it's a format like `ig-2(ig-2, ig-autoreply:ig-2)`\n\n. **\"Which lanes stop\" is more useful to a human than \"which profile broke,\"** so the referencing lanes are shown in parentheses.\n\nIt's registered with launchd twice a day, at 07:10 and 19:10. The three keys `NiceValue=10 / ProcessType=Background / LowPriorityIO=true`\n\nmake it low priority so it doesn't interrupt other processing.\n\nThe profile dir `ig-1-live`\n\nis referenced simultaneously by two lanes, `ig-autoreply:ig-1`\n\nand `ig-1`\n\n. Inspecting it twice is wasteful, but I don't want to throw away the information about \"which lanes will stop.\" `deduplicate_profiles`\n\nsatisfies both requirements at once.\n\n```\ndeduplicate_profiles() {\n  LC_ALL=C sort -t \"$TAB\" -k1,1 -k3,3 \"$CANDIDATES_FILE\" | \\\n    awk -F '\\t' '\n      {\n        dir = $1; platform = $2; lane = $3\n        if (!(dir in seen)) {\n          seen[dir] = 1\n          order[++count] = dir\n          platforms[dir] = platform\n          lanes[dir] = lane\n        } else {\n          if (index(\",\" platforms[dir] \",\", \",\" platform \",\") == 0) {\n            platforms[dir] = platforms[dir] \",\" platform\n          }\n          if (index(\",\" lanes[dir] \",\", \",\" lane \",\") == 0) {\n            lanes[dir] = lanes[dir] \", \" lane\n          }\n        }\n      }\n      END {\n        for (i = 1; i <= count; i++) {\n          dir = order[i]\n          printf \"%s\\t%s\\t%s\\n\", dir, platforms[dir], lanes[dir]\n        }\n      }\n    ' >\"$PROFILES_FILE\"\n}\n```\n\nThe check `index(\",\" platforms[dir] \",\", \",\" platform \",\") == 0`\n\nverifies \"isn't this platform already included?\" Adding commas front and back before searching prevents partial matches — it's how searching for `instagram`\n\ncorrectly counts the `instagram`\n\ninside `instagram,threads`\n\nas one entry. The `order`\n\narray preserves insertion order, and the `END`\n\nblock outputs in that order, for log readability.\n\nThe notification body that reaches Discord takes the form `ig-2(ig-2, ig-autoreply:ig-2)`\n\n. **Putting the lane name, not the profile name, in parentheses** is because \"ig-autoreply's ig-2 will stop\" makes the human's next action clearer than \"ig-2 broke.\" Looking at a profile name doesn't immediately tell you which job's which entry point it is. A lane name maps one-to-one to a starting point in the code.\n\nThe loop that processes 22 profiles in order is simple.\n\n```\nwhile IFS=$'\\t' read -r dir platforms lanes; do\n  [ -n \"$dir\" ] || continue\n  name=${dir##*/}\n  check_profile \"$dir\" \"$platforms\"\n\n  case \"$CHECK_STATUS\" in\n    OK)\n      log \"ok $name ($platforms; lanes=$lanes; dir=$dir)\"\n      ok=$((ok + 1))\n      ;;\n    EXPIRED)\n      log \"login missing $name ($platforms; lanes=$lanes; dir=$dir)\"\n      printf '%s(%s)\\n' \"$name\" \"$lanes\" >>\"$BROKEN_FILE\"\n      expired=$((expired + 1))\n      ;;\n    *)\n      log \"unknown $name ($platforms; lanes=$lanes; dir=$dir): $CHECK_DETAIL\"\n      unknown=$((unknown + 1))\n      ;;\n  esac\ndone <\"$PROFILES_FILE\"\n```\n\nProfiles that come back EXPIRED don't send a notification immediately — they're written line by line into a temp file, `BROKEN_FILE`\n\n. That way everything is aggregated after all processing finishes and sent as a single notification. With 22 profiles and 4 broken, notifying one at a time would mean four consecutive posts to Discord. Login expiration is infrequent but urgent — receiving multiple notifications itself gets in the way of comprehension, registering as \"multiple alerts are coming in.\"\n\nFolding BROKEN_FILE into a semicolon-separated string after the loop with `while IFS= read -r item`\n\nfollows the same thinking. A single notification body conveys \"which ones are broken.\"\n\n```\ncleanup() {\n  local file\n  for file in \"$TEMP_COOKIE_DB\" \"$CANDIDATES_FILE\" \"$PROFILES_FILE\" \\\n              \"$SOURCE_FILE\" \"$BROKEN_FILE\"; do\n    [ -n \"$file\" ] && /bin/rm -f \"$file\"\n  done\n}\ntrap cleanup EXIT\ntrap 'exit 130' HUP INT TERM\n```\n\nThis script calls `mktemp`\n\nup to 27 times (4 fixed files + up to 22 per-profile cookie copies + 1 inside `write_state`\n\n). Without `trap cleanup EXIT`\n\n, an abort from an undefined-variable reference under `set -u`\n\nor a `sqlite3`\n\ncrash would leave copies of cookie files in `/tmp`\n\n. Cookies are credentials, so leaving them lying around in `/tmp`\n\nis bad.\n\nThe `HUP INT TERM`\n\ntrap returns `exit 130`\n\nso that Ctrl-C or a stop signal from launchd doesn't return `exit 0`\n\n. launchd records `exit 0`\n\nas \"normal termination.\" Distinguishing forced stops from normal completion lets you determine later from the log whether \"launchd stopped it\" or \"the script completed normally.\"\n\n```\nwrite_state() {\n  local value=\"$1\" temp_state\n  temp_state=\"$STATE_FILE.tmp.$$\"\n  printf '%s' \"$value\" >\"$temp_state\" && /bin/mv \"$temp_state\" \"$STATE_FILE\"\n}\n```\n\nThe reason for writing to `$STATE_FILE.tmp.$$`\n\nand then `mv`\n\n-ing, rather than overwriting `$STATE_FILE`\n\ndirectly with `>`\n\n, is to prevent a write/read race. When launching twice a day via launchd, if concurrent execution happens through overlap with a manual run or a future shortened interval, a direct `>`\n\ncreates a state where \"another process reads a half-written empty file.\" `mv`\n\nis an inode-level operation and therefore atomic, so a reader only ever sees either \"the previous complete value\" or \"the new complete value.\" Including `$$`\n\n(the process ID) in the filename prevents collisions when multiple processes create temp files at the same time.\n\nThe first version hardcoded profile dirs in an array.\n\n```\n# 初期実装（削除済み・擬似コード）\nPROFILES=(\n  \"~/dev/social-autolike/profiles/ig-1\"\n  \"~/dev/social-autolike/profiles/ig-2\"\n  \"~/dev/social-autolike/profiles/tt-1\"\n  \"~/dev/metrics-hub/profiles/tiktok\"    # ← 遺物\n  \"~/dev/metrics-hub/profiles/tiktok2\"   # ← 遺物\n)\n```\n\n(The two commented lines read \"relic.\")\n\n**Symptom**: the next morning's 07:10 run log listed `metrics-hub/profiles/tiktok`\n\nand `tiktok2`\n\nas EXPIRED, and an alert flew to Discord.\n\n**Cause**: the actual TikTok lanes tt-1 through tt-3 pointed at `social-autolike/profiles/tt-N`\n\nvia the `reuseProfile`\n\nfield in `accounts.json`\n\n. `metrics-hub/profiles/tiktok`\n\nwas a dir used in an earlier development cycle, referenced by no job today. The dirs I enumerated \"thinking they were in use\" and the dirs the jobs actually use were different things.\n\nThe other thing I overlooked was the reverse problem. The hardcoded list **did not include** the old `metrics-hub/profiles/instagram`\n\nreferenced by `ig-autoreply:ig-1`\n\n. The same logic that produced one false alarm was hiding one separate miss. **Hardcoding creates false alarms and misses at the same time.**\n\n**The fix**: resolve dynamically with the jq query `.reuseProfile // ($root + \"/profiles/\" + .id)`\n\nagainst `accounts.json`\n\n. It's the same rule as `profileDir()`\n\nin the job's own `src/lib.js`\n\n, so when the config file changes, the script side follows automatically. The first run after the fix produced \"22 resolved / ok 20 / expired 2\" — the 2 false alarms disappeared, and instead the expiration of the previously-missed old `metrics-hub/profiles/instagram`\n\nwas newly detected.\n\n`sessionid`\n\nalone stopped a live account\nWhen I wrote the TikTok check, I wrote it with the same thinking as the other platforms.\n\n```\n-- 初期実装\nselect count(*) from cookies where name='sessionid' and host_key like '%tiktok%';\n```\n\n(The comment reads \"initial implementation.\")\n\nApplying this to the live `tt-1`\n\n, **count=0 came back and it was judged EXPIRED.**\n\n**Symptom**: an active TikTok account is detected as logged out.\n\n**Cause**: checking the cookie copy directly with `sqlite3`\n\nshowed that `tt-1`\n\nhad no row named `sessionid`\n\n. What it actually contained were two rows, `sessionid_ss`\n\nand `sid_tt`\n\n.\n\n```\nname           | host_key\nsessionid_ss   | .tiktok.com\nsid_tt         | .tiktok.com\n```\n\nTikTok's cookie composition differs by environment and account attributes: profiles with only `sessionid`\n\n, profiles with only `sessionid_ss`\n\n, and profiles with all three were all mixed together. Betting on one specific kind falsely flags healthy accounts that don't have it.\n\n**The fix**: bundle them with `IN ('sessionid','sessionid_ss','sid_tt')`\n\nso that \"OK if even one row exists.\" What matters is the direction of the judgment: not \"all three are required\" but \"valid if at least one of the three is present.\" **The error of \"marking a live one NG\" is worse than \"marking an expired one OK\"** — because the former is the act of stopping a machine that's running, by your own hand.\n\nAt first I tried to read the cookie DB directly without a copy.\n\n```\n# コピーなし版（初期実装）\ncount=$(/usr/bin/sqlite3 \"$COOKIE_DB\" \"$query\" 2>/dev/null)\necho \"rc=$?\"  # → rc=5 (SQLITE_BUSY)\n```\n\n(The comment reads \"no-copy version (initial implementation).\")\n\n**Symptom**: when run during hours when Chrome is up, every profile becomes UNKNOWN.\n\n**Cause**: Chrome holds a WAL-mode lock on the Cookies DB, and an external process trying to open it directly gets SQLITE_BUSY (error code 5). The scheduled 07:10 run often overlaps with the morning hours when Chrome is running, so it failed nearly every time.\n\nBecause there was logic turning `sqlite3`\n\nerrors into UNKNOWN, every profile was treated as UNKNOWN, no notification fired, and I didn't notice until I looked at the log. The UNKNOWN design protected me from false alarms, but it also meant I was generating a state of \"22 UNKNOWNs lined up\" every single day.\n\n**The fix**: make a separate copy on the filesystem with `mktemp`\n\nand throw the query at the copy. The copy is a snapshot, so it's unrelated to Chrome's lock state. If the copy fails, it continues as UNKNOWN with `CHECK_DETAIL=\"Cookies DB copy failed\"`\n\n, skipping only that one entry and continuing the inspection of the remaining profiles.\n\nThis is about how deep a hole you can fall into by missing a session expiration.\n\n`unfollow-core.js`\n\nhas a feature that **physically deletes a target from the follow ledger after 6 cumulative failures** (a give-up design to break out of quarantine's infinite loop). On the same day, a change also went in raising the unfollow job from once a day to four times a day.\n\n`ig-2`\n\nhad lost its `sessionid`\n\n. When you try to perform an unfollow operation while logged out, Instagram redirects to the login API. To the job, this looks like \"the operation failed.\" If the failure count piles up at a pace of four times a day, **reaching the threshold of 6 doesn't even take two days.** Once a ledger row is deleted, that target can never be unfollowed again — because the record itself is gone.\n\n**Symptom** (no actual damage, since I stopped it just in time): the job keeps running on an account with an expired session, and the failure count is climbing rapidly.\n\n**The causal chain**: logged out → every operation treated as \"failure\" → give-up count increases → threshold exceeded → ledger row deleted. The problem was not distinguishing whether the cause of \"failure\" was \"the environment side (logged out)\" or \"the target side (a target that genuinely can't be operated on).\"\n\n**The fix**: immediately after `unfollow-sweep.js`\n\nstarts, before opening the browser, check the cookie, and if logout is confirmed, exit immediately without writing a single byte to either the ledger or quarantine.\n\n``` js\nconst loginState = await hasValidSessionCookie(profileDir, 'instagram')\nif (shouldAbortForLogin(loginState)) {\n  await stop({ stopReason: 'need-login' })\n  return\n}\n```\n\n`shouldAbortForLogin`\n\nis a pure function returning three values: `true (cookie confirmed) → continue / false (logout confirmed) → abort / null (undeterminable) → continue`\n\n. It continues on `null`\n\nbecause \"couldn't determine\" and \"is logged out\" are different events. Stopping here would shut down every live account merely because a cookie couldn't be read.\n\n**Lesson**: when designing a \"discard what failed\" mechanism, if you don't separate out the case where the cause of failure is on the environment side (logout, rate limiting, network outage), you'll discard correct data. Always design give-up and health-check as a set.\n\nThis predates running `profile-session-guard.sh`\n\n. My Discord notification router had a DENY pattern of `いいね\\d+件`\n\n(\"N likes\") — to drop result reports into the digest.\n\nThe problem was that when a job that detected a login expiration sent the message `いいね0件 (need-login)`\n\n(\"0 likes (need-login)\"), the `いいね\\d+件`\n\npattern also matched `いいね0件`\n\nand dropped it into the digest. ** \\d+ matches zero as much as anything else** — \"0 items\" and \"1000 items\" are both swallowed by the same pattern.\n\n**Symptom**: the job is reporting a login expiration, but nothing arrives in the Discord alerts channel. It was buried in the digest.\n\n**The fix**: change it so that `need-login`\n\n/ `未ログイン`\n\n/ `再ログイン`\n\n/ `セッション切れ`\n\n/ `GUIログイン`\n\n/ `凍結`\n\n/ `permanently`\n\nare treated as HARD_ACTION and **evaluated before every DENY rule.** Login expiration is the kind of alert that can never be recovered from unless a human manually operates a GUI. If the notification doesn't arrive, nothing starts. **Alerts of the \"nothing gets solved unless the person moves their hands\" variety must never be allowed to be swallowed by the router's DENY rules.**\n\nI've written the individual histories in detail in the first and middle sections. Here I'll enumerate the stumbling patterns as bullet points so anyone hitting the same structure can inspect their own code.\n\n`main`\n\nelement.`needLogin()`\n\nused the condition \"zero of `article`\n\n/ `[role=feed]`\n\n/ `video`\n\n/ `main`\n\n/ `[data-e2e]`\n\n,\" even on the logout screen one `main`\n\nwas hit and the check slipped through. `ig-2`\n\nran 12 times a day in that state, with 0 likes and 0 follows, unnoticed by anyone for days.`/login`\n\n.`hasValidSessionCookie`\n\nreturns false, abort immediately; otherwise defer to the existing DOM check. The cookie check and the DOM check aren't an OR — the cookie check is placed as a gate in front.`~/dev/metrics-hub/profiles/tiktok`\n\nand `tiktok2`\n\nin an in-script array, but the actual TikTok lanes tt-1 through tt-3 referenced `~/dev/social-autolike/profiles/tt-N`\n\nvia the `reuseProfile`\n\nfield in `accounts.json`\n\n. The 07:10 run the day after creation falsely reported those 2 as EXPIRED.`~/dev/metrics-hub/profiles/instagram`\n\n, referenced by `ig-autoreply:ig-1`\n\n. Hardcoding is \"the configuration I think exists,\" not \"the configuration that's actually running.\"`reuseProfile`\n\nfield, you will drift.`profiles/<id>`\n\nmisses accounts swapped to a different dir via `reuseProfile`\n\n.`.reuseProfile // ($root + \"/profiles/\" + .id)`\n\nused in `load_accounts()`\n\nin `profile-session-guard.sh`\n\nis the same rule as `profileDir()`\n\nin `src/lib.js`\n\n. If those don't match, the script and the job look at different dirs.`sessionid`\n\nalone stops live accounts.`select count(*) from cookies where name='sessionid' and host_key like '%tiktok%'`\n\nagainst the live tt-1 gave count=0. What it actually contained were two rows, `sessionid_ss`\n\nand `sid_tt`\n\n.`sessionid`\n\n, profiles with only `sessionid_ss`\n\n, and profiles with all three are mixed together. Using `IN ('sessionid','sessionid_ss','sid_tt')`\n\nfor \"OK if any one of them exists\" is the measurement-based correct answer.`mktemp`\n\nfirst lets you read regardless of Chrome's lock state.`mktemp`\n\nitself can fail.`/tmp`\n\n, a mount failure, and so on. Passing `TEMP_COOKIE_DB`\n\nto `sqlite3`\n\nwhile it's still an empty string creates an unexpected file. The `[ -z \"$TEMP_COOKIE_DB\" ]`\n\ncheck is mandatory.`Default/Network/Cookies`\n\n(current Chrome), `Default/Cookies`\n\n, `Network/Cookies`\n\n, and `Cookies`\n\nin order and use the first one found, the DB won't be found on older profiles and some Chromium-family browsers.`sqlite3`\n\nexit code and the output value.`grep -Eq '^[0-9]+$'`\n\n.`\\d+`\n\nincludes 0.`いいね\\d+件`\n\n, then `いいね0件 (need-login)`\n\nalso drops into the digest. A real case where nothing reached the alerts channel and I didn't notice the login expiration until I dug through the logs.`ig-2(ig-2, ig-autoreply:ig-2) is broken`\n\nimmediately tells you what to do next, more than `ig-2 is broken`\n\ndoes. Profile names are a filesystem convenience; humans understand the correspondence to jobs better through lane names.`UNFOLLOW_GIVEUP_FAILS = 6`\n\nin `unfollow-core.js`\n\nis designed to break quarantine's infinite loop, but in a logged-out state every unfollow operation is treated as a \"failure.\" If the job runs four times a day, it reaches 6 cumulative failures within two days and the ledger row is physically deleted. Once deleted, it can never be restored.`null`\n\n) must not be treated the same as confirmed logout (`false`\n\n).The DOM, the URL, and the page title are by-products of the UI. If you want to know the authentication state, look at the authentication state itself. The SQL that `cookie_query()`\n\nthrows settles the login state in one line.\n\n```\n# instagram の例\nprintf \"%s\" \"select count(*) from cookies where name='sessionid' and host_key like '%instagram.com%';\"\n```\n\n(The comment reads \"example for instagram.\")\n\nNo matter how the UI changes, the presence of the `sessionid`\n\ncookie maps one-to-one to Instagram's login state.\n\n```\nTEMP_COOKIE_DB=$(mktemp /tmp/profile-session-guard.cookies.XXXXXX)\n[ -z \"$TEMP_COOKIE_DB\" ] || /bin/cp \"$COOKIE_DB\" \"$TEMP_COOKIE_DB\" 2>/dev/null || return\n# ...クエリ実行...\n/bin/rm -f \"$TEMP_COOKIE_DB\"\n```\n\n(The comment reads \"...run the query...\")\n\nThe copy is a snapshot, so it's readable regardless of Chrome's WAL lock. Cookies are credentials, so delete them reliably with `trap cleanup EXIT`\n\n.\n\n```\n\"$JQ\" -r --arg root \"$ROOT\" \\\n  '.accounts[] | [.id, .platform, (.reuseProfile // ($root + \"/profiles/\" + .id))] | @tsv' \\\n  \"$ACCOUNTS_CONFIG\"\n```\n\nWhen the config file changes, the script side follows automatically. The key is matching the resolution rule to `profileDir()`\n\nin `src/lib.js`\n\n. It starts rotting the moment you hardcode it.\n\n```\nselect count(*) from cookies\nwhere name in ('sessionid','sessionid_ss','sid_tt')\n  and host_key like '%tiktok%';\n```\n\nBy measurement, tt-1 had the two rows `sessionid_ss`\n\nand `sid_tt`\n\n, and `sessionid`\n\ndid not exist. Betting on one kind produces false negatives from environmental differences.\n\n```\nif [ \"$sqlite_rc\" -ne 0 ] || ! printf '%s' \"$count\" | /usr/bin/grep -Eq '^[0-9]+$'; then\n  unknown=1\n  CHECK_DETAIL=\"sqlite3 failed for $platform\"\n  continue  # EXPIREDにはしない\nfi\n```\n\n(The comment reads \"don't make it EXPIRED.\")\n\nUnsupported platforms, a corrupted DB, or a failed `mktemp`\n\nare handled as UNKNOWN rather than EXPIRED. In an environment where 17 lanes shutting down entirely does more damage than missing one lane's expiration, the fail-safe becomes UNKNOWN = continue.\n\nBefore a destructive operation (ledger deletion, quarantine writes), place a gate that confirms you're in a state where you can operate correctly.\n\n``` js\nconst loginState = await hasValidSessionCookie(profileDir, 'instagram')\nif (shouldAbortForLogin(loginState)) {\n  await stop({ stopReason: 'need-login' })\n  return  // 台帳に1バイトも書かない\n}\n```\n\n(The comment reads \"don't write a single byte to the ledger.\")\n\n`shouldAbortForLogin`\n\nis a pure function with three values: `true (valid) → continue / false (logout confirmed) → abort / null (undeterminable) → continue`\n\n. Undeterminable continues — stopping here would shut everything down merely because a cookie couldn't be read.\n\n```\n[ -f \"$STATE_FILE\" ] && previous=$(/bin/cat \"$STATE_FILE\" 2>/dev/null)\n\nif [ \"$broken\" = \"$previous\" ]; then\n  log \"login missing unchanged ($broken); notification skipped\"\nelse\n  notify \"🔑 ログイン切れ: $broken ...\"\nfi\nwrite_state \"$broken\"\n```\n\nDon't fire notifications while the same breakage persists. Too many notifications bury the real alerts — a separate case of actual damage I experienced.\n\nRegister the following at the top of the notification router as HARD_ACTION.\n\n```\nneed-login / 未ログイン / 再ログイン / セッション切れ /\nGUIログイン / 凍結 / permanently / sessionid.{0,10}失効\n```\n\nThere was a real case where the `いいね\\d+件`\n\nDENY pattern swallowed `いいね0件 (need-login)`\n\n. When an alert of the \"can never be recovered from unless the person operates a GUI\" variety gets buried, every day until you notice is results lost.\n\n```\n🔑 ログイン切れ: ig-2(ig-2, ig-autoreply:ig-2) — 該当レーンは成果ゼロのまま exit 0 を返し続けます。再ログインが必要\n```\n\nThe profile name `ig-2`\n\nalone doesn't tell you which job stops. Putting the lane names in parentheses makes the receiving human's next action clear. This is why lane names aren't discarded during deduplication.\n\nWhen `ig-1-live`\n\nis referenced by both the `ig-1`\n\nand `ig-autoreply:ig-1`\n\nlanes, inspecting twice is wasteful and scatters the lane information. Deduplicate by `canonical`\n\npath, and keep the lane names comma-separated.\n\n```\nlanes[dir] = lanes[dir] \", \" lane  # ig-1, ig-autoreply:ig-1\nif [ ! -d \"$dir\" ]; then\n  log \"skip missing $dir ($platform; lane=$lane)\"\n  return 0\nfi\n```\n\nHandling profiles that haven't been created yet, or have been deleted, as UNKNOWN makes lanes still under construction emit noise every run. Confirm existence before adding to the candidates.\n\n```\ntemp_state=\"$STATE_FILE.tmp.$$\"\nprintf '%s' \"$value\" >\"$temp_state\" && /bin/mv \"$temp_state\" \"$STATE_FILE\"\n```\n\nOverwriting directly with `>`\n\ncreates a race where another process reads \"a half-written empty file.\" `mv`\n\nis an inode-level atomic operation. Including the process ID `$$`\n\nin the temp filename also prevents collisions when multiple processes run simultaneously.\n\n```\nfor candidate in \\\n  \"$dir/Default/Network/Cookies\" \\\n  \"$dir/Default/Cookies\" \\\n  \"$dir/Network/Cookies\" \\\n  \"$dir/Cookies\"; do\n  [ -f \"$candidate\" ] && COOKIE_DB=\"$candidate\" && return 0\ndone\n```\n\nThe path differs by Chrome version and across some Chromium-family browsers. Betting on one pattern means the DB is permanently undetected on some profiles.\n\nMoving session-liveness detection from \"how the page looks\" to \"does the cookie actually exist\" — this shift is a story about implementation technique and, at the same time, about a design philosophy: what you treat as your primary source.\n\nThe reason `ig-2`\n\nran for days with 0 likes was that the check depended on *guessing* the authentication state. If one `main`\n\nelement existed, it concluded \"probably logged in.\" Reading the cookie directly would have settled it in one query — I just hadn't asked that question.\n\nThe numbers `profile-session-guard.sh`\n\nproduced on its first run were `resolved 22 unique existing profiles / ok=20 / expired=2`\n\n. The 2 false alarms from the hardcoded list disappeared, and one separate expiration the hardcoded version had missed was newly detected. Creating false alarms and misses at the same time — that's not a metaphor, it's what actually happened.\n\nThe larger an automation environment gets, the more an individual failure looks like nothing more than \"results are low.\" Errors stop making noise. A state of doing nothing gets recorded as \"no problems.\" What this script solves isn't only a technical problem — it's the structural problem that **breaking makes no sound.**\n\nWhen a session expires, the time until it reaches a human is exactly the results lost. Making cookies the primary source, sending notifications only on changes, designing give-up and health-check as a set — the reason I ended up implementing these three independently in four places on the same day is that, once you see it, they're all the same structural problem.\n\nI've put the full picture of the system, the breakdown of the ¥1.2M/month, and a 30-day procedure into 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/your-cron-job-exits-0-and-does-nothing-reading-chrome-s-cookie-sqlite-to-know-if", "canonical_source": "https://dev.to/bokuwalily/your-cron-job-exits-0-and-does-nothing-reading-chromes-cookie-sqlite-to-know-if-a-session-is-3c55", "published_at": "2026-08-17 05:00:06+00:00", "updated_at": "2026-08-17 05:12:26.919374+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Instagram", "Chrome", "Claude Code", "Playwright", "Puppeteer", "browser-use"], "alternates": {"html": "https://wpnews.pro/news/your-cron-job-exits-0-and-does-nothing-reading-chrome-s-cookie-sqlite-to-know-if", "markdown": "https://wpnews.pro/news/your-cron-job-exits-0-and-does-nothing-reading-chrome-s-cookie-sqlite-to-know-if.md", "text": "https://wpnews.pro/news/your-cron-job-exits-0-and-does-nothing-reading-chrome-s-cookie-sqlite-to-know-if.txt", "jsonld": "https://wpnews.pro/news/your-cron-job-exits-0-and-does-nothing-reading-chrome-s-cookie-sqlite-to-know-if.jsonld"}}