夜間に回していた個人開発のメンテナンス処理を、朝になって手動で止めたときのことでした。systemctl stop を打って、プロンプトはすぐ戻ってきました。ところが数時間後、VM のディスク使用率のアラートが飛んできます。調べると、止めたはずのジョブが起動していた next build のプロセスが一つ、CPU をつかんだまま生き残っていました。親は消えているのに、子だけが動き続けている。ポートも握ったままで、次のセッションが同じポートで立ち上がらずに失敗していました。
停止コマンドは成功していました。失敗していたのは、その停止が子プロセスまで届いていなかったことです。以下では、headless(-p / SDK)モードの Claude Code が Bash 実行中に停止されたときに何が起きていたのか、Claude Code 2.1.212 の修正でそれがどう変わったのか、そして新しい exit 143 という契約を前提に監督側と後片づけをどう組み直すかを、実際に踏んだ順序で書いていきます。
停止したはずのジョブが、CPU を食い続けていた
まず、何が孤児になっていたのかを整理します。headless の Claude Code は、Bash ツールを呼ぶときに子プロセスとしてシェルを起動します。そのシェルがさらに npm run build を起動し、build が別のワーカーを起動する。ツリーは深くなります。
| 層 | プロセス | 役割 |
| 親 | claude(headless) | ターンを回し、ツールを呼ぶ |
| 子 | bash -c | ツール実行のシェル |
| 孫 | npm / node | ビルドやテストの本体 |
| ひ孫 | worker スレッド群 | 実際に CPU を使う |
systemctl stop や timeout が送るのは、既定では親プロセスへの SIGTERM です。親の claude はシグナルを受けてターンを畳もうとします。問題は、そのとき親が子のツリーを明示的に終わらせないまま先に抜けてしまうと、孫・ひ孫が親を失った孤児(orphan)として init(PID 1)に引き取られ、そのまま走り続けることでした。ビルドは止まらず、ポートは解放されず、テンポラリの中間ファイルはディスクを埋め続けます。
私が踏んだのはまさにこれで、しかも厄介なのは「停止コマンドの終了コードは 0 だった」ことです。監督している側からは、成功したように見えていました。本番運用でこの落とし穴に一度ハマると、原因の切り分けに時間を取られます。対処の第一歩は、まず何が孤児として残るのかを正確に知ることでした。
なぜ孤児化するのか — シグナルはプロセスグループには自動で届かない
ここは Claude Code に限らない Unix の話ですが、原因の核心なので押さえておきます。kill <pid> や既定の SIGTERM は、指定した単一の PID にしか届きません。子孫プロセスへは自動的には伝播しません。
伝播させるには二つの道があります。一つはプロセスグループ全体へ送る方法で、kill -TERM -<pgid>(PID の前にマイナスを付けるとプロセスグループ ID として解釈されます)を使います。もう一つは、親プロセス自身が SIGTERM を受け取ったハンドラの中で、自分の子孫を辿って明示的に終わらせてから抜ける方法です。
修正前の headless Claude Code は、Bash 実行中に停止されたケースで、この「抜ける前に子孫を片づける」処理が抜けていました。親は行儀よく終了するのに、孫は取り残される。だからこそ、監督スクリプト側で KillMode を握っていなかった環境では、孤児が素通りしていたわけです。
| 送り方 | 届く範囲 | 孫プロセス |
kill -TERM <pid> | その PID のみ | 取り残される可能性 |
kill -TERM -<pgid> | プロセスグループ全体 | まとめて届く |
| 親がハンドラで子孫を kill | 親が辿った範囲 | 親の実装しだい |
2.1.212 が変えたこと — 中断・kill・exit 143 という契約
Claude Code 2.1.212 で、この挙動が修正されました。print/SDK モードで Bash 実行中に SIGTERM を受け取ると、Claude Code は進行中のターンを中断し、起動していたコマンドのプロセスツリーを kill してから、exit 143 で終了します。
143 という数字には意味があります。Unix では、シグナルで終了したプロセスの終了コードは 128 + シグナル番号 になります。SIGTERM は 15 番なので、128 + 15 = 143 です。つまり exit 143 は「SIGTERM を受けて、それを尊重して畳んで終わった」という明確な合図になりました。以前のように親だけが静かに 0 で抜けて孫が残る、という曖昧な状態ではなくなったのです。
これは自動運用にとって、地味ですが大きな変化です。停止の合図を送ったあと、監督側が確認すべきことが「終了コードが 0 か」から「終了コードが 143 で、かつ子孫が残っていないか」へと具体化しました。契約がはっきりしたぶん、こちらの設計も明確に書けます。
Before / After — 監督スクリプトを exit 143 前提で書き直す
まず、timeout で headless ジョブを回す最小の監督スクリプトです。修正前の書き方は、孤児が残ることを想定していませんでした。
Before(孤児を取りこぼす書き方):
#!/usr/bin/env bash
# 制限時間を超えたら止める、だけの監督
timeout 30m claude -p "$(cat nightly_task.md)" \
--output-format json > run.log
echo "exit=$?"
# timeout は既定で SIGTERM を親に送るだけ。
# 孫の build が残っても、この行までは到達し exit を表示してしまう
timeout 30m は制限時間で親に SIGTERM を送りますが、それだけです。claude が抜けたあとに孫が残っても、スクリプトは何事もなかったように次へ進みます。
After(プロセスグループごと畳み、exit 143 を解釈する):
#!/usr/bin/env bash
set -uo pipefail
LOG=run.log
# setsid で新しいプロセスグループを作り、ジョブ全体を一つのグループにまとめる。
# timeout は --signal で TERM、--kill-after で猶予後に KILL を送る。
setsid timeout --signal=TERM --kill-after=20s 30m \
claude -p "$(cat nightly_task.md)" --output-format json > "$LOG" &
JOB_PGID=$!
# 監督プロセス自身が停止を受けたら、ジョブのグループ全体へ TERM を送る
cleanup() {
# マイナス付き = プロセスグループ全体へ伝播
kill -TERM -"$JOB_PGID" 2>/dev/null || true
# 猶予後に残っていれば KILL
sleep 20
kill -KILL -"$JOB_PGID" 2>/dev/null || true
}
trap cleanup TERM INT
wait "$JOB_PGID"
CODE=$?
case "$CODE" in
0) echo "完了: 正常終了" ;;
143) echo "停止: SIGTERM を尊重して畳みました(想定内)" ;;
124) echo "警告: timeout 到達で強制停止(作業は未完)" ;;
*) echo "異常: exit=$CODE" ;;
esac
変えたのは次の三点です。
setsid でジョブを独立したプロセスグループにまとめる
- 停止シグナルを
kill -TERM -"$JOB_PGID" とマイナス付きでグループ全体へ送る
- 終了コード 143 を「想定内の丁寧な停止」として明示的に分岐する
2.1.212 の修正で Claude Code 自身が子ツリーを kill してくれるようになったので、--kill-after は保険として残しつつ、本来の後片づけは Claude Code に任せられます。私は、終了コード 143 を想定内として扱い、それ以外だけを要調査に回す運用を推奨します。
systemd で回している場合の設定
systemd のサービスとして常駐・定期実行している場合は、ユニット側で停止の届き方を制御します。ここが緩いと、せっかくの 2.1.212 の修正も監督層で台無しになります。
[Service]
Type=simple
ExecStart=/usr/local/bin/nightly-claude.sh
# 停止時は SIGTERM を送る(既定)。まず Claude Code に丁寧に畳ませる
KillSignal=SIGTERM
# コントロールグループ全体を対象にする。孫まで確実に届く
KillMode=control-group
# 停止の猶予。Claude Code のツリー kill を待てる長さにする
TimeoutStopSec=45
# 143 を失敗として警告メールを飛ばさない(尊重した停止なので成功扱い)
SuccessExitStatus=143
KillMode=control-group が肝です。systemd はサービスを cgroup として管理しているので、これを指定すると停止時に cgroup 内の全プロセスへシグナルが届きます。仮に将来 Claude Code 側の挙動が変わっても、監督層でツリー全体を掌握できている状態を保てます。SuccessExitStatus=143 を入れておくと、丁寧に畳んだ停止を「失敗」と誤検知して深夜にアラートで起こされずに済みます。
孤児が出ていたかを、事後に確かめる
「もう直ったはず」で終わらせず、自分の環境で実際に孤児が残っていないかを確かめておくと安心です。停止シグナルを送る前後で、ジョブのプロセスグループに属する PID を数える簡単な検証です。
#!/usr/bin/env bash
# ジョブを起動して PGID を掴む
setsid claude -p "$(cat probe_task.md)" > /dev/null 2>&1 &
PGID=$!
sleep 8 # ビルド等が孫プロセスを起こすのを待つ
echo "停止前のグループ内プロセス数:"
pgrep -g "$PGID" | wc -l
# 丁寧に止める
kill -TERM -"$PGID"
sleep 5 # ツリー kill が完了するのを待つ
REMAIN=$(pgrep -g "$PGID" | wc -l)
echo "停止5秒後の残存プロセス数: $REMAIN"
if [ "$REMAIN" -eq 0 ]; then
echo "OK: 孤児は残っていません"
else
echo "要調査: $REMAIN 個が残存。監督層の KillMode を確認"
pgrep -g "$PGID" -a
fi
私はこの検証を、Claude Code を更新したタイミングで一度回すようにしています。停止5秒後の残存が 0 になることを目視できると、監督スクリプトの --kill-after や systemd の TimeoutStopSec を短めに詰めても大丈夫だ、という判断がつきます。数字で確かめられると、勘で猶予を長く取る癖から抜けられます。
それでも自分で片づけるもの — SessionEnd フックとの分担
プロセスツリーの kill は、あくまで「走っていたプロセスを止める」ところまでです。プロセスが握っていた副作用、たとえば書きかけのロックファイル、途中まで作られた成果物、確保したままのポート予約の記録などは、プロセスが消えても自動では消えません。ここは自分で畳む領域です。
役割分担を整理すると次のようになります。
| 対象 | 誰が畳むか | タイミング |
| 走行中の子・孫プロセス | Claude Code(2.1.212 以降) | SIGTERM 受信時にツリー kill |
| ロック・一時ファイル | SessionEnd フック / trap | セッション終了時 |
| ジョブ全体の監督・再送 | timeout / systemd | 制限時間・停止指示 |
一時ファイルやロックの後始末は、監督スクリプトの trap に足すか、Claude Code の SessionEnd フックに寄せるのが素直です。フックを使った後片づけの組み方は、SessionEnd フックで後片づけとログを一本化するで扱っています。停止そのものが正しく届いたかを別の角度から監視したい場合は、サイレントスキップを見抜くデッドマンスイッチの考え方が組み合わせやすいです。無人実行での権限の握り方全般は、許可プロンプトを deny by default で設計するにまとめてあります。
まとめ — 次に一度だけ、停止を試してみる
2.1.212 の修正は、headless の Claude Code が停止されたときの振る舞いを、曖昧な 0 から明確な exit 143 へと変えました。中断し、ツリーを kill し、シグナルを尊重して畳む。この契約がはっきりしたぶん、監督側は「143 なら想定内、それ以外は要調査」と単純に書けるようになります。
もし夜間や定期実行で headless の Claude Code を回しているなら、次にやることは一つで十分です。実行中のジョブに一度だけ手動で SIGTERM を送り、5秒後にプロセスが残っていないかを pgrep -g で確かめてみてください。0 になっていれば、あなたの停止処理は子孫まで届いています。残っていたら、監督層の KillMode か猶予の設定に、まだ埋めるべき隙間があります。
私自身、孤児が一つ残っていただけでポートが塞がり、翌晩のジョブが丸ごと空振りした経験があります。止める仕組みは、動かす仕組みと同じくらい設計に値します。お読みいただきありがとうございました。