Every setting in my Instagram automation script was correct. The action caps were correct. The delays were correct. And the job died with SIGKILL every single morning for two weeks — because nobody, including me, had ever multiplied those two correct numbers together.
Why This Setup Works
"Running" and "Earning" Are Not the Same Thing
My Mac currently runs more than 160 launchd-managed jobs. IG engagement, X auto-likes, note auto-posting, Threads, TikTok, follow management for each social network — all of them are registered as .plist files, and they keep grinding away in the background while I sleep, while I eat, and while I'm sitting in job interviews.
This setup is the skeleton of ¥1.2M in monthly revenue. My own hands-on time is close to zero: Claude Code writes code autonomously, Codex handles implementation, launchd handles scheduling. It took me six months to build a system where all I do is design and diagnose anomalies.
But a setup like this leaves room for a bug you can never find, no matter how many lines of code you read.
That's what this article is about.
Not a "Bug" — a Missing Multiplication
One morning in August 2026, I noticed that the IG engagement job brand-404/sns/ig_engage.py was finishing with exit 124 every day.
Exit 124 means timeout. It's the exit code you get when SIGKILL is sent to a process. Thinking something was wrong with the code, I opened ig_engage.py and read it. The logic was correct. Exception handling was there. Playwright session management was fine. There was no bug anywhere.
And yet it was being SIGKILLed every day.
The cause wasn't a single line of code. It was a missing piece of arithmetic: I had never once multiplied two configuration values together.
The Pattern Most Readers Will Hit
When you write an automation script, you think about two things separately.
Caps — how many actions per day. For IG, you decide something like "62 likes, 24 follows, 15 unfollows." Sensible values, chosen with Instagram's rate limits in mind.
Delays — random intervals between actions to look human. You set something like "minimum 20 seconds, maximum 60 seconds." Also a sensible value, chosen to avoid bot detection.
Both are correct. Both are reasonable. But I had never once computed their product.
最大アクション数: likes 62 + follows 24 + unfollows 15 = 101回
平均待機時間: (20 + 60) / 2 = 40秒
合計待機時間のみ: 101 × 40 = 4,040秒
起動jitter最大: 900秒(人間らしく起動タイミングをばらつかせる設定)
実行枠(launchd + browser-slot): BROWSER_SLOT_TIMEOUT_SEC = 2,400秒
(Max actions: likes 62 + follows 24 + unfollows 15 = 101. Average delay: (20 + 60) / 2 = 40s. Total sleep time alone: 101 × 40 = 4,040s. Max startup jitter: 900s — a setting that randomizes launch timing to look human. Execution window via launchd + browser-slot: BROWSER_SLOT_TIMEOUT_SEC = 2,400s.)
4,040s + 900s = 4,940s. That's 1.7× the 2,400-second execution window. Both settings were correct; only the combination was broken.
Every day, the window expired before all actions finished and the process was SIGKILLed. This went on for two weeks.
Why SIGKILL Mass-Produces "Unrelated" Failures
Here's the core of the story.
If the only consequence were "it stopped partway and didn't finish the remaining actions," the loss would be a partially completed growth campaign. But the actual damage went further.
When you do browser automation with Playwright, the standard practice is to put session cleanup in a finally block.
ctx = await browser.new_context(...)
try:
await do_engage_actions(ctx)
finally:
await ctx.close() # ← ここが大事
SIGKILL does not pass through that finally block.
SIGTERM (signal 15) can be caught by Python so it can clean up, but SIGKILL (signal 9) has the kernel kill the process immediately, so no handler runs at all. As a result, ctx.close() is never called and the Chromium process is left dangling.
What happens when this repeats every day?
My ~/Documents/claude-obsidian/wiki/learning/mac-fleet-resource-leaks.md records the measured numbers. On August 5, 2026, accumulated processes including orphaned Chromium peaked at 1,527, of which 395 were node. CPU idle dropped to 18%, and load average hit 24.6. Chrome headless startup hit its 180-second timeout — meaning it couldn't even launch — and not just IG but X, Threads, TikTok, and note all went down simultaneously.
The IG engagement SIGKILL was surfacing as a completely different phenomenon: total collapse of social-media automation.
Blowing the time budget shows up as machine-wide trouble that looks unrelated. That's the nastiest part of this problem. You can't find it by reading code, and because the symptom appears somewhere else entirely, root-causing it takes a long time.
The Structural Reason "Code That Doesn't Know Its Window" Gets Written
Let me lay out why this pattern is so easy to create.
When you're developing an automation script and verifying it locally, you don't think about the execution window. While debugging you narrow caps down to 5 items, and once it works you restore production values. You shorten delay to make verification faster, and lengthen it in production.
This split between the "verification phase" and the "production settings phase" is what causes the multiplication to fall through the cracks. The code only ever runs with the combination of "production caps × production delay" in production — and that's the exact place where nobody has computed whether the total time fits in the execution window.
launchd's BROWSER_SLOT_TIMEOUT_SEC=2400 lives outside the script, in the plist or environment variables. likes_cap=62 lives in the script's config values. action_min_s=20 lives somewhere else again. In each of those places, every value looks correct. There is no place where the product gets computed.
The Overall Flow
System Architecture
First, a diagram of the environment where the problem occurred.
┌─────────────────────────────────────────────────────────────┐
│ macOS launchd(160本以上のジョブを管理) │
│ │
│ com.lily.ig-engage(毎日06:00発火) │
│ └─ StartInterval: 86400 │
│ └─ BROWSER_SLOT_TIMEOUT_SEC: 2400 ←── 実行枠 │
└─────────────────┬───────────────────────────────────────────┘
│ 発火
▼
┌─────────────────────────────────────────────────────────────┐
│ ~/.claude/scripts/browser-slot.sh │
│ グローバル同時3本制限 + groupごとの上限を管理 │
│ 取得できなければ exit 0(skip) │
│ 取得できたら BROWSER_SLOT_TIMEOUT_SEC を子プロセスに継承 │
└─────────────────┬───────────────────────────────────────────┘
│ スロット取得成功
▼
┌─────────────────────────────────────────────────────────────┐
│ brand-404/sns/ig_engage.py │
│ │
│ 設定値(スクリプト内): │
│ likes_cap: 62 │
│ follows_cap: 24 │
│ unfollows_cap: 15 │
│ action_min_s: 20 ← delay の下限 │
│ action_max_s: 60 ← delay の上限 │
│ start_jitter_max_s: 900 │
│ │
│ ※ BROWSER_SLOT_TIMEOUT_SEC との積を計算する箇所が存在しない │
└─────────────────┬───────────────────────────────────────────┘
│ Playwright起動
▼
┌─────────────────────────────────────────────────────────────┐
│ Chromium(Playwright管理) │
│ Instagram へのアクション実行 │
│ │
│ アクション間 sleep(random(20, 60)) │
│ 101回 × 平均40秒 = 4,040秒 ←── 実行枠2400秒を大幅超過 │
└─────────────────┬───────────────────────────────────────────┘
│ 2400秒経過
▼
┌─────────────────────────────────────────────────────────────┐
│ browser-slot.sh が SIGKILL を送信 │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ig_engage.py の finally 節はスキップされる │ │
│ │ ctx.close() が呼ばれない │ │
│ │ Chromium プロセスが孤児化して常駐 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 翌日も同じことが繰り返される(exit 124) │
└─────────────────────────────────────────────────────────────┘
│ 孤児Chromiumが蓄積
▼
┌─────────────────────────────────────────────────────────────┐
│ Macリソースの圧迫(mac-fleet-resource-leaks.md 実測値) │
│ │
│ プロセス総数: → 1,527本(nodeだけで395本) │
│ CPU idle: → 18% │
│ load avg: → 24.6 │
│ Chrome起動: → 180秒タイムアウト(全レーン機能停止) │
└─────────────────────────────────────────────────────────────┘
Direction of the Fix
There were two options.
A. Lower the caps — drop likes_cap from 62 to 30 and the product fits in the window. But that means cutting the Instagram account growth campaign itself in half. It means lowering a number tied directly to revenue.
B. Make the runtime self-adjust within the budget — leave the caps alone. Instead, have the script always know how much time it has left and exit 0 on its own before the window closes.
I chose B.
Implementing compute_budget()
The idea is simple. When the script starts, compute a deadline once — "by when do I have to be done?" After that, before each action, check whether sleeping now would blow past the deadline, and return immediately if it would.
import os
import time
BUDGET_MARGIN_S = 120 # デッドライン直前の余裕(クリーンアップ用)
def compute_budget() -> float | None:
"""
IG_ENGAGE_BUDGET_SEC → BROWSER_SLOT_TIMEOUT_SEC → 0 の順で読む。
0(または未設定)のときは None を返し、全判定を無効化する。
環境変数が消えた瞬間にジョブが止まる作りにしない。
"""
raw = int(
os.getenv("IG_ENGAGE_BUDGET_SEC")
or os.getenv("BROWSER_SLOT_TIMEOUT_SEC")
or 0
)
if raw == 0:
return None
return time.time() + raw - BUDGET_MARGIN_S
def over_budget(deadline: float | None) -> bool:
if deadline is None:
return False
return time.time() >= deadline
The important part is the deadline=None fallback. When the environment variable isn't set (local debug runs, test environments, and so on), budget control is completely disabled and existing behavior is unchanged. The principle is: a new feature's default should sit on the "do nothing" side.
Handling Startup Jitter
ig_engage.py had a setting that inserts an initial random wait of up to 900 seconds, to make launch timing look human.
start_jitter_max_s = 900
# 修正前
jitter = random.uniform(0, start_jitter_max_s)
time.sleep(jitter)
The problem is that this jitter can eat the entire execution window. In the worst case, startup jitter alone consumes 900 seconds, leaving only 1,500 seconds for the main work. And then the 4,040 seconds of caps × delay begins on top of that.
After the fix, jitter is clamped to 20% of the remaining budget.
def compute_jitter(deadline: float | None, jitter_max: float) -> float:
if deadline is None:
return random.uniform(0, jitter_max)
remaining = deadline - time.time()
# 残予算の20%を超えないようにクランプ
capped_max = min(jitter_max, remaining * 0.2)
return random.uniform(0, max(0, capped_max))
With BROWSER_SLOT_TIMEOUT_SEC=2400, the remaining budget right after startup is about 2,280 seconds (after subtracting BUDGET_MARGIN_S). 20% of that is 456 seconds. Startup jitter is limited to a maximum of 456 seconds, guaranteeing at least 1,824 seconds for the main work.
Wiring It Into the Action Loop
The existing code had logic in four places that exits early on block detection.
# 修正前
if blocked["hit"]:
logger.warning("ブロック検知: 終了します")
return 1
The fix is just adding or over_budget(deadline) at each of those places. No new if-blocks, no new classes. It piggybacks on the existing decision points.
# 修正後
if blocked["hit"] or over_budget(deadline):
if over_budget(deadline):
logger.info("実行予算切れ: 正常終了します (exit 0)")
else:
logger.warning("ブロック検知: 終了します (exit 1)")
return 0 if over_budget(deadline) else 1
Block detection is still exit 1. Budget exhaustion is exit 0. This distinction matters, so the monitoring system doesn't confuse "something is wrong" with "planned early termination."
Make the Sleep Itself Budget-Aware
The sleeps between actions also need to respect the budget.
def action_sleep(min_s: float, max_s: float, deadline: float | None) -> bool:
"""
残予算が action_min_s を切ったら、sleepせずに False を返す(打ち切り合図)。
それ以外は残予算に収まる範囲でsleepしてTrueを返す。
"""
if deadline is None:
time.sleep(random.uniform(min_s, max_s))
return True
remaining = deadline - time.time()
if remaining < min_s:
# 1アクション分も残っていない → 次のアクションを実行しても終わらない
return False
# 残予算に収まる上限でsleep
actual_max = min(max_s, remaining - min_s)
time.sleep(random.uniform(min_s, max(min_s, actual_max)))
return True
When remaining < min_s (remaining time is below the minimum wait time), it's certain that the next action would be cut off before completing. At that point, the function skips the sleep and returns False, and the caller decides to terminate normally.
The Actual Main Loop
Put it all together and the main loop looks like this.
async def run_engage(account: str) -> int:
deadline = compute_budget()
# 起動jitter(予算の20%上限)
jitter = compute_jitter(deadline, start_jitter_max_s)
if jitter > 0:
logger.info(f"起動jitter: {jitter:.0f}秒待機 (残予算: {deadline - time.time():.0f}秒)")
time.sleep(jitter)
async with async_playwright() as pw:
ctx = await pw.chromium.launch_persistent_context(profile_dir, **launch_opts)
try:
page = await ctx.new_page()
await login_if_needed(page, account)
liked = followed = unfollowed = 0
# いいねループ
for target in get_like_targets():
# 予算チェック: ブロック検知と同じ条件に相乗り
if blocked["hit"] or over_budget(deadline):
break
await like_post(page, target)
liked += 1
if not action_sleep(action_min_s, action_max_s, deadline):
logger.info(f"予算切れでlikeループ打ち切り: {liked}件完了")
break
# フォローループ(同様の構造)
for candidate in get_follow_candidates():
if blocked["hit"] or over_budget(deadline):
break
await follow_user(page, candidate)
followed += 1
if not action_sleep(action_min_s, action_max_s, deadline):
logger.info(f"予算切れでfollowループ打ち切り: {followed}件完了")
break
# アンフォローループ(同様の構造)
# ...
logger.info(f"完了: likes={liked}, follows={followed}, unfollows={unfollowed}")
return 0
finally:
# SIGKILLではなく正常終了なので、ここが必ず実行される
await ctx.close()
logger.info("Chromiumセッション正常クローズ")
finally: await ctx.close() is guaranteed to run because this is exit 0 — a normal termination. Unlike when it was getting SIGKILLed, Chromium doesn't get orphaned.
Verifying With Numbers
Let's recompute the runtime after the fix.
予算: BROWSER_SLOT_TIMEOUT_SEC=2400, BUDGET_MARGIN_S=120
実効予算: 2400 - 120 = 2,280秒
起動jitter上限: min(900, 2280 × 0.2) = min(900, 456) = 456秒
起動jitterが最大456秒だったとして、本体処理への残予算:
2,280 - 456 = 1,824秒
1,824秒で何アクション実行できるか(average delay 40秒として):
1,824 ÷ 40 ≒ 45アクション
likes_cap=62, follows_cap=24, unfollows_cap=15 の合計101アクションには届かないが、
SIGKILLされるより45アクション完遂して exit 0 する方が遥かに良い。
Chromiumも孤児化しない。
(Budget: BROWSER_SLOT_TIMEOUT_SEC=2400, BUDGET_MARGIN_S=120. Effective budget: 2400 − 120 = 2,280s. Jitter ceiling: min(900, 2280 × 0.2) = 456s. If jitter hits its 456s max, the main work gets 2,280 − 456 = 1,824s. At an average delay of 40s, 1,824 ÷ 40 ≈ 45 actions. That falls short of the 101 total actions from the caps — but completing 45 actions and exiting 0 is vastly better than being SIGKILLed, and Chromium doesn't get orphaned.)
In practice, startup jitter rarely reaches the 456-second ceiling; the average is around 228 seconds. In that case the main work gets 2,052 seconds of remaining budget, and the action count lands around 51.
Dealing With the "Silently Green" Problem
Here a new problem appears.
The moment budget exhaustion turns into exit 0, the monitoring system sees "normal termination." Every day it "runs fine," but the actual action count is less than half the cap — and that state is invisible to everyone.
~/Documents/claude-obsidian/wiki/learning/execution-budget-vs-caps.md also records how I handled this.
STREAK_ALERT_THRESHOLD = 3 # 何日連続で鳴らすか
ENGAGE_BUDGET_STATE_FILE = "~/dev/brand-404/state/engage_budget.json"
def update_budget_streak(was_budget_limited: bool,
likes: int, likes_cap: int,
follows: int, follows_cap: int) -> None:
state = load_json(ENGAGE_BUDGET_STATE_FILE, default={
"streak": 0,
"last_date": None,
"last_alert_date": None,
})
today = date.today().isoformat()
if state["last_date"] == today:
return # 同日の2回目以降は無視
if was_budget_limited:
state["streak"] = state.get("streak", 0) + 1
else:
state["streak"] = 0
state["last_date"] = today
# 3日連続 かつ 今日まだアラートを出していない場合のみ通知
if state["streak"] >= STREAK_ALERT_THRESHOLD:
if state.get("last_alert_date") != today:
send_alert(
f"⚠️ IGエンゲージが実行予算で打ち切られています\n"
f"連続 {state['streak']} 日\n"
f"likes: {likes}/{likes_cap}, "
f"follows: {follows}/{follows_cap}"
)
state["last_alert_date"] = today
save_json(ENGAGE_BUDGET_STATE_FILE, state)
Don't fire on day one (a single heavy day would make you the boy who cried wolf). Don't fire every day (continuous alarms stop being read). Fire only after three consecutive days, and only once per day.
This design separates "stopping myself and exiting 0" from "detecting an anomaly." It treats normal early termination and a structurally-over-window caps setting as two different things.
How to Tell Where compute_budget() Belongs
Let me also lay out the criteria for deciding "which scripts should get this."
Required conditions (when all apply)
- Uses a browser such as Playwright (there's a risk of orphaning on SIGKILL)
- Has an externally imposed runtime limit, such as
browser-slot.shor launchd's StartCalendarInterval - The product of action count × average wait time could exceed the execution window
Not needed (when any one applies)
- Doesn't use a browser (API calls, etc., where no cleanup is needed if cut off partway)
- No runtime limit (manual runs, or cron with no time limit)
- Only one action (no loop, so the product problem doesn't arise)
In this case, besides ig_engage.py, the same pattern existed in the X automation (x_engage.py) and Threads follow management (threads_follow.py). I did the work of adding compute_budget() to each of them at the same time.
(Continued in the second half)
Implementation Details
Why deadline Is Exposed in Function Signatures
The first design question I wrestled with during implementation was "where should deadline live?"
Class variable, singleton, global — there were several options, but I rejected all of them and went with "thread deadline: float | None through every function signature." There are two reasons.
The first is testability. over_budget(None) always returns False. action_sleep(20, 60, None) behaves exactly like the existing sleep. When the test side wants to disable budget control, it doesn't have to mock environment variables or rewrite config files — just pass None and all of the control disappears.
The second is making call paths visible. When deadline is lined up as an argument, the fact that "this function is budget-aware" shows up in the signature. Hide it in a class variable and you can't tell from looking at a function whether it has a time constraint.
# deadline を渡す側(明示的)
async def run_like_loop(page, targets, deadline: float | None) -> int:
liked = 0
for target in targets:
if over_budget(deadline):
break
await like_post(page, target)
liked += 1
if not action_sleep(action_min_s, action_max_s, deadline):
break
return liked
On the calling side you write run_like_loop(page, targets, deadline). Call it with deadline=None and you get an unlimited debug mode.
The Intent Behind the Three-Stage Environment Variable Fallback
Inside compute_budget(), variables are read in this order.
raw = int(
os.getenv("IG_ENGAGE_BUDGET_SEC")
or os.getenv("BROWSER_SLOT_TIMEOUT_SEC")
or 0
)
IG_ENGAGE_BUDGET_SEC is a script-specific override variable. It isn't set in production. It's used when you want to set a shorter-than-production value to test just the budget control. For example, run the script manually with IG_ENGAGE_BUDGET_SEC=300 and you can confirm it cuts off after five minutes.
BROWSER_SLOT_TIMEOUT_SEC is the value that browser-slot.sh passes to child processes. It's configured on the plist side. The script inherits "by when should I finish" from the parent that launched it. The script itself manages nothing.
The 0 fallback means "deadline=None, all checks disabled." For manual runs that don't go through browser-slot.sh, or other environments where the variable isn't set, budget control is quietly disabled. This is intentional design. A feature flag's default should sit on the "do nothing" side. I don't build things that stop the job the moment an environment variable disappears.
The Boundary in action_sleep() — Why Compare Against min_s
remaining = deadline - time.time()
if remaining < min_s:
return False
The key point is that this compares against min_s, not max_s.
remaining < max_s would mean "give up unless the maximum wait time can be secured." But action_sleep() has the ability to clamp the sleep shorter, so even if it can't reach max_s, it can sleep as long as it has at least min_s.
The reason for remaining < min_s is that I want to detect the moment when the next action cannot complete within the deadline even if executed. If you can't even secure the minimum sleep value, then sleeping and starting the action means a high chance of being SIGKILLed mid-action. Returning False and ending the action loop is cleaner than that.
# sleepのクランプ
actual_max = min(max_s, remaining - min_s)
time.sleep(random.uniform(min_s, max(min_s, actual_max)))
return True
remaining - min_s is the ceiling. This guarantees that "after the sleep ends, at least min_s worth of execution window remains for the next action." With 50 seconds remaining and min_s=20, max_s=60, the actual sleep lands at 30 seconds max (50−20).
Why Piggybacking on Block Detection Means "No New If-Blocks"
The code already had four places for block detection, expired-login detection, and other abnormal-termination decisions. Each of them is a path that returns 1 (error).
Creating a new if-block would mean "budget exhaustion" starts existing as an independent control flow. That leaves a debt for the future maintainer (including future me): "you have to read here to understand budget-exhaustion behavior."
With the approach of adding or over_budget(deadline) to existing decision points, reading "the paths by which this script terminates" naturally surfaces budget exhaustion too. One existing control flow gains a condition; no new control flow is born.
# 4箇所のうち1箇所
if blocked["hit"] or over_budget(deadline):
reason = "予算切れ" if over_budget(deadline) else "ブロック検知"
code = 0 if over_budget(deadline) else 1
logger.info(f"{reason}で終了 (exit {code})")
return code
blocked["hit"] and over_budget(deadline) mean different things. The former is an anomaly — "Instagram detected us." The latter is normal — "I stopped myself on schedule." Preserving that distinction in both the return code and the log message lets the monitoring system sort by exit code.
Rolling It Out to x_engage.py and threads_follow.py
Scripts with the same structure as ig_engage.py included the X automation (x_engage.py) and Threads follow management (threads_follow.py). Both launch via browser-slot.sh and have sleeps in their action loops — the same pattern.
The rollout started by extracting compute_budget() and action_sleep() into a utility file that can be shared across scripts.
# brand-404/sns/_budget.py
import os, time, random
BUDGET_MARGIN_S = 120
def compute_budget(env_specific: str | None = None) -> float | None:
raw = int(
(os.getenv(env_specific) if env_specific else None)
or os.getenv("BROWSER_SLOT_TIMEOUT_SEC")
or 0
)
return None if raw == 0 else time.time() + raw - BUDGET_MARGIN_S
def over_budget(deadline: float | None) -> bool:
return deadline is not None and time.time() >= deadline
def action_sleep(min_s: float, max_s: float, deadline: float | None) -> bool:
if deadline is None:
time.sleep(random.uniform(min_s, max_s))
return True
remaining = deadline - time.time()
if remaining < min_s:
return False
actual_max = min(max_s, remaining - min_s)
time.sleep(random.uniform(min_s, max(min_s, actual_max)))
return True
Each script's import became a single line.
from _budget import compute_budget, over_budget, action_sleep
Where I Got Stuck
Stuck #1: "I Read the Code Three Times and There Was No Bug"
It took more than two weeks before I noticed the IG engagement job was finishing with exit 124.
The symptom I saw first was something else entirely. My Discord alert channel was piling up with metrics-hub collection failure notifications every day. All lanes — X, Threads, TikTok, note — kept emitting browserType.launchPersistentContext: Timeout 180000ms exceeded. Chromium couldn't launch even after 180 seconds.
I started investigating on the assumption that it was "a Chrome problem." I suspected a version mismatch and checked Playwright's update history. I suspected a leftover SingletonLock and cleaned out profile directories. Neither was it.
The correct diagnostic viewpoint is recorded in mac-fleet-resource-leaks.md like this: "Because both Chrome and claude -p were down, I could immediately conclude it wasn't Chrome-specific but a compute-resource problem." If either one is alive, you can narrow it to a Chrome-specific issue. If both are dead, it's a problem with the resources underneath Chrome.
Counting processes gave 1,527 (395 from node alone). CPU idle 18%. Load average 24.6. Orphaned Chromium had accumulated, and the 160+ constantly running jobs were all fighting over CPU time.
I discovered that IG engagement was being SIGKILLed every day when I followed the logs chronologically. Two weeks of exit 124 records, lined up at the same time every day. Each SIGKILL skipped the finally block, and orphaned Chromium kept piling up. That accumulation manifested in a completely different form: Chrome's 180-second startup timeout.
There was not a single line of bug in the code itself. No matter how many times I read ig_engage.py, it was correct. The logic was correct. Exception handling was there. Playwright session management was fine. The very act of reading code was ineffective against this class of bug — that experience stuck with me.
Stuck #2: "The Moment I Made It exit 0, It Really Did Become Invisible"
The night I deployed the fix, the monitoring dashboard went all green. Naturally, since it now finished with exit 0. It felt like "fixed."
Three days later, a notification came into Discord. "⚠️ IG engagement is being cut off by execution budget / 3 consecutive days / likes: 43/62, follows: 18/24"
That was the first I learned that for three days, action counts had been getting cut off at around 70% of the caps every day.
I added the streak alert on the same day as the fix, but this was the moment I felt it had "really been necessary." Without it, the state of "terminating normally every day, but at 70% results" would have continued indefinitely, and I would never have seen that fact.
"Normal termination" is not normal. There are two kinds of normal termination: "finished having achieved the goal" and "wrapped up because time ran out." Monitoring systems normally don't distinguish the two.
The streak alert design of not firing on one day and only firing on three consecutive days comes from this dilemma. Fire on a single day and you get an alert every time a heavy day causes a one-off cutoff, and people stop reading them. Fire every day and it becomes a chronic alarm that gets ignored. Three consecutive days is the minimum sample size that demonstrates the fact "the caps are structurally too large for the execution window."
In response to this notification, instead of lowering the caps, I raised BROWSER_SLOT_TIMEOUT_SEC from 2,400 to 3,600 seconds. There was slack before and after the time window when IG engagement runs, so widening the window let me avoid cutting the growth campaign.
Stuck #3: "A Check at the Top of the Loop Wasn't Enough"
My first implementation placed over_budget(deadline) only at the top of each loop.
# 最初の実装
for target in get_like_targets():
if blocked["hit"] or over_budget(deadline):
break
await like_post(page, target)
time.sleep(random.uniform(action_min_s, action_max_s)) # ← ここが問題
After like_post() finishes and time.sleep() begins, even if the deadline passes mid-sleep, nothing stops until the check at the top of the next loop. If a 60-second sleep starts when only 20 seconds of budget remain, it overruns by 40 seconds before trying to proceed to the next action, and only then does over_budget() become True.
In practice, the BUDGET_MARGIN_S=120 slack meant a 40-second overrun wasn't a problem. But on days when startup jitter landed near its 456-second ceiling, the remaining budget for the main work got tight. In those cases the sleep ate deep into the remaining budget, and there were cases that exceeded the margin.
That's the motivation for creating action_sleep(). Give the sleep itself a notion of remaining budget, and this overrun structurally stops happening.
# 修正後
for target in get_like_targets():
if blocked["hit"] or over_budget(deadline):
break
await like_post(page, target)
if not action_sleep(action_min_s, action_max_s, deadline):
# 残予算が action_min_s を切った → 次のアクションを実行しても終わらない
logger.info(f"予算切れでlikeループ打ち切り: {liked}件完了")
break
When action_sleep() returns False, the remaining budget is under action_min_s. Running another loop iteration risks being SIGKILLed before the action completes. That's why receiving False triggers an immediate break.
Stuck #4: "In a Rolled-Out Script, Budget Control Was Silently Disabled"
After extracting _budget.py into a shared utility, I wired it into threads_follow.py. The code is correctly implemented. I put deadline = compute_budget("THREADS_FOLLOW_BUDGET_SEC") at the top and added over_budget(deadline) to each loop.
But when I ran a test locally, budget exhaustion never happened at all.
The cause was that BROWSER_SLOT_TIMEOUT_SEC wasn't set.
threads_follow.py was originally launched directly, without going through browser-slot.sh. It was one of the "22 out of 49 that weren't going through it" state recorded in mac-fleet-resource-leaks.md. Without going through browser-slot, BROWSER_SLOT_TIMEOUT_SEC never arrives as an environment variable. compute_budget() reads raw=0 and returns deadline=None. Every budget check is quietly disabled.
Nothing shows up in the logs either. deadline=None operates normally as "no budget control," so no error or warning occurs. And reading the code, it looks like "budget control is in place."
The fix had two stages. First, I changed threads_follow.py to launch via browser-slot.sh so that BROWSER_SLOT_TIMEOUT_SEC gets inherited. Second, I made compute_budget() log the deadline state.
def compute_budget(env_specific: str | None = None) -> float | None:
raw = int(
(os.getenv(env_specific) if env_specific else None)
or os.getenv("BROWSER_SLOT_TIMEOUT_SEC")
or 0
)
if raw == 0:
logger.debug("budget制御: 無効(環境変数なし)")
return None
deadline = time.time() + raw - BUDGET_MARGIN_S
logger.info(
f"budget制御: 有効 (raw={raw}s, margin={BUDGET_MARGIN_S}s, "
f"deadline=T+{raw - BUDGET_MARGIN_S}s)"
)
return deadline
Startup logs now carry either budget制御: 有効 (raw=2400s, margin=120s, deadline=T+2280s) or budget制御: 無効(環境変数なし) ("budget control: enabled/disabled"). Whether budget control is in effect can be confirmed by looking at a single line in the log file.
The reason I didn't want to change the deadline=None design is to uphold the principle "don't build things that stop the job the moment an environment variable disappears." When running the script in debug runs, non-production environments, or test environments, having budget control cut execution off partway is confusing. Being disabled by None is the correct behavior. But if the fact that it was disabled is in the log, the investigation of "why isn't it cutting off?" gets answered in one second.
What these four sticking points have in common is the property of "invisible from reading code."
In #1, the product of config values is invisible. In #2, the meaning in the exit code is invisible. In #3, the time consumed inside the sleep is invisible. In #4, the presence or absence of the environment variable is invisible.
Many problems in automation scripts are hard to find via code review. The case where the code is correct but the combination of configuration, environment, and runtime is broken is only visible through logs and measured values.
The pattern recorded in execution-budget-vs-caps.md as "the implementation doesn't know the size of the container" is a broader problem that includes these four. Discord's 2,000-character limit per message, the window size of Chrome screenshots, the output dimensions of AI image generation — four instances of the same pattern showed up in the same window at once. Write the limit and the time/capacity it takes to reach that limit in separate places, and both can be correct while only the combination breaks. Hold constraints as the multiplied result, and decide in advance what happens when they're exceeded — that's the biggest principle I took from this whole series of fixes.
Gotchas
The "Reading Code Gives No Answer" Category
The "every line is correct" kind of stuck takes the most time. As written above, I read ig_engage.py three times and found no bug. The answer isn't in the code, so the act of reading code is ineffective. With this type of sticking point, the assumptions "I'm reading it wrong" and "one more pass and I'll spot it" are what drag it out. There is no way to discover a problem in the product of config values other than logs and measured values.
The indirection of "the symptom appears somewhere else" slows down diagnosis. The IG engagement SIGKILL caused all lanes — X, Threads, TikTok, note — to stop. As recorded in mac-fleet-resource-leaks.md, the total process count on August 5, 2026 was 1,527 (395 from node alone), and load average was 24.6. What surfaced was the Playwright error browserType.launchPersistentContext: Timeout 180000ms exceeded (Chrome's 180-second startup timeout), which looks like "a Chrome problem." The real cause was the IG script orphaning Chromium every day.
The question "are Chrome and claude -p both dead?" reduces diagnosis to one line. If either is alive, it's a Chrome-specific problem. If both are dead, you can immediately conclude it's a compute-resource problem. Knowing this, you decide to "dig into resources" before even considering the version-mismatch theory and the leftover-SingletonLock theory.
A reversal happens where "the processes you launched become part of the failure." mac-fleet-resource-leaks.md records that an over-broad grep I launched during investigation burned 73% CPU for 43 minutes. The investigation tool eats resources and worsens the symptom further. The loop of "the reason it isn't fixed is my own investigation command" really does happen. If you launch a broad grep, you need the awareness that you are responsible for it until you reap it.
The "Window Concept Scattered Across Multiple Places" Category
Beyond Playwright, "implementations that don't know the size of the container" showed up four times in the same window. execution-budget-vs-caps.md records this as a "same-window pattern."
Discord's 2,000-character per-message limit —
sendDiscordReportinlily-line-funnel/scripts/pdca.mjswas POSTing the full text in one shot. On days when the report was long, the entire daily notification failed. The limit is a Discord spec, the character count is the script's output; those two lived in separate places and nobody was multiplying them.Chrome screenshot window size — an in-code comment saying "use a large window so the height is automatic (content fit)" was wrong; in reality it captures at the window size as-is. Table images for note were always 1760×4000px, i.e. published with a huge white margin below the table. The correct approach is two passes (measure scrollHeight with
--dump-dom, then capture), which fits within 1760×1178. The key is setting the first pass's window height to 200 — leave it at 2,000 and scrollHeight never goes below 2,000, so the same bug remains.Built-in imagegen output dimensions — even when instructed "4:5 portrait 1024×1280," raw output comes back as 1122×1402 or 1003×1568. Generation tools' aspect specifications can't be trusted, so forced normalization after saving is required every time.
These three are exactly the same pattern as the IG engagement SIGKILL problem. Write "the limit" and "the time/capacity it takes to reach that limit" in separate places, and both are correct while only the combination breaks.
The "Fixing It Makes Another Problem Invisible" Category
The moment I switched to exit 0, "only running at 70% every day" was invisible to everyone for three days. execution-budget-vs-caps.md records that "the moment you turn a cutoff into a normal termination, you enter the silent-success antipattern." Until "⚠️ IG engagement is being cut off by execution budget / 3 consecutive days / likes: 43/62, follows: 18/24" arrived in Discord, the monitoring dashboard was all green. Had I not included the streak alert in the same change, this state would have continued indefinitely.
Adding compute_budget() to a script that doesn't go through browser-slot silently disables it. As detailed in part 2, threads_follow.py was originally launched directly without browser-slot.sh. BROWSER_SLOT_TIMEOUT_SEC never arrives, so raw=0, deadline=None, and every check is quietly disabled. Nothing appears in the logs — no warning, nothing. mac-fleet-resource-leaks.md records that "22 out of 49 were slipping straight past the concurrency gate." Roll out without checking whether a script goes through browser-slot, and "I added budget control" ends up not matching reality.
You can't notice that "the reaper isn't working" until you fix the reaper. auto-reboot.sh was judging on vm.swapusage's total (allocated size), so transient Spotlight spikes were falsely detected as emergency reboots. On top of that, the condition "don't reboot if any job is running" was structurally impossible to trigger in an environment where 130 jobs run constantly. The reboot-requested count was zero. I couldn't notice that the safety net had never once worked until I started debugging.
The "Scattered Config Values / Double Acquisition" Category
The "double acquisition" that consumed two global slots was something I built into the wiring myself. run-account.sh acquires a slot internally, but the plist also wrapped it in browser-slot.sh, so one job consumed two slots. Effective capacity was halved, and I only noticed because a log's group name differed from what I expected. After wiring something up, you need to look first for "behavior different from intent," not for "evidence that it worked."
dasd (macOS's Duet Activity Scheduler) had 47GB of swap despite 264MB of physical RSS. Looking at the top 10 processes, it never shows up. A reaper has to work on compressed memory (CMPRS), not RSS, or it walks right past the real culprit. The correct command is a single sudo killall dasd, and launchd rebuilds a new 28MB process. No reboot was needed.
Best Practices
① Compute max_actions × avg_delay before launch and write it in one place as the execution-budget. Stop managing caps and delays as separate variables; compute the multiplied duration up front as EXPECTED_DURATION_SEC. If that value exceeds the execution window, handle it as a design problem before you ever get into the script.
② Put compute_budget()'s default on the "do nothing" side (deadline=None). Don't design things that stop the job the moment an environment variable disappears. Read in three stages — IG_ENGAGE_BUDGET_SEC → BROWSER_SLOT_TIMEOUT_SEC → 0 — and if it's 0, disable all checks and preserve existing behavior exactly.
③ Always log whether budget control is enabled or disabled at startup.
if raw == 0:
logger.debug("budget制御: 無効(環境変数なし)")
return None
logger.info(f"budget制御: 有効 (raw={raw}s, deadline=T+{raw - BUDGET_MARGIN_S}s)")
The investigation "why isn't it cutting off?" gets answered in one second. The purpose is to make visible the state where compute_budget() is quietly returning None.
④ Clamp startup jitter to 20% of the remaining budget. Capping with min(jitter_max, remaining * 0.2) prevents the case where jitter eats the entire execution window. With BROWSER_SLOT_TIMEOUT_SEC=2400, the jitter ceiling becomes 456 seconds, guaranteeing at least 1,824 seconds for the main work.
⑤ Give action_sleep() the remaining budget too; don't rely on a check at the top of the loop alone. Even with a check at the top of the loop, if the deadline passes during a sleep nothing stops until the next top-of-loop check. A design where False is returned when remaining < action_min_s and the caller immediately breaks is what creates a structure that stops just short of SIGKILL.
⑥ Clearly distinguish budget exhaustion as exit 0 from block detection as exit 1. This keeps the monitoring system from confusing "something is wrong" with "planned early termination." Piggybacking on the same condition is enough; don't create a new control flow.
if blocked["hit"] or over_budget(deadline):
code = 0 if over_budget(deadline) else 1
logger.info(f"{'予算切れ' if code==0 else 'ブロック検知'}で終了 (exit {code})")
return code
⑦ Fire the alert on three consecutive days — not on one day, and not every day. Keep {streak, last_date, last_alert_date} in state/engage_budget.json and notify only when it's three consecutive days and today hasn't been notified yet. Fire on one day and a one-off heavy day makes you the boy who cried wolf; fire every day and it gets ignored. Three days is the minimum sample size to demonstrate "the caps are structurally too large for the execution window."
⑧ Thread deadline through every function signature to make it visible. Hide it in a class variable or a global and the fact that "this function is budget-aware" disappears from the signature. Writing action_sleep(min_s, max_s, deadline) means switching between debug mode (call with deadline=None) and production mode (pass a real value) is complete with a single argument. The test side just passes None instead of mocking environment variables.
⑨ Every time you roll out, check "does it go through browser-slot?" and extract shared utilities. Even if you build a shared module like _budget.py, budget control is silently disabled in scripts where BROWSER_SLOT_TIMEOUT_SEC never arrives. Check whether the script you're rolling out to goes through browser-slot.sh, and if it doesn't, wire that up first before integrating.
⑩ Keep a separate reaper for orphaned processes, and design on the assumption that you will be SIGKILLed. chrome-reaper.sh TERMs-then-KILLs orphaned Chrome with parent PID=1 (where --user-data-dir is under ~/dev/) and Chromium / chrome-headless-shell under ms-playwright that exceed 30 minutes. Even with perfect budget control, other jobs will get SIGKILLed. The fact that "SIGKILL doesn't pass through finally" doesn't change, so running a reaper every 10 minutes is your fail-safe.
⑪ The broader the symptom, the more you narrow the root cause by "what is not dead simultaneously." If both Chrome and claude -p are dead, it's compute resources. If only Chrome is dead, it's a Chrome-specific problem. When multiple lanes go down at once, the first thing to check is the resource side (process count, load average, swap usage, remaining disk). In the August 5, 2026 case, ps aux | wc -l gave 1,527 and load average was 24.6. The moment those numbers appear, the order becomes "crush the resource side before reading the individual logs of every lane."
⑫ Periodically confirm that "the safety net has never once fired." Just as auto-reboot.sh's reboot-requested count was zero, a safety net can look configured while its conditions actually make firing impossible. Print trigger= / decision= / blocking_jobs= all in the dry-run output and the state "the trigger condition was met, but a running job means no reboot" reads in one line. Safety-net effectiveness needs to be verified at least once a month.
⑬ Rigorously avoid reporting temporal sequence as causation. mac-fleet-resource-leaks.md contains a record where "load dropped from 82 to 24" was reported as "the effect of the exclusion." In reality the exclusion itself was ineffective and it was a timing coincidence. When an ineffective move gets enshrined as canon, you try it first at the next incident. Even when the fix and the improved numbers sit close together in time, writing it as causation requires a separate comparison against "what would have happened without the exclusion."
Summary
IG engagement was being SIGKILLed every day for two weeks. There wasn't a single line of bug in ig_engage.py's code, and reading the code couldn't find it. The cause was a missing piece of arithmetic: I had never once multiplied the cap likes_cap=62 with the wait times action_min_s=20 / action_max_s=60. 101 actions × an average of 40 seconds = 4,040 seconds, plus up to 900 seconds of startup jitter, gives 4,940 seconds. That's 1.7× the execution window of BROWSER_SLOT_TIMEOUT_SEC=2400.
SIGKILL does not pass through Python's finally block. Without ctx.close() ever being called, Chromium was orphaned and accumulated every day. The result was 1,527 processes, load average 24.6, Chrome's 180-second startup timeout, and every social-media lane going down. One script's time overrun surfaced as machine-wide trouble that looked completely unrelated.
The direction of the solution wasn't "lower the caps and cut the growth campaign" but "have the script itself know its remaining time and stop on its own with exit 0 before the window closes." compute_budget() computes the deadline exactly once at startup, over_budget(deadline) piggybacks on each loop's existing checks, and action_sleep() gives the sleep itself a notion of remaining budget. On top of that, a streak alert that detects "three consecutive days of budget cutoff" makes the quiet degradation of "it became exit 0 but results are at 70%" visible.
In the same window, three more instances showed up at once: "a report POSTed without knowing Discord's 2,000-character limit," "the gap between Chrome screenshot window size and output size," and "the built-in imagegen's aspect specification can't be trusted." The pattern is identical in all of them. Write "the limit" and "the time/capacity it takes to reach that limit" in separate places, and both are correct while only the combination breaks. Holding constraints as the multiplied result, and deciding in advance what happens when they're exceeded, is the only way to get ahead of failures that reading code can't reveal.
Many problems in automation scripts are hard to find via code review. The case where the code is correct but the combination of configuration, environment, and runtime is broken is only visible through logs and measured values. "Running" and "earning" are not the same thing — and neither are "terminating normally" and "achieving the goal."
I've written up the full picture of the setup, the breakdown of the ¥1.2M/month, and the 30-day procedure in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)