個人開発で続けている壁紙アプリに新しいシリーズを追加するとき、素材1枚から8種類の派生画像を書き出すバッチを回します。App Store と Google Play の両方に出しているので、解像度違いも含めると枚数はすぐにまとまります。終わるまでに10分近くかかります。
その10分のあいだ、私は別の作業に移ったつもりでいて、実際には3分おきにターミナルへ戻っていました。終わったかどうかを見に行くためだけに。
Claude Code に長い作業を任せているときも同じでした。別のセッションでビルドを走らせ、こちらのセッションでは設計の話を続ける。構成そのものは快適なのに、完了を知る手段がないせいで、覗きに行く回数は結局減りませんでした。
v2.1.236 で入った notify_when_idle は、この「覗きに行く」動作をほぼ丸ごと消してくれます。ただし通知は届かないことがあります。届かなかったときに黙って待ち続けないための備えまで含めて、この記事で組み上げます。
notify_when_idle が引き受けること、引き受けないこと
notify_when_idle は、同じマシン上で動いている別の Claude Code セッションに対して「次にアイドルになったら一度だけ知らせてほしい」と依頼する仕組みです。クロスセッションのメッセージ送信に添えるオプションで、対象は macOS と Linux です。
設計として好ましいのは次の3点です。
- オプトイン。頼んだときだけ動きます。既定で通知が飛び回ることはありません
- 一度きり。以後ずっと知らせ続ける状態にはなりません
- ポーリングなし。待つ側が定期的に問い合わせる必要がありません
一方で、引き受けてくれない範囲もはっきりしています。通知が伝えるのは「アイドルになった」という事実だけで、作業が成功したのか失敗したのかは含まれません。ビルドがコンパイルエラーで落ちたときも、セッションはアイドルになります。
もうひとつ、依頼した相手のセッションが落ちてしまった場合には、通知そのものが発生しません。プロセスが消えた以上、アイドルになる主体がいないからです。処理が長いほど、この経路を踏む確率は上がります。
つまり notify_when_idle は「待ち時間を減らす」ためのものであって、「結果を保証する」ためのものではありません。ここを分けて考えると、次に何を足せばよいかがはっきりします。
まず2セッション構成を組む
手順そのものは短いです。
claude --versionで v2.1.236 以上であることを確認します- 長い処理を任せる側のセッションを開き、ビルドやバッチを投げます
- 依頼する側のセッションから、クロスセッションのメッセージ送信に
notify_when_idleを添えて送ります - 通知が来たら、結果を取りに行きます
オプションの正確な綴りや引数の並びは版によって変わりうるため、手元の版のヘルプで一度確認しておくことをおすすめします。私自身は更新のたびに一度だけ確認して、それきり触っていません。
この4手順だけでも、待ち時間の体感はかなり変わります。問題は、4番目の「通知が来たら」が成立しなかったときです。
通知が届かない経路は3つある
v2.1.238 より前は、いくつかの経路でメッセージが落ちても、送信側には成功したように見えていました。現在は落ちたことが送信側へ返ります。
| 経路 | 何が起きているか | 送信側に返るもの |
|---|---|---|
| 受信側が受信拒否 | 相手が crossSessionInbound: "refuse" を設定している | refused |
| レート制限 | 短時間に送りすぎている | 落ちたことが伝わる |
| 受信箱が満杯 | 相手が読んでいないメッセージで埋まっている | 落ちたことが伝わる |
以前は「送ったのに何も起きない」が「送れたが相手が忙しいのだろう」と区別できませんでした。この修正で、少なくとも依頼が成立しなかったことは分かります。
ただし、依頼が成立したかどうかと、作業が終わったかどうかは別の話です。相手のセッションが途中で落ちれば、依頼は成立していても通知は来ません。ここを埋めるのが、次に作る完了マーカーです。
完了マーカーを1ファイルだけ置く
やることは単純で、長い処理の外側に薄い包みをかぶせ、状態を1ファイルだけ書き出します。通知の有無に関係なく、そのファイルを読めば現在地が分かる、という状態を作ります。
#!/usr/bin/env bash
# run-marked.sh — 長い処理を包み、開始・心拍・終了を1つのファイルに残す
set -uo pipefail
MARK="${1:?マーカーファイルのパスを渡してください}"; shift
TMP="${MARK}.tmp.$$"
write_mark() {
printf '{"state":"%s","pid":%d,"at":%d,"exit":%s}\n' \
"$1" "$$" "$(date +%s)" "${2:-null}" > "$TMP"
mv -f "$TMP" "$MARK"
}
write_mark running
# 2秒ごとに心拍を打つ。親が消えれば一緒に止まる
( while kill -0 "$$" 2>/dev/null; do sleep 2; write_mark running; done ) &
HB=$!
"$@"; CODE=$?
kill "$HB" 2>/dev/null
write_mark done "$CODE"
exit "$CODE"使うときは、これまでのコマンドの前に置くだけです。
chmod +x run-marked.sh
./run-marked.sh /tmp/build.mark npm run build書き方の理由を3つだけ説明させてください。
別名で書いてから mv -f する理由。同じファイルを直接 > で開くと、その瞬間に中身がいったん空になります。待つ側が運悪くそのタイミングで読むと、状態が空文字として観測されます。別名に書いてから rename すると、待つ側が読むのは常に「ひとつ前の完全な内容」か「新しい完全な内容」のどちらかになります。1行の違いですが、長時間ポーリングされる前提では効いてきます。
心拍を打つ理由。running のまま更新が止まった状態と、running のまま動き続けている状態は、ファイルの中身だけでは区別できません。時刻を上書きし続けることで、待つ側が「更新が止まった」という異常を検出できるようになります。
終了コードを残す理由。通知が伝えてくれないのがまさにこれでした。成功と失敗を呼び出し側へ持ち帰るために、exit の値をそのまま書いておきます。
待つ側は3つの状態を切り分ける
待つ側は、マーカーを読んで「終わった」「まだ動いている」「心拍が途絶えた」を分けます。
#!/usr/bin/env bash
# wait-for.sh — マーカーを見て3つの状態を切り分ける
set -uo pipefail
MARK="${1:?マーカーファイルのパスを渡してください}"
TIMEOUT="${2:-1800}" # 待ち時間の上限(秒)
STALE="${3:-10}" # 心拍がこの秒数途絶えたら異常とみなす
START=$(date +%s)
field() { sed -n "s/.*\"$1\":\"\{0,1\}\([a-z0-9]*\)\"\{0,1\}.*/\1/p" "$MARK"; }
while :; do
NOW=$(date +%s); ELAPSED=$(( NOW - START ))
if [ -f "$MARK" ]; then
STATE=$(field state); AT=$(field at); AGE=$(( NOW - ${AT:-NOW} ))
case "$STATE" in
done)
CODE=$(field exit)
echo "done exit=${CODE} waited=${ELAPSED}s"; exit "${CODE:-0}" ;;
running)
if [ "$AGE" -gt "$STALE" ]; then
echo "stale 心拍が ${AGE}s 途絶えました waited=${ELAPSED}s"; exit 75
fi ;;
esac
fi
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then echo "timeout ${TIMEOUT}s"; exit 124; fi
sleep 1
done手元の Linux 環境で3通り走らせた結果です。心拍の間隔は2秒、STALE は8秒にしています。
--- ケース1: 正常終了 ---
done exit=0 waited=6s
waiter exit=0
--- ケース2: 失敗 (exit 3) ---
done exit=3 waited=4s
waiter exit=3
--- ケース3: 途中で SIGKILL ---
stale 心拍が 9s 途絶えました waited=10s
waiter exit=75ケース3が、この仕組みを足した理由そのものです。処理を包んでいたプロセスを強制終了したので、done は書かれません。通知も来ません。それでも待つ側は10秒後に「心拍が途絶えた」と判断して抜けています。マーカーの中身は {"state":"running","pid":4,...} のまま残っており、どこで止まったかも後から読めます。
正常に動いているあいだ、マーカーの時刻は2秒ごとに進みます。
{"state":"running","pid":64,"at":1787378969,"exit":null}
{"state":"running","pid":64,"at":1787378971,"exit":null}
{"state":"running","pid":64,"at":1787378973,"exit":null}待つ側の終了コードは、そのまま次の判断に使えます。
| 状態 | 判定条件 | 終了コード | 次にすること |
|---|---|---|---|
| done | state が done | 処理の終了コードをそのまま返す | 成功/失敗で分岐する |
| stale | 心拍が STALE 秒を超えて止まった | 75 | 再実行する |
| timeout | 全体の上限を超えた | 124 | 打ち切って調べる |
75 は一時的な失敗を表す慣習的な値で、再実行の合図として扱いやすい番号です。124 は timeout コマンドが使う値に揃えてあります。呼び出し側で番号を見るだけで、再実行すべきか調べるべきかを分けられます。
無人で回すときに変える2箇所
手元で使う分には既定値のままで足ります。スケジュール実行に組み込むときは、2箇所だけ調整してください。
STALE は心拍の間隔の3〜5倍に。CI コンテナや共有マシンでは、負荷が高い瞬間に心拍が1回飛ぶことがあります。間隔と同じ値にすると、正常なのに stale と判定されます。2秒間隔なら8〜10秒が扱いやすい範囲でした。
TIMEOUT は普段の所要時間の2倍程度に。上限を長く取りすぎると、止まっていることに気づくのが翌朝になります。派生画像のバッチは10分前後なので、私は1200秒にしています。
上流の処理が終わったかどうかを下流が自分で確かめる、という考え方をスケジュール実行全体へ広げた話は、無人スケジューラの完了台帳と依存バリアで扱っています。複数のタスクが日をまたいで連なる構成を組んでいる方には、こちらが近いはずです。
今日いちばん長く待たされている処理をひとつだけ選んで、run-marked.sh で包んでみてください。包むだけで、待つ側を書く前から「今どこにいるか」が読めるようになります。そこから先は、通知が届く日も届かない日も、同じやり方で待てます。
お読みいただきありがとうございました。