cd /news/developer-tools/your-cron-job-exits-0-and-does-nothi… · home topics developer-tools article
[ARTICLE · art-99391] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Your Cron Job Exits 0 and Does Nothing: Reading Chrome's Cookie SQLite to Know If a Session Is Actually Alive

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.

read26 min views7 publishedAug 17, 2026

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.

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

This time, the story is: "the job ran every day, produced zero results, and nobody noticed."

The scary thing about automation is that when it breaks, it makes no noise.

The job for my Instagram auto-like system (social-autolike

) 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

was scheduled to run 12 times a day. Each run logged "complete." No errors reached the monitoring dashboard.

The reality: likes = 0, follows = 0. It had been doing absolutely nothing for days.

The cause was simple. The ig-2

account was logged out. Its Instagram session had expired. But the job, staring at a logout screen, kept returning exit 0

on the grounds that "there were no targets left" or "no accounts matched the criteria."

The job did have login-detection code at the time. needLogin()

in social-autolike/src/run.js

decided that login was required based on roughly these two conditions:

/login

article

/ [role=feed]

/ video

/ main

/ [data-e2e]

existThe problem is the second half of that second condition.

Instagram's logout screen has a main

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

elements means login is needed," opening the logout screen still found one main, and the check slipped through.

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

Chrome's session management is recorded in a SQLite database file at Default/Network/Cookies

(or Default/Cookies

, depending on the Chrome version). Instagram's login state is managed by a cookie named sessionid

, and if that cookie exists for the instagram.com

domain, that profile is logged in.

The 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

cookie maps one-to-one to login state.

Making cookies your primary source is a shift from guessing to observing the fact.

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

When 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"?

Even 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

had lost.

The profile-session-guard.sh

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

What the script does breaks into four stages.

[設定ファイル3本]
  accounts.json
  ig-reply-accounts.json      ─→ [プロファイルdir一覧を解決]
  post-accounts.json               + 重複を1件に集約
  + 固定2件(別リポ)
         ↓
[各プロファイルdirでCookie DBを探す]
  Default/Network/Cookies
  Default/Cookies             ─→ [見つかったら mktemp でコピー]
  Network/Cookies
  Cookies
         ↓
[sqlite3 でCookieを検索]
  platform別のSQLクエリ      ─→ [OK / EXPIRED / UNKNOWN を判定]
  コピーは終了時に削除
         ↓
[結果を集計してstateファイルと照合]
  切れ方が前回と同じ → 通知スキップ
  新しい切れ          → Discord通知 + exit 1
  全員OK              → exit 0

(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

when found; then query cookies with sqlite3

using 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

, everyone OK → exit 0

.)

Let's walk through it in order.

The script's first job is to determine which profiles to inspect.

The 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

reuseProfile

field in social-autolike/accounts.json

. A hardcoded list starts rotting the moment you write it.The current implementation resolves them from the config files the jobs actually reference.

load_accounts() {
  local lane platform dir
  if ! "$JQ" -r --arg root "$ROOT" \
    '.accounts[] | [.id, .platform, (.reuseProfile // ($root + "/profiles/" + .id))] | @tsv' \
    "$ACCOUNTS_CONFIG" >"$SOURCE_FILE"; then
    log "unknown resolver: accounts.json could not be read"
    return 1
  fi

  while IFS=$'\t' read -r lane platform dir; do
    add_candidate "$dir" "$platform" "$lane"
  done <"$SOURCE_FILE"
}

The key is the .reuseProfile // ($root + "/profiles/" + .id)

part of the jq

query. If reuseProfile

is written in the config file, use that path; otherwise fall back to the default path profiles/<id>

. This is the same resolution rule as profileDir()

in the job's own src/lib.js

. Because the config file is the source of truth, the dir the job references and the dir the script inspects stay in sync.

The same processing is applied to ig-reply-accounts.json

(for the auto-reply lanes) and post-accounts.json

(for the posting lanes), plus two fixed entries from a separate repo.

add_candidate "~/dev/bokuwalily-sns/profiles/ig-post" "instagram" "bokuwalily-sns:ig-post"
add_candidate "~/dev/brand-404/profiles/ig-o81" "instagram" "brand-404:ig-o81"

There are cases where multiple lanes reference the same profile dir (for example, ig-1-live

is used by both ig-autoreply:ig-1

and ig-1

). Inspecting it twice is wasteful, so duplicates are removed by canonical

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

[2026-08-09 07:10:05] resolved 22 unique existing profiles

The hardcoded version included dirs that didn't even exist in the inspection targets, but add_candidate

checks [ ! -d "$dir" ]

and skips, so dirs that haven't been created yet produce neither an inspection nor an error.

The find_cookie_db

function runs against each profile dir.

find_cookie_db() {
  local dir="$1" candidate
  COOKIE_DB=""
  for candidate in \
    "$dir/Default/Network/Cookies" \
    "$dir/Default/Cookies" \
    "$dir/Network/Cookies" \
    "$dir/Cookies"; do
    if [ -f "$candidate" ]; then
      COOKIE_DB="$candidate"
      return 0
    fi
  done
  return 1
}

Chrome places the cookie file differently depending on the version. Default/Network/Cookies

is current, but older profiles and some Chromium-based browsers may have it at Default/Cookies

or Network/Cookies

. It tries four patterns and uses the first one it finds.

Once found, always copy it with mktemp before reading.

TEMP_COOKIE_DB=$(mktemp /tmp/profile-session-guard.cookies.XXXXXX)
if [ -z "$TEMP_COOKIE_DB" ] || ! /bin/cp "$COOKIE_DB" "$TEMP_COOKIE_DB" 2>/dev/null; then
  CHECK_DETAIL="Cookies DB copy failed"
  [ -n "$TEMP_COOKIE_DB" ] && /bin/rm -f "$TEMP_COOKIE_DB"
  TEMP_COOKIE_DB=""
  return
fi

If Chrome is running and holding the cookie DB, trying to open it directly with sqlite3

produces 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

.

The cookie DB is SQLite, so we throw a sqlite3

query at the copy. What matters is that the cookie name to check differs by platform.

cookie_query() {
  case "$1" in
    x)
      printf "%s" "select count(*) from cookies where name='auth_token' and (host_key like '%x.com%' or host_key like '%twitter.com%');"
      ;;
    instagram)
      printf "%s" "select count(*) from cookies where name='sessionid' and host_key like '%instagram.com%';"
      ;;
    threads)
      printf "%s" "select count(*) from cookies where name='sessionid' and host_key like '%threads%';"
      ;;
    tiktok)
      printf "%s" "select count(*) from cookies where name in ('sessionid','sessionid_ss','sid_tt') and host_key like '%tiktok%';"
      ;;
  esac
}

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

tt-1

was confirmed to actually hold all three. If I judged on sessionid

alone, an account holding only sessionid_ss

or sid_tt

would be misjudged as "logged out." Since count(*)

comes back, 0

means EXPIRED and 1

or more means that platform is OK. When one profile covers multiple platforms (a composite profile like instagram,threads

), it becomes EXPIRED the moment even one of them is broken.

count=$(/usr/bin/sqlite3 "$TEMP_COOKIE_DB" "$query" 2>/dev/null)
sqlite_rc=$?
if [ "$sqlite_rc" -ne 0 ] || ! printf '%s' "$count" | /usr/bin/grep -Eq '^[0-9]+$'; then
  unknown=1
  CHECK_DETAIL="sqlite3 failed for $platform"
elif [ "$count" -eq 0 ]; then
  CHECK_STATUS="EXPIRED"
  CHECK_DETAIL="required cookie missing for $platform"
  break
fi

Validating both the sqlite3

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

After inspecting all 22 profiles, it aggregates.

log "check complete ok=$ok expired=$expired unknown=$unknown"

If 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

had been dropped into the digest.

So the previous list of broken profiles is saved in a state

file, and it only notifies when the contents change.

previous=""
[ -f "$STATE_FILE" ] && previous=$(/bin/cat "$STATE_FILE" 2>/dev/null)

if [ -n "$broken" ]; then
  if [ "$broken" = "$previous" ]; then
    log "login missing unchanged ($broken); notification skipped"
  else
    body="🔑 ログイン切れ: $broken — 該当レーンは成果ゼロのまま exit 0 を返し続けます。再ログインが必要"
    if notify "$body"; then
      log "notified login missing ($broken)"
    fi
  fi
  write_state "$broken"
  exit 1
fi

write_state ""
exit 0

The notification text includes $broken

, but that isn't just an account name — it's a format like ig-2(ig-2, ig-autoreply:ig-2)

. "Which lanes stop" is more useful to a human than "which profile broke," so the referencing lanes are shown in parentheses.

It's registered with launchd twice a day, at 07:10 and 19:10. The three keys NiceValue=10 / ProcessType=Background / LowPriorityIO=true

make it low priority so it doesn't interrupt other processing.

The profile dir ig-1-live

is referenced simultaneously by two lanes, ig-autoreply:ig-1

and ig-1

. Inspecting it twice is wasteful, but I don't want to throw away the information about "which lanes will stop." deduplicate_profiles

satisfies both requirements at once.

deduplicate_profiles() {
  LC_ALL=C sort -t "$TAB" -k1,1 -k3,3 "$CANDIDATES_FILE" | \
    awk -F '\t' '
      {
        dir = $1; platform = $2; lane = $3
        if (!(dir in seen)) {
          seen[dir] = 1
          order[++count] = dir
          platforms[dir] = platform
          lanes[dir] = lane
        } else {
          if (index("," platforms[dir] ",", "," platform ",") == 0) {
            platforms[dir] = platforms[dir] "," platform
          }
          if (index("," lanes[dir] ",", "," lane ",") == 0) {
            lanes[dir] = lanes[dir] ", " lane
          }
        }
      }
      END {
        for (i = 1; i <= count; i++) {
          dir = order[i]
          printf "%s\t%s\t%s\n", dir, platforms[dir], lanes[dir]
        }
      }
    ' >"$PROFILES_FILE"
}

The check index("," platforms[dir] ",", "," platform ",") == 0

verifies "isn't this platform already included?" Adding commas front and back before searching prevents partial matches — it's how searching for instagram

correctly counts the instagram

inside instagram,threads

as one entry. The order

array preserves insertion order, and the END

block outputs in that order, for log readability.

The notification body that reaches Discord takes the form ig-2(ig-2, ig-autoreply:ig-2)

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

The loop that processes 22 profiles in order is simple.

while IFS=$'\t' read -r dir platforms lanes; do
  [ -n "$dir" ] || continue
  name=${dir##*/}
  check_profile "$dir" "$platforms"

  case "$CHECK_STATUS" in
    OK)
      log "ok $name ($platforms; lanes=$lanes; dir=$dir)"
      ok=$((ok + 1))
      ;;
    EXPIRED)
      log "login missing $name ($platforms; lanes=$lanes; dir=$dir)"
      printf '%s(%s)\n' "$name" "$lanes" >>"$BROKEN_FILE"
      expired=$((expired + 1))
      ;;
    *)
      log "unknown $name ($platforms; lanes=$lanes; dir=$dir): $CHECK_DETAIL"
      unknown=$((unknown + 1))
      ;;
  esac
done <"$PROFILES_FILE"

Profiles that come back EXPIRED don't send a notification immediately — they're written line by line into a temp file, BROKEN_FILE

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

Folding BROKEN_FILE into a semicolon-separated string after the loop with while IFS= read -r item

follows the same thinking. A single notification body conveys "which ones are broken."

cleanup() {
  local file
  for file in "$TEMP_COOKIE_DB" "$CANDIDATES_FILE" "$PROFILES_FILE" \
              "$SOURCE_FILE" "$BROKEN_FILE"; do
    [ -n "$file" ] && /bin/rm -f "$file"
  done
}
trap cleanup EXIT
trap 'exit 130' HUP INT TERM

This script calls mktemp

up to 27 times (4 fixed files + up to 22 per-profile cookie copies + 1 inside write_state

). Without trap cleanup EXIT

, an abort from an undefined-variable reference under set -u

or a sqlite3

crash would leave copies of cookie files in /tmp

. Cookies are credentials, so leaving them lying around in /tmp

is bad.

The HUP INT TERM

trap returns exit 130

so that Ctrl-C or a stop signal from launchd doesn't return exit 0

. launchd records exit 0

as "normal termination." Distinguishing forced stops from normal completion lets you determine later from the log whether "launchd stopped it" or "the script completed normally."

write_state() {
  local value="$1" temp_state
  temp_state="$STATE_FILE.tmp.$$"
  printf '%s' "$value" >"$temp_state" && /bin/mv "$temp_state" "$STATE_FILE"
}

The reason for writing to $STATE_FILE.tmp.$$

and then mv

-ing, rather than overwriting $STATE_FILE

directly with >

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

creates a state where "another process reads a half-written empty file." mv

is an inode-level operation and therefore atomic, so a reader only ever sees either "the previous complete value" or "the new complete value." Including $$

(the process ID) in the filename prevents collisions when multiple processes create temp files at the same time.

The first version hardcoded profile dirs in an array.

PROFILES=(
  "~/dev/social-autolike/profiles/ig-1"
  "~/dev/social-autolike/profiles/ig-2"
  "~/dev/social-autolike/profiles/tt-1"
  "~/dev/metrics-hub/profiles/tiktok"    # ← 遺物
  "~/dev/metrics-hub/profiles/tiktok2"   # ← 遺物
)

(The two commented lines read "relic.")

Symptom: the next morning's 07:10 run log listed metrics-hub/profiles/tiktok

and tiktok2

as EXPIRED, and an alert flew to Discord.

Cause: the actual TikTok lanes tt-1 through tt-3 pointed at social-autolike/profiles/tt-N

via the reuseProfile

field in accounts.json

. metrics-hub/profiles/tiktok

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

The other thing I overlooked was the reverse problem. The hardcoded list did not include the old metrics-hub/profiles/instagram

referenced by ig-autoreply:ig-1

. The same logic that produced one false alarm was hiding one separate miss. Hardcoding creates false alarms and misses at the same time.

The fix: resolve dynamically with the jq query .reuseProfile // ($root + "/profiles/" + .id)

against accounts.json

. It's the same rule as profileDir()

in the job's own src/lib.js

, 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

was newly detected.

sessionid

alone stopped a live account When I wrote the TikTok check, I wrote it with the same thinking as the other platforms.

-- 初期実装
select count(*) from cookies where name='sessionid' and host_key like '%tiktok%';

(The comment reads "initial implementation.")

Applying this to the live tt-1

, count=0 came back and it was judged EXPIRED.

Symptom: an active TikTok account is detected as logged out.

Cause: checking the cookie copy directly with sqlite3

showed that tt-1

had no row named sessionid

. What it actually contained were two rows, sessionid_ss

and sid_tt

.

name           | host_key
sessionid_ss   | .tiktok.com
sid_tt         | .tiktok.com

TikTok's cookie composition differs by environment and account attributes: profiles with only sessionid

, profiles with only sessionid_ss

, and profiles with all three were all mixed together. Betting on one specific kind falsely flags healthy accounts that don't have it.

The fix: bundle them with IN ('sessionid','sessionid_ss','sid_tt')

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

At first I tried to read the cookie DB directly without a copy.

count=$(/usr/bin/sqlite3 "$COOKIE_DB" "$query" 2>/dev/null)
echo "rc=$?"  # → rc=5 (SQLITE_BUSY)

(The comment reads "no-copy version (initial implementation).")

Symptom: when run during hours when Chrome is up, every profile becomes UNKNOWN.

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.

Because there was logic turning sqlite3

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

The fix: make a separate copy on the filesystem with mktemp

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

, skipping only that one entry and continuing the inspection of the remaining profiles.

This is about how deep a hole you can fall into by missing a session expiration.

unfollow-core.js

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

ig-2

had lost its sessionid

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

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.

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

The fix: immediately after unfollow-sweep.js

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

const loginState = await hasValidSessionCookie(profileDir, 'instagram')
if (shouldAbortForLogin(loginState)) {
  await stop({ stopReason: 'need-login' })
  return
}

shouldAbortForLogin

is a pure function returning three values: true (cookie confirmed) → continue / false (logout confirmed) → abort / null (undeterminable) → continue

. It continues on null

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

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.

This predates running profile-session-guard.sh

. My Discord notification router had a DENY pattern of いいね\d+件

("N likes") — to drop result reports into the digest.

The problem was that when a job that detected a login expiration sent the message いいね0件 (need-login)

("0 likes (need-login)"), the いいね\d+件

pattern also matched いいね0件

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

Symptom: the job is reporting a login expiration, but nothing arrives in the Discord alerts channel. It was buried in the digest.

The fix: change it so that need-login

/ 未ログイン

/ 再ログイン

/ セッション切れ

/ GUIログイン

/ 凍結

/ permanently

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

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

main

element.needLogin()

used the condition "zero of article

/ [role=feed]

/ video

/ main

/ [data-e2e]

," even on the logout screen one main

was hit and the check slipped through. ig-2

ran 12 times a day in that state, with 0 likes and 0 follows, unnoticed by anyone for days./login

.hasValidSessionCookie

returns 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

and tiktok2

in an in-script array, but the actual TikTok lanes tt-1 through tt-3 referenced ~/dev/social-autolike/profiles/tt-N

via the reuseProfile

field in accounts.json

. The 07:10 run the day after creation falsely reported those 2 as EXPIRED.~/dev/metrics-hub/profiles/instagram

, referenced by ig-autoreply:ig-1

. Hardcoding is "the configuration I think exists," not "the configuration that's actually running."reuseProfile

field, you will drift.profiles/<id>

misses accounts swapped to a different dir via reuseProfile

..reuseProfile // ($root + "/profiles/" + .id)

used in load_accounts()

in profile-session-guard.sh

is the same rule as profileDir()

in src/lib.js

. If those don't match, the script and the job look at different dirs.sessionid

alone stops live accounts.select count(*) from cookies where name='sessionid' and host_key like '%tiktok%'

against the live tt-1 gave count=0. What it actually contained were two rows, sessionid_ss

and sid_tt

.sessionid

, profiles with only sessionid_ss

, and profiles with all three are mixed together. Using IN ('sessionid','sessionid_ss','sid_tt')

for "OK if any one of them exists" is the measurement-based correct answer.mktemp

first lets you read regardless of Chrome's lock state.mktemp

itself can fail./tmp

, a mount failure, and so on. Passing TEMP_COOKIE_DB

to sqlite3

while it's still an empty string creates an unexpected file. The [ -z "$TEMP_COOKIE_DB" ]

check is mandatory.Default/Network/Cookies

(current Chrome), Default/Cookies

, Network/Cookies

, and Cookies

in order and use the first one found, the DB won't be found on older profiles and some Chromium-family browsers.sqlite3

exit code and the output value.grep -Eq '^[0-9]+$'

.\d+

includes 0.いいね\d+件

, then いいね0件 (need-login)

also 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

immediately tells you what to do next, more than ig-2 is broken

does. Profile names are a filesystem convenience; humans understand the correspondence to jobs better through lane names.UNFOLLOW_GIVEUP_FAILS = 6

in unfollow-core.js

is 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

) must not be treated the same as confirmed logout (false

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

throws settles the login state in one line.

printf "%s" "select count(*) from cookies where name='sessionid' and host_key like '%instagram.com%';"

(The comment reads "example for instagram.")

No matter how the UI changes, the presence of the sessionid

cookie maps one-to-one to Instagram's login state.

TEMP_COOKIE_DB=$(mktemp /tmp/profile-session-guard.cookies.XXXXXX)
[ -z "$TEMP_COOKIE_DB" ] || /bin/cp "$COOKIE_DB" "$TEMP_COOKIE_DB" 2>/dev/null || return
/bin/rm -f "$TEMP_COOKIE_DB"

(The comment reads "...run the query...")

The 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

.

"$JQ" -r --arg root "$ROOT" \
  '.accounts[] | [.id, .platform, (.reuseProfile // ($root + "/profiles/" + .id))] | @tsv' \
  "$ACCOUNTS_CONFIG"

When the config file changes, the script side follows automatically. The key is matching the resolution rule to profileDir()

in src/lib.js

. It starts rotting the moment you hardcode it.

select count(*) from cookies
where name in ('sessionid','sessionid_ss','sid_tt')
  and host_key like '%tiktok%';

By measurement, tt-1 had the two rows sessionid_ss

and sid_tt

, and sessionid

did not exist. Betting on one kind produces false negatives from environmental differences.

if [ "$sqlite_rc" -ne 0 ] || ! printf '%s' "$count" | /usr/bin/grep -Eq '^[0-9]+$'; then
  unknown=1
  CHECK_DETAIL="sqlite3 failed for $platform"
  continue  # EXPIREDにはしない
fi

(The comment reads "don't make it EXPIRED.")

Unsupported platforms, a corrupted DB, or a failed mktemp

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

Before a destructive operation (ledger deletion, quarantine writes), place a gate that confirms you're in a state where you can operate correctly.

const loginState = await hasValidSessionCookie(profileDir, 'instagram')
if (shouldAbortForLogin(loginState)) {
  await stop({ stopReason: 'need-login' })
  return  // 台帳に1バイトも書かない
}

(The comment reads "don't write a single byte to the ledger.")

shouldAbortForLogin

is a pure function with three values: true (valid) → continue / false (logout confirmed) → abort / null (undeterminable) → continue

. Undeterminable continues — stopping here would shut everything down merely because a cookie couldn't be read.

[ -f "$STATE_FILE" ] && previous=$(/bin/cat "$STATE_FILE" 2>/dev/null)

if [ "$broken" = "$previous" ]; then
  log "login missing unchanged ($broken); notification skipped"
else
  notify "🔑 ログイン切れ: $broken ..."
fi
write_state "$broken"

Don't fire notifications while the same breakage persists. Too many notifications bury the real alerts — a separate case of actual damage I experienced.

Register the following at the top of the notification router as HARD_ACTION.

need-login / 未ログイン / 再ログイン / セッション切れ /
GUIログイン / 凍結 / permanently / sessionid.{0,10}失効

There was a real case where the いいね\d+件

DENY pattern swallowed いいね0件 (need-login)

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

🔑 ログイン切れ: ig-2(ig-2, ig-autoreply:ig-2) — 該当レーンは成果ゼロのまま exit 0 を返し続けます。再ログインが必要

The profile name ig-2

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

When ig-1-live

is referenced by both the ig-1

and ig-autoreply:ig-1

lanes, inspecting twice is wasteful and scatters the lane information. Deduplicate by canonical

path, and keep the lane names comma-separated.

lanes[dir] = lanes[dir] ", " lane  # ig-1, ig-autoreply:ig-1
if [ ! -d "$dir" ]; then
  log "skip missing $dir ($platform; lane=$lane)"
  return 0
fi

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

temp_state="$STATE_FILE.tmp.$$"
printf '%s' "$value" >"$temp_state" && /bin/mv "$temp_state" "$STATE_FILE"

Overwriting directly with >

creates a race where another process reads "a half-written empty file." mv

is an inode-level atomic operation. Including the process ID $$

in the temp filename also prevents collisions when multiple processes run simultaneously.

for candidate in \
  "$dir/Default/Network/Cookies" \
  "$dir/Default/Cookies" \
  "$dir/Network/Cookies" \
  "$dir/Cookies"; do
  [ -f "$candidate" ] && COOKIE_DB="$candidate" && return 0
done

The path differs by Chrome version and across some Chromium-family browsers. Betting on one pattern means the DB is permanently undetected on some profiles.

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

The reason ig-2

ran for days with 0 likes was that the check depended on guessing the authentication state. If one main

element existed, it concluded "probably logged in." Reading the cookie directly would have settled it in one query — I just hadn't asked that question.

The numbers profile-session-guard.sh

produced on its first run were resolved 22 unique existing profiles / ok=20 / expired=2

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

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

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

I've put the full picture of the system, the breakdown of the ¥1.2M/month, and a 30-day procedure into 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/your-cron-job-exits-…] indexed:0 read:26min 2026-08-17 ·