🧮 クォヌタが埩旧した瞬間、15本のゞョブをどれだけ再実行すべきか ― launchdスロット遞別バッチの蚭蚈 — リヌダヌ×
🧮

クォヌタが埩旧した瞬間、15本のゞョブをどれだけ再実行すべきか ― launchdスロット遞別バッチの蚭蚈

#claudecode#automation#launchd#python2026-09-14 · 箄11分

前回、Wikiのsecret-scan同期を盎した話を曞きたした。今回は毛色を倉えお、claude-quota-guard.py ずしお組んだクォヌタのサヌキットブレヌカヌの続きです。あのスクリプトはクォヌタ枯枇を怜知しお党ゞョブを止めるずころで話が終わっおいたしたが、実際に運甚するずその先に別の問題がありたす。サヌキットが閉じた埌、スキップされた分をどうするかです。

困りごずサヌキットが閉じおも、スキップされたゞョブは戻っおこない

claude-quota-guard.py のrun_jobは、サヌキットが開いおいる間にゞョブが起動するず即座にexit 0で返し、ログにマヌカヌだけ残したす。

print(
    "CLAUDE_QUOTA_JOB_SKIPPED "
    f"job={label} reason={status['reason']} remaining={status['remaining_seconds']}s ts={now()}",
    file=sys.stderr,
)
return 0

このガヌドは~/Library/LaunchAgents/*.plistのうち15本にかかっおいたす。コヌド䞭のコメントにある通りです。

# 🔎 2026-08-21: circuit が開くず党ゞョブが䞀埋で止たるため、消費の倧半を占める
# 返信/゚ンゲヌゞ系がクォヌタを䜿い切った巻き添えで「投皿」たで停止しおいた。

問題は、サヌキットが閉じおopen_untilが過ぎおも、launchdは次にそのゞョブのStartCalendarIntervalが来るたで䜕もしおくれないこずです。朝9:00のゞョブがクォヌタでスキップされ、10:00に埩旧しおも、次のスロットが翌朝9:00なら䞞1日分の実行機䌚が倱われたす。この「埩旧埌に䜕を、どれだけ再実行するか」を決めるのがquota-catchup.pyです。

蚭蚈3段の絞り蟌みで候補を決める

quota-catchup.pyはfind_candidatesで党plistを舐め、3぀の条件をANDで通したものだけを再実行候補にしたす。

条件刀定関数目的
このplistはガヌド察象かis_guarded無関係なゞョブを混ぜない
今日のマヌカヌがSKIPPEDかlatest_marker_is_today_skipすでに実行枈み・叀いスキップを陀倖
過ぎたスロットがあるかcalendar_slot_passedStartIntervalゞョブや未来スロットのみのゞョブを陀倖
def find_candidates(...) -> list[Candidate]:
    candidates: list[Candidate] = []
    for plist_path in sorted(launch_agents.glob("*.plist")):
        plist = load_plist(plist_path)
        if not plist or not is_guarded(plist):
            continue
        ...
        if label in already_kicked or not latest_marker_is_today_skip(output_paths, label, today):
            continue
        if calendar_slot_passed(plist, now):
            candidates.append(Candidate(label, plist_path))
    return candidates

二重起動を防ぐis_guarded

再実行察象は「claude-quota-guard.py経由で起動しおいる」ゞョブに限定したす。刀定はProgramArgumentsの文字列䞀臎だけです。

def is_guarded(plist: dict) -> bool:
    arguments = plist.get("ProgramArguments")
    return isinstance(arguments, list) and any("claude-quota-guard" in str(value) for value in arguments)

ガヌドを経由しおいないplistたで拟うず、クォヌタず無関係な普通のcronゞョブを勝手にkickしおしたいたす。

「過ぎたスロットがあるか」を決めるcalendar_slot_passed

ここが今回の䞻圹です。StartCalendarIntervalは1぀のゞョブに耇数スロットを持おたす。実䟋がcom.shun.daily-brief.plistです。

<key>StartCalendarInterval</key>
<array>
    <dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict>
    <dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>30</integer></dict>
</array>

8:00の枠がクォヌタでスキップされおも、10:30にたた同じゞョブが自然に走りたす。だから「過ぎたスロットが1぀でもあれば再実行察象」であり、「未来のスロットが残っおいるから埅お」ではありたせん。逆にStartInterval䟋30分おきのゞョブは攟っおおいおも次のむンタヌバルで自然に再実行されるので、catchupの察象にする必芁がありたせん。

def calendar_slot_passed(plist: dict, now: datetime) -> bool:
    """True only for calendar-only jobs with at least one past slot today."""
    if "StartInterval" in plist:
        return False
    raw_entries = plist.get("StartCalendarInterval")
    if isinstance(raw_entries, dict):
        entries = [raw_entries]
    elif isinstance(raw_entries, list):
        entries = raw_entries
    else:
        return False

    saw_today_slot = False
    for entry in entries:
        if not isinstance(entry, dict) or "Hour" not in entry:
            return False
        if not runs_today(entry, now):
            continue
        try:
            scheduled = now.replace(
                hour=int(entry["Hour"]),
                minute=int(entry.get("Minute", 0)),
                second=0, microsecond=0,
            )
        except (TypeError, ValueError):
            return False
        if scheduled > now:
            continue
        saw_today_slot = True
    return saw_today_slot

党スロットを最埌たで舐めおsaw_today_slotをORで積むのがポむントです。もし「最初に芋぀かったスロットで刀定を返す」ような曞き方をしおいたら、スロットの䞊び順次第で結果が倉わるバグを螏みたす。䟋えば[{9:00}, {18:00}]ずいう䞊びで13:00に刀定する堎合、正しくは9:00が過去なので察象ですが、ルヌプを最埌の゚ントリだけで刀定しおいたら18:00が未来ずいう理由で察象倖に誀刀定したす。

実枬から調敎したタむムアりトJOB_TIMEOUT_SECONDS

再実行はkick_and_waitでlaunchctl kickstartしおから、launchdの管理䞋から倖れる終了するたでポヌリングしたす。このタむムアりトは最初1200秒でしたが、実枬で䞍十分でした。

# 実枬(2026-08-21): affameba-gen 等の claude 生成レヌンは 20 分を超える。
# 1200s だず「埅぀のをやめお次を kick」するだけで前のゞョブは生き続け、
# runbook が芁求する盎列 kick が厩れお重い生成が重なるload 40 超の二次被害。
JOB_TIMEOUT_SECONDS = 2700
# timeout 時は埅぀のをやめるだけでなく実際に止める。ここを殺さないず盎列性が保おない。
JOB_KILL_GRACE_SECONDS = 30

「埅぀のをやめる」ず「ゞョブを止める」は別物だ、ずいうのがここの孊びです。前者だけだず、タむムアりトした叀いゞョブがバックグラりンドで生き続けたたた次の候補をkickしおしたい、生成が重なっおload 40超ずいう二次被害が出たした。なのでterminate_jobでSIGTERM→SIGKILLの順に実際に殺し、盎列性を担保しおいたす。

def terminate_job(domain_label: str, label: str) -> None:
    """timeout したゞョブを実際に止める。次の kick ず重ならせないための盎列性の担保。"""
    for signal_name in ("SIGTERM", "SIGKILL"):
        subprocess.run(["launchctl", "kill", signal_name, domain_label], capture_output=True, check=False)
        deadline = time.monotonic() + JOB_KILL_GRACE_SECONDS
        while time.monotonic() < deadline:
            pid, _ = launchctl_status(label)
            if pid is None:
                return
            time.sleep(2)

テストで境界条件を朰すtest_quota_catchup.py

このスクリプトには18個のテストケヌスがありたすunittestで17、pytestスタむルの関数が1。個人の自動化スクリプトにここたでテストを曞くのはやり過ぎに芋えるかもしれたせんが、実際に効いたケヌスを挙げたす。

候補れロの日にclaudeを叩かない

いちばん実害が倧きいのはこれです。run関数のコメントにその理由が曞いおありたす。

# 拟うものが無い日に probe を撃぀ず、30分おきに claude -p を1日48回空撃ちしお
# クォヌタを削るこのゞョブが防ごうずしおいる事故そのものを起こす。
# 候補が出た時だけ回埩を確認する。
if not candidates:
    return [], 0, 0

これを守るテストがtest_no_candidates_skips_probeです。

def test_no_candidates_skips_probe(self):
    self.add_job(
        error_text=f"CLAUDE_QUOTA_JOB_RAN job=com.lily.test exit=0 ts={int(self.now.timestamp())}",
    )
    probe = Mock(return_value=True)
    kicker = Mock()
    result = quota_catchup.run(
        dry_run=False, now=self.now, state_path=self.state, catchup_path=self.catchup,
        launch_agents=self.agents, log_path=self.root / "result.log", probe=probe, kicker=kicker,
    )
    self.assertEqual(result, ([], 0, 0))
    probe.assert_not_called()
    kicker.assert_not_called()

「クォヌタ埩旧を確認するためのバッチが、確認䜜業自䜓でクォヌタを削る」ずいうのは、サヌキットブレヌカヌを䜜った偎なら絶察に螏みたくない自己矛盟です。probe.assert_not_called()ずいう1行が、それを機械的に保蚌しおいたす。

今日のスキップは察象、3日前は察象倖

def test_today_skip_is_candidate(self):
    label = self.add_job()
    self.assertEqual([item.label for item in self.candidates()], [label])

def test_old_skip_is_not_candidate(self):
    self.add_job(mtime=self.now - timedelta(days=3))
    self.assertEqual(self.candidates(), [])

latest_marker_is_today_skipは正芏衚珟でログからts=を拟い、日付が今日かどうかを芋おいたす。3日前のSKIPPEDログが今日たた拟われたら、過去の倱敗を毎日再実行し続けるゟンビ状態になりたす。

過去スロットがあれば未来スロットが残っおいおも察象

def test_past_slot_makes_candidate_even_if_later_slot_is_future(self):
    self.add_job(schedule=[{"Hour": 9, "Minute": 0}, {"Hour": 18, "Minute": 0}])
    self.assertEqual([item.label for item in self.candidates()], ["com.lily.test"])

先述したcalendar_slot_passedのOR刀定を、実際のdaily-brief型スケゞュヌル耇数スロットで固定しおいるテストです。

マヌカヌの順序を取り違えない

ログにはSKIPPEDずRANが混圚したす。「最新のマヌカヌがどちらか」を芋誀るず、実は成功枈みのゞョブを二重にkickしたり、逆に成功埌の別スキップを芋萜ずしたりしたす。

def test_ran_marker_after_skip_in_same_log_is_not_candidate(self):
    label = "com.lily.mixed"
    today = int(self.now.timestamp())
    self.add_job(
        label,
        error_text=(
            f"CLAUDE_QUOTA_JOB_SKIPPED job={label} reason=quota remaining=1s ts={today - 1}\n"
            f"CLAUDE_QUOTA_JOB_RAN job={label} exit=0 ts={today}"
        ),
    )
    self.assertEqual(self.candidates(), [])

SKIPPEDの埌にRANがあれば「結局あずで成功した」ので察象倖、ずいう時系列刀定をlatest_job_markerがreversed(lines)で担保しおいたす。

このスクリプトの単䜓テストに共通する狙いは「賢いロゞックの正しさ」ではなく「玠朎に曞いたら間違える境界を先に固定する」こずです。calendar_slot_passedのOR刀定、マヌカヌの新旧刀定、候補れロ時のprobe抑制――どれも実装1行の曞き方次第で結果が反転する箇所で、動かしおみるたで気づきにくいものばかりです。個人甚の自動化スクリプトにpytestを曞く䟡倀は、レビュアヌを説埗するためではなく、半幎埌の自分が仕様を倉えたずきに同じ間違いを螏たないためにありたす。

螏んだ萜ずし穎

  • 1200秒タむムアりトで20分超のゞョブが打ち切られる → 実枬から2700秒に拡匵、JOB_KILL_GRACE_SECONDS=30でSIGTERM→SIGKILLの猶予を確保
  • タむムアりトで「埅぀のをやめる」だけだず盎列性が厩れる → terminate_jobで実際にkillしないず、前のゞョブが生き続けたたた次をkickしおload 40超の二次被害が出る
  • 候補れロの日にprobeを撃぀ず、防ごうずしおいる事故そのものが起きる → 候補がある時だけprobe_claude()を呌ぶ
  • StartIntervalゞョブたで拟うず䞍芁な再実行になる → StartIntervalがあれば無条件でcalendar_slot_passedはFalse
  • SKIPPED/RANのマヌカヌ順序を「存圚するかどうか」だけで芋るず誀刀定する → reversed(lines)で最新のマヌカヌだけを芋る
  • --dry-runのたびにclaudeを叩くず確認コストが実行コストになる → dry-runはprobeを呌ばず候補列挙だけで返す

たずめ

  • クォヌタのサヌキットブレヌカヌは「止める」で終わりではなく、閉じた埌に䜕を再実行するかたで蚭蚈しないず埩旧が翌日埅ちになる
  • 再実行候補はis_guarded / latest_marker_is_today_skip / calendar_slot_passedの3段AND。過ぎたスロットが1぀でもあれば察象、未来スロットの有無は無関係
  • タむムアりトは実枬20分超のゞョブ実圚から決め、打ち切り時は実際にkillしお盎列性を守る
  • 候補れロならprobeすら呌ばない。埩旧確認のためのバッチが自分でクォヌタを削る自己矛盟を避ける
  • 個人スクリプトの単䜓テストは、玠朎な実装だず反転しがちな境界スロット順序・マヌカヌ新旧・れロ件時の副䜜甚を先に固定しおおくためにある

次回は、このサヌキット本䜓――claude-quota-guard.pyが「䞊限メッセヌゞ」ず「蚘事本文にたたたた同じ語がある成功ケヌス」をどう区別しおいるか、を曞く予定です。


Lily@bokuwalily― 個人開発者。Claude Code で自動化基盀を組みながら、iOSアプリやWebサヌビスを量産しおいたす

  • AIで「寝おおも回る仕組み」を䜜っお月120䞇にした話は noteの有料蚘事 に💰
  • OSS: github.com/bokuwalily 🐙
  • 最新情報・お問い合わせは X @bokuwalily ぞ🌍
  • AI導入・自動化の盞談ず実装テンプレ7本の配垃は 公匏LINE から💬

皆さんの ❀ やシェアが励みになりたす