0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Posted at

前作「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」を見るだけだ。

<!-- com.shun.vault-auto-ingest.plist(StartCalendarInterval 抜粋) -->
<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サービスを量産しています

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?