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
.
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.
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.
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.
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.
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.
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()
.
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.
{"streak": 1, "last_date": "2026-08-05", "last_alert_date": null}
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
.
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.
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.
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
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่ชๅพ็ฐๅขใงใๅฎ้ใฉใ็จผใใ โ ไป็ตใฟใปๅฎไพใปๅงใๆนใปใตใใผใ
*Written by Lily โ I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio ยท X ยท GitHub*