0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

⏱ caps × delayを一度も掛け算しなかったら毎日SIGKILLされた

0
Posted at

月10万の大学生が掛け持ち60万になり、会社都合で0に戻り、半年でClaude Code自律環境を建て直して今は月商120万——そのインフラが2週間、毎日静かに自滅していました。

なぜこの仕組みが効くのか

「動いている」と「稼いでいる」は別物です

わたしのMacには現在、launchdが管理するジョブが160本以上走っています。IGエンゲージ、X自動いいね、note自動投稿、Threads、TikTok、各SNSのフォロー管理——これらは全部 .plist として登録されていて、わたしが寝ている間も、食事をしている間も、就活の面接を受けている間も、バックグラウンドで淡々と動き続けます。

この構成が月商120万の骨格です。自分が手を動かす時間はほぼゼロで、Claude Codeが自律的にコードを書き、Codexが実装を担い、launchdがスケジューリングする。わたしがやるのは設計と異常の診断だけという体制を半年かけて組みました。

ところがこの体制には、コードを1行も読んでも絶対に発見できないバグが潜む余地があります。

今回の話はそれです。

「バグ」ではなく「算術の欠落」

2026年8月のある朝、IGエンゲージジョブ brand-404/sns/ig_engage.py が毎日 exit 124 で終わっていることに気づきました。

exit 124はタイムアウトです。プロセスに対してSIGKILLが送られたときの終了コードです。コードに何か問題があるのかと思い、ig_engage.py を開いて読みました。ロジックは正しい。例外処理もある。Playwrightのセッション管理も問題ない。どこにもバグはありません。

にもかかわらず、毎日SIGKILLされていました。

原因は1行のコードではなく、2つの設定値を一度も掛け合わせていなかったという算術の欠落でした。

読者の多くが踏む型

自動化スクリプトを書くとき、人は2つのことを別々に考えます。

上限(caps)——1日に何回アクションするか。IGなら「いいね62回、フォロー24回、アンフォロー15回」のように決めます。Instagramのレートリミットを意識した、適切な値です。

待機時間(delay)——人間らしく見せるために、アクション間にランダムな間隔を挟みます。「最小20秒、最大60秒」のように設定します。これも、BOT検出を避けるための適切な値です。

2つとも正しい。2つとも妥当。しかしその積を計算したことが一度もなかった。

最大アクション数: 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秒

4,040秒 + 900秒 = 4,940秒。実行枠の2,400秒に対して 1.7倍。どちらの設定も正しいのに、組み合わせだけが破綻していました。

毎日、全アクションを終える前に枠が切れてSIGKILLされていた。これが2週間続いていました。

SIGKILLが「無関係な障害」を量産する理由

ここが本題の核心です。

単に「途中で止まって残りのアクションができなかった」だけなら、損失は成長施策の部分完了で済みます。しかし実害はそれだけではありませんでした。

Playwrightでブラウザ自動化をするとき、セッションの後片付けは finally 節に書くのが定石です。

ctx = await browser.new_context(...)
try:
    await do_engage_actions(ctx)
finally:
    await ctx.close()  # ← ここが大事

SIGKILLはこの finally 節を通りません。

SIGTERM(シグナル15)はPythonが補足してクリーンアップできますが、SIGKILL(シグナル9)はカーネルが即座にプロセスを殺すため、いかなるハンドラも実行されません。結果として、ctx.close() が呼ばれないまま Chromium プロセスだけが宙に浮いた状態になります。

これが毎日繰り返されると何が起きるか。

わたしの ~/Documents/claude-obsidian/wiki/learning/mac-fleet-resource-leaks.md にはその実測値が記録されています。2026年8月5日、孤児Chromiumを含む溜まったプロセスがピーク時に1,527本に達し(うちnodeが395本)、CPU idle率が18%まで落ち、load averageが24.6を記録しました。Chrome headlessの起動時間は180秒タイムアウト——つまり起動すらできない状態——になり、IGだけでなくX、Threads、TikTok、noteの全レーンが同時に機能停止しました。

IGエンゲージのSIGKILLが、SNS自動化全滅という全く別の現象として現れていたのです。

時間予算の超過が、無関係に見えるマシン全体の不調として現れる。 これがこの問題の最も厄介なところです。コードを読んでも発見できないうえ、症状が別のところに出るため、原因の特定にも時間がかかります。

「枠を知らずに走る」コードが生まれる構造的な理由

なぜこの型が生まれやすいかを整理しておきます。

自動化スクリプトを開発するとき、ローカルで動作確認するときは実行枠を意識しません。デバッグ中は caps を5件に絞って動かし、動いたら本番値に戻します。delay は検証を速くするために短くして、本番では長くします。

この「動作確認フェーズ」と「本番設定フェーズ」の分断が、積の計算を抜け落とさせます。コードは本番で初めて「本番の caps × 本番の delay」という組み合わせで動くのですが、そこで初めて「合計時間が実行枠に収まるか」を誰も計算していないという状況が生まれます。

launchdの BROWSER_SLOT_TIMEOUT_SEC=2400 はスクリプトの外、plistや環境変数に書いてあります。likes_cap=62 はスクリプトの設定値に書いてあります。action_min_s=20 も別の場所に書いてあります。それぞれの場所では全て正しい値に見えます。積を計算する場所が存在しないのです。


全体の流れ

システム構成の全体像

まず、問題が発生していた環境の構成を図示します。

┌─────────────────────────────────────────────────────────────┐
│  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秒タイムアウト(全レーン機能停止)             │
└─────────────────────────────────────────────────────────────┘

修正の方向性

解決策は2択でした。

A. caps を下げる——likes_cap を62から30に下げれば積が実行枠に収まります。ただしこれは、Instagramのアカウント成長施策そのものを半分にするということです。収益に直結する数字を下げることになります。

B. 実行時間側を予算内に自己調整させる——caps は変えない。その代わり、スクリプトが「自分の残り時間」を常に把握して、枠が切れる前に自ら exit 0 する。

選んだのはBです。

compute_budget() の実装

考え方はシンプルです。スクリプトが起動したとき、「自分がいつまでに終わらなければならないか」というデッドラインを1回だけ計算する。その後は、各アクションの前に「今からsleepしたらデッドラインを超えるか」を確認して、超えるなら即座に返る。

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

重要なのは deadline=None のフォールバックです。環境変数が設定されていない場合(ローカルのデバッグ実行、テスト環境など)、予算制御を完全に無効化して既存の挙動を一切変えません。新機能の既定値は「何もしない」側に置くという原則です。

起動jitterの扱い

ig_engage.py には、起動タイミングを人間らしくばらつかせるために、最初に最大900秒のランダムウェイトを入れる設定がありました。

start_jitter_max_s = 900

# 修正前
jitter = random.uniform(0, start_jitter_max_s)
time.sleep(jitter)

問題は、このjitterが実行枠の全量を食いうるという点です。最悪の場合、起動jitterだけで900秒使い、本体処理に1,500秒しか残らない。その上でcaps × delayの4,040秒が始まります。

修正後は、jitterを残予算の20%でクランプします。

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))

BROWSER_SLOT_TIMEOUT_SEC=2400 のとき、起動直後の残予算は約2,280秒(BUDGET_MARGIN_Sを引いた後)。その20%は456秒。起動jitterは最大456秒に制限され、本体処理に1,824秒以上が保証されます。

アクションループへの組み込み

既存のコードには、ブロック検知で早期終了するロジックが4箇所ありました。

# 修正前
if blocked["hit"]:
    logger.warning("ブロック検知: 終了します")
    return 1

修正は各箇所に or over_budget(deadline) を追加するだけです。新しいifブロックもクラスも作りません。既存の判定点に相乗りします。

# 修正後
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

ブロック検知は依然として exit 1 です。予算切れは exit 0。この区別が重要で、監視システムが「問題あり」と「予定の早期終了」を混同しないようにします。

sleepそのものも予算を意識させる

アクション間のsleepでも予算を守る必要があります。

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

remaining < min_s(残り時間が最小待機時間を下回る)になったとき、次のアクションを実行しても完了前に切られることが確定します。このタイミングで、sleepをスキップして False を返し、呼び出し元が正常終了の判断をします。

実際のメインループ

これらを組み合わせると、メインループは次のような構造になります。

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() が確実に実行されるのは、exit 0(正常終了)だからです。SIGKILLを受けていたときと違い、Chromiumが孤児化しません。

数字で確認する

修正後の実行時間を計算し直します。

予算: 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も孤児化しない。

実際には起動jitterが456秒の上限に達することは稀で、平均は228秒程度です。その場合、本体処理への残予算は2,052秒、アクション数は51回前後になります。

「静かに緑になる」問題への対処

ここで1つ新しい問題が生まれます。

予算切れで exit 0 にした瞬間、監視システムには「正常終了」と見えます。毎日「ちゃんと動いている」が、実際のアクション数は上限の半分以下——これが誰にも見えない状態になります。

~/Documents/claude-obsidian/wiki/learning/execution-budget-vs-caps.md にはこの問題への対処も記録されています。

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)

1日では鳴らさない(単発の重い日で狼少年になる)。毎日は鳴らさない(鳴り続けると読まれなくなる)。3日連続で初めて鳴らし、同日は1回だけ。

この設計は「自分から止まって exit 0」と「異常の検知」を分離します。正常な早期終了と、構造的に caps が実行枠を超えている状態を区別して扱います。

compute_budget() を入れるべき場所の見分け方

これを「どのスクリプトに入れるべきか」の判断基準も整理しておきます。

必須の条件(全て満たすとき)

  • Playwrightなどブラウザを使う(SIGKILLで孤児化するリスクがある)
  • browser-slot.sh や launchd の StartCalendarInterval など、外部から実行時間に上限がある
  • アクション数 × 平均待機時間の積が実行枠を超える可能性がある

不要な条件(任意の1つが当てはまるとき)

  • ブラウザを使わない(APIコールなど、途中で切られてもクリーンアップが不要)
  • 実行時間の上限がない(手動実行やcronで時間制限なし)
  • アクションが1件(ループがないので積の問題が生じない)

今回のケースで言えば、ig_engage.py のほかに、同じパターンを持つスクリプトがXの自動化(x_engage.py)、Threadsのフォロー管理(threads_follow.py)にも存在しました。それぞれで compute_budget() を入れる作業を同時に行いました。


(後半へ続く)

実装の詳細

deadline を関数シグネチャに露出させる理由

実装で最初に悩んだのが、「deadline をどこに持つか」という設計の問いでした。

クラス変数にする、シングルトンにする、グローバルで持つ——いくつか選択肢がありましたが、全部却下して「全ての関数シグネチャに deadline: float | None を通す」方式にしました。理由は2つあります。

1つ目はテスト容易性。over_budget(None) は必ず False を返す。action_sleep(20, 60, None) は既存のsleepそのまま動く。テスト側で「budget制御を無効にしたい」と思ったとき、環境変数をモックしたり設定ファイルを書き換えたりせず、None を渡すだけで全ての制御が消えます。

2つ目は呼び出し経路の可視化。deadline が引数として並んでいると、「この関数はbudget-aware」という事実がシグネチャに現れます。クラス変数に隠蔽すると、関数を見ただけでは時間制約を持つかどうかがわかりません。

# 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

呼び出し側では run_like_loop(page, targets, deadline) と書きます。deadline=None で呼べば上限なしのデバッグモードになる。

環境変数の3段階フォールバックの意図

compute_budget() の中で、変数は次の順番で読みます。

raw = int(
    os.getenv("IG_ENGAGE_BUDGET_SEC")
    or os.getenv("BROWSER_SLOT_TIMEOUT_SEC")
    or 0
)

IG_ENGAGE_BUDGET_SEC はスクリプト専用の上書き変数です。本番では設定しません。本番より短い値をセットして予算制御だけをテストしたいときに使います。たとえば IG_ENGAGE_BUDGET_SEC=300 をつけてスクリプトを手動実行すると、5分で打ち切られることが確認できます。

BROWSER_SLOT_TIMEOUT_SEC は browser-slot.sh が子プロセスに渡す値です。plist側で設定してあります。スクリプトは「自分がいつまでに終わるべきか」を、起動した親から引き継ぎます。スクリプト自身は何も管理しない。

0 のフォールバックは「deadline=None、全判定無効」を意味します。browser-slot.sh を経由しない手動実行や、変数を設定していない別環境では、予算制御が静かに無効化されます。これは意図的な設計です。機能フラグの既定値は「何もしない」側に置く。環境変数が消えた瞬間にジョブが止まる作りにはしません。

action_sleep() の境界値——なぜ min_s と比べるか

remaining = deadline - time.time()
if remaining < min_s:
    return False

ここで max_s ではなく min_s と比較しているのがポイントです。

remaining < max_s だと、「最大待機時間を確保できなければ諦める」という意味になります。しかし action_sleep() はsleepを短くクランプする機能を持っているので、max_s に届かなくても min_s 以上あればsleepできます。

remaining < min_s にしている理由は「次のアクションを実行しても、deadline内に完了できない」タイミングを検知したいからです。sleepの最小値すら確保できないなら、sleepしてアクションを開始しても、その最中にSIGKILLされる可能性が高い。それより False を返してアクションループを終了させる方が、クリーンです。

# 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 が上限です。これは「sleepが終わった後に最低 min_s 分だけ次のアクション実行枠が残る」ことを保証しています。残り50秒で min_s=20, max_s=60 なら、実際のsleepは最大30秒(50-20)に収まる。

ブロック検知への相乗りがなぜ「新しいifブロックを作らない」方式か

コードの中にはブロック検知・ログイン切れ検知・その他の異常終了判定がすでに4箇所ありました。それぞれが return 1(エラー)を返す経路です。

新しいifブロックを作ると、「予算切れ」という概念が独立した制御フローとして存在し始めます。それは将来のメンテナンス者(未来の自分を含む)に「ここを読まないと予算切れの挙動がわからない」という負債を残します。

既存の判定点に or over_budget(deadline) を追加するアプローチだと、「このスクリプトが終了する経路」を読むと自然に予算切れも出てきます。既存の制御フローが1つ増えるだけで、新しい制御フローは生まれません。

# 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"] と over_budget(deadline) は意味が違います。前者は「Instagramに検知された」という異常で、後者は「時間通りに自分から止まった」という正常です。この区別をreturn codeと、ログメッセージの両方に残しておくことで、監視システムがexitコードで振り分けできます。

x_engage.py と threads_follow.py への横展開

ig_engage.py と同じ構造を持つスクリプトとして、Xの自動化(x_engage.py)とThreadsのフォロー管理(threads_follow.py)がありました。どちらも browser-slot.sh 経由で起動していて、アクションループにsleepを持つ、同じパターンです。

横展開は compute_budget() と action_sleep() をスクリプト間で共有できるユーティリティファイルに切り出すことから始めました。

# 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

各スクリプトのimportが1行になりました。

from _budget import compute_budget, over_budget, action_sleep

私が詰まった話

詰まり①「コードを3回読んでも、バグはなかった」

IGエンゲージジョブが exit 124 で終わっていることに気づいたのは、2週間以上経ってからでした。

最初に見えていた症状は全然別のことでした。Discordのアラートチャンネルに、metrics-hub の収集失敗通知が毎日大量に積まれる。X、Threads、TikTok、noteの全レーンで browserType.launchPersistentContext: Timeout 180000ms exceeded が出続ける。Chromiumが180秒かけても起動できない状態です。

わたしは「Chromeの問題」だと思って調査を始めました。バージョン不整合を疑い、Playwrightのアップデート履歴を確認しました。SingletonLock の残留を疑い、プロファイルディレクトリを掃除しました。どちらも違いました。

正しい切り分けの視点は、mac-fleet-resource-leaks.md にこう記録されています。「Chrome も claude -p も両方落ちていたので、Chrome固有の問題ではなく計算機資源側と即断できた」。どちらかが生きていれば、Chrome固有の問題に絞れます。どちらも死んでいれば、Chromeの下にあるリソース全体の問題です。

プロセス数を数えると1,527本(nodeだけで395本)でした。CPU idle率18%。load averageが24.6。孤児Chromiumが蓄積していて、常時起動している160本以上のジョブが全部CPU時間を奪い合っていました。

IGエンゲージが毎日SIGKILLされていることが判明したのは、ログを時系列で追ったときです。exit 124 の記録が2週間分、毎日同じ時刻に並んでいました。SIGKILLを受けるたびに finally 節がスキップされ、Chromiumが孤児化して溜まり続けていた。その蓄積がChrome 180秒タイムアウトという全く別の形で現れていました。

コード自体には1行もバグがありませんでした。ig_engage.py を何度読んでも正しい。ロジックは正しい。例外処理もある。Playwrightのセッション管理も問題ない。コードを読む行為そのものが、この種のバグに対して無効だったという経験は、かなり印象に残っています。

詰まり②「exit 0 にした瞬間、本当に見えなくなった」

修正を入れた当日の夜、監視ダッシュボードが全部緑になりました。exit 0 で終わるようになったので、当然です。「直った」という感覚がありました。

3日後、Discordに通知が来ました。「⚠️ IGエンゲージが実行予算で打ち切られています / 連続3日 / likes: 43/62, follows: 18/24」

3日間、毎日アクション数が上限の70%前後で打ち切られていたことを、そこで初めて知りました。

streak alertを入れたのは修正と同日でしたが、このとき「本当に必要だった」と実感しました。入れていなかったら、「毎日正常終了しているが成果は7割」という状態がずっと続いて、わたしにはその事実が見えなかったはずです。

「正常終了」は正常ではありません。正常終了には「目標を達成して終わった」と「時間切れで切り上げた」の2種類があります。監視システムは通常、この2つを区別しません。

streak alertの1日では鳴らさない/3日連続で初めて鳴らすという設計は、このジレンマから来ています。1日だけで鳴らすと、重い日に単発で切り上げた場合に毎回アラートが飛んで、読まれなくなります。毎日鳴らすと、慢性的に鳴り続けて無視されます。3日連続は「構造的に caps が実行枠に対して過大」という事実を示す、最低限のサンプル数です。

この通知を受けて、caps を下げるのではなく、BROWSER_SLOT_TIMEOUT_SEC を2,400秒から3,600秒に引き上げる対応を取りました。IGエンゲージが起動する時間帯の前後に余裕があったので、枠を広げる方が成長施策を削らずに済みました。

詰まり③「ループの先頭にチェックを入れただけでは足りなかった」

最初に書いた実装は、各ループの先頭だけに over_budget(deadline) を置いていました。

# 最初の実装
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))  # ← ここが問題

like_post() が終わって time.sleep() に入った後、sleepの途中でdeadlineを超えても、次のループの先頭チェックまでは止まりません。sleepが60秒で、残り予算が20秒のときに入ってしまうと、40秒オーバーしてから次のアクションへ進もうとして、そこで over_budget() がTrueになります。

実際には BUDGET_MARGIN_S=120 の余裕があるので、40秒のオーバーは問題になりませんでした。でも起動jitterが456秒の上限近くで発生した日には、本体処理への残予算がぎりぎりになります。そのときsleepが残予算を大きく食い込んで、margin の範囲を超えるケースが出ました。

action_sleep() を作った動機はここにあります。sleepそのものに残予算の概念を持たせると、このオーバーが構造的に起きなくなります。

# 修正後
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

action_sleep() が False を返したとき、残予算は action_min_s 未満です。次のループを回しても、アクション完了前にSIGKILLされる可能性があります。False を受けて即 break するのは、そのためです。

詰まり④「横展開したスクリプトで、budget制御が黙って無効化していた」

_budget.py を共有ユーティリティに切り出した後、threads_follow.py に組み込みました。コード上は正しく実装されています。deadline = compute_budget("THREADS_FOLLOW_BUDGET_SEC") を先頭に置いて、各ループに over_budget(deadline) を追加しました。

しかしローカルでテスト実行したとき、予算切れが一切起きませんでした。

原因は BROWSER_SLOT_TIMEOUT_SEC が設定されていなかったことです。

threads_follow.py はもともと browser-slot.sh を経由しない直接起動でした。mac-fleet-resource-leaks.md の「49本中22本が経由していなかった」状態の1本です。browser-slot経由でないと、BROWSER_SLOT_TIMEOUT_SEC が環境変数として届きません。compute_budget() は raw=0 を読んで deadline=None を返す。全ての budget チェックが静かに無効化されます。

ログにも何も出ません。deadline=None は「budget制御なし」として正常に動作するので、エラーも警告も起きません。コードを読んでも、「budget制御が入っている」ように見えます。

対処は2段階でした。まず threads_follow.py を browser-slot.sh 経由に変更して、BROWSER_SLOT_TIMEOUT_SEC が継承されるようにしました。次に、compute_budget() にdeadlineの状態をログに残させました。

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

起動時のログに budget制御: 有効 (raw=2400s, margin=120s, deadline=T+2280s) か budget制御: 無効(環境変数なし) が残ります。予算制御が効いているかどうかを、ログファイルを1行見るだけで確認できます。

deadline=None の設計を変えたくなかったのは、「環境変数が消えた瞬間にジョブが止まる作りにしない」という原則を守るためです。デバッグ実行・本番外環境・テスト環境でスクリプトを動かすとき、budget制御が入っていると実行が途中で打ち切られて混乱します。None で無効化されるのは正しい挙動です。ただし、その「無効化された」という事実がログに残っていれば、「なぜ打ち切られないのか」という調査に1秒で答えが出ます。


この4つの詰まりに共通しているのは、「コードを読んでも見えない」という性質です。

詰まり①は設定値の積が見えない。詰まり②はexit codeに意味が見えない。詰まり③はsleepの中の時間消費が見えない。詰まり④は環境変数の有無が見えない。

自動化スクリプトの問題は、コードレビューで発見しにくいものが多いです。コードが正しくても、設定・環境・実行時間の組み合わせが壊れているというケースは、ログと実測値でしか見えません。

execution-budget-vs-caps.md に記録してある「器のサイズを実装が知らない」という型は、この4つを含む、より広い問題です。Discord 1メッセージの2,000字上限、Chromeスクリーンショットのウィンドウサイズ、AI画像生成の出力寸法——同じ窓で同じ型の失敗が4件同時に出ました。上限と、その上限に到達するまでにかかる時間・容量を、別々の場所に書くと、両方とも正しいのに組み合わせだけが破綻します。制約は掛け算した結果で持って、超えたときに何が起きるかを先に決めておく——これが今回の一連の修正から得た、一番大きな原則です。

つまずきポイント

コードを読んでも答えが出ない系

「どの行も正しい」という詰まりに最も時間がかかります。 前段で書いたとおり、ig_engage.py を3回読んでもバグは見つかりませんでした。答えがコードの中にないので、コードを読む行為が無効になります。このタイプのつまずきは、「自分の読み方が悪い」「もう1周すれば気づく」という思い込みが長期化を招きます。設定値どうしの積の問題は、ログと実測値 以外で発見する方法がありません。

「症状が別のところに出る」という間接化が切り分けを遅らせます。 IGエンゲージのSIGKILLが原因で、X・Threads・TikTok・noteの全レーンが停止しました。mac-fleet-resource-leaks.md に記録されている通り、2026年8月5日のプロセス総数は1,527本(nodeだけで395本)、load averageは24.6でした。表に出ていたのは browserType.launchPersistentContext: Timeout 180000ms exceeded(Chrome起動180秒タイムアウト)というPlaywrightエラーで、「Chromeの問題」に見えます。本当の原因はIGのスクリプトが毎日Chromiumを孤児化させていたことでした。

「ChromeとClaude -pが両方死んでいるか」という問いが切り分けを1行にします。 どちらかが生きていればChrome固有の問題。どちらも死んでいれば計算機資源側の問題と即断できます。これを知っていれば、バージョン不整合説とSingletonLock残留説を検討に入れる前に「リソース側を掘る」と決まります。


「自分で投げたプロセスが障害の一部になる」という逆転が起きます。 mac-fleet-resource-leaks.md には、調査中に投げた広すぎるgrepが43分間CPUを73%焼き続けていたと記録されています。調査ツールが資源を食い、症状をさらに悪化させる。「直っていない理由が自分の調査コマンド」というループは、実際に起きます。広いgrepを投げたら、自分が回収するまで責任を持つという意識が要ります。


「枠」の概念が複数の場所に散在する系

Playwright以外でも「器のサイズを知らない実装」は同じ窓で4件同時に出ました。 execution-budget-vs-caps.md はこれを「同窓の型」として記録しています。

  • Discord 1メッセージ2,000字制限——lily-line-funnel/scripts/pdca.mjs の sendDiscordReport が全文を1回でPOSTしていました。レポートが長い日は日次通知ごと落ちる。上限はDiscordの仕様、文字数はスクリプトの出力、この2つが別々の場所にあって誰も掛け算していませんでした。

  • Chromeスクリーンショットのウィンドウサイズ——「高さは自動(コンテンツfit)にするため大きめwindow」というコード内コメントが誤りで、実際はウィンドウサイズをそのまま撮ります。note用の表画像が常に1760×4000px、つまり表の下に巨大な白余白のまま公開されていました。正しくは2パス(--dump-dom でscrollHeightを測ってから撮る)で、1760×1178に収まります。1パス目のウィンドウ高さを200にするのがポイントで、2,000のままだとscrollHeightが2,000未満にならず同じバグが残ります。

  • 内蔵imagegenの出力寸法——「4:5縦 1024×1280」と指示しても素の出力は1122×1402や1003×1568で返ります。生成ツールのアスペクト指定は信用できないので、保存後に毎回強制正規化が必要です。

この3件はIGエンゲージのSIGKILL問題と完全に同じ型です。「上限」と「その上限に到達するまでにかかる時間・容量」を別々の場所に書くと、両方正しいのに組み合わせだけが破綻します。


「直した瞬間に別の問題が見えなくなる」系

exit 0に変えた瞬間、「毎日7割しか動いていない」が3日間誰にも見えませんでした。 execution-budget-vs-caps.md には「打ち切りを正常終了にした瞬間、silent-success-antipatternに入る」と記録されています。Discordに「⚠️ IGエンゲージが実行予算で打ち切られています / 連続3日 / likes: 43/62, follows: 18/24」が来るまで、監視ダッシュボードは全部緑でした。streak alertを同じ変更の中に入れていなければ、この状態は無期限に続いていました。

browser-slot経由でないスクリプトに compute_budget() を入れても、黙って無効化されます。 p2で詳述した通り、threads_follow.py はもともと browser-slot.sh を経由しない直接起動でした。BROWSER_SLOT_TIMEOUT_SEC が届かないので raw=0、deadline=None、全判定が静かに無効化されます。ログにも警告も何も出ません。mac-fleet-resource-leaks.md には「49本中22本が同時実行ゲートを素通りしていた」と記録されています。browser-slot経由かどうかを確認しないまま横展開すると、「budget制御を入れた」が事実と異なる状態になります。

「回収係が効いていない」ことに、回収係を直すまで気づけません。 auto-reboot.sh は vm.swapusage の total(確保サイズ)で判定していたため、Spotlightの一過性スパイクで緊急再起動と誤検知していました。さらに「走行中ジョブがあれば再起動しない」という条件が、130本のジョブが常時走っている環境で永久に発火できない構造になっていました。reboot-requested 件数はゼロ。保険が一度も効いていないことに、デバッグを始めるまで気づけませんでした。


設定値の散在・二重取得系

グローバル枠を2つ食っていた「二重取得」は、配線した本人が作り込みました。 run-account.sh が内部でスロットを取っているのに、plist側でも browser-slot.sh で包んだ結果、1ジョブが枠を2つ消費していました。実効容量が半減しているのに、ログのgroup名が想定と違うことで初めて気づきました。配線したら「効いた証拠」でなく「意図と違う挙動」を先に探す必要があります。

dasd(macOSのDuet Activity Scheduler)は物理RSS264MBなのに47GBのswapを抱えていました。 上位10プロセスを見ていても永久に映りません。回収係はRSS基準ではなく圧縮メモリ(CMPRS)基準でないと本命を素通りします。正しいコマンドは sudo killall dasd 1本で、launchdが28MBの新プロセスを立て直します。再起動は不要でした。


ベストプラクティス

① 起動前に max_actions × avg_delay を計算し、execution-budget として1箇所に書く。 caps と delays を別々の変数で管理するのをやめ、掛け算した所要時間を EXPECTED_DURATION_SEC として先に求める。その値が実行枠を超えていれば、スクリプトに入る前に設計の問題として対処します。

② compute_budget() の既定値は「何もしない」側(deadline=None)に置く。 環境変数が消えた瞬間にジョブが止まる設計にしません。IG_ENGAGE_BUDGET_SEC → BROWSER_SLOT_TIMEOUT_SEC → 0 の3段階で読み、0なら全判定を無効化して既存の挙動をそのまま保ちます。

③ 起動ログにbudget制御の有効/無効を必ず残す。

if raw == 0:
    logger.debug("budget制御: 無効(環境変数なし)")
    return None
logger.info(f"budget制御: 有効 (raw={raw}s, deadline=T+{raw - BUDGET_MARGIN_S}s)")

「なぜ打ち切られないのか」という調査に1秒で答えが出ます。compute_budget() が静かに None を返している状態を可視化することが目的です。

④ 起動jitterは残予算の20%でクランプする。 min(jitter_max, remaining * 0.2) で上限を設けることで、jitterが実行枠の全量を食うケースを防ぎます。BROWSER_SLOT_TIMEOUT_SEC=2400 のとき、jitterの上限は456秒になり、本体処理に1,824秒以上が保証されます。

⑤ action_sleep() にも残予算を持たせ、ループ先頭だけの確認に依存しない。 ループ先頭でチェックしても、sleep中にdeadlineを超えると次の先頭チェックまで止まりません。remaining < action_min_s のとき False を返して呼び出し元が即 break する設計が、SIGKILLの一歩手前で止まる構造を作ります。

⑥ 予算切れは exit 0、ブロック検知は exit 1 と明確に区別する。 監視システムが「問題あり」と「予定の早期終了」を混同しないようにします。同じ条件に相乗りするだけでよく、新しい制御フローは作りません。

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

⑦ 3日連続でアラートを鳴らし、1日では鳴らさず、毎日は鳴らさない。 state/engage_budget.json に {streak, last_date, last_alert_date} を持たせ、3日連続かつ当日未通知のときだけ通知します。1日だけで鳴らすと重い日の単発で狼少年になり、毎日鳴らすと無視されます。「構造的にcapsが実行枠に対して過大」という事実を示すための最低サンプル数が3日です。

⑧ deadline を全関数のシグネチャに通して可視化する。 クラス変数やグローバルに隠蔽すると、「この関数はbudget-aware」という事実がシグネチャから消えます。action_sleep(min_s, max_s, deadline) と書くことで、deadline=None で呼べばデバッグモード、実値を渡せば本番モードという切り替えが引数1つで完結します。テスト側は環境変数をモックせず None を渡すだけで済みます。

⑨ 横展開のたびに「browser-slot経由か」を確認し、共有ユーティリティに切り出す。 _budget.py のような共有モジュールを作ったとしても、BROWSER_SLOT_TIMEOUT_SEC が届かないスクリプトでは budget 制御が黙って無効化されます。横展開するスクリプトが browser-slot.sh を経由しているかを確認し、経由していなければ先に配線してから組み込みます。

⑩ 孤児プロセスを回収する係を別で持ち、SIGKILLされる前提で設計する。 chrome-reaper.sh は親PID=1 の孤児Chrome(--user-data-dir が ~/dev/ 配下)と ms-playwright 配下のChromium/chrome-headless-shellで30分超をTERM→KILLします。budget制御が完璧でも、別のジョブがSIGKILLされることは起きます。「SIGKILLされると finally を通らない」という事実は変わらないので、回収係を10分おきに走らせておくことがフェイルセーフになります。

⑪ 症状が広いときほど「何が同時に死んでいないか」で真因を絞る。 ChromeもClaude -pも両方死んでいれば計算機資源側。Chromeだけ死んでいればChrome固有の問題。複数レーンが一斉に落ちたとき、最初に確認すべきはリソース側(プロセス数・load average・swap使用量・ディスク残量)です。2026年8月5日のケースでは、ps aux | wc -l が1,527本、load averageが24.6でした。この数字が出た瞬間、全レーンの個別ログを読むより先にリソース側を潰す順番になります。

⑫ 「保険が一度も発火していない」ことを定期的に確認する。 auto-reboot.sh の reboot-requested 件数がゼロだったように、保険が設定されているように見えても実際には発火できない条件になっていることがあります。dry-runの出力に trigger= / decision= / blocking_jobs= を全部出しておくと、「発火条件は満たしたが、走行中ジョブがあるので再起動しない」という状態が1行で読めます。保険の有効性は月1回は確認が必要です。

⑬ 「時間的な前後を因果として報告しない」を徹底する。 mac-fleet-resource-leaks.md には「load 82→24に落ちた」を「除外の効果」として報告した記録が残っています。実際は除外自体が無効で、タイミングの偶然でした。無効な手が正典として残ると、次の障害で同じ手をまず試してしまいます。修正と改善の数字が近い時刻に並んでいても、因果として書くには別途「除外がなかった場合との比較」が必要です。


まとめ

IGエンゲージが2週間、毎日SIGKILLされていました。ig_engage.py のコードには1行もバグがなく、コードを読んでも発見できませんでした。原因は likes_cap=62 という上限と action_min_s=20 / action_max_s=60 という待機時間を一度も掛け合わせていなかったという、算術の欠落でした。101アクション × 平均40秒 = 4,040秒に起動jitter最大900秒を加えると4,940秒。実行枠 BROWSER_SLOT_TIMEOUT_SEC=2400 の1.7倍です。

SIGKILLはPythonの finally 節を通りません。ctx.close() が呼ばれないまま、Chromiumが毎日孤児化して蓄積し続けました。その結果が、プロセス1,527本・load average 24.6・Chrome起動180秒タイムアウト・全SNSレーン機能停止です。IGエンゲージという1本のスクリプトの時間超過が、全く無関係に見えるマシン全体の不調として現れました。

解決の方向は「capsを下げて成長施策を削る」ではなく、「スクリプト自身が残り時間を把握して、枠が切れる前に exit 0 で自ら止まる」でした。compute_budget() が起動時に1回だけデッドラインを計算し、over_budget(deadline) が各ループの既存判定に相乗りし、action_sleep() がsleepそのものに残予算の概念を持ちます。さらに「3日連続で予算打ち切り」を検知するstreak alertが、「exit 0になったが成果は7割」という静かな劣化を可視化します。

同じ窓で「Discordの2,000字上限を知らないままPOSTしていたレポート」「Chromeスクリーンショットのウィンドウサイズと出力サイズの乖離」「内蔵imagegenのアスペクト指定が信用できない」という3件が同時に出ました。型は全て同じです。「上限」と「その上限に到達するまでにかかる時間・容量」を別々の場所に書くと、両方正しいのに組み合わせだけが破綻します。 制約は掛け算した結果で持ち、超えたときに何が起きるかを先に決めておくことが、コードを読んでも見えない障害への唯一の先手です。

自動化スクリプトの問題は、コードレビューで発見しにくいものが多いです。コードが正しくても、設定・環境・実行時間の組み合わせが壊れているケースは、ログと実測値でしか見えません。「動いている」と「稼いでいる」は別物で、「正常終了している」と「目標を達成している」も別物です。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?