← 一覧に戻る

Codex stuck の根本原因と防止策

2026年5月6日 20:48 更新

一言でいうと

タイムアウト無しの待機と親プロセス死亡が重なって、ジョブが永遠に running 表示のまま固まる。

なぜこれが必要なのか

Codex(OpenAI のコード分析 AI)を Claude Code から呼ぶ仕組み codex-companion.mjs(Claude Code 内部の Codex 起動ラッパー)で、ジョブが30時間以上 "running" 表示のまま不動になる現象が再三起きていた。再投入すれば動くものの、原因不明のまま放置すると次の3つのリスクが残る。

何をしたのか

調査の結果、再現可能な根本原因と diff(差分)パターンを特定し、検出スクリプト + 防止策5つを実装した。

調査範囲

根本原因(メカニズム)

① タイムアウト無しの await

Codex とのやりとりは「ターン」単位(1往復の質問→回答)で行われる。lib/codex.mjs(Codex プロトコル実装)の captureTurn 関数は、ターン完了通知 turn/completed を待つために state.completion という Promise(JS の非同期処理を表す箱)を await する。

現状: 上限なしで永遠に待つ
return await state.completion;
あるべき: ハードリミット付き
return await Promise.race([state.completion, hardTimeout(MAX_TURN_MS)]);

Codex 側が turn/completed を送ってこなければ、companion プロセスは何時間でも待ち続ける。これが「30時間 stuck」の構造的な土台になっている。

state.jsonupdatedAt は phase が変わった時しか動かない

lib/tracked-jobs.mjscreateJobProgressUpdater(進捗を state ファイルに反映する関数)は phase(「starting」「running」等の段階)か threadId/turnId(ターン識別子)が変わった瞬間にしか書き込まない。

このため Codex が同じ running phase 内で「コマンド A 実行→コマンド B 実行→…」を続けている間、updatedAtTurn started イベント時点で凍結する。実例として今日の 11:45 開始の zaim review ジョブは updatedAt: 11:45:14 で固定され、ログの最終行だけが 11:45:20 まで進んでいた。

③ 親 companion プロセスが死ぬと status が永遠に running

runTrackedJob(ジョブ実行を追跡する関数)は開始時に status: "running" と自分の PID(プロセス番号)を state.json に書き込む。完了時に completed/failed へ書き換える。

ところが SIGKILL(強制終了シグナル)・OOM(メモリ枯渇)・ターミナル閉じ・親 Claude セッション終了などで companion プロセスが消えると、catch ブロックも finally ブロックも走らない。結果として "running" のまま PID だけが死亡した orphan が量産される

システム上で実測した orphan ジョブ(status=running なのに PID 死亡)

リポジトリジョブ ID放置時間
aiharataketotask-mnfjgedb-9qzvuq約35日
aiharataketotask-mnjwgj0d-l5dt0x約32日
codex-review-j005review-mnr5zkul-7bmdd0約27日
aiharataketotask-mnmefdpr-fiwxga約30日
claude-code-zaimreview-motzpx4f-30et05本日午前(最新)

stuck と正常完了の差分

同じ broker・同じ codex app-server 上で、stuck ジョブの直後 39 秒後に開始した別ジョブは正常完了している。これは broker 側は無事で、stuck の原因は クライアント(companion)側のみであることを裏付ける。

項目stuck (review-motzpx4f)正常 (review-motzqrov)
statusrunningcompleted
pid66250(死亡)null(クリア済み)
completedAt無しあり
updatedAt と log 最終行のずれあり(Turn started で凍結)無し(同期)

再現手順(高頻度)

  1. /codex:adversarial-review(Codex に変更点をレビューさせるコマンド)を実行する
  2. Codex が「Turn started」を出した直後(=ターン中)に、Claude Code のターミナルを閉じる、または kill -9 <codex-companion の PID> で強制終了
  3. state.json を見ると当該ジョブは status: "running"pid: 死んだ番号completedAt: null のまま固定
  4. 次回以降 /codex:status でも「running」と表示されるが、実体は orphan

※ 一部の broker(通信仲介役)は親の Claude Code セッションが落ちても残るため、再投入で別 companion が同じ broker に接続して新ジョブとしては動く(=「再投入で動く」現象の説明)。

検出スクリプト(実装済)

~/secretary-state/codex-stuck-detector.sh として実装。

判定基準(OR 条件)

使い方

性能: 5件 stuck を含む全リポジトリ走査で 0.46 秒(60秒制限に対して十分余裕)。終了コード 0=stuck無し / 1=stuck検出 / 2=引数エラー。

防止策(優先度順)

P1① 検出スクリプトを launchd(macOS の常駐タスクスケジューラ)で 10 分毎に実行

すぐ手を打てるのはこれ。codex-stuck-detector.sh --kill-orphans を 10 分間隔で動かせば orphan は短時間で failed へ自動収束する。self-timer スキルや schedule スキルで設定可能。

P1/codex:status 表示時の lazy reap(怠惰な回収)

lib/job-control.mjs(ジョブ表示ロジック)の buildStatusSnapshot 内で、status=running のジョブの PID を毎回 kill -0 で確認し、死亡なら表示前に failed に書き換える。ユーザーが status を見るたびに自動修復される。プラグインのアップストリーム(openai/codex プラグイン本体)への PR 候補。

P2captureTurn にハードタイムアウト導入

lib/codex.mjsstate.completionPromise.race(複数 Promise のうち最初に解決した方を採用する仕組み)で包み、最大 30 分(モデル長考でも収まる目安)で reject させる。これが構造的な根本治療。長時間タスクを許容したい場合は --max-turn-min オプションで上書き。

P2④ heartbeat(生存信号)を state.json にも書く

現状 updatedAt は phase が変わる時しか動かない。各コマンド実行(item/starteditem/completed)でも updatedAt を bump(更新)するように createJobProgressUpdater を拡張すれば、「updatedAt が古い ⇒ stuck」が綺麗に成り立ち、検出器の精度も上がる。

P3⑤ detect → 正規 cancel ルートで停止

現状 --kill-orphans は state.json を直接書き換えるだけで、broker 側の thread/turn は生きてる可能性がある。検出後に /codex:cancel <jobId> を呼んで interruptAppServerTurn(Codex 側にもターン中断を伝える既存関数)経由で正規ルートを通すと、broker 側のリソースも綺麗に解放される。

ポイント・補足

「再投入で動く」現象の説明 — broker は親 Claude セッションと独立した常駐プロセスとして残るため、新しい companion が起動して同じ broker に繋ぐだけで新ジョブとして動く。古い "running" 表示は単なる state ファイルの残骸。

codex-companion.mjs 自体には罪が薄い — Codex CLI 公式が提供する仕様(プラグイン v1.0.1)。アップストリームに pull request する価値あり。特に lazy reap(②)は副作用が小さく、merge 確率が高い。

30時間放置の真因は #1+#3 — companion が state.completion を await したまま、ターン完了通知も来ず、PID は死んでないがフォアグラウンド処理が止まったまま、というパターンが本命。検出スクリプトはこれも "silent: 30h" として検出できる。

判断に迷ったこと — 検出器を Bash で書くか Node で書くか。Bash + jq に倒したのは ~/secretary-state/ 内の他スクリプトが Bash で揃っており、運用上の一貫性を優先したため。性能(0.46秒)も問題なし。

📝 質問モード — テキストを選択してね
✓ 質問を送信しました