🔄 「スリープで死んでも朝には終わってる」― 多スロット+doneマーカーで作る自己回復型launchdジョブ — リーダー×
🔄

「スリープで死んでも朝には終わってる」― 多スロット+doneマーカーで作る自己回復型launchdジョブ

#claudecode#launchd#自動化#macos2026-07-12 · 約9

前作「launchdジョブを自己アンロードさせる」では、一度きり走って終わったら自分でunloadするジョブを作った。今回はその逆 ―― 途中死を前提に設計し、1日複数スロット発火して「成功するまで再試行・成功後は即終了」を実現するパターンの話だ。

毎朝 Claude Code に Obsidian Vault の更新を任せているが、macOSのスリープ・ネット未接続・claude timeoutのどれかで途中死することが繰り返し起きた。「完璧に防ぐ」より「死んでも朝には終わってる」方が現実的だと気づいて設計を変えた。

困りごと:「起動したが完走しなかった」がサイレントに積み重なる

launchdのジョブが完走しないパターンは3つある。

  1. 蓋閉じスリープ(バッテリー駆動) ―― caffeinate -s はAC電源時しか効かない。バッテリーで使っていると蓋を閉じた瞬間にジョブが凍結し、wake後にtimeoutで刈られる
  2. ネット未接続 ―― wake直後はWiFiが繋がっていない。git pushやclaudeのAPI呼び出しがタイムアウトする
  3. 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 0git 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サービスを量産しています

皆さんの ❤️ やシェアが励みになります!