ある朝、いつもの時刻にログを開きましたら、前の晩の一行目だけが残っておりました。個人開発のアプリで使っている依存ライブラリのリリースノートと、ストアの規約ページを夜のあいだに巡回させている小さなタスクです。
プロセスは生きておりました。CPU も食っておりません。エラーも出ておりません。ただ、返ってこないのです。
原因は、巡回先のうち一つが応答を開いたまま閉じないサーバーだったことでした。そして当時の Claude Code は、そういう相手をいつまでも待つようにできておりました。
返ってこないときに、まずどこを見るか
止まったタスクを前にすると、プロンプトや権限設定を疑いたくなります。私も最初はそちらから手を付けました。結果は芳しくありませんでした。プロンプトを削っても、権限を広げても、翌朝の状態は同じだったのです。
いま思えば、返ってこないという症状をエラーの一種として数えていたことが、遠回りの始まりだったのかもしれません。順番が違っておりました。返ってこない不具合では、書いた内容より先に「誰が待っているか」を特定します。 待っているのが自分の書き方なのか、道具なのか、相手のサーバーなのかで、直す場所がまるで変わります。
見る順番はこうしております。
- ログの最終行。どのツール呼び出しの直後で止まっているか
- プロセスの状態。落ちているのか、生きたまま待っているのか
- その行に出ている URL を、手元の
curlで同じように叩き直すこと
3 番目で答えが出ることが多いです。ブラウザで開けるページでも、curl で叩くと終わらないことがあります。
# --max-time を付けずに叩くと、こちらも同じように返ってきません
curl -sS -o /dev/null -w 'http=%{http_code} total=%{time_total}s\n' \
--max-time 20 "https://example.com/release-notes"
echo "exit=$?"--max-time に当たると curl は終了コード 28 を返します。ここで 28 が出るなら、詰まっているのは自分の書き方ではありません。
応答を閉じないサーバーを、手元で作ってみます
相手のサーバーを再現できると、対策の効きを確かめられます。依存を足さずに、これだけで用が足ります。
// slow-server.mjs — ヘッダと途中までの本文を返し、以後は閉じないサーバー
import { createServer } from "node:http";
createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.write("<html><body><p>preparing the report…</p>");
// 本文を書き切らず、res.end() も呼ばないまま放置します
}).listen(8080, () => {
console.log("listening on http://127.0.0.1:8080");
});node slow-server.mjs で起動して、先ほどの curl を http://127.0.0.1:8080 へ向けると、20 秒きっかりで 28 が返ります。同じ URL を Claude Code の WebFetch に渡すと、v2.1.268 より前では待ち続けます。
これが、夜のあいだに一度しか起きない類の詰まり方でした。日中に手で回しているときは、返ってこないと気づいた時点で自分が止めます。無人で走らせていると、止める人がいないのです。
0 は「待たない」ではなく「期限を外す」です
v2.1.268(9 月 10 日)で、この待ち方に区切りが入りました。公式のチェンジログには、応答を閉じ切らないサーバーで WebFetch が無期限に待つ問題を修正し、取得は 300 秒で失敗するようになったこと、そして CLAUDE_CODE_WEBFETCH_DEADLINE_MS で期限を上書きできることが書かれております。
ここで私は一度つまずきました。0 を「待たずに即座に打ち切る」の意味だと読んで、巡回タスクの環境変数に置いてしまったのです。翌朝の状態は、修正前と同じでした。0 は期限を無効にする指定でした。
| 設定値 | WebFetch の待ち方 | 向く場面 |
|---|---|---|
| 未設定 | 300 秒(既定)で失敗します | 手元で対話しながら使うとき |
60000 | 60 秒で失敗します | 取得先が多い巡回。早めに諦めさせたいとき |
0 | 期限が外れ、閉じない応答を待ち続けます | 旧来の挙動へ戻したいときのみ |
巡回タスクでは、既定の 300 秒でも長すぎることがあります。取得先が 12 件あって、そのうち 3 件が閉じないサーバーだった場合、それだけで 15 分が待ち時間に消えるからです。私は 60 秒に寄せております。
{
"env": {
"CLAUDE_CODE_WEBFETCH_DEADLINE_MS": "60000"
}
}設定ファイルに置くほどでもないときは、走らせる側でそのつど渡す形でも構いません。
CLAUDE_CODE_WEBFETCH_DEADLINE_MS=60000 claude -p "$(cat ./prompts/watch-release-notes.txt)"内側の期限と、外側の上限
期限が付いたことで、WebFetch 一回あたりの最悪値は読めるようになりました。ただし、タスク全体の最悪値はまだ読めません。取得先の数だけ 60 秒が積み上がりますし、詰まる場所は WebFetch だけとも限らないからです。
そこで、走らせる側にもう一枚、上限を敷いております。
#!/usr/bin/env bash
set -uo pipefail
LOG="${HOME}/logs/watch-release-notes-$(date +%F).log"
mkdir -p "$(dirname "$LOG")"
# 内側: WebFetch 一回あたりの期限
export CLAUDE_CODE_WEBFETCH_DEADLINE_MS=60000
start=$(date +%s)
# 外側: タスク全体の上限。TERM で終わらなければ 30 秒後に KILL
timeout --signal=TERM --kill-after=30s 20m \
claude -p "$(cat ./prompts/watch-release-notes.txt)" >> "$LOG" 2>&1
code=$?
elapsed=$(( $(date +%s) - start ))
case "$code" in
0) printf 'ok elapsed=%ss\n' "$elapsed" >> "$LOG" ;;
124) printf 'CEILING 外側の上限に当たりました elapsed=%ss\n' "$elapsed" >> "$LOG" ;;
*) printf 'FAILED exit=%s elapsed=%ss\n' "$code" "$elapsed" >> "$LOG" ;;
esac
exit "$code"外側の値は、内側の期限に取得先の数を掛けて、少し余裕を足した程度で足ります。20 分という数字自体に根拠はなく、私の巡回リストの長さから逆算しただけのものです。
なぜ二枚要るのかと言えば、内側は「この一回を諦める」ための仕組みで、外側は「今夜はもう終わりにする」ための仕組みだからです。役割が違いますので、片方で兼ねられません。待ち時間の上限は、道具の内側と実行の外側に一つずつ置きます。 この置き方にしてから、翌朝ログの末尾が空白のままということはなくなりました。
当たったことを、翌朝わかるようにしておく
期限を置いただけでは、まだ半分です。当たったことが記録に残らないと、取得できていないページがあることに気づけません。静かな失敗は、返ってこないより厄介なことがあります。
巡回リストの前さばきを、タスクの外側で済ませる形にしました。
#!/usr/bin/env bash
set -uo pipefail
SRC="./urls.txt" # 巡回したい URL を1行ずつ
TODAY="${HOME}/logs/urls-$(date +%F).txt"
REPORT="${HOME}/logs/probe-$(date +%F).tsv"
: > "$TODAY"; : > "$REPORT"
while read -r url; do
[ -z "$url" ] && continue
t0=$(date +%s)
http=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "$url") || http="000"
printf '%s\t%s\t%ss\n' "$url" "$http" "$(( $(date +%s) - t0 ))" >> "$REPORT"
[ "$http" = "200" ] && printf '%s\n' "$url" >> "$TODAY"
done < "$SRC"
# 200 を返した URL だけを本番のプロンプトへ渡します
echo "probe: $(wc -l < "$TODAY") / $(grep -cve '^$' "$SRC") 件が応答しました"http=000 の行が、応答を返さなかった URL です。翌朝この 1 ファイルを見れば、昨夜どこが欠けたのかがひと目で分かります。取得先が固定の巡回では、この前さばきのほうが本体の調整より効きました。
キャッシュの持ち方と合わせて考えたい方は、調べ物タスクの WebFetch キャッシュ TTL を、自分の使い方に合わせて選ぶも併せてご覧ください。期限は一回の取得を諦める話で、TTL は取得そのものを減らす話ですので、両方を触ると効き方が分かりやすくなります。
今夜の巡回を回す前に、CLAUDE_CODE_WEBFETCH_DEADLINE_MS を一度だけ短めに置いて、翌朝ログの末尾を確かめていただければと思います。空白で終わっていないことが確認できれば、それだけで夜の見通しはずいぶん変わります。
夜のあいだ何かを走らせている方に、ひとつでも手がかりが渡れば嬉しく思います。ありがとうございました。