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?

サーキットが開いている間に、1本だけ本物のリクエストを通す ― 優先プローブとSKIPPED記録の落とし穴

0
Posted at

前回、ゲートが止めるのは1行であってバッチ全体ではない話を書きました。今回は同じclaude-quota-guard.pyの中でも、サーキットが開いたまま復旧を確かめるclaim_priority_proberun_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-468PRIORITY_PROBE_INTERVALclaude-quota-guard.py:25で既定1800秒)

last_priority_probelocked_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_jobsubprocess.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

見ての通り、この判定はマーカーの種類(SKIPPEDRANか)しか見ておらず、RAN側のexitコードは一切チェックしていません。プローブが空振りしてexit=1で終わっても、最後に出たログがCLAUDE_QUOTA_JOB_RANである限りlatest_marker_is_today_skipはFalseを返し、find_candidatesの再実行対象から静かに外れます。

その結果、サーキットが本当に閉じた後も、このジョブは「今日はもうRAN済み」という誤った記録のまま、次のスケジュール時刻(翌朝など)まで放置されます。実際にnote2-dailycodex-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_probelocked_statelast_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サービスを量産しています

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

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?