{"slug": "exit-0-lies-a-job-that-needed-4940s-in-a-2400s-slot-and-the-3-day-streak-that-it", "title": "exit 0 Lies: A Job That Needed 4,940s in a 2,400s Slot, and the 3-Day Streak That Exposed It", "summary": "A developer's autonomous Claude Code setup for Instagram engagement was silently failing, with jobs timing out at 2,400 seconds despite needing up to 4,940 seconds. The developer fixed the timeout issue but then faced 'exit 0 lies,' where jobs terminate normally but incomplete, requiring a monitoring mechanism to detect truncation over three consecutive days.", "body_md": "I grew a side hustle from ¥100k a month to ¥600k by stacking gigs, watched it drop to zero overnight when the company pulled the plug, then spent six months rebuilding an autonomous Claude Code setup from scratch. It now does ¥1.2M a month in revenue. This series is the record of the holes I fell into and the designs I salvaged on the way there.\n\nThis happened in August 2026. My IG engagement job (`ig_engage.py`\n\n) was ending with exit 124 every single day.\n\nexit 124 means SIGKILL — the code you get when launchd force-kills a job. The Perl supervisor inside `browser-slot.sh`\n\nis built so that the moment the configured timeout passes, it fires `alarm $timeout; ... exit 124 if $timed_out;`\n\n, following the framework-wide convention of \"on timeout, exit with 124.\"\n\nDigging through the logs, it was the same thing every day: `TIMEOUT: killed after 2400s`\n\n.\n\nNot a single line of code was broken. The cap for likes was 62, follows 24, unfollows 15 — a maximum of 101 actions total. The wait between actions was random, minimum 20 seconds, maximum 60, averaging 40. Startup jitter was random, up to 900 seconds. Every one of those numbers is reasonable on its own.\n\nBut **I had never once multiplied them together.**\n\n```\n101アクション × 平均40秒 = 4,040秒\n+ 起動ジッター最大 900秒\n────────────────────────\n最大所要時間         4,940秒\n実行枠（BROWSER_SLOT_TIMEOUT_SEC） 2,400秒\n```\n\n4,940 ÷ 2,400 ≈ **1.7×**. Every setting was correct; only the combination was broken. This is exactly the kind of problem a code review will never catch. Read any individual line and it looks right.\n\nSIGKILL had a second, concrete cost. Because Playwright's `finally: ctx.close()`\n\nnever runs, Chromium processes get orphaned and pile up. Every morning `~/.cache/lily-browser-slots/slot.log`\n\nwas full of `result=timeout:2400s`\n\n, but the real damage wasn't there — it was the machine-wide load climbing with no ceiling.\n\n**The fix was not \"lower the caps.\"** The caps *are* the ceiling on the engagement strategy. Lower them and the growth work shrinks. What needed to change was **how the execution time gets spent**: stop on my own terms and return exit 0 before SIGKILL arrives. I redesigned it so the script computes its own deadline and cuts the run short before hitting it, accepting that it may not reach the caps.\n\nAnd that creates a new problem.\n\n**exit 0 lies.**\n\n`ig_engage.py`\n\nnow finishes green every day. launchd records the job as successful. The dashboard status is fine. But in reality there are days when only 60 of the 101 actions completed. It was cut short because the budget ran out, and the log says \"normal termination.\"\n\nThis is **silent success**: the automation looks like it's working correctly, but it quietly stops halfway to the result. That's the danger of scheduled jobs. If you were doing it by hand, you'd notice — \"huh, that's fewer than usual today.\" Automation doesn't let you notice. exit 0 comes back every day, so nobody questions it. Follower growth stalls and you think \"probably the algorithm.\" In fact, it's just stopping at 40 actions every day.\n\nThe moment you turn truncation into normal termination, **you need a mechanism to quietly monitor the mechanism that quietly stops.**\n\nThis isn't about the work. It's about the environment.\n\nThe essential value of automation is that it runs while you sleep. But the moment it becomes *pretending* to run while you sleep, that value is gone — and it's gone without you noticing.\n\nSay the automation you set up terminated normally again today. Was that a normal termination that produced the expected result? Or a normal termination that was cut short partway? The exit code can't tell the two apart.\n\nThe mechanism I designed to draw that distinction is a threshold: **three consecutive days of truncation, alert once per day.**\n\n**It does not fire after one day.**\n\nOn a heavy day, you can get a single truncation and that's it. Followers spiked the day before so engagement work grew, the API was slow, another job contended for the slot — there are endless one-off reasons. Fire on day one and you become the boy who cried wolf before there's a real problem. When notifications arrive daily, humans stop reading them.\n\n**It does not fire every day.**\n\nIf truncation happens three days running, that's a structural problem: the balance between caps and execution time is permanently off. But turning that fact into a daily alert is pointless. It just becomes \"here it is again,\" and nobody feels like acting on it.\n\nThe three-consecutive-days threshold sits **outside single-event noise**, and the once-per-day frequency stays **within what a human can act on**. There's a sweet spot between those two failure modes.\n\nHere's the structure in one diagram.\n\n```\nlaunchd が ig_engage.py を起動\n        │\n        ▼\n  compute_budget()\n  ┌─────────────────────────────────────────────┐\n  │ 1. IG_ENGAGE_BUDGET_SEC（環境変数）を読む    │\n  │ 2. なければ BROWSER_SLOT_TIMEOUT_SEC を読む  │  ← 2,400秒\n  │ 3. どちらもなければ 0（全判定を無効化）      │\n  │ deadline = START_TS + budget - 120秒         │  ← BUDGET_MARGIN_S\n  └─────────────────────────────────────────────┘\n        │\n        ▼\n  起動ジッター（ランダム待機）\n  ┌───────────────────────────────────────┐\n  │ wait = min(900, (deadline - now) × 0.2) │  ← 残予算の20%でクランプ\n  └───────────────────────────────────────┘\n        │\n        ▼\n  アクションループ（likes → follows → unfollows）\n  ┌──────────────────────────────────────────────────────┐\n  │ ループ先頭: if blocked[\"hit\"] or over_budget(): break │  ← 既存判定に相乗り（4箇所）\n  │                                                        │\n  │ over_budget() の内訳:                                 │\n  │   残予算 < action_min_s(20秒) → True                 │\n  │   deadline を過ぎている        → True                 │\n  │                                                        │\n  │ action_sleep() で待機するとき:                        │\n  │   sleep時間を残予算内に収める                         │\n  └──────────────────────────────────────────────────────┘\n        │\n        ▼\n  return 0  ← exit 124 の代わりに \"正常終了\"\n  （ブロック検知・ログイン切れの exit 1 経路は一切触れない）\n        │\n        ▼\n  state/engage_budget.json を更新\n  ┌─────────────────────────────────────────┐\n  │ {                                         │\n  │   \"streak\": N,          // 連続打ち切り回数 │\n  │   \"last_date\": \"YYYY-MM-DD\",              │\n  │   \"last_alert_date\": \"YYYY-MM-DD\"         │\n  │ }                                         │\n  └─────────────────────────────────────────┘\n        │\n        ├── 今日も打ち切りだった場合\n        │       streak += 1\n        │       streak ≥ 3 かつ last_alert_date ≠ today\n        │              ↓\n        │         alerts へ通知（1日1回だけ）\n        │         \"capsが実行時間に対して過大：予算到達での打ち切りが\n        │          N日連続（likes XX/62, follows XX/24, unfollows XX/15）\"\n        │\n        └── 打ち切りなしだった場合\n                streak = 0 にリセット\n```\n\nThe amount of code that changed is smaller than you'd imagine. I didn't build any new control structures or classes. There were four existing `if blocked[\"hit\"]:`\n\nchecks, and I just appended `or over_budget()`\n\nto each. The truncation path is a single line: `return 0`\n\n. Following the rule that a new feature's default should be \"do nothing,\" when `compute_budget()`\n\ncan't obtain a budget (the value is 0 or unset), `deadline = None`\n\ndisables every check, so **existing behavior doesn't change by a millimeter.** The principle is: never build something that stops the job the instant an environment variable disappears.\n\nThe startup jitter clamp of `min(900, (deadline - now) × 0.2)`\n\nis arithmetic too. Using the original `start_jitter_max_s=900`\n\nas-is means up to 900 of the 2,400-second budget vanishes into jitter — 37.5% of it. Nearly 40% of the budget goes to a wait before any action happens. Capping it at 20% of the remaining budget guarantees time for the actions themselves even when jitter runs long. 20% of 2,400 seconds is 480 seconds, so it's shorter than the original 900 — and the shorter the budget, the shorter the jitter.\n\nThe structure where `browser-slot.sh`\n\npasses `BROWSER_SLOT_TIMEOUT_SEC`\n\nas the timeout setting and the Perl supervisor returns exit 124 on overrun is unchanged. If `ig_engage.py`\n\nfinishes on its own before the deadline, the supervisor returns `exit $? >> 8`\n\n(= exit 0). Once exit 124 stops appearing in the logs, from the outer framework's point of view everything is \"operating normally.\"\n\nWhich is exactly why the inner streak monitoring is required. Green from the outside, but stopping at 40 actions a day on the inside — the only way to detect that gap is for the job itself to record its own performance.\n\nFirst, the outer frame. `ig_engage.py`\n\nis not launched directly by launchd; it goes through `browser-slot.sh`\n\n. That shell script is the slot management layer controlling how many Chromium instances run in parallel.\n\nThe core of the script is a supervisor written in Perl. It's embedded as a bash heredoc and expanded at runtime with `perl -e`\n\n.\n\n``` php\nmy $timed_out = 0;\nlocal $SIG{ALRM} = sub { $timed_out = 1; stop_tree(); };\nalarm $timeout;\nwhile (waitpid($child, 0) == -1) {\n  next if $!{EINTR};\n  exit 1;\n}\nalarm 0;\nexit 124 if $timed_out;\nexit $? >> 8;\n```\n\n`alarm $timeout`\n\nsets the timer, and `waitpid`\n\nwaits until the child process exits. When the timeout hits, `SIGALRM`\n\nsets `$timed_out = 1`\n\nand `stop_tree()`\n\nruns. `stop_tree()`\n\nbuilds the process tree with `/bin/ps -axo pid=,ppid=`\n\nand sends TERM then KILL to every descendant.\n\n```\nsub stop_tree {\n  return if $stopping++;\n  my @pids = descendants($child);\n  kill \"TERM\", reverse(@pids), $child;\n  select undef, undef, undef, 1;\n  kill \"KILL\", grep { kill 0, $_ } reverse(@pids), $child;\n}\n```\n\nThis is the crucial part. `kill \"KILL\"`\n\nsends SIGKILL to the process. Even if Playwright calls `ctx.close()`\n\nin the `finally`\n\nblock of `async with browser.new_context() as ctx:`\n\n, there's no resisting SIGKILL. The finally never runs, and the Chromium process is left behind as an orphan.\n\nAt the end of `browser-slot.sh`\n\n, RESULT is split by exit code.\n\n```\nif [ \"$status\" -eq 124 ]; then\n  echo \"TIMEOUT: killed after ${TIMEOUT_SEC}s\"\n  RESULT=\"timeout:${TIMEOUT_SEC}s\"\nelse\n  RESULT=\"exit:$status\"\nfi\n```\n\nSo when `ig_engage.py`\n\nexits 0 on its own, `$? >> 8`\n\nis 0 and the bash-side status is 0 as well. `RESULT`\n\ngets logged as `exit:0`\n\n. Unless the supervisor reaches its `exit 124`\n\n, nothing is recorded as a timeout. That's the payoff of the \"stop from the inside first\" design.\n\nThe Python side starts with a function called `compute_budget()`\n\n. Environment variables are read in three stages.\n\n``` php\ndef compute_budget() -> float | None:\n    for key in (\"IG_ENGAGE_BUDGET_SEC\", \"BROWSER_SLOT_TIMEOUT_SEC\"):\n        val = os.environ.get(key, \"\")\n        if val.strip().isdigit() and int(val) > 0:\n            return float(val)\n    return None\n```\n\n`IG_ENGAGE_BUDGET_SEC`\n\nis read first so that a job-specific budget can be decoupled from `BROWSER_SLOT_TIMEOUT_SEC`\n\n. There are situations where you want to squeeze only the engagement job's budget without touching the slot-level timeout. If neither is set, it returns `None`\n\n.\n\nOn the calling side, `deadline`\n\nis decided like this.\n\n```\nBUDGET_MARGIN_S = 120  # スーパーバイザーがSIGKILLを送る前に確実に終わるための余裕\n\nbudget = compute_budget()\nif budget is not None:\n    deadline = START_TS + budget - BUDGET_MARGIN_S\nelse:\n    deadline = None  # 全判定を無効化\n```\n\nThe 120-second cushion in `BUDGET_MARGIN_S = 120`\n\nis the buffer for the Python side to reach `return 0`\n\nbefore the supervisor's `alarm $timeout`\n\ngoes off. It accounts for the time between the last action finishing and writing `engage_budget.json`\n\nplus sending the alert.\n\nWhen `deadline`\n\nis `None`\n\n, the `over_budget()`\n\nthat follows always returns `False`\n\n. In other words, the moment the environment variable disappears, the script doesn't run unbounded — it reverts to its previous state of leaving everything to the supervisor. That's the principle of putting a new feature's default on the \"do nothing\" side.\n\nThe action loop in `ig_engage.py`\n\nalready had four abort checks. Originally they existed only for block detection.\n\n```\n# 変更前（ブロック検知のみ）\nif blocked[\"hit\"]:\n    break\n\n# 変更後（予算チェックを相乗り）\nif blocked[\"hit\"] or over_budget():\n    break\n```\n\nI just added `or over_budget()`\n\nat those four points. No new `if`\n\nblocks, no new classes. Here's what `over_budget()`\n\ncontains.\n\n``` php\ndef over_budget() -> bool:\n    if deadline is None:\n        return False\n    now = time.time()\n    if now >= deadline:\n        return True\n    remaining = deadline - now\n    return remaining < ACTION_MIN_S  # 20秒\n```\n\nThe rule \"abort if under 20 seconds remain\" is baked in. 20 seconds is the value of `action_min_s`\n\n. The reasoning: if you start the next action, there's a high chance the supervisor kills you partway through. Better to `return 0`\n\nnow and leave a record than to start something you can't finish.\n\nThe between-action wait, `action_sleep()`\n\n, is also kept inside the budget.\n\n``` php\ndef action_sleep(min_s: float, max_s: float) -> None:\n    if deadline is not None:\n        remaining = deadline - time.time()\n        max_s = min(max_s, remaining - ACTION_MIN_S)\n        if max_s <= 0:\n            return  # 待たずに即返す\n    time.sleep(random.uniform(min_s, min(min_s, max_s)))\n```\n\nThe upper bound on the wait is \"remaining budget minus one action's worth (20 seconds).\" If less than 20 seconds remain, it doesn't sleep at all and returns immediately. The next `over_budget()`\n\ncheck then truncates the run.\n\nThe startup jitter is one line of code.\n\n```\nwait = min(START_JITTER_MAX_S, (deadline - time.time()) * 0.2)\n```\n\n`START_JITTER_MAX_S`\n\nwas originally 900 seconds. The intent in the original design was \"wait a random amount up to 900 seconds to spread out start times.\" But against a 2,400-second budget, a maximum 900-second jitter is **37.5%**. Almost 40% disappears into waiting alone.\n\nCapping it at 20% of the remaining budget means that when the budget is long the jitter is long (preserving the spreading effect), and when the budget is short the jitter is short (not eating into action time). 20% of 2,400 seconds is 480 seconds, so it's shorter than the original 900.\n\nLet's actually run the numbers.\n\n```\ndeadline = START_TS + 2400 - 120 = START_TS + 2280秒\n起動直後の残予算 ≒ 2280秒\n20% = 456秒\nmin(900, 456) = 456秒\n```\n\nThat keeps startup jitter to at most 456 seconds. The remaining 1,824 seconds go to processing actions. It doesn't reach the 4,040 seconds implied by 101 actions × 40 seconds average, but 2,280 seconds is worth roughly 72 actions. It can't process the full volume — but it can terminate normally without SIGKILL.\n\nThe structure of `state/engage_budget.json`\n\nis simple.\n\n```\n{\n  \"streak\": 2,\n  \"last_date\": \"2026-08-14\",\n  \"last_alert_date\": \"2026-08-12\"\n}\n```\n\nThe file is updated when the job ends. Whether truncation occurred is judged by comparing executed action counts against the caps.\n\n```\nwas_truncated = (\n    likes_done < LIKES_CAP or\n    follows_done < FOLLOWS_CAP or\n    unfollows_done < UNFOLLOWS_CAP\n) and budget_hit  # 上限到達で打ち切った場合のみ\n```\n\nThe separate `budget_hit`\n\nflag exists to distinguish \"stopped by block detection\" from \"stopped by budget exhaustion.\" Aborts due to block detection are not added to the streak. What I want to count is strictly the structural problem: \"caps × delays doesn't fit in the slot.\"\n\nThe alert condition is an AND of two things.\n\n```\ntoday = datetime.date.today().isoformat()\nif streak >= 3 and state.get(\"last_alert_date\") != today:\n    send_alert(\n        f\"capsが実行時間に対して過大：予算到達での打ち切りが{streak}日連続\\n\"\n        f\"likes {likes_done}/{LIKES_CAP}, \"\n        f\"follows {follows_done}/{FOLLOWS_CAP}, \"\n        f\"unfollows {unfollows_done}/{UNFOLLOWS_CAP}\"\n    )\n    state[\"last_alert_date\"] = today\n```\n\nThe `last_alert_date != today`\n\ncondition is what implements \"once per day.\" The notification fires on a day that satisfies `streak >= 3`\n\n, and no matter how many times the job runs later that day, `last_alert_date`\n\nis already stamped with today so there's no duplicate. If the streak stays at 3 or more the next day, another single message goes out.\n\nThree times, an implementation that was \"correct\" as a design produced a different problem once it actually ran.\n\nThe design that `over_budget()`\n\nreturns `False`\n\nwhen `deadline=None`\n\nwas correct. The problem was on the `action_sleep()`\n\nside.\n\n``` python\n# バグのあった版\ndef action_sleep(min_s, max_s):\n    remaining = deadline - time.time()  # deadline が None → TypeError\n    max_s = min(max_s, remaining - ACTION_MIN_S)\n    ...\n```\n\nI was dereferencing `deadline`\n\nwithout a None check. The symptom was \"the job dies immediately after startup, exit code 1.\" It only occurred in the development environment where `BROWSER_SLOT_TIMEOUT_SEC`\n\nwasn't set, so it went unnoticed in production for a week.\n\n```\n# slot.log で確認したログ\n2026-08-09T06:01:03+0900 label=ig-engage group=engage result=exit:1\n2026-08-10T06:01:14+0900 label=ig-engage group=engage result=exit:1\n```\n\n`result=exit:1`\n\nis indistinguishable from block detection. In the log it looks identical to a block. That's what delayed the discovery.\n\nThe fix is trivial. I put a guard at the top of `action_sleep()`\n\n.\n\n``` python\ndef action_sleep(min_s, max_s):\n    if deadline is None:\n        time.sleep(random.uniform(min_s, max_s))\n        return\n    remaining = deadline - time.time()\n    ...\n```\n\nThe lesson: \"if you design `deadline=None`\n\nas the disable switch, every place that dereferences `deadline`\n\nneeds a None check.\" Obvious in hindsight, but fixing only `over_budget()`\n\nand missing `action_sleep()`\n\nis entirely plausible.\n\nThis is from before the clamp existed. Against a 2,400-second budget, startup jitter went up to 900 seconds. On days when a long jitter got drawn, only 1,500 seconds of budget remained after startup — but even that was not the issue.\n\nThe real problem was the ordering: **the jitter runs before the over_budget() check.** There were days when the deadline passed during the 900-second jitter wait. By the time jitter finished, the remaining budget was negative, the very first `over_budget()`\n\ncheck truncated immediately, and it did `return 0`\n\nwith zero actions.\n\n```\n# state/engage_budget.json を確認\n{\"streak\": 1, \"last_date\": \"2026-08-05\", \"last_alert_date\": null}\n# ただし likes_done=0, follows_done=0 という状況\n```\n\nLikes were 0, yet the truncation streak was accumulating. The alert hadn't arrived yet. But a full day of processing was completely skipped.\n\nWhat I should have done before adding the clamp was to hold the awareness from the start that **jitter also comes out of the budget**. Add up jitter, waits, and action time together and check that the total fits in the execution slot. Stacking local optimizations breaks the whole.\n\nThere were days when the streak went past 3 and no notification arrived. Digging into the logs, the streak was being reset every day.\n\n```\n// 月曜\n{\"streak\": 1, \"last_date\": \"2026-08-11\"}\n// 火曜\n{\"streak\": 1, \"last_date\": \"2026-08-12\"}  // ← 積み上がっていない\n```\n\nThe cause was the `last_date`\n\nupdate logic. The initial implementation determined \"did truncation happen today?\" by \"does today's date match `last_date`\n\n?\"\n\n```\n# バグのあった版\ntoday = datetime.date.today().isoformat()\nif was_truncated:\n    if state.get(\"last_date\") == today:\n        pass  # 今日は既にカウント済み\n    else:\n        state[\"streak\"] = state.get(\"streak\", 0) + 1\n        state[\"last_date\"] = today\n```\n\nAt a glance it looks right. But when the `else`\n\nbranch updates `last_date`\n\nto today, it never considered the case where the previous `last_date`\n\nis something other than yesterday. If the job is skipped for two days (slot contention producing skip:global-limit) and then resumes, the streak doesn't trace back to the prior day and gets reset.\n\nThe correct approach is to make \"was yesterday a truncation?\" the continuation condition for the streak.\n\n```\ntoday = datetime.date.today()\nyesterday = (today - datetime.timedelta(days=1)).isoformat()\ntoday_str = today.isoformat()\n\nif was_truncated:\n    if state.get(\"last_date\") == yesterday:\n        state[\"streak\"] = state.get(\"streak\", 0) + 1\n    elif state.get(\"last_date\") != today_str:\n        state[\"streak\"] = 1  # 連続が途切れた、1から再スタート\n    state[\"last_date\"] = today_str\nelse:\n    state[\"streak\"] = 0\n    state[\"last_date\"] = today_str\n```\n\nThe logic: if yesterday was a truncation, continue; if the truncation record is from some day other than yesterday, reset the streak to 1. With this, resuming after a two-day skip no longer continues the consecutive count incorrectly.\n\nA week after landing this fix, the first alert finally came through: \"capsが実行時間に対して過大：予算到達での打ち切りが3日連続（likes 58/62, follows 18/24, unfollows 10/15）.\" Only when the notification arrived could I confirm the imbalance in the sense of \"three consecutive days.\"\n\nAll three sticking points share one shape.\n\n**The implementation is correct \"somewhere,\" but the connecting seam is missing.**\n\n`deadline=None`\n\nworks in `over_budget()`\n\nbut not in `action_sleep()`\n\n. The clamp applies to action time but not to jitter. The `last_date`\n\nupdate works for continuing the count but defines continuity too loosely. Read any piece in isolation and it looks correct. The problem lives at the boundary where multiple parts connect.\n\nThat's what makes automation scary. Bugs at the seams are discovered late because the run is succeeding (exit 0). Even looking at the log, it looks \"normal.\" It keeps running silently wrong until you line the numbers up chronologically and notice that something which should be growing isn't.\n\nI built the streak monitoring precisely to make that \"quietly running but half-empty\" state visible as a number. But monitoring code gets stuck too. Since I have no appetite for building monitoring for the monitoring, I write monitoring code with the policy \"keep it simple, in a line count where the logic is obvious at a glance.\" Complex monitoring code is itself a breeding ground for bugs.\n\nThe previous section covered three in detail: the missed `deadline=None`\n\ndereference, jitter overrunning the budget, and the `last_date`\n\nstreak bug. Here I'll aggregate all the failures that surfaced in the same window, including those.\n\n**I never once computed the product of caps × delays.** likes 62 / follows 24 / unfollows 15 = a maximum of 101 actions, average wait 40 seconds. 101 × 40 = 4,040 seconds. That's 1.7× the execution slot `BROWSER_SLOT_TIMEOUT_SEC=2400`\n\n. Not one line of code was wrong. Every setting was reasonable on its own. Only the product was broken.\n\n**I cut the caps by \"nominal value × 0.8\" and didn't even reach actuals.** When a Claude quota exhaustion dropped posts to zero on 2026-08-13, I cut every lane by a flat 20%. But `x-autoreply`\n\n's actuals are 63–93, while its cap was 150. 150 × 0.8 = 120 doesn't come anywhere near an actual of 93. It looked like a cut but cut nothing. The correct cap has to be set at **actuals × 0.8**. For `ig-autoreply`\n\nI dropped `OPEN_POLICY ? Infinity`\n\nand changed it to 160 (80% of the actual 200). There are situations where a cap isn't \"a value that constrains execution\" but merely \"a description of execution.\"\n\n**SIGKILL orphaned Chromium and machine load went vertical.** `~/.cache/lily-browser-slots/slot.log`\n\nwas full of `result=timeout:2400s`\n\nevery morning. But that wasn't where the real damage was. Playwright's `finally: ctx.close()`\n\ncan't resist SIGKILL. Orphaned Chromium processes piled up and the whole machine degraded. A classic case of a time-budget overrun surfacing as a seemingly unrelated system failure.\n\n**I throttled SLOT_MAX on a hunch and 30% of jobs got skipped.** The record lives in a comment in `browser-slot.sh`\n\n.\n\n```\n  # 実測(2026-08-09): Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd(47GB)/\n  # ComfyUI(12GB)/iii(3.5GB)であってブラウザジョブではなかった。上限3は過剰に厳しく\n  # 1日で92回のskip(全体の30%)を出していたので5に緩める。\n  SLOT_MAX=\"${BROWSER_SLOT_MAX:-6}\"\n```\n\nI assumed browser jobs were causing swap exhaustion and clamped down to `SLOT_MAX=3`\n\n, which produced 92 skips a day — 30% of the total. When I actually measured, the culprits were `dasd`\n\n(47GB) and `ComfyUI`\n\n(12GB); Chrome was using only 0.7GB.\n\n`browser-slot.sh`\n\nrecords the history.\n\n```\n  # 2026-08-15: 朝の7本同時timeoutで枠6が死んだrunに占有され、投稿レーン(xpilot.autopost\n  # 等)が global-limit で9回skipした。いいね/フォローは1回落ちても翌回で取り返せるが、\n  # 投稿はその時間帯の枠が消えると二度と埋まらない。\n  SLOT_RESERVED_GROUPS=\"${BROWSER_SLOT_RESERVED_GROUPS:-post}\"\n  SLOT_RESERVE_COUNT=\"${BROWSER_SLOT_RESERVE:-1}\"\n```\n\nThe priority difference — \"a like can be recovered tomorrow, but the 9am posting slot is gone once 9am passes\" — was buried under a design that treated all slots flat.\n\n**Discord's 2,000-character limit dropped an entire daily report.** `sendDiscordReport`\n\nin `lily-line-funnel/scripts/pdca.mjs`\n\nwas sending the whole report in a single POST. On days when the report ran long, the notification itself disappeared. A silent state where it's failing but there's no failure log. The fix is line-based chunking, counting characters with `[...s].length`\n\n(surrogate-pair safe), and truncating the body of Discord send errors to the first 800 characters while keeping the full text in the log.\n\n**Chrome screenshots did not \"auto-fit\" the height.** A comment in the code said \"use a large window so the height is automatic (fits content)\" — but that was wrong. `--headless=new --screenshot`\n\ncaptures the window size exactly as given. Even with short content, you get the padding of the specified size. Table images for note were being published at a constant 1760×4000px (with an enormous white margin below). The fix is a two-pass approach: read `scrollHeight`\n\nfrom `--dump-dom`\n\nin the first pass, then specify that height for the capture in the second pass. But the first pass's window height must be **200**. Leave it at 2000 and `scrollHeight`\n\nnever falls below 2000, so the same bug remains. The result went from 1760×4000 to **1760×1178**.\n\n**I trusted imagegen's output dimensions to match what I specified.** Even when instructed \"4:5 portrait, 1024×1280,\" the actual output comes back as 1122×1402 or 1003×1568. Aspect ratio specification in a generation tool is not a guarantee. Inconsistency remained across lanes — some normalized with `sips`\n\n, some didn't — and the finished AI portraits varied lane to lane. The right answer is to force-normalize after every generation and verify the actual dimensions.\n\n**The logs couldn't tell me whether the limiter was the cap or the supply.** While investigating why follower growth had stalled, there was no way to tell from the logs whether it had \"hit the cap and stopped\" or \"run out of candidates to process.\" Just emitting the single line `日次上限に到達して打ち切り: ig-autoreply 160/160`\n\nat truncation time eliminates the investigation time lost to the \"probably the algorithm\" misdiagnosis.\n\n** result=exit:1 told me nothing about the kind of error.** The\n\n`deadline=None`\n\nTypeError, aborts from block detection, and aborts from an expired login were all recorded as the same `exit:1`\n\n— as detailed in the previous section. When the exit code is identical but the causes differ, investigation starts by hunting for clues outside the log.After writing your settings, work out the worst-case duration (max caps × max delay + jitter ceiling) and compare it against `BROWSER_SLOT_TIMEOUT_SEC`\n\n. \"The caps are correct\" and \"the delays are correct\" are separate checks. The combination is where it first breaks. In this case, 101 × 60 + 900 = 6,960 seconds > 2,400 seconds was an answer available before ever running it.\n\nCaps are the ceiling on your growth strategy. Lower them and the strategy's effect shrinks. Computing a deadline with `compute_budget()`\n\nand doing `return 0`\n\nbefore it loses less. It's an implementation that changes your position from the one receiving SIGKILL to the one stopping voluntarily.\n\n``` php\ndef compute_budget() -> float | None:\n    for key in (\"IG_ENGAGE_BUDGET_SEC\", \"BROWSER_SLOT_TIMEOUT_SEC\"):\n        val = os.environ.get(key, \"\")\n        if val.strip().isdigit() and int(val) > 0:\n            return float(val)\n    return None  # 全判定を無効化\n```\n\nWhen `deadline = None`\n\n, `over_budget()`\n\nalways returns `False`\n\n. The moment the environment variable disappears, behavior reverts to what it was before. Never build something that stops the job the instant an environment variable disappears — that's the base principle.\n\n```\n# 変更前\nif blocked[\"hit\"]:\n    break\n\n# 変更後（4箇所に追加するだけ）\nif blocked[\"hit\"] or over_budget():\n    break\n```\n\nNo new classes, no new `if`\n\nblocks. The smaller the change, the lower the risk of breaking existing behavior. The main logic change here was adding `or over_budget()`\n\nin four places plus one line of `return 0`\n\n.\n\n```\nwait = min(START_JITTER_MAX_S, (deadline - time.time()) * 0.2)\n```\n\nAgainst a 2,400-second budget, a max 900-second jitter is 37.5%. Clamping at 20% of the remaining budget (480 seconds) means the shorter the budget, the shorter the jitter. 20% of 2,400 seconds is 480 seconds, shorter than the original 900, and the jitter automatically shrinks as the budget gets eaten into. An implementation that doesn't check whether budget remains after the jitter will terminate normally with zero actions.\n\n```\nwas_truncated = (\n    likes_done < LIKES_CAP or\n    follows_done < FOLLOWS_CAP or\n    unfollows_done < UNFOLLOWS_CAP\n) and budget_hit\n```\n\nKeeping a separate `budget_hit`\n\nflag separates aborts from block detection from aborts from budget exhaustion. The streak counts only the \"caps vs. execution time mismatch.\" Days when you were blocked don't increment the streak.\n\n| Lane | Actuals/day | Old cap | New cap (actuals × 0.8) |\n|---|---|---|---|\n| x-autoreply | 63–93 | 150 | 75 |\n| ig-autoreply (DM) | 200 | Infinity | 160 |\n| x-outbound | 120 | 120 | 96 |\n| threads-engage out | 47–50 | 50 | 40 |\n\nAgainst an actual of 93, taking the cap of 150 × 0.8 = 120 doesn't reach the actuals. The correct order is: measure the actuals first, then set the cap below them. If a cap isn't below the actuals, it isn't a cap — it's just a number.\n\n```\nSLOT_RESERVED_GROUPS=\"${BROWSER_SLOT_RESERVED_GROUPS:-post}\"\nSLOT_RESERVE_COUNT=\"${BROWSER_SLOT_RESERVE:-1}\"\n```\n\nReflect the priority difference — \"a posting window, once today's time slot passes, is never filled\" vs. \"a like can be recovered tomorrow\" — in the slot design. Treat all slots flat and the important jobs get thinned out. Engagement jobs (likes/follows) run under an effective cap of `effective_max = SLOT_MAX - SLOT_RESERVE_COUNT`\n\n, while posting jobs can use up to `SLOT_MAX`\n\n.\n\nDiscord's 2,000 characters, Chrome's scrollHeight, imagegen's output dimensions — all of them break if you implement on the assumption that \"the value I specified is the value I get.\" Constraints have to be held as \"the worst case of the numbers that come out,\" not \"the numbers I wrote.\" Building chunking before sending, size measurement before capturing, and forced normalization after generating into the process prevents bugs where the gap between specified and actual only surfaces later.\n\nFire on day one and single-event noise makes them unread. Fire every day and it becomes \"here it is again.\" Three consecutive days is the shortest streak that sits outside a one-off heavy day. Once per day is the amount a human can act on. A single `last_alert_date != today`\n\ncondition prevents duplicate notifications within the same day.\n\n```\ntoday = datetime.date.today().isoformat()\nif streak >= 3 and state.get(\"last_alert_date\") != today:\n    send_alert(\n        f\"capsが実行時間に対して過大：予算到達での打ち切りが{streak}日連続\\n\"\n        f\"likes {likes_done}/{LIKES_CAP}, \"\n        f\"follows {follows_done}/{FOLLOWS_CAP}, \"\n        f\"unfollows {unfollows_done}/{UNFOLLOWS_CAP}\"\n    )\n    state[\"last_alert_date\"] = today\n```\n\nJudging by \"is last_date today?\" means the streak doesn't accumulate correctly when the job resumes after a two-day skip. Making \"is last_date yesterday?\" the continuation condition defines continuity correctly across skips.\n\n```\nyesterday = (today - datetime.timedelta(days=1)).isoformat()\nif was_truncated:\n    if state.get(\"last_date\") == yesterday:\n        state[\"streak\"] = state.get(\"streak\", 0) + 1\n    elif state.get(\"last_date\") != today_str:\n        state[\"streak\"] = 1  # 連続が途切れた\n    state[\"last_date\"] = today_str\nelse:\n    state[\"streak\"] = 0\n    state[\"last_date\"] = today_str\n日次上限に到達して打ち切り: ig-autoreply 160/160\n```\n\nWith just this one line, the investigation into \"is stalled follower growth caused by the cap or by insufficient supply?\" takes two minutes. Without it, you can dig through logs for two hours and still not know. Write out the reason processing stopped, at the moment it stops.\n\nThe comments in `browser-slot.sh`\n\nrecord the measured numbers and the reasoning behind the decision. The measurement \"9 Chrome-family jobs total 0.7GB; the culprit is dasd at 47GB\" makes the next investigation take two minutes. When a setting has no comment, the next person to handle it (future you) starts the same investigation from scratch.\n\nDuring the period when the automation was \"quietly running but half-empty,\" the dashboard was clean, launchd recorded success, and exit 0 came back every morning.\n\nThe structure of the problem is simple. I wrote the caps and the execution time in separate places and never multiplied them together. The result: SIGKILL every day, orphaned Chromium, and a degraded machine. Discord's 2,000 characters, Chrome's scrollHeight, and imagegen's output dimensions are the same shape. Read any line of code and it's \"correct.\" Only the combination is broken. This bug will not be found in code review. The only thing that prevents it is the habit of computing the product of your settings.\n\nThe fix with the smaller diff is the correct implementation. Compute the deadline with `compute_budget()`\n\n, piggyback `over_budget()`\n\non the four existing decision points, stop yourself with `return 0`\n\n. The core logic change came to a two-digit line count.\n\nBut the moment you choose \"stop yourself and exit 0,\" you take on the responsibility of quietly monitoring the mechanism that quietly stops. The three-consecutive-days + once-per-day alert design is the minimal structure that discharges that responsibility. The point between the two thresholds — don't fire on day one, don't fire every day — was the only place where you can detect a structural problem while avoiding the boy who cried wolf.\n\nTrust exit 0, or verify what's inside exit 0 — that difference is the fork between an autonomous environment that sustains ¥1.2M a month and one that keeps quietly stopping.\n\nThe full picture of the system, the breakdown of the ¥1.2M/month, and a 30-day playbook are collected 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/exit-0-lies-a-job-that-needed-4940s-in-a-2400s-slot-and-the-3-day-streak-that-it", "canonical_source": "https://dev.to/bokuwalily/exit-0-lies-a-job-that-needed-4940s-in-a-2400s-slot-and-the-3-day-streak-that-exposed-it-dng", "published_at": "2026-08-27 11:00:06+00:00", "updated_at": "2026-08-27 11:18:58.574846+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Claude Code", "Playwright", "launchd"], "alternates": {"html": "https://wpnews.pro/news/exit-0-lies-a-job-that-needed-4940s-in-a-2400s-slot-and-the-3-day-streak-that-it", "markdown": "https://wpnews.pro/news/exit-0-lies-a-job-that-needed-4940s-in-a-2400s-slot-and-the-3-day-streak-that-it.md", "text": "https://wpnews.pro/news/exit-0-lies-a-job-that-needed-4940s-in-a-2400s-slot-and-the-3-day-streak-that-it.txt", "jsonld": "https://wpnews.pro/news/exit-0-lies-a-job-that-needed-4940s-in-a-2400s-slot-and-the-3-day-streak-that-it.jsonld"}}