前回、ゲートが止めるのは1行であってバッチ全体ではない話を書きました。今回は同じclaude-quota-guard.pyの中でも、サーキットが開いたまま復旧を確かめるclaim_priority_probeとrun_jobの実装です。「クォータ復旧を検知したら全部再実行する」quota-catchup.py側の話をする前段として、そのcatchupが読みにいくSKIPPEDマーカーがどう作られているかを先に見ておきます。
困りごと:サーキットは全ジョブを一律に止めるが、投稿系は待てない
claude-quota-guard.pyはクォータ上限を検知するとopen_untilまでEXIT_CIRCUIT_OPEN(75)を返し、ガード対象の15本すべてを一律に止めます。コード中のコメントに、なぜこれだけでは足りなかったかが書いてあります。
# 🔴 2026-08-21: circuit が開くと全ジョブが一律で止まるため、消費の大半を占める
# 返信/エンゲージ系がクォータを使い切った巻き添えで「投稿」まで停止していた。
# 実測(launchd.log 累計): xpilot.autopost は 231実行/308スキップ=実行率43%で、
# threadspilot.engage(64%) より優先度が低い扱いになっていた。投稿はその時間帯を逃すと
# 二度と埋まらないので、--priority を付けたジョブだけは circuit が開いていても
# この間隔で1回だけ試行を許す。試行が通ればクォータ回復の早期検知にもなる
# (従来は open_until まで盲目的に待つだけだった)。
(claude-quota-guard.py:18-24)
一律に止めると、消費量の大きいエンゲージ系に巻き添えを食って投稿系(xpilot.autopost)が実行率43%まで落ちていた、という実測です。しかもopen_untilは「上限文からパースしたreset時刻」か「6時間cooldown」のどちらかで決まる(record_claude_result)ので、実際のクォータがそれより早く戻っていても、サーキットは律儀にその時刻まで開いたままです。投稿のように「その時間帯を逃すと二度と埋まらない」ジョブにとって、これは無視できません。
設計:--priorityジョブだけに30分1回の試行権を渡す
対策は「サーキットが開いていても、優先ジョブにだけ一定間隔で1回、本物のリクエストを通す」ことです。呼び出し側はrun_jobにコマンドを渡すときに--priorityを付けます。
def run_job(label: str, command: list[str], priority: bool = False) -> int:
if not command:
print("claude quota guard: --job requires a command after --", file=sys.stderr)
return 2
status = circuit_status()
probe_claimed = False
if status["is_open"]:
if not (priority and claim_priority_probe()):
print(
"CLAUDE_QUOTA_JOB_SKIPPED "
f"job={label} reason={status['reason']} remaining={status['remaining_seconds']}s ts={now()}",
file=sys.stderr,
)
return 0
probe_claimed = True
print(
"CLAUDE_QUOTA_JOB_PRIORITY_PROBE "
f"job={label} reason={status['reason']} remaining={status['remaining_seconds']}s ts={now()}",
file=sys.stderr,
)
(claude-quota-guard.py:471-490)
priorityが立っていても、実際に試行できるかどうかはclaim_priority_probe()が握っています。
def claim_priority_probe() -> bool:
"""circuit が開いている間、優先ジョブに試行権を1つ渡す。
間隔は全優先ジョブで共有する(=1本が使ったら次の枠まで他も待つ)。上限に本当に
達している間に何本も叩いてもクォータは戻らないため、叩く回数自体を絞る。
"""
if PRIORITY_PROBE_INTERVAL <= 0:
return False
with locked_state() as state:
last = int(state.get("last_priority_probe") or 0)
current = now()
if current - last < PRIORITY_PROBE_INTERVAL:
return False
state["last_priority_probe"] = current
return True
(claude-quota-guard.py:454-468、PRIORITY_PROBE_INTERVALはclaude-quota-guard.py:25で既定1800秒)
last_priority_probeはlocked_state()(fcntl.flockで排他するJSON永続状態)に載っている1個のタイムスタンプです。ジョブ単位ではなくサーキット単位でこの値を持つのがポイントで、「誰が試行したか」は問わず「最後に誰かが試行してから30分経ったか」だけを見ます。
試行権を得た側は、subprocess.runする子プロセスの環境変数にCLAUDE_QUOTA_PRIORITY_PROBE=1を渡します。
guard = str(Path(__file__).resolve())
env = os.environ.copy()
env["CLAUDE_AUTOMATION_GUARD"] = "1"
env["CLAUDE"] = guard
env["CLAUDE_BIN"] = guard
if probe_claimed:
# 内側の run_claude に「プローブとして走っている」ことを伝える(これが無いと circuit で即 75 になる)
env["CLAUDE_QUOTA_PRIORITY_PROBE"] = "1"
(claude-quota-guard.py:491-498)
このフラグを受け取る側がrun_claudeです。ジョブコマンドの内部でclaude本体を叩くとき、guard自身を経由するようにPATHが差し替えられているので、子プロセスの中でもう一段circuit_status()のチェックが走ります。
def run_claude(arguments: list[str]) -> int:
status = circuit_status()
if status["is_open"]:
# run_job が優先プローブを claim した子プロセスだけは circuit を素通りして実 claude を叩く。
# 結果は record_claude_result に入るので、上限文なら circuit が延び、成功なら閉じる。
if os.environ.get("CLAUDE_QUOTA_PRIORITY_PROBE") != "1":
print(
"CLAUDE_QUOTA_CIRCUIT_OPEN "
f"reason={status['reason']} remaining={status['remaining_seconds']}s",
file=sys.stderr,
)
return EXIT_CIRCUIT_OPEN
print(
"CLAUDE_QUOTA_PRIORITY_PROBE_PASS "
f"reason={status['reason']} remaining={status['remaining_seconds']}s",
file=sys.stderr,
)
(claude-quota-guard.py:416-432)
環境変数を伝播させ忘れると、run_job側で試行権を得ても、内側のrun_claudeがもう一度circuitを見て即EXIT_CIRCUIT_OPENを返すだけになります。二段構えのゲートを両方通すための1行がenv["CLAUDE_QUOTA_PRIORITY_PROBE"] = "1"です。
そして実際に叩いた結果は、いつも通りrecord_claude_resultに入ります。上限文がまだ返ってくればopen_untilが延び、成功すればcircuitが閉じます。試行が失敗しても成功しても、その1回の結果がそのままサーキットの次の状態を決めるという意味で、これは「1本だけ通す本物のリクエスト」です。
踏んだ落とし穴1:30分1本は「全優先ジョブの共有カウンタ」
claim_priority_probeのdocstringにある通り、PRIORITY_PROBE_INTERVALはジョブ単位ではなく全体で共有です。--priorityが付いたジョブが同じ30分の枠に複数スケジュールされていても、最初の1本がclaim_priority_probe()を通した瞬間にlast_priority_probeが更新されるので、後続はpriority and claim_priority_probe()の時点でFalseになり、実クライアントを1回も叩かずにSKIPPEDへ落ちます。
これは狙った挙動です。上限に本当に達している間は何本叩いてもクォータは戻らないので、試行回数自体を絞る設計になっています。ただし運用上は「優先ジョブが複数あるからといって、それぞれが独立に30分おきの機会を持つわけではない」ことを忘れると、「なぜこのジョブだけ復旧確認の機会が回ってこないんだろう」で1本ハマります。
踏んだ落とし穴2:プローブが空振りすると、黙ってRAN exit≠0として残る
これが今回の主題です。run_jobはsubprocess.runの後、プローブが空振りしたかどうかを見てマーカーを出し分けます。
try:
result = subprocess.run(command, env=env, check=False)
except OSError as exc:
print(f"claude quota guard job={label}: {exc}", file=sys.stderr)
return 127
if probe_claimed and result.returncode != 0 and circuit_status()["is_open"]:
# 優先プローブが上限のまま空振りした。RAN exit≠0 のまま残すと quota-catchup.py
# (最新マーカー=SKIPPED だけを再実行)から漏れ、復帰後も当日分が欠番になる
# (2026-09-13〜16 の note2-daily / codex-note-funnel 実測)。SKIPPED として記録する。
print(
"CLAUDE_QUOTA_JOB_SKIPPED "
f"job={label} reason=priority-probe-quota exit={result.returncode} ts={now()}",
file=sys.stderr,
)
return result.returncode
print(
f"CLAUDE_QUOTA_JOB_RAN job={label} exit={result.returncode} ts={now()}",
file=sys.stderr,
)
return result.returncode
(claude-quota-guard.py:499-518)
この分岐を入れる前は、プローブが試行権を得て実際に子プロセスを起動した時点で「試行した」という事実だけを見て、末尾のCLAUDE_QUOTA_JOB_RANを素通りで出していました。プローブがまた上限に当たってexit≠0で終わっても、ログに残るのはCLAUDE_QUOTA_JOB_RAN job=... exit=1です。
問題は、このRANマーカーがquota-catchup.pyにとって「もう済んだ扱い」になることです。quota-catchup.pyが再実行候補を絞るlatest_job_markerはこう書かれています。
def latest_job_marker(paths: list[Path], label: str) -> Optional[Tuple[str, int]]:
"""Return the newest timestamped skip/run marker for one launchd label."""
marker_pattern = re.compile(
r"CLAUDE_QUOTA_JOB_(SKIPPED|RAN)\s+job="
+ re.escape(label)
+ r"(?=\s|$).*\bts=(\d+)(?=\s|$)"
)
...
def latest_marker_is_today_skip(paths: list[Path], label: str, today: datetime.date) -> bool:
marker = latest_job_marker(paths, label)
if marker is None:
return False
marker_type, timestamp = marker
return marker_type == "SKIPPED" and datetime.fromtimestamp(timestamp).astimezone().date() == today
(quota-catchup.py:160-193)
見ての通り、この判定はマーカーの種類(SKIPPEDかRANか)しか見ておらず、RAN側のexitコードは一切チェックしていません。プローブが空振りしてexit=1で終わっても、最後に出たログがCLAUDE_QUOTA_JOB_RANである限りlatest_marker_is_today_skipはFalseを返し、find_candidatesの再実行対象から静かに外れます。
その結果、サーキットが本当に閉じた後も、このジョブは「今日はもうRAN済み」という誤った記録のまま、次のスケジュール時刻(翌朝など)まで放置されます。実際にnote2-dailyとcodex-note-funnelが2026-09-13〜16の間、この経路で当日分が欠番になったとコメントに実測が残っています。
直し方はシンプルで、「プローブがcircuit open中に空振りした」という条件(probe_claimed and result.returncode != 0 and circuit_status()["is_open"])を先に判定し、その場合だけCLAUDE_QUOTA_JOB_RANではなくCLAUDE_QUOTA_JOB_SKIPPED reason=priority-probe-quotaを出します。関数の戻り値自体はresult.returncodeのまま変えていません。launchd側のLastExitStatusには実際の失敗が正しく記録されつつ、quota-catchup.pyが見るログマーカーだけを「まだ再試行すべきもの」に付け替える、という切り分けです。
exit codeと再実行すべきかどうかは別の軸です。今回のバグは、この2つを「RANという1つのマーカー」に押し込めていたために、quota-catchup.py側がexit≠0のケースを想定していなかったことで起きました。ログマーカーを設計するときは、「実際に何が起きたか」と「後続バッチが次に何をすべきか」を同じ文字列に相乗りさせないほうが安全です。
まとめ
- サーキットは一律に止めるが、投稿系のように機会を逃すと取り返せないジョブは
--priorityで30分に1回だけ試行権を持てる - 試行権は
claim_priority_probeがlocked_stateのlast_priority_probeで管理し、ジョブ単位ではなく全優先ジョブで共有する。複数の優先ジョブが同じ枠に重なっても機会は1回 - 試行権を得た子プロセスには
CLAUDE_QUOTA_PRIORITY_PROBE=1を渡す必要がある。渡し忘れると内側のrun_claudeが二段目のcircuitチェックで即座に弾く - プローブが空振りしたら
CLAUDE_QUOTA_JOB_RAN exit≠0ではなくCLAUDE_QUOTA_JOB_SKIPPED reason=priority-probe-quotaとして記録する。quota-catchup.pyの再実行判定はマーカーの種類しか見ておらず、exitコードは見ていないため - ログマーカーは「実際に何が起きたか」と「後続バッチが何をすべきか」を分けて設計する
次回は、このSKIPPEDマーカーをquota-catchup.pyがどう拾って復旧後にどれだけ再実行するか決めているかを書きます。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!