exit 0 Lies: A Job That Needed 4,940s in a 2,400s Slot, and the 3-Day Streak That Exposed It 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. 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. This happened in August 2026. My IG engagement job ig engage.py was ending with exit 124 every single day. exit 124 means SIGKILL — the code you get when launchd force-kills a job. The Perl supervisor inside browser-slot.sh is built so that the moment the configured timeout passes, it fires alarm $timeout; ... exit 124 if $timed out; , following the framework-wide convention of "on timeout, exit with 124." Digging through the logs, it was the same thing every day: TIMEOUT: killed after 2400s . Not 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. But I had never once multiplied them together. 101アクション × 平均40秒 = 4,040秒 + 起動ジッター最大 900秒 ──────────────────────── 最大所要時間 4,940秒 実行枠(BROWSER SLOT TIMEOUT SEC) 2,400秒 4,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. SIGKILL had a second, concrete cost. Because Playwright's finally: ctx.close never runs, Chromium processes get orphaned and pile up. Every morning ~/.cache/lily-browser-slots/slot.log was full of result=timeout:2400s , but the real damage wasn't there — it was the machine-wide load climbing with no ceiling. 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. And that creates a new problem. exit 0 lies. ig engage.py now 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." This 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. The moment you turn truncation into normal termination, you need a mechanism to quietly monitor the mechanism that quietly stops. This isn't about the work. It's about the environment. The 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. Say 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. The mechanism I designed to draw that distinction is a threshold: three consecutive days of truncation, alert once per day. It does not fire after one day. On 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. It does not fire every day. If 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. The 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. Here's the structure in one diagram. launchd が ig engage.py を起動 │ ▼ compute budget ┌─────────────────────────────────────────────┐ │ 1. IG ENGAGE BUDGET SEC(環境変数)を読む │ │ 2. なければ BROWSER SLOT TIMEOUT SEC を読む │ ← 2,400秒 │ 3. どちらもなければ 0(全判定を無効化) │ │ deadline = START TS + budget - 120秒 │ ← BUDGET MARGIN S └─────────────────────────────────────────────┘ │ ▼ 起動ジッター(ランダム待機) ┌───────────────────────────────────────┐ │ wait = min 900, deadline - now × 0.2 │ ← 残予算の20%でクランプ └───────────────────────────────────────┘ │ ▼ アクションループ(likes → follows → unfollows) ┌──────────────────────────────────────────────────────┐ │ ループ先頭: if blocked "hit" or over budget : break │ ← 既存判定に相乗り(4箇所) │ │ │ over budget の内訳: │ │ 残予算 < action min s 20秒 → True │ │ deadline を過ぎている → True │ │ │ │ action sleep で待機するとき: │ │ sleep時間を残予算内に収める │ └──────────────────────────────────────────────────────┘ │ ▼ return 0 ← exit 124 の代わりに "正常終了" (ブロック検知・ログイン切れの exit 1 経路は一切触れない) │ ▼ state/engage budget.json を更新 ┌─────────────────────────────────────────┐ │ { │ │ "streak": N, // 連続打ち切り回数 │ │ "last date": "YYYY-MM-DD", │ │ "last alert date": "YYYY-MM-DD" │ │ } │ └─────────────────────────────────────────┘ │ ├── 今日も打ち切りだった場合 │ streak += 1 │ streak ≥ 3 かつ last alert date ≠ today │ ↓ │ alerts へ通知(1日1回だけ) │ "capsが実行時間に対して過大:予算到達での打ち切りが │ N日連続(likes XX/62, follows XX/24, unfollows XX/15)" │ └── 打ち切りなしだった場合 streak = 0 にリセット The 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" : checks, and I just appended or over budget to each. The truncation path is a single line: return 0 . Following the rule that a new feature's default should be "do nothing," when compute budget can't obtain a budget the value is 0 or unset , deadline = None disables 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. The startup jitter clamp of min 900, deadline - now × 0.2 is arithmetic too. Using the original start jitter max s=900 as-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. The structure where browser-slot.sh passes BROWSER SLOT TIMEOUT SEC as the timeout setting and the Perl supervisor returns exit 124 on overrun is unchanged. If ig engage.py finishes on its own before the deadline, the supervisor returns exit $? 8 = exit 0 . Once exit 124 stops appearing in the logs, from the outer framework's point of view everything is "operating normally." Which 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. First, the outer frame. ig engage.py is not launched directly by launchd; it goes through browser-slot.sh . That shell script is the slot management layer controlling how many Chromium instances run in parallel. The core of the script is a supervisor written in Perl. It's embedded as a bash heredoc and expanded at runtime with perl -e . php my $timed out = 0; local $SIG{ALRM} = sub { $timed out = 1; stop tree ; }; alarm $timeout; while waitpid $child, 0 == -1 { next if $ {EINTR}; exit 1; } alarm 0; exit 124 if $timed out; exit $? 8; alarm $timeout sets the timer, and waitpid waits until the child process exits. When the timeout hits, SIGALRM sets $timed out = 1 and stop tree runs. stop tree builds the process tree with /bin/ps -axo pid=,ppid= and sends TERM then KILL to every descendant. sub stop tree { return if $stopping++; my @pids = descendants $child ; kill "TERM", reverse @pids , $child; select undef, undef, undef, 1; kill "KILL", grep { kill 0, $ } reverse @pids , $child; } This is the crucial part. kill "KILL" sends SIGKILL to the process. Even if Playwright calls ctx.close in the finally block of async with browser.new context as ctx: , there's no resisting SIGKILL. The finally never runs, and the Chromium process is left behind as an orphan. At the end of browser-slot.sh , RESULT is split by exit code. if "$status" -eq 124 ; then echo "TIMEOUT: killed after ${TIMEOUT SEC}s" RESULT="timeout:${TIMEOUT SEC}s" else RESULT="exit:$status" fi So when ig engage.py exits 0 on its own, $? 8 is 0 and the bash-side status is 0 as well. RESULT gets logged as exit:0 . Unless the supervisor reaches its exit 124 , nothing is recorded as a timeout. That's the payoff of the "stop from the inside first" design. The Python side starts with a function called compute budget . Environment variables are read in three stages. php def compute budget - float | None: for key in "IG ENGAGE BUDGET SEC", "BROWSER SLOT TIMEOUT SEC" : val = os.environ.get key, "" if val.strip .isdigit and int val 0: return float val return None IG ENGAGE BUDGET SEC is read first so that a job-specific budget can be decoupled from BROWSER SLOT TIMEOUT SEC . 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 . On the calling side, deadline is decided like this. BUDGET MARGIN S = 120 スーパーバイザーがSIGKILLを送る前に確実に終わるための余裕 budget = compute budget if budget is not None: deadline = START TS + budget - BUDGET MARGIN S else: deadline = None 全判定を無効化 The 120-second cushion in BUDGET MARGIN S = 120 is the buffer for the Python side to reach return 0 before the supervisor's alarm $timeout goes off. It accounts for the time between the last action finishing and writing engage budget.json plus sending the alert. When deadline is None , the over budget that follows always returns False . 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. The action loop in ig engage.py already had four abort checks. Originally they existed only for block detection. 変更前(ブロック検知のみ) if blocked "hit" : break 変更後(予算チェックを相乗り) if blocked "hit" or over budget : break I just added or over budget at those four points. No new if blocks, no new classes. Here's what over budget contains. php def over budget - bool: if deadline is None: return False now = time.time if now = deadline: return True remaining = deadline - now return remaining < ACTION MIN S 20秒 The rule "abort if under 20 seconds remain" is baked in. 20 seconds is the value of action min s . The reasoning: if you start the next action, there's a high chance the supervisor kills you partway through. Better to return 0 now and leave a record than to start something you can't finish. The between-action wait, action sleep , is also kept inside the budget. php def action sleep min s: float, max s: float - None: if deadline is not None: remaining = deadline - time.time max s = min max s, remaining - ACTION MIN S if max s <= 0: return 待たずに即返す time.sleep random.uniform min s, min min s, max s The 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 check then truncates the run. The startup jitter is one line of code. wait = min START JITTER MAX S, deadline - time.time 0.2 START JITTER MAX S was 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. Capping 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. Let's actually run the numbers. deadline = START TS + 2400 - 120 = START TS + 2280秒 起動直後の残予算 ≒ 2280秒 20% = 456秒 min 900, 456 = 456秒 That 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. The structure of state/engage budget.json is simple. { "streak": 2, "last date": "2026-08-14", "last alert date": "2026-08-12" } The file is updated when the job ends. Whether truncation occurred is judged by comparing executed action counts against the caps. was truncated = likes done < LIKES CAP or follows done < FOLLOWS CAP or unfollows done < UNFOLLOWS CAP and budget hit 上限到達で打ち切った場合のみ The separate budget hit flag 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." The alert condition is an AND of two things. today = datetime.date.today .isoformat if streak = 3 and state.get "last alert date" = today: send alert f"capsが実行時間に対して過大:予算到達での打ち切りが{streak}日連続\n" f"likes {likes done}/{LIKES CAP}, " f"follows {follows done}/{FOLLOWS CAP}, " f"unfollows {unfollows done}/{UNFOLLOWS CAP}" state "last alert date" = today The last alert date = today condition is what implements "once per day." The notification fires on a day that satisfies streak = 3 , and no matter how many times the job runs later that day, last alert date is 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. Three times, an implementation that was "correct" as a design produced a different problem once it actually ran. The design that over budget returns False when deadline=None was correct. The problem was on the action sleep side. python バグのあった版 def action sleep min s, max s : remaining = deadline - time.time deadline が None → TypeError max s = min max s, remaining - ACTION MIN S ... I was dereferencing deadline without 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 wasn't set, so it went unnoticed in production for a week. slot.log で確認したログ 2026-08-09T06:01:03+0900 label=ig-engage group=engage result=exit:1 2026-08-10T06:01:14+0900 label=ig-engage group=engage result=exit:1 result=exit:1 is indistinguishable from block detection. In the log it looks identical to a block. That's what delayed the discovery. The fix is trivial. I put a guard at the top of action sleep . python def action sleep min s, max s : if deadline is None: time.sleep random.uniform min s, max s return remaining = deadline - time.time ... The lesson: "if you design deadline=None as the disable switch, every place that dereferences deadline needs a None check." Obvious in hindsight, but fixing only over budget and missing action sleep is entirely plausible. This 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. The 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 check truncated immediately, and it did return 0 with zero actions. state/engage budget.json を確認 {"streak": 1, "last date": "2026-08-05", "last alert date": null} ただし likes done=0, follows done=0 という状況 Likes were 0, yet the truncation streak was accumulating. The alert hadn't arrived yet. But a full day of processing was completely skipped. What 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. There were days when the streak went past 3 and no notification arrived. Digging into the logs, the streak was being reset every day. // 月曜 {"streak": 1, "last date": "2026-08-11"} // 火曜 {"streak": 1, "last date": "2026-08-12"} // ← 積み上がっていない The cause was the last date update logic. The initial implementation determined "did truncation happen today?" by "does today's date match last date ?" バグのあった版 today = datetime.date.today .isoformat if was truncated: if state.get "last date" == today: pass 今日は既にカウント済み else: state "streak" = state.get "streak", 0 + 1 state "last date" = today At a glance it looks right. But when the else branch updates last date to today, it never considered the case where the previous last date is 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. The correct approach is to make "was yesterday a truncation?" the continuation condition for the streak. today = datetime.date.today yesterday = today - datetime.timedelta days=1 .isoformat today str = today.isoformat if was truncated: if state.get "last date" == yesterday: state "streak" = state.get "streak", 0 + 1 elif state.get "last date" = today str: state "streak" = 1 連続が途切れた、1から再スタート state "last date" = today str else: state "streak" = 0 state "last date" = today str The 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. A 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." All three sticking points share one shape. The implementation is correct "somewhere," but the connecting seam is missing. deadline=None works in over budget but not in action sleep . The clamp applies to action time but not to jitter. The last date update 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. That'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. I 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. The previous section covered three in detail: the missed deadline=None dereference, jitter overrunning the budget, and the last date streak bug. Here I'll aggregate all the failures that surfaced in the same window, including those. 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 . Not one line of code was wrong. Every setting was reasonable on its own. Only the product was broken. 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 '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 I dropped OPEN POLICY ? Infinity and 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." SIGKILL orphaned Chromium and machine load went vertical. ~/.cache/lily-browser-slots/slot.log was full of result=timeout:2400s every morning. But that wasn't where the real damage was. Playwright's finally: ctx.close can'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. I throttled SLOT MAX on a hunch and 30% of jobs got skipped. The record lives in a comment in browser-slot.sh . 実測 2026-08-09 : Chrome系ジョブ9個で合計0.7GB。swap枯渇の主犯は dasd 47GB / ComfyUI 12GB /iii 3.5GB であってブラウザジョブではなかった。上限3は過剰に厳しく 1日で92回のskip 全体の30% を出していたので5に緩める。 SLOT MAX="${BROWSER SLOT MAX:-6}" I assumed browser jobs were causing swap exhaustion and clamped down to SLOT MAX=3 , which produced 92 skips a day — 30% of the total. When I actually measured, the culprits were dasd 47GB and ComfyUI 12GB ; Chrome was using only 0.7GB. browser-slot.sh records the history. 2026-08-15: 朝の7本同時timeoutで枠6が死んだrunに占有され、投稿レーン xpilot.autopost 等 が global-limit で9回skipした。いいね/フォローは1回落ちても翌回で取り返せるが、 投稿はその時間帯の枠が消えると二度と埋まらない。 SLOT RESERVED GROUPS="${BROWSER SLOT RESERVED GROUPS:-post}" SLOT RESERVE COUNT="${BROWSER SLOT RESERVE:-1}" The 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. Discord's 2,000-character limit dropped an entire daily report. sendDiscordReport in lily-line-funnel/scripts/pdca.mjs was 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 surrogate-pair safe , and truncating the body of Discord send errors to the first 800 characters while keeping the full text in the log. 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 captures 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 from --dump-dom in 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 never falls below 2000, so the same bug remains. The result went from 1760×4000 to 1760×1178 . 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 , 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. 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 at truncation time eliminates the investigation time lost to the "probably the algorithm" misdiagnosis. result=exit:1 told me nothing about the kind of error. The deadline=None TypeError, aborts from block detection, and aborts from an expired login were all recorded as the same exit:1 — 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 . "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. Caps are the ceiling on your growth strategy. Lower them and the strategy's effect shrinks. Computing a deadline with compute budget and doing return 0 before it loses less. It's an implementation that changes your position from the one receiving SIGKILL to the one stopping voluntarily. php def compute budget - float | None: for key in "IG ENGAGE BUDGET SEC", "BROWSER SLOT TIMEOUT SEC" : val = os.environ.get key, "" if val.strip .isdigit and int val 0: return float val return None 全判定を無効化 When deadline = None , over budget always returns False . 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. 変更前 if blocked "hit" : break 変更後(4箇所に追加するだけ) if blocked "hit" or over budget : break No new classes, no new if blocks. The smaller the change, the lower the risk of breaking existing behavior. The main logic change here was adding or over budget in four places plus one line of return 0 . wait = min START JITTER MAX S, deadline - time.time 0.2 Against 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. was truncated = likes done < LIKES CAP or follows done < FOLLOWS CAP or unfollows done < UNFOLLOWS CAP and budget hit Keeping a separate budget hit flag 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. | Lane | Actuals/day | Old cap | New cap actuals × 0.8 | |---|---|---|---| | x-autoreply | 63–93 | 150 | 75 | | ig-autoreply DM | 200 | Infinity | 160 | | x-outbound | 120 | 120 | 96 | | threads-engage out | 47–50 | 50 | 40 | Against 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. SLOT RESERVED GROUPS="${BROWSER SLOT RESERVED GROUPS:-post}" SLOT RESERVE COUNT="${BROWSER SLOT RESERVE:-1}" Reflect 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 , while posting jobs can use up to SLOT MAX . Discord'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. Fire 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 condition prevents duplicate notifications within the same day. today = datetime.date.today .isoformat if streak = 3 and state.get "last alert date" = today: send alert f"capsが実行時間に対して過大:予算到達での打ち切りが{streak}日連続\n" f"likes {likes done}/{LIKES CAP}, " f"follows {follows done}/{FOLLOWS CAP}, " f"unfollows {unfollows done}/{UNFOLLOWS CAP}" state "last alert date" = today Judging 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. yesterday = today - datetime.timedelta days=1 .isoformat if was truncated: if state.get "last date" == yesterday: state "streak" = state.get "streak", 0 + 1 elif state.get "last date" = today str: state "streak" = 1 連続が途切れた state "last date" = today str else: state "streak" = 0 state "last date" = today str 日次上限に到達して打ち切り: ig-autoreply 160/160 With 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. The comments in browser-slot.sh record 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. During 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. The 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. The fix with the smaller diff is the correct implementation. Compute the deadline with compute budget , piggyback over budget on the four existing decision points, stop yourself with return 0 . The core logic change came to a two-digit line count. But 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. Trust 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. The full picture of the system, the breakdown of the ¥1.2M/month, and a 30-day playbook are collected 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