「スリープで死んでも朝には終わってる」― 多スロット+doneマーカーで作る自己回復型launchdジョブ
前作「launchdジョブを自己アンロードさせる」では、一度きり走って終わったら自分でunloadするジョブを作った。今回はその逆 ―― 途中死を前提に設計し、1日複数スロット発火して「成功するまで再試行・成功後は即終了」を実現するパターンの話だ。
毎朝 Claude Code に Obsidian Vault の更新を任せているが、macOSのスリープ・ネット未接続・claude timeoutのどれかで途中死することが繰り返し起きた。「完璧に防ぐ」より「死んでも朝には終わってる」方が現実的だと気づいて設計を変えた。
困りごと:「起動したが完走しなかった」がサイレントに積み重なる
launchdのジョブが完走しないパターンは3つある。
- 蓋閉じスリープ(バッテリー駆動) ――
caffeinate -sはAC電源時しか効かない。バッテリーで使っていると蓋を閉じた瞬間にジョブが凍結し、wake後にtimeoutで刈られる - ネット未接続 ―― wake直後はWiFiが繋がっていない。git pushやclaudeのAPI呼び出しがタイムアウトする
- claude timeout ―― 28時間分の会話ログを消化するingestが単一スロットに収まらずtimeout(2026-06-11〜13に3日連続で発生した)
どれも「ジョブは起動した・exit codeも0」に見えることがあるので、気づくのが遅れる。
全体設計:4スロット + doneマーカー
解決策は単純で、plistに複数の StartCalendarInterval を仕込み、スクリプト冒頭で「本日分が成功済みなら即 exit 0」を見るだけだ。
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Hour</key><integer>4</integer><key>Minute</key><integer>55</integer>
</dict>
<dict>
<key>Hour</key><integer>8</integer><key>Minute</key><integer>20</integer>
</dict>
<dict>
<key>Hour</key><integer>10</integer><key>Minute</key><integer>45</integer>
</dict>
<dict>
<key>Hour</key><integer>12</integer><key>Minute</key><integer>15</integer>
</dict>
</array>
4:55が理想的な起動タイム。これが成功すれば後の3スロット(8:20 / 10:45 / 12:15)は全部 doneマーカーを見て3秒で終わる。
スクリプト冒頭のチェックがこれ。
TODAY=$(date +%Y%m%d)
DONE_MARKER="$HOME/.claude/logs/.vault-ingest-done-${TODAY}"
# 0. 本日分が既に成功していれば即終了(catch-upスロットの空振り。ログも汚さない)
[ -f "$DONE_MARKER" ] && exit 0
doneマーカーは最後のステップが成功した時だけ touch する。途中死したランではマーカーが立たないので、次のスロットが全体をやり直す。
ステップ別半マーカー:完了済みステップを2回やらない
claudeのingestは1本あたり25分以上かかることがある。次スロットで再試行するとき、成功済みのステップまで繰り返すのは無駄だ。そこでステップごとに「半マーカー」を置いた。
STEP2A_MARKER="$HOME/.claude/logs/.vault-ingest-step2a-claude-${TODAY}"
STEP2B_MARKER="$HOME/.claude/logs/.vault-ingest-step2b-codex-${TODAY}"
STEP2_MARKER="$HOME/.claude/logs/.vault-ingest-step2-done-${TODAY}"
ingestを呼び出す関数はこう書いてある。
ingest_src() {
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 "..." \
--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; }
}
# 2a: Claudeログ(先に処理)。2b: Codexログ(2aの結果も見て統合)
ingest_src "$STEP2A_MARKER" "$KB/raw/conversations/" "claude" 1500 ""
ingest_src "$STEP2B_MARKER" "$KB/raw/codex-conversations/" "codex" 1500 "..."
# 両サブが済んだ時だけ「本日ingest完了」を立てる
[ -f "$STEP2A_MARKER" ] && [ -f "$STEP2B_MARKER" ] && touch "$STEP2_MARKER"
step2a(Claudeログ)が成功 → step2b(Codexログ)がtimeout → 次スロット起動 → step2aはskip → step2bだけ再試行。両方済んだら STEP2_MARKER を立てて次の工程へ進む。半マーカーは ${TODAY} サフィックスで翌日に自然にリセットされる。
ステップ分割したのは実際の障害から。28hのClaudeログ+Codexログを1本で処理しようとして40分枠に収まらず3日連続timeout→hot.mdが凍結した(2026-06-11〜13)。ソース別2本に分けてそれぞれ25分timeout(1500秒)に収めたことで安定した。
caffeinate -i exec:実行中スリープを阻む
スクリプト自身を caffeinate 配下で再実行するパターン。
if [ -z "${CAFFEINATED:-}" ]; then
exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi
exec で自分自身を置き換えるので、caffeinate は子プロセスではなく「プロセス自身の親」になる。CAFFEINATED=1 を環境変数に乗せて再帰呼び出しを防ぐ。
-s(AC電源時のシステムスリープ防止)はバッテリー駆動では無効。バッテリーで蓋を閉じると凍結する。これは防ぎきれないので「凍結したランは timeout で刈られ、次スロットがやり直す」設計にしている。-i(アイドルスリープ防止)は電源に関係なく効くが、蓋閉じは止めない。
90秒ネット待ちループ
wake直後にすぐ動いても WiFi が繋がっていない。git pushやclaudeのAPI呼び出しが即失敗する。
net_ok=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18; do
if /usr/bin/nc -z -G 3 1.1.1.1 443 2>/dev/null; then net_ok=1; break; fi
sleep 5
done
[ -z "$net_ok" ] && echo "[$(date '+%F %T')] WARN: 網未接続のまま続行(失敗時は次スロットが再試行)" >> "$LOG"
18回 × 5秒 = 最大90秒待つ。接続を確認したらすぐ break する。90秒経っても繋がらない場合は警告だけ出して続行する。ここでexitしないのは、オフライン環境でもstep1(ローカルのログ変換)だけなら動くため。その後のgit pushやclaudeが失敗したら次スロットが再試行する。
nc -z -G 3 の -G はmacOSの connect timeout(秒)指定で、GNU ncとはフラグが違う点に注意。
macOS TCC プリフライト
launchd配下では ~/Documents/ が TCC(Full Disk Access)の保護対象で、GUIから /bin/bash にフルディスクアクセスを付与しないと読み書きできない。サイレントに exit 0 で終わると1週間気づかない。冒頭で明示的に検知する。
if ! ( cd "$VAULT" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1 ); then
echo "[$(date '+%F %T')] ❌ FDA未付与: launchdから '$VAULT' にアクセス不可(TCC保護)。" >> "$LOG"
echo " 解決: システム設定 > プライバシーとセキュリティ > フルディスクアクセス で /bin/bash を許可。" >> "$LOG"
notify_fail "FDA未付与: vault にアクセス不可(設定→フルディスクアクセス→/bin/bash)"
exit 1
fi
git rev-parse --git-dir が成功するかどうかでVaultへのアクセス権を確かめる。失敗したら exit 1(doneマーカーを立てない)→ 次スロットが再試行 → FDA付与後に自然に解消する。
iCloud同期フォルダ(
~/Documents/配下)は特に保護が強い。シンボリックリンクで~/に逃がす案はiCloudが競合処理でsymlinkを壊すため不可(2026-06-01 検証済み)。Vaultは直接~/Documents/下に置く。
失敗の可視化:デスクトップ FAILED マーカー
ログを見に行かないと気づかない障害は放置される。失敗時はデスクトップに FAILED-${TODAY}.md を置き、成功時に自動削除する。
notify_fail() {
local reason="$1"
mkdir -p "$HOME/Desktop/Daily Brief"
{ echo "# Daily Brief 生成失敗 — $(date '+%F %T')"
echo "- 理由: ${reason}"
echo "- 詳細ログ: ~/.claude/logs/vault-auto-ingest.log"
echo "- 自動再試行: 8:20 / 10:45 / 12:15(成功したらこのファイルは自動で消える)"
} > "$FAILED_FILE"
/usr/bin/osascript -e "display notification \"${reason}\" with title \"Daily Brief 生成失敗\"" >/dev/null 2>&1
}
step2.6(ブリーフのアーカイブ)が成功した時だけ rm -f "$FAILED_FILE" する。「成功した日だけファイルが消える」=「ファイルが残ってる日は失敗中」。
ロック:二重実行レース
launchd版と手動版が同時に走るレースが実際に起きた(2026-06-10)。mkdir ベースのロックで排除する。
LOCKDIR="$HOME/.claude/locks/vault-auto-ingest.lock"
if ! /bin/mkdir "$LOCKDIR" 2>/dev/null; then
oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || true)
if [ -n "${oldpid:-}" ] && kill -0 "$oldpid" 2>/dev/null; then
echo "[$(date '+%F %T')] 別インスタンス実行中(pid=${oldpid}) — skip" >> "$LOG"
exit 0
fi
rm -rf "$LOCKDIR"
/bin/mkdir "$LOCKDIR" 2>/dev/null || exit 0
fi
echo $$ > "$LOCKDIR/pid"
trap 'rm -rf "$LOCKDIR"' EXIT INT TERM
staleなロック(プロセスが死んで残ったlock dir)は pid 死活チェック(kill -0)で自動回収する。mkdir はアトミックなので、複数プロセスが同時に叩いても一方だけが成功する。
踏んだ落とし穴
- caffeinate -s がバッテリー駆動で無効 → 「蓋閉じ凍結は防がない・次スロットがやり直す」設計に転換。
-iだけでは蓋閉じは止まらない - claude ingestが3日連続timeout → step2をソース別2本(step2a/step2b)に分割、各々を1500秒timeout+半マーカーで管理(2026-06-13修正)
- TCC保護でサイレントexit 0 →
git rev-parseプリフライトで早期検知。サイレント失敗が1週間気づかれなかった - launchd最小PATHでnode/gitが見つからない → VaultのcommitフックがNode.js製のため、nvm binを動的にPATHに通す
- ログが5MB超え →
stat -f%zでサイズチェック、超過で.oldにローテート(claude出力を丸ごとログに溜めるため意外に膨れる) - GNU timeoutなしだとtimeoutが効かない →
TIMEOUT_BIN=/opt/homebrew/bin/timeoutを先に確認し、なければ素通し(brew install coreutilsが前提)
まとめ
- plistに複数の
StartCalendarIntervalを入れ、冒頭で doneマーカーを見て成功済みなら即 exit 0 ―― これだけで自己回復型ジョブになる - ステップ別半マーカー(step2a/step2b)で「完了済みステップの再実行なし・失敗ステップだけ再試行」を実現
caffeinate -i -sは万能ではない。バッテリー蓋閉じは防げないので、凍結したランを次スロットが引き継ぐ設計にする- ネット待ちは 18×5秒=最大90秒ループ。接続できなくても即exitしない(次スロットが再試行する設計)
- macOS TCCは
~/Documents/を保護する。launchdからのアクセスはgit rev-parseプリフライトで確認し、サイレント失敗を防ぐ
前作「launchdジョブを自己アンロードさせる」と組み合わせると、「一度きり完走させたいジョブ」と「1日1回確実に完走させたいジョブ」の2パターンが揃う。次回は、このジョブが積み上げ続けたVaultをClaude Codeのコンテキストに効率よく注入する仕組みについて書く予定だ。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!