「Claude Code環境」シリーズの続きです。前回は Google Calendar を Vault の ground truth にした話 を書きました。今回は同じ vault-auto-ingest ジョブで起きた timeout 地獄と、それを「サブステップ冪等化」で抜け出した話です。
毎朝 hot.md が更新されない状態が3日続いたとき、原因は「launchd が発火していない」ではなく「発火したけど途中で死んでいた」でした。外側の再試行(複数スロット発火)は既に入れていた。問題は内側にあった。
困りごと:step2 が毎日 timeout して hot.md が3日凍結した
vault-auto-ingest.sh の step2 は、直近28時間分の会話ログを消化して Vault の wiki/ を更新するステップです。活動が多い時期、このステップに Claude Code ログ + Codex ログ が両方どっさり積み上がり、全記事リライトまで含めると40分の枠に収まらなくなりました。
# vault-auto-ingest.sh L26-28 のコメント(実際の記述)
# step2 は活動多発期に28h分のClaude+Codexログ消化+全記事リライトが40分枠に収まらず
# 連日timeout(hot.md凍結の真因, 2026-06-11〜13)。
plist は 4:55 / 8:20 / 10:45 / 12:15 の4スロットを持っています。これは外側の再試行で、「今日成功した」マーカー(DONE_MARKER)があれば後続スロットはスキップする設計です。でも DONE_MARKER が立つ前提は step2 が通ること ―― そのstep2が毎回 timeout するのでマーカーが永遠に立たず、4スロット全滅が続きました。
外側の再試行(複数スロット)は「今日どこかで成功させる」ための仕組みで、「途中まで成功した部分を生かす」機能はありません。それが内側の再開ロジックが必要な理由です。
設計:ソース別に分割し各サブステップに独立マーカーを持たせる
解決策は step2 を「Claude ログ消化」と「Codex ログ消化」の2本に割り、それぞれが独立したマーカーファイルを持つ形に変えることでした。
STEP2_MARKER="$HOME/.claude/logs/.vault-ingest-step2-done-${TODAY}"
STEP2A_MARKER="$HOME/.claude/logs/.vault-ingest-step2a-claude-${TODAY}"
STEP2B_MARKER="$HOME/.claude/logs/.vault-ingest-step2b-codex-${TODAY}"
マーカーは3層構造になっています。
| マーカー | 意味 |
|---|---|
STEP2A_MARKER |
Claude ログ消化が本日完了 |
STEP2B_MARKER |
Codex ログ消化が本日完了 |
STEP2_MARKER |
両方完了(合成マーカー) |
そしてこの3層を ingest_src() 関数で統一的に扱います。
ingest_src() { # $1=マーカー $2=ソースdir $3=ソース名 $4=timeout秒 $5=追加指示
local marker="$1" src="$2" name="$3" to="$4" extra="$5"
[ -f "$marker" ] && { echo "[$(date '+%F %T')] step2($name) は本日実施済み — skip" >> "$LOG"; return 0; }
cd "$VAULT" && run_to "$to" "$CLAUDE" -p \
"... ${src} に直近28時間で追加・更新されたファイル(${name}由来の会話ログ)だけを対象に ... ${extra}" \
--dangerously-skip-permissions >> "$LOG" 2>&1 \
&& { touch "$marker"; echo "[$(date '+%F %T')] step2($name) 完了" >> "$LOG"; return 0; } \
|| { echo "[$(date '+%F %T')] WARN: step2($name) 失敗/timeout(次スロットで再試行)" >> "$LOG"; return 1; }
}
肝は2行目の [ -f "$marker" ] && return 0 です。マーカーがあれば即return、なければ claude -p を走らせてマーカーを立てる。これだけで「同日の再試行で完了済みサブを繰り返さない」冪等性が得られます。
呼び出しはこうなっています。
ingest_src "$STEP2A_MARKER" "$KB/raw/conversations/" "claude" 1500 ""
ingest_src "$STEP2B_MARKER" "$KB/raw/codex-conversations/" "codex" 1500 "Codex由来でも既存記事に統合し、Claude側と重複する話題は新記事を作らず追記でまとめろ。"
# 両サブが済んだ時だけ合成マーカーを立てる
[ -f "$STEP2A_MARKER" ] && [ -f "$STEP2B_MARKER" ] && touch "$STEP2_MARKER"
timeout は各1500秒(25分)。元の「両方まとめて40分枠」を「各25分×2本・独立」に変えたことで、一方が timeout しても翌スロットでその一方だけ再試行できるようになりました。
どの粒度でマーカーを切るか
「全部を1マーカー」から「1ステップ1マーカー」まで粒度の選択肢があります。今回の基準はこうです。
マーカーを切る境界 = 「独立して再実行できる作業単位」
Claude ログと Codex ログは別ソースで、一方が終わっても他方は無関係に処理できます。これが独立単位の条件です。逆に「hot.md の最終整合」は両方の結果を見て1回やるべき作業なので、2本の ingest の後に独立して1回置く形が正しい。
分割しすぎると管理コストが上がります(マーカーファイルが乱立し、デバッグのとき追いにくい)。今回は「ソース種別」を粒度にしましたが、もし1ソースの消化に30分超かかる量になれば「ソース × 時間窓」に細分化する、というのが次の手です。
合成マーカーのロジック:AND 条件でデグレを防ぐ
[ -f "$STEP2A_MARKER" ] && [ -f "$STEP2B_MARKER" ] && touch "$STEP2_MARKER"
AND 条件で STEP2_MARKER を立てるのが重要で、OR にしてはいけません。OR にすると「Codex ログが timeout して消化できていない日でも step2 完了扱いになり、翌日の Codex ログ量がさらに膨れる」という悪循環に入ります。
STEP2_MARKER がないまま当日のスロットが4本全部終わると、DONE_MARKER も立ちません(DONE_MARKER を立てる直前に today-brief.md の鮮度チェックがあり、ingest が不完全だとブリーフが古いまま → 鮮度不足で DONE_MARKER を書かない設計になっています)。翌日の 4:55 スロットがゼロから走るのではなく、STEP2A/B のうち未完のものだけ走る ―― これが「内側の再開」の動作です。
multi-slot-launchd-retry(外側の再試行)と substep-idempotent-marker(内側の再開)の組み合わせが揃って初めて「今日どこかで必ず完了する」が保証されます。どちらか片方だけでは4スロット全滅が起こりえます。
踏んだ落とし穴
- timeout 秒を短く切りすぎて常に失敗 → ソースの量に余裕を持たせた1500秒に。活動が少ない日は1分で終わるので、长めに設定して損はない
-
STEP2_MARKERが先に立っていると両サブの skip 判定より前に抜ける →STEP2_MARKERチェックをingest_src呼び出しの外(上位)に置き、入口で早期return させた -
マーカーの日付を
TODAY=$(date +%Y%m%d)で固定しているが、日付跨ぎランの場合に翌日のマーカーに書く →TODAYはスクリプト冒頭の1回だけで決定するので実際には跨がない。ただし意識しておかないとはまる -
Codex ログのソース (
raw/codex-conversations/) が空の日は ingest_src が空振りで正常終了し STEP2B_MARKER が立つ → 空振り正常終了なのでマーカーが立つのは正しい。問題なし - スリープ凍結でマーカーが半端に残るケース → caffeinate -s(AC電源時のみ有効)+ 次スロットでの再開で対処済み。凍結したランが残したマーカーはその日の実績として正しく扱われる
まとめ
- step2 が timeout する根本原因は「2ソースを1枠に詰め込んでいた」こと。分割が先
-
ingest_src()関数で「マーカーがあればスキップ、なければ実行してマーカーを立てる」を1関数に閉じ込めた - マーカーの粒度は「独立して再実行できる作業単位」で切る。細かすぎると管理コストが上がる
- 合成マーカーは AND 条件。OR にすると不完全消化が永続する悪循環に入る
- 外側の再試行(複数スロット)と内側の再開(サブステップ冪等化)は役割が違う。両方要る
次回は、ingest の結果を受け取る側 ―― hot.md の内容が膨らみすぎて Claude のコンテキスト圧迫が始まった問題と、Obsidian の [[WikiLink]] 構造を使って参照を遅延解決する設計を書きます。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!