I Never Multiplied Two Config Values — and Got SIGKILLed Every Day for Two Weeks A developer's Instagram automation script was killed with SIGKILL every day for two weeks because two configuration values—action caps and delays—were never multiplied together, exceeding the execution window. The bug left orphaned Chromium processes, accumulating to 1,527 processes and degrading system performance. The developer's setup runs over 160 launchd-managed jobs generating ¥1.2M in monthly revenue. 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. 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. 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. 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. 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. 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. 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秒タイムアウト(全レーン機能停止) │ └─────────────────────────────────────────────────────────────┘ 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. 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. python 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. 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 . php 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. 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." The sleeps between actions also need to respect the budget. php 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. Put it all together and the main loop looks like this. php 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. 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. 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. compute budget Belongs Let me also lay out the criteria for deciding "which scripts should get this." Required conditions when all apply browser-slot.sh or launchd's StartCalendarInterval Not needed when any one applies 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 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. python 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. 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. 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 . 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. 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. python 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. python from budget import compute budget, over budget, action sleep 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. 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. 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 . 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. php 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. 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 . 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 — sendDiscordReport in lily-line-funnel/scripts/pdca.mjs was 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 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 "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. ① 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 break s 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." 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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート https://note.com/bokuwalily/n/n849b3a07784a Written by Lily — I ship iOS apps and automate my content stack with Claude Code. Follow along: Portfolio https://bokuwalily.com · X https://x.com/bokuwalily · GitHub https://github.com/bokuwalily