Claude Codeの無人実行を検証:56回のテストで見つかった終了判定とプロセス残留の問題

claude -p をスケジューラから毎日呼び出す運用では、終了コードが0なら成功とみなしたくなる。しかし終了コード0は「プロセスが正常に終わった」ことしか示さない。成果物が作られたか、中身が正しいか、タイムアウトしたプロセスの後始末が済んだかは、別に確かめる必要がある。

私が個人用に作っているアイデア探索ツールの日次処理は、Claude CodeかCodexのCLIを無人で呼び出している。この呼び出しが失敗したのに成功と記録されないかどうかを、Claude Codeに検証させた。28ケースを2回ずつ、計56回実行した。そのうち実際にClaude Codeを呼び出したのは12回で、残り44回は失敗を再現するための擬似CLIである。結論を先に書くと、終了コードだけで判定すると、失敗させたはずの50回のうち22回が成功に見えた。さらに、Windowsでは、タイムアウト時に子孫プロセスが残る問題が見つかり、修正した。

この記事は、その実験と修正の記録である。検証用の最小コードも載せる。

目次

実験の設計と、この記事の範囲

私は実験の目的と安全条件(本番ジョブは動かさない、認証情報は触らない、push・commitはしない)を指示し、実験の設計、検証コードの作成、実行、修正、テストはClaude Codeに任せた。この記事で扱う作業には、Claude Code(Sonnet 5.5)を使っている。指示は3回に分けて出した。実験、修正、この記事の準備である。AIによる実験結果の報告を確認し、発見された問題について追加の修正・検証を指示した。実行・修正・テストの結果は、特に断らない限りClaude Codeが報告した値である。

実験環境は、Windows 10 Pro、Node.js v24.15.0、Claude Code 2.1.319である。実際にClaude Codeを呼び出したケースでは、モデルに claude-haiku-5-5、推論レベルに low を指定した。検証用のコードと結果は私の非公開リポジトリにあり、読者は直接確認できない。記事中のコードは、保存されたコードから抜き出したか、実験コードを短くまとめたものである。

無人実行で何が問題になるのか

人間が見ている対話実行なら、失敗はその場で気づく。無人実行では、スクリプトが記録した「成功」か「失敗」だけが手がかりになる。次のような状況で、終了コードだけの判定は間違える。

  • CLIが終了コード0で終わったが、成果物ファイルを作っていない。
  • 標準出力のJSONが途中で切れている、またはエラーを示すフィールドを含んでいる。
  • 子プロセスが異常終了していたが、親は0で終わった。
  • タイムアウトで親を止めたが、親が起動した子孫プロセスが残っている。

どれも「失敗を成功と記録する」か「後始末をせずに失敗と記録する」ことにつながる。今回は、これらを意図的に起こして、判定方式を比較した。

検証方法

比較したのは2つの判定方式である。「素朴判定」は終了コードが0なら成功とする。「厳格判定」は次を全て満たしたときだけ成功とする。

  • 起動に成功している(spawnエラーがない)
  • タイムアウトしていない
  • 終了コードが0である
  • Claude Codeの出力(--output-format json のエンベロープ)が解析でき、is_error が偽で、structured_output がある
  • 成果物ファイルが存在し、空でなく、内容が期待どおりである
  • 実行後に子孫プロセスが残っていない

厳格判定の最小の形は次のとおりである。タイムアウト時にプロセスツリーごと止める処理も含めている。実験で使ったコードを短くまとめたもので、Windows向けである。

const { spawn, execFileSync } = require('node:child_process');
const fs = require('node:fs');

function run({ exe, args, timeoutMs, artifact, validate }) {
  return new Promise((resolve) => {
    let timedOut = false, spawnError = null;
    const child = spawn(exe, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
    child.stdout.resume(); child.stderr.resume();
    child.on('error', (e) => { spawnError = e; });
    const timer = setTimeout(() => {
      timedOut = true;
      try { execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); }
      catch { child.kill(); } // ツリー終了に失敗したときだけ従来の kill
    }, timeoutMs);
    child.on('close', (code) => {
      clearTimeout(timer);
      const reasons = [];
      if (spawnError) reasons.push('SPAWN_ERROR');
      if (timedOut) reasons.push('TIMEOUT');
      if (!spawnError && !timedOut && code !== 0) reasons.push(`EXIT_${code}`);
      if (!fs.existsSync(artifact)) reasons.push('ARTIFACT_MISSING');
      else { const err = validate(fs.readFileSync(artifact, 'utf8')); if (err) reasons.push(`ARTIFACT_${err}`); }
      resolve({ code, verdict: reasons.length ? `FAILURE[${reasons}]` : 'SUCCESS' });
    });
  });
}

終了コード0なのに成果物を作らないCLIを渡すと、この関数は FAILURE[ARTIFACT_MISSING] を返す。終了コードだけを見る判定なら SUCCESS になる。この最小例も、Claude Codeが書いて実行した。上の2つのケース(成果物なしと正常)で、期待どおりの結果になることを確かめている。実験本体の厳格判定は、これに出力JSONの検査と残留プロセスの確認を加えたものである。

ケースは、正常終了(A)、CLI起動失敗(B)、認証失敗(C)、タイムアウト(D)、終了コード0だが成果物なし(E)、出力の異常(F)、子プロセスの異常終了(G)の7群に分けた。Claude Codeを実際に呼び出したのは、次の12回である。

  • A 正常終了:2回。42を答えさせるだけの小さなタスク
  • B3 存在しないモデル名を指定:2回
  • C1 認証情報なし、C2 無効なAPIキー:各2回
  • D4 Claude CodeがBashツールで長時間コマンドを実行中にタイムアウト:現行方式と修正方式で各2回

認証失敗は、空の設定ディレクトリを CLAUDE_CONFIG_DIR に指定し、認証関連の環境変数を取り除き、無効なダミーキーを渡して再現した。実際の認証情報には触れていない。残りの44回は、失敗の様子を決めた擬似CLIで、出力や終了コードを自在に作れる代わりに、実際のClaude Codeの挙動を表すものではない。

実測のうち、費用は小さい。Aの1回で記録された total_cost_usd は約0.0001ドル(0.0000757)で、所要時間は3.5〜4.0秒だった。ツールを使うD4のような実行の費用は集計していない。

28ケース×2回の結果

厳格判定は、全ケースで事前に決めた期待どおりの結果になった。ただし、この期待は、厳格判定が検出できるように設計したケースについて決めたものである。厳格判定が見逃す失敗が他にないことを示す結果ではない。

群内容素朴判定厳格判定
A実Claude、正常終了(2回)成功成功
B存在しないexe、起動直後クラッシュ、不正モデル(実Claude)失敗失敗(不正モデルは404を検出)
C認証情報なし、無効なAPIキー(実Claude)失敗失敗(is_error、401を検出)
Dタイムアウト(擬似・実Claude)失敗失敗(残留の有無は次節)
E終了0・成果物なし/空/壊れたJSON/内容不整合成功失敗
F出力の異常(JSONでない、途中切れ、is_error真、stderrにusage limit、stderrのみ)と、5MBの大量出力大量出力以外は成功大量出力は成功、他は失敗
G子が異常終了して親は0、親がabort、親が0xC0000005で終了、孫プロセスが残留親が0のものは成功失敗

実Claudeを呼び出した B・C は、終了コードが1で、素朴判定でも失敗になった。Claude Code自体は、認証失敗や不正なモデル名のとき、エラー情報を含むJSONを出して非ゼロで終了する。無人運用で問題になるのは、これらの「正直に失敗するCLI」ではなく、終了コード0のまま成果物が足りないケースだった。

終了コードだけでは見逃した22件

失敗するはずなのに素朴判定が成功とした実行は、失敗を期待した50回のうち22回だった。この50回は、56回から、後述の無効な2回(G4)と、成功を期待した4回(A、大量出力)を除いた数である(56 − 2 − 4 = 50)。

内訳は、E(成果物なし、空、壊れたJSON、内容不整合)、F(JSONでない、途中切れ、is_error 真、stderrにusage limit、stderrのみ)のうち5ケース(F1・F2・F3・F4・F6)、G1(子の異常終了)、G4b(孫の残留)の計11ケース×2回である。いずれも擬似CLIで、実験用に意図的に作った失敗である。22/50は、無人運用でこの割合の誤成功が起きるという頻度ではない。終了コードだけの判定が、これらの種類の失敗を原理的に見分けられないことを示す数字である。

私の日次処理では、終了コード0のあとに成果物の検証(スキーマと意味の検証)をもともと行っていた。そのため、この22件の種類の失敗は、本番では成功と記録されない設計になっていた。今回の実験で新たに見つかった問題は、次の子孫プロセスの残留だった。

Windowsでの子孫プロセス残留問題

日次処理の runProcess は、タイムアウト時に child.kill() を呼んでいた。Windowsでは、child.kill() は直接の子プロセスだけを終了させる。その子が起動したプロセスは残る場合がある。

最初の擬似CLIでの検証では、残留が再現しなかった。Node.jsで作った擬似CLIが、Node.jsで孫プロセスを起動していたためである。Windowsでは、Node.jsの子プロセスは、親が終了すると一緒に終了する仕組みの中で動いているようだった(私はlibuvがJob Objectを使っているためと理解しているが、その実装自体は今回確認していない)。観測した事実は、孫を detached: true で起動すると残ること、そうしないと残らないことである。この性質のため、孫が親と一緒に消えるケース(G4)は、残留の検証として成立しなかった。G4の2回は無効として、集計から除いた。

そこで、孫を detached にした擬似CLIと、実際のClaude Codeで再検証した。後者では、Claude CodeにBashツールで ping を実行させ、その実行中にタイムアウトさせた。結果は次のとおりである。

ケース終了方式残留プロセス
detachedの孫を持つ擬似CLI(2回)child.kill()2
同上(2回)taskkill /PID /T /F0
実Claude、Bash実行中(2回)child.kill()4
同上(2回)taskkill /PID /T /F0

実Claudeの「4」は、実験用ラッパーが、コマンドラインに目印の文字列を含むプロセスを数えた値である。ping 本体以外のラッパープロセスを含む可能性があるが、内訳は確認していない。後から別の数え方(シェルを除外)で測ったときは1だった(後述)。数え方が違うため数字が違うが、child.kill() だと残り、ツリー終了だと残らない、という方向は同じである。

私の日次処理のClaude Code呼び出しは、--tools "" でツールを無効にしているため、この残留は起きにくい構成である。しかし、既定のオペレーターであるCodexはシェルコマンドを実行し得る。Codexで実際に残留するかは、検証していない。

taskkill /PID /T /Fによる修正

同じリポジトリの別の処理には、すでにツリー終了の実装があった。Claude Codeは、これを日次処理の runProcess にも適用した。変更は、プロセスツリーを終了させる関数の追加と、タイムアウト時の1行である。

function terminateProcessTree(pid) {
  if (!Number.isInteger(pid) || pid <= 0) return false;
  if (process.platform === 'win32') {
    try { execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }); return true; } catch { return false; }
  }
  try { process.kill(-pid, 'SIGTERM'); return true; } catch { return false; }
}

// runProcess のタイムアウト処理
const timer = setTimeout(() => {
  timedOut = true;
  if (!killTreeImpl(child.pid || null)) { try { child.kill(); } catch { /* already gone */ } }
}, timeoutMs);

killTreeImpl は引数で差し替えられる。テストで任意の関数を渡せるようにするためである。ツリー終了に失敗したときだけ、従来の child.kill() に戻る。Windows以外では process.kill(-pid) を使うが、子が別のプロセスグループで起動されていないと効かない。今回の検証は全てWindows上で行っており、Windows以外での動作は保証しない。

修正後に、実際のClaude Codeで、本番の runProcess を直接使って再度測った。ping実行中にタイムアウトさせ、child.kill() のみの場合(killTreeImpl を常に失敗させて再現)と既定の修正後を、各1回ずつ試した。残留は、前者が1、後者が0だった。1回ずつの測定である。

修正後の回帰テスト

Claude Codeは、テストを7つ追加した。残留の回帰テストが3つ、COMPLETE の扱いの契約を固定するテストが4つである。残留のテストは次の3つである。

  • タイムアウト後に、detachedな孫プロセスが生き残らない。
  • タイムアウト時に、注入したツリー終了関数が子のPIDで1回だけ呼ばれる。その関数が失敗を返したときは child.kill() に戻る。
  • 正常終了と非ゼロ終了では、ツリー終了が呼ばれず、状態の記録も変わらない。

孫プロセスを残す検証用の子は、次のような小さなスクリプトである(保存されたフィクスチャの抜粋)。

const { spawn } = require('child_process');
const fs = require('fs');
const [mode, pidFile] = process.argv.slice(2);
if (mode === 'hang-with-descendant') {
  const c = spawn(process.execPath, ['-e', 'setTimeout(()=>{},120000)'],
    { detached: true, stdio: 'ignore', windowsHide: true });
  c.unref();
  fs.writeFileSync(pidFile, String(c.pid));
  setInterval(() => process.stdout.write('{"type":"heartbeat"}\n'), 50);
}

修正前のコードに対して、このテストを実行すると、1つ目(孫が消えること)と2つ目(注入点の存在)が失敗した。3つ目は、挙動を変えないことの確認なので、修正前にも通る。修正後は3つとも通った。

テスト全体の結果は次のとおりである。日次処理のオペレーター関連の5ファイルは、新規7件を含む105件が全て通った。リポジトリ全体では3158件中3149件が通り、9件が失敗した。失敗した9件は、私の非公開データ(実データのストア)を読むテストが中心に見える。今回の変更との関係は全件は調べておらず、修正前に全体を実行して比較してもいないため、これらが以前から失敗していたとは断定できない。

COMPLETE状態とジョブ成功判定の違い

実験では、もう1つ気になる点があった。runProcess が終了コード0の時点で、試行の状態ファイルに COMPLETE と書くことだった。この記録は、成果物の検証より前に行われる。意味検証で却下された試行でも、状態ファイルには COMPLETE が残る。誤成功につながるように見える。

Claude Codeは、まずこれを再現するテストを書き、再現した。そのうえで、却下時に状態を RESULT_INVALID に書き換える修正を試した。すると、既存のテストが1件失敗した。そのテストには、「試行の状態ファイルは、Claude Codeが解析可能な出力を返した、というプロセス層の記録なので、COMPLETE のままにする。意味検証の失敗は、投げられる例外で表す」という趣旨のコメントがあった。つまり、COMPLETE はジョブの成功ではなく、プロセスが完了したことを表す、という設計だった。

ジョブ全体の成否は、次の2点で決まる。意味検証に失敗すると例外が投げられること。検証を通った結果だけが、最終ファイルに昇格されること。却下された試行は、例外になり、最終ファイルは作られない。誤成功は起きていなかった。

このため、この修正は取り消し、コードは変えなかった。代わりに、現状の契約をテストで固定した。再試行しても却下された場合は例外が投げられ、最終ファイルは作られず、各試行の状態は COMPLETE のままになる。再試行で成功した場合は、成功した試行の結果だけが昇格される。

残る注意点は、状態ファイルだけを見て成功と判断する別のツールを作ると誤判定することである。私のダッシュボードの未完了run判定は、このような run を「失敗」でなく「未完了」と表示する。表示だけの問題で、今回は変更していない。

未解決事項・制約

  • 実際のClaude Codeは12回しか呼んでいない。E・F・Gの44回は擬似CLIで、実際のClaude Codeがこれらの挙動を示すかは、確認していない。
  • Codex(私の日次処理の既定のオペレーター)のタイムアウトで、子孫が残るかは未検証である。課金が別のアカウントにかかるため、実行しなかった。
  • 検証はWindows 10上だけである。Windows以外で、同じ修正が機能するかは確認していない。
  • タスクスケジューラ経由の非対話実行、OAuthトークンの有効期限切れ、15分のタイムアウトで長時間実行した場合の課金は、検証していない。認証失敗は、空の設定ディレクトリと無効なダミーキーでのみ再現した。
  • 厳格判定の期待値は、検出できるように設計したケースについて定めたものである。実運用で出る、想定外の失敗は含まれない。
  • この修正とテストは、Claude Code自身が書いて確認した。第三者によるレビューは、記録で確認できていない。

他の自動運用へ応用できる教訓

今回の検証から、無人実行の判定について、次のように整理した。

  • 終了コード0は成功の必要条件にすぎない。成果物の存在、空でないこと、内容の整合を、終了コードとは別に確かめる。
  • CLIが出力するエラー情報(is_error など)は、終了コードが0でも確認する。stderrの文字列(usage limit など)の検知は誤検知があり得るので、補助的な信号として使うほうがよい。
  • 「状態」と「判定」を分ける。プロセスが完了したという記録と、ジョブが成功したという判定は、別のものとして保つ。状態名を変える前に、それを読む側を調べる。
  • Windowsでは、タイムアウト時に child.kill() だけでは子孫が残る場合がある。ツリーごと止め、止めたあとに残留が0であることを実測する。擬似的な検証で残留が再現しなくても、検証側の都合(今回は親子が同じ仕組みで管理されていた)かもしれない。
  • 既存の仕組みで足りている部分は、足さない。今回、終了コード0の誤成功は、既存の成果物検証で防げており、追加の仕組みは不要だった。変更したのは、タイムアウト時の後始末の1か所である。
  • 修正が既存の契約を壊すなら、まず契約を読む。COMPLETE の件は、修正が既存のテストを壊したことで、設計上の意図に気づいた。