running 表示のまま固まる。Codex(OpenAI のコード分析 AI)を Claude Code から呼ぶ仕組み codex-companion.mjs(Claude Code 内部の Codex 起動ラッパー)で、ジョブが30時間以上 "running" 表示のまま不動になる現象が再三起きていた。再投入すれば動くものの、原因不明のまま放置すると次の3つのリスクが残る。
/codex:status(実行中ジョブを一覧表示するコマンド)の running 表示が orphan(親が死んだ孤児)か実際に動いてるかを区別できないcodex app-server プロセスが各リポジトリ毎に常駐し続ける(実測で9個アクティブ)調査の結果、再現可能な根本原因と diff(差分)パターンを特定し、検出スクリプト + 防止策5つを実装した。
調査範囲
codex-companion.mjs 本体(1008行)と lib/ 配下7ファイル(state/tracked-jobs/codex/app-server)~/.claude/plugins/data/codex-openai-codex/state/ 配下24リポジトリ分の state.jsonps で生存している Codex プロセスツリー(broker × 9件)awaitCodex とのやりとりは「ターン」単位(1往復の質問→回答)で行われる。lib/codex.mjs(Codex プロトコル実装)の captureTurn 関数は、ターン完了通知 turn/completed を待つために state.completion という Promise(JS の非同期処理を表す箱)を await する。
Codex 側が turn/completed を送ってこなければ、companion プロセスは何時間でも待ち続ける。これが「30時間 stuck」の構造的な土台になっている。
state.json の updatedAt は phase が変わった時しか動かないlib/tracked-jobs.mjs の createJobProgressUpdater(進捗を state ファイルに反映する関数)は phase(「starting」「running」等の段階)か threadId/turnId(ターン識別子)が変わった瞬間にしか書き込まない。
このため Codex が同じ running phase 内で「コマンド A 実行→コマンド B 実行→…」を続けている間、updatedAt は Turn started イベント時点で凍結する。実例として今日の 11:45 開始の zaim review ジョブは updatedAt: 11:45:14 で固定され、ログの最終行だけが 11:45:20 まで進んでいた。
status が永遠に runningrunTrackedJob(ジョブ実行を追跡する関数)は開始時に status: "running" と自分の PID(プロセス番号)を state.json に書き込む。完了時に completed/failed へ書き換える。
ところが SIGKILL(強制終了シグナル)・OOM(メモリ枯渇)・ターミナル閉じ・親 Claude セッション終了などで companion プロセスが消えると、catch ブロックも finally ブロックも走らない。結果として "running" のまま PID だけが死亡した orphan が量産される。
システム上で実測した orphan ジョブ(status=running なのに PID 死亡)
| リポジトリ | ジョブ ID | 放置時間 |
|---|---|---|
| aiharataketo | task-mnfjgedb-9qzvuq | 約35日 |
| aiharataketo | task-mnjwgj0d-l5dt0x | 約32日 |
| codex-review-j005 | review-mnr5zkul-7bmdd0 | 約27日 |
| aiharataketo | task-mnmefdpr-fiwxga | 約30日 |
| claude-code-zaim | review-motzpx4f-30et05 | 本日午前(最新) |
同じ broker・同じ codex app-server 上で、stuck ジョブの直後 39 秒後に開始した別ジョブは正常完了している。これは broker 側は無事で、stuck の原因は クライアント(companion)側のみであることを裏付ける。
| 項目 | stuck (review-motzpx4f) | 正常 (review-motzqrov) |
|---|---|---|
| status | running | completed |
| pid | 66250(死亡) | null(クリア済み) |
| completedAt | 無し | あり |
| updatedAt と log 最終行のずれ | あり(Turn started で凍結) | 無し(同期) |
/codex:adversarial-review(Codex に変更点をレビューさせるコマンド)を実行するkill -9 <codex-companion の PID> で強制終了state.json を見ると当該ジョブは status: "running"、pid: 死んだ番号、completedAt: null のまま固定/codex:status でも「running」と表示されるが、実体は orphan※ 一部の broker(通信仲介役)は親の Claude Code セッションが落ちても残るため、再投入で別 companion が同じ broker に接続して新ジョブとしては動く(=「再投入で動く」現象の説明)。
~/secretary-state/codex-stuck-detector.sh として実装。
判定基準(OR 条件)
status=running で PID が kill -0(生存確認)に失敗status=running でログ最終行 timestamp が N 分以上前(既定10分)使い方
~/secretary-state/codex-stuck-detector.sh--threshold-min 5--json(他ツールへ流す用)failed に書き換え: --kill-orphans(破壊的なので明示フラグ)性能: 5件 stuck を含む全リポジトリ走査で 0.46 秒(60秒制限に対して十分余裕)。終了コード 0=stuck無し / 1=stuck検出 / 2=引数エラー。
すぐ手を打てるのはこれ。codex-stuck-detector.sh --kill-orphans を 10 分間隔で動かせば orphan は短時間で failed へ自動収束する。self-timer スキルや schedule スキルで設定可能。
/codex:status 表示時の lazy reap(怠惰な回収)lib/job-control.mjs(ジョブ表示ロジック)の buildStatusSnapshot 内で、status=running のジョブの PID を毎回 kill -0 で確認し、死亡なら表示前に failed に書き換える。ユーザーが status を見るたびに自動修復される。プラグインのアップストリーム(openai/codex プラグイン本体)への PR 候補。
captureTurn にハードタイムアウト導入lib/codex.mjs の state.completion を Promise.race(複数 Promise のうち最初に解決した方を採用する仕組み)で包み、最大 30 分(モデル長考でも収まる目安)で reject させる。これが構造的な根本治療。長時間タスクを許容したい場合は --max-turn-min オプションで上書き。
state.json にも書く現状 updatedAt は phase が変わる時しか動かない。各コマンド実行(item/started・item/completed)でも updatedAt を bump(更新)するように createJobProgressUpdater を拡張すれば、「updatedAt が古い ⇒ stuck」が綺麗に成り立ち、検出器の精度も上がる。
現状 --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秒)も問題なし。