朝、前夜に走らせておいた定期ジョブの出力を開いたら、記事の途中で文章が切れていました。エラーログは空。終了コードも、私が想定していたどのパターンとも一致しません。
ファイルは確かに存在していて、サイズもそれなりにありました。だから後続の処理は「もう作ってある」と判断して、そのまま次へ進んでいたのです。
壊れていたのは、ジョブではなく、私の再開ロジックの前提でした。
予算上限による停止は、成功でも失敗でもありません
Claude Code のヘッドレス実行には --max-budget-usd があり、累積コストが閾値を超えた時点で実行が打ち切られます。同時サブエージェント数の上限(現在は20)と同じく、暴走を止めるための安全装置です。
ここで見落としやすいのは、この停止が成功でも失敗でもない第三の終了だという点です。
| 終了の種類 | 起きること | 再試行の意味 |
|---|---|---|
| 正常終了 | 全工程が完了 | 不要 |
| 例外・クラッシュ | 途中で異常終了 | 一時的な要因なら有効 |
| 予算上限による停止 | 正常な工程の途中で打ち切り | 同じ上限では同じ場所で止まります |
クラッシュを前提にした設計は「異常な状態を検知して巻き戻す」ことを考えます。ところが予算停止は、ポリシー通りに正しく動いた結果です。巻き戻すべき異常が、どこにも記録されません。
そして停止する場所は、コストの累積が閾値を跨いだ瞬間に決まります。つまり私が設計したチェックポイントとは無関係な位置です。ファイルを書いている最中かもしれませんし、書き終えてインデックスを更新する直前かもしれません。
この「どこで止まるか分からない」性質を、感覚ではなく数字で把握したいと考えました。
停止点を総当たりする実験装置
実機で --max-budget-usd を少しずつ変えながら何十回も走らせれば、当然その分の費用が発生します。個人開発の予算でそれをやる合理性はありません。
代わりに、同じ構造を持つ再現環境を Linux サンドボックス上に作りました。工程ごとに「コスト単位」を加算し、閾値を超えた瞬間に os._exit(9) で即座に落ちるパイプラインです。後始末をせずに落ちる点が、予算停止の挙動と同じ形になります。
#!/usr/bin/env python3
"""停止点を総当たりするためのパイプライン。
mode=naive : 直接上書き。再開はファイルの存在で判定
mode=safe : .part へ書き fsync してから os.replace。再開はインデックスで判定
"""
import json, os, sys, pathlib
ROOT, CAP, MODE = pathlib.Path(sys.argv[1]), float(sys.argv[2]), sys.argv[3]
ART, IDX = ROOT / "artifacts", ROOT / "index.json"
ITEMS = ["alpha", "bravo", "charlie", "delta"]
spent = 0.0
def charge(u):
"""工程完了ごとにコストを加算。閾値を超えたら巻き戻さずに落ちる"""
global spent
spent += u
with open(ROOT / "spend.log", "a") as f:
f.write(f"{u}\n")
if spent > CAP:
os._exit(9) # ポリシー停止。例外ではないので finally も走りません
def read_index():
return json.loads(IDX.read_text())["items"] if IDX.exists() else []
def write_index(items):
if MODE == "safe":
t = IDX.with_suffix(".json.part")
with open(t, "w") as f:
json.dump({"items": items}, f)
f.flush(); os.fsync(f.fileno())
os.replace(t, IDX) # 同一ファイルシステム内なら原子的に差し替わります
else:
IDX.write_text(json.dumps({"items": items}))
ART.mkdir(parents=True, exist_ok=True)
if MODE == "safe":
for p in ART.glob("*.part"):
p.unlink() # 前回の中断で残った書きかけを掃除します
items = read_index()
done = {e["name"] for e in items}
for name in ITEMS:
if MODE == "naive":
if (ART / f"{name}.md").exists():
continue # 「ファイルがある=完了」という素朴な判定
else:
if name in done:
continue # インデックスだけを唯一の真実として扱います
charge(1.0) # 計画
body = f"# {name}\n" + "x" * 200_000 + "\nEND\n"
charge(2.0) # 生成
target = ART / f"{name}.md"
path = target.with_suffix(".md.part") if MODE == "safe" else target
with open(path, "w") as f:
f.write(body[: len(body) // 2])
f.flush(); os.fsync(f.fileno())
charge(0.5) # 書き込みの「最中」に置いた停止点
f.write(body[len(body) // 2 :])
f.flush(); os.fsync(f.fileno())
if MODE == "safe":
os.replace(path, target)
charge(0.5)
items.append({"name": name, "bytes": len(body)})
write_index(items) # コミットしてから課金する順序が後で効いてきます
charge(0.5)
print("COMPLETE")1件あたりのコストは 1.0 + 2.0 + 0.5 + 0.5 + 0.5 で 4.5 単位、4件で 18.0 単位が理想値になります。上限を 0.5 刻みで 0.5 から 18.0 まで動かせば、36通りの停止点を機械的に踏み分けられます。
検証側は、再開が終わった後の状態を厳しく見ます。インデックスに載っている全件について、ファイルが存在し、バイト数が一致し、末尾に終端マーカーがあること。加えて、書きかけの .part が残っていないこと。
#!/usr/bin/env python3
"""再開完了後の厳格な検証。サイズ一致と終端マーカーの両方を見ます"""
import json, sys, pathlib
ROOT = pathlib.Path(sys.argv[1])
ART, IDX = ROOT / "artifacts", ROOT / "index.json"
EXPECT = ["alpha", "bravo", "charlie", "delta"]
bad = []
idx = {e["name"]: e["bytes"]
for e in (json.loads(IDX.read_text())["items"] if IDX.exists() else [])}
for n in EXPECT:
p = ART / f"{n}.md"
if n not in idx:
bad.append(f"missing-index:{n}"); continue
if not p.exists():
bad.append(f"missing-file:{n}"); continue
data = p.read_bytes()
if len(data) != idx[n]:
bad.append(f"size-mismatch:{n}({len(data)}!={idx[n]})")
elif not data.endswith(b"END\n"):
bad.append(f"truncated:{n}")
for p in ART.glob("*.part"):
bad.append(f"leftover-part:{p.name}")
print(("CORRUPT " + ",".join(bad)) if bad else "OK")なお、ここで得られる数値は再現環境のものであり、Claude Code 実機の課金点そのものではありません。課金がどの粒度で載るかは実装依存です。それでも、停止が内部の課金点と外部の設計上の境界のズレで起きるという構造は共通しています。今回知りたいのはその構造の方でした。