先週の火曜、深夜に回していた背景セッションのひとつが、朝まで「実行中」のまま止まっていました。
agents ビューを開くと、ほかの11本は緑のチェックが並んでいるのに、その1本だけがくるくると回転アイコンのまま。ログの末尾は7時間前で、そこから一文字も進んでいませんでした。処理そのものは軽い依存更新でしたから、本来なら数分で終わっているはずのものです。何が起きていたのかというより、「なぜ朝まで気づけなかったのか」のほうが、私にはこたえました。
私は個人開発でいくつかの壁紙アプリと技術ブログを運営していて、依存更新・変更履歴の下書き・クラッシュ要約・広告設定の点検といった細かな作業を、深夜に Claude Code のヘッドレス実行でまとめて走らせています。多い夜は12セッション。2026年7月6日の更新で agents ビューと背景セッションの信頼性が改善され、複数の実行を一枚の画面で見られるようになったことは、この運用にとって素直にありがたい変化でした。ただ、実際に一週間使ってみて、ひとつはっきりしたことがあります。agents ビューが教えてくれるのは「そこにセッションがある」ことであって、「そのセッションが動いている」ことではない 、ということです。
その差を埋めるために私が組んだのが、外付けのハートビート層とリコンサイラでした。設計と実装を、順を追って共有します。
「実行中」が嘘をつく瞬間
まず、なぜ「実行中」の表示だけでは足りないのかを、失敗の側から整理します。
背景セッションが止まる原因は、大きく分けて二種類あります。ひとつはクラッシュ で終わるもの。プロセスが落ちれば agents ビューの状態も失敗に変わりますから、これは見つけやすい部類です。厄介なのはもうひとつ、プロセスは生きているのに前に進んでいない タイプです。
私が実際に遭遇したものだけでも、こういう状態がありました。
停滞の種類 プロセス agents ビューの表示 実際の進捗
ネットワーク応答待ちのハング 生存 実行中 停止
返ってこないツール呼び出し 生存 実行中 停止
無音のリトライループ 生存 実行中 実質停止
入力待ちで固着 生存 実行中 停止
いずれも「実行中」と表示され続けます。プレゼンス(そこに在る)は真ですが、ライブネス(生きて進んでいる)は偽です。可観測性の言葉を借りれば、私たちが欲しいのは在席確認ではなく生存確認のほうなのです。
この区別は、無人運用では致命的な差になります。人が画面を見ている日中なら、回転アイコンが長いと違和感で気づけます。けれど背景セッションは、見ていないから背景に回しているわけで、違和感を抱く人がそこにいません。だから停滞は、翌朝までまるごと放置されます。
セッションの身元をコミットまで一本の糸で辿る設計については、以前可読なセッション名で無人実行に相関キーを通す記事 に書きました。今回はその手前、そもそも「そのセッションは今も生きているのか」を問う層の話です。
プレゼンスとライブネスを分ける
設計方針はひとつだけです。agents ビューにライブネスの信号を外から足す 。
具体的には、各背景セッションに一定の間隔で「まだ生きています」という小さな刻印を残させます。そして別の小さなプロセスが、その刻印の鮮度を見張ります。刻印が新しければ生存、古ければ停滞の疑い。これがハートビートの発想です。
なぜ外付けなのか。agents ビューやセッション状態そのものを改造することはできませんし、するべきでもありません。私たちが握れるのは、セッションの内側で何かが起きるたびに副作用として信号を出すこと、そしてその信号を外から観察することだけです。この二つを組み合わせれば、公式機能を一切壊さずにライブネス層を後付けできます。
刻印の中身は、驚くほど小さくて構いません。私が実際に書き込んでいるのは、セッション名・エポック秒・現在フェーズの三つだけです。
{ "session" : "wallpaper-ios-deps-0706-a3f" , "ts" : 1751850600 , "phase" : "tool:bash" }
ここで date の文字列ではなくエポック秒(ts)を使う のが肝心です。以前、ログの日付を素の date で書いていて UTC と JST がずれ、前日のファイルを上書きしてしまう事故をやりました。時刻の比較を機械にさせるなら、タイムゾーンの解釈が入り込まない整数で持つのが安全です。鮮度は「今のエポック秒 − ts」という引き算一回で出ます。
ハートビートをフックで刻む
次の問いは「誰が刻印を書くか」です。
素直に考えると、セッションと並走する監視プロセスを別に立てたくなります。けれど私はこれを避けました。監視プロセスを立てると、今度はその監視プロセスが死んでいないかを誰が見るのか、という入れ子の問題が始まるからです。背景セッションが12本あれば、監視プロセスも12本。管理対象が倍になります。
そこで使うのが PostToolUse フック です。Claude Code はツール呼び出しのたびにフックを発火できます。ここにハートビートの書き込みを仕込めば、セッションが何か作業をするたびに、副作用として刻印が更新されます。別プロセスは要りません。セッションが死ねばフックも一緒に止まるので、監視の入れ子も生まれません。
まず、刻印を書く小さなスクリプトです。
#!/usr/bin/env bash
# heartbeat.sh — 呼ばれるたびに現在のセッションの生存刻印を更新する
# 使い方: heartbeat.sh <phase>
set -euo pipefail
# セッション名は起動時に環境変数で渡しておく(可読名と揃える)
SESSION = "${ CC_SESSION_NAME :- unknown }"
PHASE = " ${1 :- tool } "
# クラウド同期フォルダを避け、ローカルの tmpfs に置く(理由は後述)
HB_DIR = "/tmp/cc-heartbeat"
mkdir -p " $HB_DIR "
# エポック秒で書く。date 文字列は使わない(タイムゾーン事故の回避)
NOW = $( date +%s )
# 1行の JSON を原子的に置き換える(途中まで書いた壊れた行を読ませない)
TMP = "$( mktemp "${ HB_DIR }/.tmp.XXXXXX")"
printf '{"session":"%s","ts":%s,"phase":"%s"}\n' " $SESSION " " $NOW " " $PHASE " > " $TMP "
mv -f " $TMP " "${ HB_DIR }/${ SESSION }.json"
mktemp に書いてから mv で置き換えているのは、リコンサイラが「書きかけの半分の行」を読む可能性を消すためです。同じディレクトリ内の mv は原子的なので、読み手は常に完全な1行だけを見ます。
このスクリプトをフックから呼びます。プロジェクトの .claude/settings.json に次を加えます。
{
"hooks" : {
"PostToolUse" : [
{
"matcher" : "*" ,
"hooks" : [
{
"type" : "command" ,
"command" : "heartbeat.sh tool:${TOOL_NAME}"
}
]
}
]
}
}
matcher を * にして全ツールで発火させ、フェーズにツール名を載せています。こうすると刻印を見るだけで「最後に何をしていたか」まで分かります。朝、停滞していたセッションのフェーズが tool:web_fetch で止まっていれば、ネットワーク待ちのハングだと即座に見当がつきます。
停滞を判定するリコンサイラ
刻印が溜まったら、それを見張る側です。リコンサイラは「ハートビートのディレクトリを舐めて、古い刻印を停滞候補として吐く」だけの小さなプログラムで足ります。
#!/usr/bin/env node
// reconcile.mjs — ハートビートの鮮度を見て停滞候補を報告する
import { readdirSync, readFileSync } from "node:fs" ;
const HB_DIR = "/tmp/cc-heartbeat" ;
const NOW = Math. floor (Date. now () / 1000 );
// しきい値: 単発ツールの最長より十分に長く取る(理由は落とし穴の節で)
const STALE_SEC = 300 ; // 5分無音で「停滞の疑い」
const DEAD_SEC = 900 ; // 15分無音で「ほぼ停止」
const rows = [];
for ( const f of readdirSync ( HB_DIR )) {
if ( ! f. endsWith ( ".json" )) continue ;
let hb;
try {
hb = JSON . parse ( readFileSync ( `${ HB_DIR }/${ f }` , "utf8" ));
} catch {
continue ; // 壊れた行はスキップ(原子置換で基本起きないが保険)
}
const age = NOW - hb.ts;
let state = "alive" ;
if (age >= DEAD_SEC ) state = "dead?" ;
else if (age >= STALE_SEC ) state = "stale?" ;
rows. push ({ session: hb.session, ageSec: age, phase: hb.phase, state });
}
// 古い順に並べ、疑わしいものだけ非ゼロ終了で知らせる
rows. sort (( a , b ) => b.ageSec - a.ageSec);
const suspect = rows. filter (( r ) => r.state !== "alive" );
for ( const r of rows) {
console. log ( `${ r . state . padEnd ( 6 ) } ${ String ( r . ageSec ). padStart ( 5 ) }s ${ r . session } (${ r . phase })` );
}
process. exit (suspect. length > 0 ? 1 : 0 );
これを 3〜5分おきに cron や launchd で回します。非ゼロ終了を通知に紐づければ、停滞が起きた瞬間に手元へ届きます。私は macOS の launchd から呼び、疑わしいセッションが1本でもあれば通知センターに出すようにしています。実行の完了そのものを翌朝に検証する後付けの手当てとしては、以前終了時アサーションで無音の成功を疑う記事 に書きましたが、ハートビートはその手前で、走っている最中の生死を見るための層です。両方を持っておくと、走行中の停滞と終了後の空振りの両方に網がかかります。
出力はこんな具合になります。
dead? 7412s wallpaper-ios-deps-0706-a3f (tool:web_fetch)
alive 41s gemilab-changelog-0706-9c2 (tool:bash)
alive 12s blog-crash-summary-0706-77e (tool:edit)
先頭の1行が、まさに冒頭で7時間放置してしまったセッションです。phase が tool:web_fetch で止まっているので、外部取得の応答待ちでハングしたのだと一目で分かります。
実際にどれだけ縮んだか
導入前と導入後で、停滞に気づくまでの時間がどう変わったかを記録しました。私の環境(夜間に平均10〜12セッション、うち停滞は週に1〜2本程度)での二週間の実測です。
指標 導入前 導入後
停滞検知までの中央値 翌朝まで(約6〜7時間) 9分台
リコンサイラ1回の所要 — 40〜60ミリ秒
ハートビート書き込みの追加コスト — 1回あたり数ミリ秒
誤検知(生きているのに停滞判定) — 調整後は2週間で1件
数字そのものより、私にとって大きかったのは「朝いちばんに agents ビューを不安な気持ちで開かなくなった」ことでした。停滞が起きれば数分で通知が来ると分かっているので、夜間の実行を以前より落ち着いて任せられるようになりました。可観測性への投資は、たいてい計測値より先に心理的な余裕として返ってきます。
つまずいたところ
きれいに動くまでに、三つのつまずきがありました。どれも、実際に走らせてみるまで見えなかったものです。
思考中の無音を取りこぼす
PostToolUse フックは、その名のとおりツール呼び出しの後にしか発火しません。つまり、セッションがツールを一切呼ばずに長考している あいだは、ハートビートが更新されません。これを停滞と誤判定してしまうと、正常な深い推論を殺しかねません。
私はここで、しきい値を「最長の正当な単発ツール」より十分に長く取ることで折り合いをつけました。加えて、フェーズに tool: の接頭辞を残しておくと、「最後の活動がツールだった」ことが分かるので、長考中の無音と本当のハングをある程度見分けられます。厳密にやるなら、セッション開始直後に一度 phase:thinking の刻印を置き、以降ツールごとに上書きする二段構えにすると、起動直後の空白も塞げます。
クラウド同期フォルダでの書き込み競合
最初、ハートビートをワークスペース直下に置いていて、これが失敗でした。私の作業フォルダはクラウド同期されていて、数秒おきの小さな書き込みが同期処理と競合し、リコンサイラがときどき古い内容を読んでしまったのです。
解決は単純で、ハートビートは同期されないローカルの /tmp に置く こと。刻印は消えても構わない一時情報ですから、永続化する必要がありません。クラウド同期フォルダのファイル実体化まわりの落とし穴は根が深く、クラウド同期ワークスペースのファイル実体化を自動化する記事 で別途整理しています。生きているうちの高頻度な書き込みは、同期対象から外すのが鉄則です。
長い単発ツールの誤検知
code_execution のような、ひとつのツール呼び出しが90秒近くかかるものがあります。しきい値をこれより短く取ると、正当な長いツール実行の最中に「停滞」と鳴ってしまいます。
だから STALE_SEC は、環境で起こりうる最長の単発ツール実行より確実に長く 設定します。私は最長でも2分程度なので、5分をしきい値にしています。ここは各自のワークロード次第で、まずは広めに取って誤検知を潰し、慣れてから詰めるのが安全です。可観測性のしきい値設計全般は、SRE サイトリライアビリティエンジニアリング(Betsy Beyer ほか編) のアラート設計の章が、誤報とアラート疲れのトレードオフを丁寧に扱っていて、私はしきい値を決めるときに何度も読み返しました。
なぜこの形にしたか
最後に、設計判断の理由をひとつだけ残しておきます。
このハートビート層は、agents ビューを置き換えるものではありません。補完する ものです。agents ビューはプレゼンスを、コミット相関はトレーサビリティを、終了時アサーションは完了の検証を、そしてハートビートはライブネスを担います。それぞれが別の問いに答えていて、どれか一つで全部を賄おうとすると、必ずどこかに穴が開きます。長時間セッションのメモリ肥大を見張るRSS ウォッチドッグの設計 も、この可観測性の網の一目です。
無人運用の信頼性は、派手な一手ではなく、こうした小さな網目を重ねることで少しずつ上がっていくのだと、私自身、運用を続けるなかで感じています。
まず試すなら、heartbeat.sh を1つ書いて PostToolUse フックに繋ぎ、今夜の背景セッションを1本だけ見張ってみてください。翌朝、刻印の鮮度が更新され続けているのを確認できれば、そこがライブネス層の出発点になります。
夜の実行を安心して任せられる状態が、少しでも増えれば嬉しいです。お読みいただきありがとうございました。