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に小さなスクリプトで実行させた値)。

停止ポイント停止直後のbresume後のb
step_startRUNNING(試行1)COMPLETED(試行2)
child_spawnedRUNNING(試行1)COMPLETED(試行2)
after_artifactRUNNING(試行1)COMPLETED(試行2)
before_dbRUNNING(試行1)COMPLETED(試行2)
db_mid_txnPENDING(試行0)COMPLETED(試行1)
after_dbCOMPLETED(試行1)COMPLETED(試行1、再実行なし)
before_next_stepCOMPLETED(試行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 した地点の数ではなく、その瞬間に何が起きていて、何を検査しているのかである。