AIが作ったツールは、強制終了から正しく復旧できるのか? — SIGKILL・障害注入・途中再開を検証
テストが全て通るプログラムでも、実行中に電源が落ちたり、kill -9 で止められたりしたときに、前回の続きから正しく再開できるとは限らない。正常系のテストは、プロセスが最後まで生きていることを前提にしているからだ。
Claude Codeに「強制終了に耐えるツール」を作らせ、プロセスを実際に殺して、再起動後の状態を調べた。中心に据えたのは、SQLiteで状態を管理するタスク実行基盤 crwr(Exp41)である。ビルド実行(Exp34)、SQLiteのスキーマ移行(Exp36)、再開可能なファイル転送(Exp31)も、比較の対象にした。
前回までの記事では、AIの「テストPASS」報告を別セッションがレビューした結果と、実在OSSのバグをAIに答えを見せずに診断させた結果を紹介した。今回も、AIの報告と実測を分けて読む。結論を先に書くと、保存済みのテストと追加の観察の範囲では、復旧の仕組みは動いていた。ただし、障害注入テストの「7つの停止ポイント」のうち1つは、狙った場所に刺さっていなかった。
実験の設計と、この記事の範囲
私は実験の目的と大まかな条件を決め、具体的な題材の選定、詳細な仕様、テスト設計、実装、検証は主にAIに任せた。開始時に指示を出し、その後は基本的にClaude Codeに自律実行させたが、一部の実験では途中で継続の指示なども行っている。各実験の最終報告は私が読み、疑問があれば追加の検証を指示したが、コードの詳細な調査やテストの実行は基本的にAIが担当した。「Exp」に続く数字は、私がこの一連の実験に付けた通し番号である。
この記事で扱う数字は、次の3種類に分かれる。
- 報告書の自己申告:各セッションが終了時に書いた報告にある値。特に断らない限り、これに当たる。
- 保存されたコードやテストを読んで確かめた事実:テストの記述や実装の内容。
- 今回の再実行:この記事の作成にあたり、Claude CodeにExp41のテスト26件と、停止ポイントごとの状態確認を、WSL(Ubuntu)上で再実行させて得た値。この記事で「再実行」と書いたものは、これだけである。他の実験については、当時保存されたテスト結果や報告書を基に紹介している。
報告書、コード、テストは非公開のリポジトリにあり、読者は直接確認できない。記事中のコードは、保存されたコードから抜き出したものである。
この記事で扱う実験には、Claude Code Sonnet 5.5(推論レベルMedium)を使用した。Exp1〜40では、第三者による人間のコードレビューは実施していない。
通常の例外終了とSIGKILLは何が違うのか
Pythonでは、例外で落ちる場合、スタックを巻き戻す際に finally が実行され、通常のインタープリタ終了では atexit に登録した処理も実行される。SIGTERMなら、ハンドラを登録しておけば、子プロセスを片付けてから終了できる。SIGKILLはプロセスが捕捉できず、これらの終了処理は実行されないまま、その場で止まる。SIGKILLで起きることは次のとおりだ。
- 開いていたトランザクションが、COMMITされないまま放棄される。
- 書きかけのファイルが残る。
- 子プロセスが親を失い、孤児として動き続ける。
- 「処理が終わったこと」と「それを記録したこと」の間で止まると、実行済みかどうかが分からなくなる。
Exp41の障害注入は、この違いを区別して実装している。環境変数 CRWR_FAULT に before_db@b=kill のように指定すると、指定した地点で kill(実際の SIGKILL)、term(SIGTERM)、raise(例外)のいずれかが発動する。
def f(p, step=None):
if p != point or (only and only != step) or fired:
return
fired.append(1)
if action == "kill":
os.kill(os.getpid(), signal.SIGKILL)
一方、Exp34とExp36は主に os._exit(137) を使っている。これはクリーンアップを一切行わず即座に落ちる点でSIGKILLに近いが、シグナルによる終了ではない。Exp36は、これに加えて実際のSIGKILLを子プロセスに送るケース(F16)も持っている。
Exp41の実装:SQLite・lease・checkpoint
crwr はPython標準ライブラリだけで書かれた約700行のタスク実行基盤で、テストは別に319行ある。ジョブはステップのDAG(依存関係つきの順序)としてJSONで定義し、ステップは依存順に1つずつ実行する。1つのルートディレクトリにつき、扱えるジョブは1つである。ステップごとに、コマンド、タイムアウト、リトライ、生成物(artifacts)、副作用の有無(none か unsafe)を宣言する。
状態は1つのトランザクションで確定する
状態はSQLiteに置き、WALモードと synchronous=FULL を指定している。変更は全て BEGIN IMMEDIATE のトランザクション内で行う。設計の中心は、ステップの完了と、生成物のsha256の記録を、1つのトランザクションで書くことだ。完了の記録が見えるのは、COMMITが終わった後だけになる。
leaseは「期限」ではなく「プロセスの生死」で決まる
実行権(lease)は、1行だけのテーブルに、トークン、PID、/proc から読んだプロセス開始時刻、ブート識別子を持つ。新しい実行者がleaseを取得できるのは、記録されたPIDのプロセスが、開始時刻とブート識別子まで一致して生きていない場合に限る。生きていれば、Busy として終了コード75で断る。PIDが再利用されても、開始時刻が違えば別のプロセスとみなす。
ここで重要なのは、時間による期限切れがないことだ。Claude Codeにコードを検索させても、expire や ttl に当たる処理は見つからなかった(acquired 列は記録するだけ)。そのため、「期限切れ後に別のワーカーが引き継ぐ」という、分散システムでよく見るleaseとは別物になる。フリーズしたまま生きているランナーは、コードを読む限り、他の実行者に引き継がれない。この点は読解による推定で、実行して確かめてはいない。
SQLiteの管理状態を更新するトランザクションは、いずれもトークンが現在のleaseと一致するかを確認する(フェンシング)。トークンが一致しない古いランナーによる状態の更新は、LeaseLost で拒否される。保証されるのはSQLite上の状態の更新だけで、ステップが実行する外部コマンドによるファイルの更新や、ネットワークへの送信といった副作用まで阻止できるとは言えない。
def __enter__(self):
self.c = self.st.db
self.c.execute("BEGIN IMMEDIATE")
if self.fenced:
r = self.c.execute("SELECT token FROM lease WHERE id=1").fetchone()
if not r or r["token"] != self.st.token:
self.c.execute("ROLLBACK")
raise LeaseLost("lease token mismatch")
return self.c
ただし、このテスト(test_fenced_write_after_lease_stolen)は、トークンを手で書き換えて確かめるもので、本物のゾンビランナーを作っているわけではない。
再開時に何をするか
resume は、まず RUNNING のまま残ったステップを INTERRUPTED にし、記録されたプロセスグループが残っていれば(本人確認のうえ)殺す。その後、完了済みステップの生成物をsha256で再検証し、不一致があればそのステップと、依存するステップを PENDING に戻して生成物を消す。生成物は、再実行の前に必ず消されるので、途中まで書かれたファイルは信用されない。
停止したステップの扱いは、宣言された副作用で分かれる。
side_effects: "none":再実行する(冪等であるとみなす)。side_effects: "unsafe":結果が不明(unknown)として止まり、終了コード3で再開を拒否する。再実行するか失敗にするかは、オペレーターが--accept-unknown retry|failで決める。
7つの停止ポイントとその結果
テストは、a → b → c の3ステップのチェーンを作り、ステップ b の中の7か所でランナー自身にSIGKILLを送る。その後、新しいプロセスで resume し、verify と各ステップの実行回数を確かめる。
POINTS = ["step_start", "child_spawned", "after_artifact", "before_db", "db_mid_txn", "after_db", "before_next_step"]
報告書は、26件のテストが全て通ったとしている。今回、Claude Codeに保存済みのコードをWSL上で再実行させると、26件が通った(95秒。報告書の約15秒とは環境が違う)。
次に、各停止ポイントでの状態を調べるため、同じ地点を、公式テストとは別に、2ステップ(a → b)の最小のジョブで試し、停止直後のSQLiteの中身と、resume 後の状態を記録した(これもClaude Codeに小さなスクリプトで実行させた値)。
| 停止ポイント | 停止直後のb | resume後のb |
|---|---|---|
| step_start | RUNNING(試行1) | COMPLETED(試行2) |
| child_spawned | RUNNING(試行1) | COMPLETED(試行2) |
| after_artifact | RUNNING(試行1) | COMPLETED(試行2) |
| before_db | RUNNING(試行1) | COMPLETED(試行2) |
| db_mid_txn | PENDING(試行0) | COMPLETED(試行1) |
| after_db | COMPLETED(試行1) | COMPLETED(試行1、再実行なし) |
| before_next_step | COMPLETED(試行1) | COMPLETED(試行1、再実行なし) |
確認できたのは、次の点である。7か所全てでSIGKILLが発火し(終了コード-9)、resume と verify が成功し、SQLiteに記録された状態の遷移は表のとおりだった。完了が記録された後の停止ではそのステップを再実行せず、記録前の停止では再実行する。設計どおりの結果である。確認していないのは、各地点で実際にコマンドが何回実行されたか、全ての中断タイミングで復旧が安全かどうか、電源断時の永続性である。なお、「試行」はSQLite上の試行の記録で、step_start の停止では子プロセスが起動する前に止まっているため、実際にコマンドが動いた回数とは一致しない。
ただし、表の db_mid_txn の行は、設計意図と一致していない。この地点は、トランザクション内のCOMMIT直前でkillするための地点だが、ステップ名を指定した障害は、そのステップで最初に終わるトランザクションで発動する。ステップの最初のトランザクションは、完了の記録ではなく、RUNNING にするためのものだ。その結果、db_mid_txn@b が殺したのは完了記録のCOMMIT直前ではなく、開始記録のCOMMIT直前で、b は一度も実行されないまま PENDING に戻っていた(試行0)。完了記録のCOMMIT直前の停止は、このテストでは行われていない。
もう1つ、テストの検査にも弱い箇所がある。a と c の実行回数が1回であることは確かめているが、b については、完了記録より前の停止で何回実行されたかを確かめる行が、自分自身と比較する形になっている。
self.assertEqual(self.count("b"), 1 if durable else (1 if pt == "step_start" and False else self.count("b")))
durable でない5地点では、この検査は常に成立する。b が1回だけ実行されたのか2回実行されたのかを、このテストは確認していない。実行回数を確かめるテストは別にあり、安全なステップと危険なステップを扱う後述の2件がそれに当たる。
これは、AIの報告が間違っていたという話ではない。確認した範囲では、復旧は動いていた。ただし、「7つの停止ポイントで検証した」という言い方には、検査の強さの差がある。第1記事で見たとおり、テストの件数や「通った」という報告は、そのテストが何を落とせるかとは別の話だ。
二重実行は防げているのか
答えは「副作用の宣言と、オペレーターの判断による」である。報告書自身も、exactly-onceは主張していない。
- 安全な(
noneの)ステップは、完了の記録前に止まると再実行される。before_db@a=killのテストでは、実行回数が2になる。少なくとも1回は実行される、という保証である。 - 危険な(
unsafeの)ステップは、同じ止まり方をすると再実行されず、終了コード3で止まる。--accept-unknown retryを付けると実行回数が2になる。これは、二重実行の危険を人間に渡す設計である。 - ステップの実行後から、完了の状態が永続化されるまでの間で止まると、処理が済んだかどうかを、状態の記録だけからは判断できない。安全なステップはこの場合に再実行され、危険なステップは「不明」として表に出る。この区間以外にも、次に述べる孤児プロセスのように、副作用に関わる問題が残るタイミングはある。
孤児プロセスにも、一部の対策がある。ランナーを外部からSIGKILLすると、sleep していた子プロセスは生き残る(テストで確認している)。resume は、記録されたプロセスグループを、開始時刻の照合つきで殺す。
ただし、報告書自身が、未解決の問題を3つ挙げている。子プロセスの起動(Popen)から、プロセスグループの記録までの間にSIGKILLされると、孤児が記録されない。setsid() を呼ぶ子孫は、グループ単位の終了から逃れる。PIDの再利用に対する保護は、ベストエフォートである。さらに、child_spawned の障害点は記録の後に置かれているため、1つ目の窓には刺さらない(コードの記述から確認した)。
他の実験との比較
同じ「止めて、再開して、壊れていないかを見る」実験でも、状態の持ち方と検査の強さには違いがあった。
| 実験 | 状態の持ち方 | 停止方法 | 発火の確認 | 検出力の検査 | レビュー |
|---|---|---|---|---|---|
| Exp41(crwr) | SQLite(WAL、全変更がトランザクション) | 実際のSIGKILL(7地点+外部kill) | 終了コード-9の確認のみ | 実施せず | 同一セッションのみ |
| Exp34(BuildGraph) | CRC付きジャーナル+キャッシュ+プラン | os._exit(137) | 発火ログがなければ失敗 | 変異33件中31件を検出 | 同一セッションのサブエージェント |
| Exp36(SQLite移行) | SQLite(1移行=1トランザクション)+状態ファイル | os._exit(137)、実SIGKILL 1件 | 発火証拠がなければ失敗 | 変異35件中29件を検出 | 同一セッションのサブエージェント |
| Exp31(転送) | .part ファイル+状態ファイル | 切断・サーバー停止、SIGKILL 1件 | 未発火ルールはテスト失敗 | 変異23件中22件を検出 | 同一セッションのサブエージェント+別セッション |
発火の確認は、実験によって強さが違った
Exp31では、障害ルールが一度も発火しないテストが、そのまま通ってしまう「空振り」が自作テストで見つかった(PC-1)。そこで、発火しないルールがあればテストを失敗させるガードを入れた。Exp34は発火ログ、Exp36は発火証拠を成立条件にしている。Exp41は、SIGKILLによる終了コードの確認が発火の証拠になっているので、これも一定の保証にはなる。ただし、上で見たとおり、「狙った場所に発火した」ことまでは確認していない。
同一セッションのレビューが見つけたものと、見逃したもの
Exp34の同一セッションのサブエージェントは、部分的なビルドの後で、依存先が古いまま残る問題(F1)を指摘した。Exp36では、レビューが、移行内のCOMMITでトランザクションの原子性が崩れる問題(R1)を見つけた一方で、レビューが見逃した重大度の高い欠陥は別の経路で見つかった。その1つは、移行内で writable_schema を使うと、壊れたスキーマが全検査をすり抜けてCOMMITされ、DBが開けなくなる問題(D5)である。レビューの網羅性は、保証できない。
Exp31では、同一セッションのサブエージェントによる敵対レビューが、補助ファイル名の衝突で無関係な完成ファイルを消す問題(F1)を再現した。その後の別セッションによるレビューと修正の経緯は、第1記事で触れたので、ここでは繰り返さない。
電源断は、どの実験も試せていない
Exp31、34、36、41は、いずれも電源断やfsync失敗を試していない。SIGKILLで殺されるのはプロセスだけで、OSは動き続ける。OSのページキャッシュは通常は維持されるので、OSに渡し終えたデータは、プロセスが死んだ後も読める。一方、アプリケーション内に残っていてOSに渡していないデータは失われ得るし、COMMITされていないトランザクションも永続化されない。電源断では、ディスクに書き出されていないページキャッシュも失われ得る。synchronous=FULL やfsyncの効果は、この場合にしか差が出ない。SIGKILLのテストが全て通っても、電源断に対する耐久性を証明したことにはならない。
Exp20:実験基盤の側が止まる場合
Exp20は、ツールではなく、実験の仕組み側の中断を試した。ローカルのWindowsで子プロセスを殺すと、完了済み4件と書きかけの unit_004.json.tmp が残った。再実行では残りの6件だけを実行して10件が完成し、SHA-256は全て一致し、重複完了は0だった。クラウド側のセッションは rate_limit_event の後で running のまま止まり、結果は取得できなかった。止めたり再開したりする公式の操作も確認できず、この部分はUNKNOWNである。
Exp41の検証は、他の3実験より弱かった
Exp41のレビューは同一セッション内のみで、変異テストも行われていない。報告書でもTEST_QUALITYはPARTIALとされている。他の3つの実験には、検出力の検査かレビューのどちらか、あるいは両方があった。上で見た db_mid_txn のずれと自己比較の検査は、この違いと整合する。ただし、今回の確認もClaude Codeに依頼した同じ系統の読み直しで、独立したレビューではない。
実務への応用
ここから先は、実験で効果を検証したものではなく、私が結果から導いた推奨である。
- 完了の記録と、その成果物の検証値(ハッシュ)は、1つのトランザクションで確定させる。
- 「不明」を状態として表に出す。副作用のある処理は、再開時に勝手に再実行しない。
- 障害注入は、発火の証拠をテストの成立条件にする。地点名だけでなく、「その瞬間の状態」を検査する。
- 実際のSIGKILLと、
os._exitは区別して使う。孤児プロセスの確認には、実際のシグナルが要る。 - 実行権の設計は、期限で切れるのか、プロセスの生死で決まるのかを明確にする。
- SIGKILLのテストは、プロセスのクラッシュ耐性の検証であって、電源断の検証ではない。
限界
- 実験は、いずれも1回の試行である。ここに挙げた数字は観察件数であり、確率ではない。
- Exp41の再実行は、WSL上の限定された条件で行った。停止ポイントの観察は2ステップの最小ジョブで、各地点で実際にコマンドが何回実行されたかは計測していない。
- 7つの停止ポイントのうち
db_mid_txnは意図した場所に刺さっておらず、bの実行回数の検査も自己比較になっている。Exp41のテストの検出力は、その分だけ弱い。 - 電源断、fsync失敗、ディスクフルは試していない。Exp41は変異テストも行っておらず、レビューは同一セッション内のものだけである。
- Exp41はLinux専用(
/procに依存)、1ホスト、1ジョブ、順次実行のみを対象にしている。exactly-onceは保証されていない。 - 費用は、実験別に確定できていない。詳細は第1記事の限界の節に書いた。
まとめ
AIが作ったタスク実行基盤は、最小のジョブで7か所をSIGKILLで止めても、resume の後に verify が成功し、壊れた状態は見つからなかった。全ての中断タイミングや電源断での安全性は、確かめていない。完了の記録を1つのトランザクションにまとめ、不明な結果を人間に渡す設計は、実際に機能していた。
一方で、障害注入の「7つの停止ポイント」の1つは、設計した場所に刺さっておらず、実行回数の検査には自己比較の行があった。復旧できたという報告は、確認した範囲では裏づけられたが、その報告を支えるテストの強さには、地点ごとに差があった。障害耐性を確かめるときに必要なのは、kill した地点の数ではなく、その瞬間に何が起きていて、何を検査しているのかである。