朝、前夜に走らせておいた定期ジョブの出力を開いたら、記事の途中で文章が切れていました。エラーログは空。終了コードも、私が想定していたどのパターンとも一致しません。
ファイルは確かに存在していて、サイズもそれなりにありました。だから後続の処理は「もう作ってある」と判断して、そのまま次へ進んでいたのです。
壊れていたのは、ジョブではなく、私の再開ロジックの前提でした。
予算上限による停止は、成功でも失敗でもありません
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 + " \n END \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 実機の課金点そのものではありません。課金がどの粒度で載るかは実装依存です。それでも、停止が内部の課金点と外部の設計上の境界のズレで起きる という構造は共通しています。今回知りたいのはその構造の方でした。
素朴な再開が、100,006バイトのファイルを完成品として通しました
まず naive モードで、36通りの上限それぞれについて「予算停止 → 上限なしで再開 → 検証」を回しました。
モード 再開後に壊れた最終状態
naive(ファイルの存在で完了判定) 8 / 36 停止点
safe(インデックスで完了判定・原子的置換) 0 / 36 停止点
壊れた8点の中身を開くと、事の重さが分かりました。上限3.0で止めた実行の成果物ディレクトリはこうなっていました。
-rw-r--r-- 1 ... 100006 alpha.md <- 本来 200013 バイト
-rw-r--r-- 1 ... 200013 bravo.md
-rw-r--r-- 1 ... 200015 charlie.md
-rw-r--r-- 1 ... 200013 delta.md
index.json:
{"items": [{"name": "bravo", ...}, {"name": "charlie", ...}, {"name": "delta", ...}]}
alpha.md は 100,006 バイト、期待値 200,013 バイトのちょうど半分で切れています。それでも「ファイルは存在する」ため、再開処理は alpha を飛ばしました。飛ばされた結果、インデックスにも載らないまま、半分だけの成果物がディスク上に残り続けます。
朝に私が見た、途中で切れた文章の正体がこれでした。
危険なのは、停止したその瞬間ではありません。停止によって生まれた中途半端な状態を、次の実行が「完了済み」と誤読する瞬間 です。予算上限を導入すると、この誤読の機会が定期的に、しかも設計者の予期しない位置で発生するようになります。
原子的な書き込みとインデックス優先の再開
対策そのものは古典的な組み合わせです。
成果物は必ず .part に書いて fsync し、os.replace で目的の名前へ差し替えます。同一ファイルシステム内であれば置換は原子的で、途中まで書かれた本名のファイルが観測されることはありません。
起動時に残っている .part を掃除します。前回の中断の痕跡を次の実行へ持ち越しません。
完了判定の根拠をディスクの存在ではなくインデックスに置きます。インデックス自体も同じ原子的置換で更新します。
インデックスへの追記は、成果物の置換が完了した後 に行います。この順序が逆だと、載っているのに存在しない項目が生まれます。
この safe モードでは、36通りすべての停止点で再開後の状態が健全でした。
気になるのはコストです。.part への書き込みと追加の fsync、os.replace の分だけ確実に遅くなります。200件のパイプラインを各5回走らせて平均を取りました。
モード 1回あたりの実行時間(200件) 1件あたり
naive 0.532 秒 2.66 ms
safe 0.687 秒 3.44 ms
差は29.1%、1件あたり0.78ミリ秒です。生成の実コスト(モデル呼び出しの待ち時間)が秒の単位である以上、この差は誤差の側に沈みます。原子性を諦める理由は見つかりませんでした。
ここまでは、想定通りの結果です。問題はこの先でした。
直感に反した二つの結果
原子性を入れると、予算を焼き続ける再試行が生まれます
安全側に倒したのだから、あとは「完了するまで再試行する」ループを被せればよいはずでした。上限を3.0に設定して、両モードで完了まで回してみました。
モード 試行回数 総支出 最終状態
naive 5 14.0 壊れた成果物を出力して「完了」
safe 12(打ち切り上限) 42.0 何も残らず
理想値18.0に対して、safe は42.0を消費して成果ゼロでした。上限3.5では48.0まで伸びます。
理由は単純です。1件を完了させるには4.5単位が要るのに、上限が3.0では最初の1件すら commit まで到達しません。原子的な設計は「中途半端をコミットしない」ため、毎回きれいに巻き戻り、毎回きれいに同じ場所で止まります 。再試行の回数だけ、進捗ゼロで予算が溶けていきます。
皮肉なことに、壊れた出力を平気で通す naive の方が「安く終わる」わけです。もちろんどちらも許容できません。
必要だったのは、原子性ではなく進捗の観測 でした。試行の前後でコミット済み件数を比べ、増えていなければ即座に打ち切ります。
#!/usr/bin/env bash
# 進捗ゲート付きの再試行。前進しない試行を検出したら予算を焼く前に降ります
set -u
MODE = $1 ; CAP = $2 ; MAXATT = ${3 :- 12} ; D = $4
progress () {
python3 - " $D " << 'PY'
import json, pathlib, sys
p = pathlib.Path(sys.argv[1]) / "index.json"
print(len(json.loads(p.read_text())["items"]) if p.exists() else 0)
PY
}
ATT = 0 ; STALL = 0
while [ " $ATT " -lt " $MAXATT " ]; do
BEFORE = $( progress ); ATT = $(( ATT + 1 ))
python3 pipeline.py " $D " " $CAP " " $MODE " > /dev/null 2>&1 && break
AFTER = $( progress )
if [ " $AFTER " = " $BEFORE " ]; then
STALL = 1 # 上限が最小作業単位のコストを下回っています
echo "no forward progress after attempt ${ ATT }: raise --max-budget-usd" >&2
break
fi
done
exit " $STALL "
上限3.0でこのゲートを噛ませると、試行1回・支出3.5単位で「前進しない」と判定して降ります。42.0が3.5になりました。予算上限を入れるなら、進捗ゲートは同時に入れるべき対の部品 でした。片方だけでは、安全装置が浪費装置に変わります。
総支出は上限値に対して単調ではありません
進捗ゲートを効かせた状態で、上限を4.0から12.0まで0.5刻みで振り、完了までの総支出を測りました。理想値は18.0です。
上限 試行回数 総支出 理想比
4.0 5 18.0 ±0%
4.5 4 21.0 +16.7%
5.5 〜 7.0 4 27.0 +50.0%
8.0 4 30.0 +66.7%
8.5 3 18.0 ±0%
9.0 〜 9.5 2 19.0 +5.6%
10.0 〜 11.5 2 21.0 +16.7%
上限を8.0から8.5へ、わずか0.5だけ引き上げたところで、総支出が30.0から18.0へ落ちました。40%の削減です。予算の枠を広げたら安くなる、という直感に反する結果でした。
追跡すると理由が見えました。このパイプラインは成果物をコミットした直後 にコストを加算します。上限8.0では、2件目のコミット直前に閾値を跨いで落ちます。1件分の生成コストが丸ごと捨てられ、それが試行のたびに繰り返されます。上限8.5では同じ位置でコミットが先に済むため、捨てられるものがありません。
つまり無駄の量は、外から見えない内部の課金点と上限値の相対位置 で決まっています。実機のモデル呼び出しがどの粒度でコストを積むかを、外側から正確に知る方法はありません。だとすれば、最適な上限値を机上で算出しようとする発想そのものが誤りでした。
上限は「バッチ全体」ではなく「最小の再開単位」に掛けます
答えは、上限値を賢く選ぶことではなく、上限を掛ける対象を変えることでした。
バッチ全体を1回の実行で処理し、そこに上限を掛けるから、上限が「作業の途中」に落ちます。1回の実行が処理する件数を1件に絞れば、上限は必ず「作業の切れ目」に落ちます。
PER_RUN = int (os.environ.get( "PER_RUN" , "0" )) # 0 は無制限
committed = 0
for name in ITEMS :
if PER_RUN and committed >= PER_RUN :
break # 1件コミットしたら正常終了します
...
write_index(items)
committed += 1
charge( 0.5 )
上限値を4.5から12.0まで変えて測り直しました。
上限 試行回数 総支出 理想比
4.5 4 18.0 ±0%
6.0 4 18.0 ±0%
8.0 4 18.0 ±0%
12.0 4 18.0 ±0%
上限値によらず、無駄が0%になりました。チューニングという作業そのものが消えます。
この形には副産物もあります。1回の実行が短くなるため、上限は「暴走の停止装置」から「1件あたりのコスト上限」へ意味が変わります。1件が想定の3倍のコストを要求してきたとき、バッチ全体を巻き込まずにその1件だけを落とせます。異常なコストを出す入力を特定する手掛かりにもなります。
私はこの形を採ることにしました。個人開発で夜間に無人で回す構成では、翌朝に確認する対象が「どこまで進んだか」の1点に収束するのが何より助かります。
1件ずつに絞った代償は、9.3倍のプロセス起動でした
この形にはコストがあります。バッチを1回で処理していたものが、件数分のプロセス起動に置き換わるからです。
同じ再現環境で、200件を「1プロセスで通す」場合と「1件ずつ200プロセスで通す」場合を測りました。
実行の形 200件の所要時間 1件あたりの差
1プロセスで200件 656 ミリ秒 —
200プロセスで1件ずつ 6,121 ミリ秒 +27.3 ミリ秒
9.3倍です。増えた分の正体は、インタプリタの起動と初期化にかかる時間でした。
モデル呼び出しが秒の単位で待たされる工程では、27.3ミリ秒は完全に埋もれます。実際、私の夜間ジョブでは体感できる違いがありませんでした。
埋もれない構成もあります。起動のたびに大きなインデックスを読み直す、MCPサーバを立ち上げ直す、認証を取り直す。こうした初期化のコストが1件の生成コストに近づいてくると、1件ずつの分割は割に合わなくなります。
その場合は PER_RUN を1に固定せず、k にします。k の決め方は単純で、1回の実行が必ずコミットの直後で終わる値 を選ぶことです。上限は「k 件分の最悪コスト + 1件分の余裕」で置きます。
# k件ずつ処理し、上限は k×最悪コスト + 1件分の余裕 で置きます
PER_RUN = 4
WORST_PER_ITEM = 0.35 # 過去30日の「1件あたり最大」コスト(USD)。平均ではありません
CAP = $( python3 -c "print(round( $PER_RUN * $WORST_PER_ITEM + $WORST_PER_ITEM , 2))" )
PER_RUN = $PER_RUN claude -p " $PROMPT " --max-budget-usd " $CAP "
WORST_PER_ITEM を平均ではなく最大で置くところが要点です。平均で置くと、重い入力に当たった回だけ上限が生成の途中に落ちます。あの非単調な浪費が、そこから戻ってきます。
並列で走らせた途端、インデックスが唯一の真実でなくなりました
1件ずつに割ったのだから、並列に流せば起動コストは取り返せるはずでした。24件を同時に走らせてみました。
インデックスから件数が消えました。
完了の記録方式 120件中の記録漏れ 例外の発生
固定名の一時ファイル + 読んで足して書く 92 件 40 回
プロセス固有の一時ファイル + 読んで足して書く 97 件 0 回
flock で書き込みを直列化0 件 0 回
1件1ファイルの完了マーカー 0 件 0 回
24並列 × 5試行 = 120件のうち、97件が記録から抜け落ちました。成果物のファイル自体は全て正しく書かれています。消えたのはインデックスの側だけです。
原因は、原子的置換の外側にありました。os.replace は差し替えを原子的にしますが、読んで・足して・書き戻す という三手全体は保護しません。24個のプロセスが同時に同じ古いインデックスを読み、それぞれ自分の1件だけを足して書き戻します。最後に書いた1件しか残りません。
表の1行目は、もっと質の悪い壊れ方です。一時ファイル名を .part に固定していたため、あるプロセスが os.replace する前に別のプロセスが同じ .part を持ち去り、40回の例外になりました。空のインデックスを読み込んだ試行もあります。原子的に書いているつもりで、原子性の前提そのものを並列が壊していました。
2行目が怖いのは、例外が0回だという点です。ログには何も残りません。翌朝に件数が合わないことで、初めて気づきます。冒頭で私が見た、途中で切れた文章と同じ質の静けさでした。
対策は二通りあり、どちらも記録漏れは0件でした。
flock で書き込みを直列化する方法は、既存のインデックス構造をそのまま使えます。ただし24並列で平均453ミリ秒、次のマーカー方式の約2倍かかりました。差はロック待ちの時間です。
もう一方は、単一のインデックスをやめて 1件につき1ファイルの完了マーカー を置く方法です。書き込みが衝突しないため、ロックが要りません。同じ24並列で231ミリ秒でした。
def commit (root, name, artifact_bytes):
"""完了マーカーは1件1ファイル。書き込みが衝突しないためロックが不要です"""
done = root / "done"
done.mkdir( exist_ok = True )
p = done / f " { name } .json"
t = p.with_name(p.name + f ". { os.getpid() } .part" ) # 一時名はプロセス固有にします
with open (t, "w" ) as f:
json.dump({ "name" : name, "bytes" : artifact_bytes}, f)
f.flush(); os.fsync(f.fileno())
os.replace(t, p)
def completed (root):
"""再開時はマーカーを集めて完了集合を組み立てます"""
d = root / "done"
if not d.is_dir():
return set ()
return {json.loads(p.read_text())[ "name" ] for p in d.glob( "*.json" )}
「インデックスは1本のファイルで持ちたい」という要求が残る場合は、マーカーから再構成すれば済みます。1,000件の再構成が32.1ミリ秒でした。並列実行が終わった後に1回だけ走らせる用途なら、十分に安い処理です。
一時ファイル名をプロセス固有にする点は、方式によらず必要でした。os.replace の原子性は、差し替え元が自分だけのものであることを前提にしています。この前提は逐次実行では自明に満たされるため、並列にした瞬間に初めて破れます。
導入時の確認項目
これから同じ構成を組む場合、次の順序で確認していただければ、私が踏んだ穴は避けられるはずです。
成果物の書き込みが原子的か — 一時ファイルへ書いて fsync し、os.replace で差し替えているか。追記モードでの直接書き込みが残っていないか。
完了判定の根拠がインデックスか — ファイルの存在やサイズで判定していないか。存在判定は、半分だけ書かれたファイルを完成品として通します。
コミットと記録の順序 — 成果物の置換が終わってからインデックスへ追記しているか。逆順は「載っているのに無い」項目を生みます。
起動時に書きかけを掃除しているか — 残った .part を削除してから本処理へ入るか。
進捗ゲートがあるか — 前進しない試行を検出して降りる仕組みがあるか。これが無いと、上限が浪費装置に変わります。
上限を掛ける対象が最小の再開単位か — バッチ全体ではなく、1件ずつの実行に上限を掛けているか。
終了コードの取り違えがないか — 予算停止を「一時的な失敗」として無条件に再試行する分岐が残っていないか。予算停止は同じ上限では再現します。
一時ファイル名がプロセス固有か — 並列で走らせる可能性があるなら、.part の固定名が原子性の前提を壊します。
完了の記録が衝突しない形か — 単一のインデックスを読んで足して書き戻していないか。並列にするなら flock か、1件1ファイルのマーカーへ。
9項目のうち、私自身が最初から持っていたのは1と3だけでした。残りは、停止点を総当たりして初めて必要性が見えたものです。原子的な置換までは自然に書いていたのに、その原子性が再試行の浪費を生むところまでは想像が届いていませんでした。8と9に至っては、逐次で回している限り一度も表面化しません。
無人で回す仕組みを作るとき、私たちはつい「失敗したらどうするか」を考えます。けれど予算上限が生む停止は、失敗ではありません。正しく動いた結果として、設計上の境界とは無関係な場所に置かれる、静かな切れ目です。
その切れ目を数えることからしか、対処は始まらないように思います。同じ穴に落ちる方が一人でも減れば嬉しく、この記録が少しでもお役に立てば幸いです。お読みいただき、ありがとうございました。