⏱️ 将来の破壊的変更を今仕込む ― 完了後に自分を消す launchd 時限ジョブ — リーダー×
⏱️

将来の破壊的変更を今仕込む ― 完了後に自分を消す launchd 時限ジョブ

#claudecode#launchd#自動化#macOS2026-07-11 · 約7

前回、使い捨てのcronジョブをlaunchdへ移した話を書きました。今回はその応用で、まだ来ていない未来の破壊的変更に、今日のうちに時限ジョブを仕込んでおく話です。

対象は2026-07-07に予告されていたFable 5の終了。仕込んだのは07-02、実際に成立したのは07-08 06:50 ―― ゲートが開いた07-07の3スロットは全部素通りし、丸24時間・3スロット分遅れて発火しました。それでも実害はゼロで、切替は狙い通り1回だけ起き、以降このジョブは二度と動いていません。今回はその「遅れても壊れない」設計を実コードで書きます。

困りごと:未来の一回きりの作業を、未来の自分に丸投げできない

Fable 5終了のような「特定の日付を境に一度だけやる作業」は、当日その場にいないと手で対応できません。かといって当日にリマインダーで気づく前提は事故ります(寝てる・外出中・別の作業に集中している)。

じゃあ数日前から常駐監視スクリプトを立てておくか、というとそれも大げさです。欲しいのは、日付が来るまでは息を潜め、来た瞬間に1回だけ動いて、終わったら自分から消えるジョブでした。

設計:3点セット

~/.claude/scripts/model-transition-0707.sh の全文です。

#!/bin/bash
# model-transition-0707.sh — 2026-07-07にFable 5が終了するため、settings.jsonのmodelをopusへ自動切替
# 冪等: 7/7以降かつ model が fable の時だけ書き換える。成功したら自分のplistをunload。
set -uo pipefail
SETTINGS="$HOME/.claude/settings.json"
LOG="$HOME/.claude/logs/model-transition.log"
PLIST="$HOME/Library/LaunchAgents/com.user.model-transition-0707.plist"

log() { echo "[$(date '+%F %T')] $*" >> "$LOG"; }

# 7/7より前なら何もしない(catch-up発火対策)
if [ "$(date +%Y%m%d)" -lt 20260707 ]; then
  log "skip: before 2026-07-07"; exit 0
fi

current=$(jq -r '.model // empty' "$SETTINGS")
if echo "$current" | grep -qi 'fable'; then
  cp "$SETTINGS" "$SETTINGS.bak-model-transition"
  jq '.model = "opus"' "$SETTINGS" > "$SETTINGS.tmp" && jq . "$SETTINGS.tmp" > /dev/null && mv "$SETTINGS.tmp" "$SETTINGS"
  log "switched model: $current -> opus"
  /usr/bin/osascript -e 'display notification "Fable 5終了に伴いデフォルトモデルをOpusへ切替えました" with title "Claude model transition"' >/dev/null 2>&1 || true
else
  log "no-op: model is already '$current'"
fi

# 役目を終えたらジョブを外す(plistは残す=再登録可能)
launchctl unload "$PLIST" 2>/dev/null || true
log "done (job unloaded)"

1. 日付ゲートで早期発火を弾く

if [ "$(date +%Y%m%d)" -lt 20260707 ]; then
  log "skip: before 2026-07-07"; exit 0
fi

これだけです。plistを07-02から先に登録しておいても、ゲートより前の発火はjqにもsettings.jsonにも一切触らず即exit 0します。「仕込みは早めに、実行は正確に」を1行で分離しているのがポイントで、後述の3スロット×複数日ぶんの空振りはすべてここで止まります。

2. jqでバックアップ付きJSON書き換え

cp "$SETTINGS" "$SETTINGS.bak-model-transition"
jq '.model = "opus"' "$SETTINGS" > "$SETTINGS.tmp" && jq . "$SETTINGS.tmp" > /dev/null && mv "$SETTINGS.tmp" "$SETTINGS"

生成先を一度.tmpに逃がし、jq .でパース検証してからmvする3段構えです。途中でjqが壊れたJSONを吐いても、検証に失敗すればmvまで到達せず本体のsettings.jsonは無傷のまま残ります。加えて上書き前に無条件で.bak-model-transitionを残すので、書き換え自体が誤っていても手で戻せます。

そして冒頭のgrep -qi 'fable'が冪等性の要です。現在のmodelにfableという文字列が含まれる時だけ書き換えに入り、そうでなければno-opログだけ吐いて素通りします。

3. 成功後にlaunchctl unloadで自らを登録解除する

launchctl unload "$PLIST" 2>/dev/null || true
log "done (job unloaded)"

このunloadはif/elseのにあります。つまり「実際に切り替えた」場合も「もう既にopusだったので何もしなかった」場合も、日付ゲートさえ通れば必ずここに来て自分をlaunchdのキューから外します。plistファイル自体は消さない(=手でlaunchctl loadすれば再登録できる)設計ですが、スケジュール上はこの瞬間に二度と発火しなくなります。

図解:1日3スロットが「静かに空振る」理由

plistは1日3回、07-07以降ずっと起動する設定でした(現在は役目を終えて~/Library/LaunchAgents/から退避済みのため、手元の設定バックアップから復元した内容です。Labelは伏せています)。

<key>StartCalendarInterval</key><array>
  <dict><key>Hour</key><integer>6</integer><key>Minute</key><integer>50</integer></dict>
  <dict><key>Hour</key><integer>12</integer><key>Minute</key><integer>50</integer></dict>
  <dict><key>Hour</key><integer>20</integer><key>Minute</key><integer>50</integer></dict>
</array>

1日3回も刻んでいるのに、実際に効くのは生涯でたった1回です。安全網が2段構えになっているからです。

  1. 日付ゲート:07-07より前の発火は、07-02〜07-06の間に何回転がってきてもファイルに一切触れず即終了。
  2. unloadの無条件実行:07-07以降、最初に条件を満たして走った1回が(切替であれ no-op であれ)その場でジョブ自体をlaunchdから外す。

つまり同じ日の残り2スロットが空振るのは「モデルがもうopusだから」ではなく、その時点でジョブがlaunchdに登録されていないからです。冪等性チェックはあくまで保険で、主犯はunloadの方でした。

実測では、07-07の3スロットは全部発火しなかったようで(Macがスリープしていたと思われます)、実際に成立したのは翌07-08の06:50でした。settings.jsonのバックアップファイル.bak-model-transitionのタイムスタンプがJul 8 06:50:05で、これがplistの06:50スロットと一致しています。1日ぶん・3スロットぶん遅れても、日付ゲートが「過ぎた日付なら通す」だけの単純な不等号だったおかげで、遅延はそのまま吸収されました。

書き換え前のmodelの値も同じバックアップから確認できて、claude-fable-5[1m]でした。

踏んだ落とし穴

  • jq '.model = "opus"'の直書きで、切替前についていた[1m](長文脈)サフィックスを引き継げなかった → 書き換え後の値は素の"opus"。現在settings.jsonを見ると"opus[1m]"に戻っているが、これは自動切替後に手で付け直したもの。値の一部だけ置換するなら文字列全置換ではなく元の接尾辞を保つjqパッチにすべきだった
  • 07-07当日の3スロットはMacのスリープでまるごと落ちた → 「当日ぴったりに効かせたい」なら日付ゲートを-lt(より前は弾く)だけにして、-eqで当日だけに絞らないのが正解だった。絞っていたら翌日の回収すら起きず、永久に発火しないジョブになっていた
  • launchctl unloadは「実行された」ことの確認であって「成功した」ことの確認ではないset -uo pipefail-eは付けていないため、途中のjqが失敗してもスクリプトは止まらずunloadまで到達する。冪等性チェック(grep fable)が残っているので次に手動でlaunchctl loadし直せば同じ状態から拾えるが、「unloadされた=ちゃんと切り替わった」と早合点すると気づくのが遅れる
  • ログファイル(~/.claude/logs/model-transition.log)は今回書く時点で既に残っていなかった → 定期のログ整理で消えていたらしく、実行の裏取りに使えたのはsettings.jsonのバックアップファイルのタイムスタンプだけだった。無人ジョブの証跡はログ1箇所に頼らず、書き換え対象側にも自然に残る形(バックアップファイル・ファイル自体のmtime)にしておいた方が強い

まとめ

  • 未来の破壊的変更(Fable 5終了)には、当日を待ち構えるのではなく数日前にlaunchdへ時限ジョブを仕込んでおく
  • 3点セット:日付ゲートで早期発火を弾く/jqでバックアップ付きJSON書き換え/成功後にlaunchctl unloadで自らを消す
  • plistの複数スロットは「繰り返し切り替える」ためではなく「その時刻にMacが起きていない可能性」への保険。実際に効くのは生涯で最初の1回だけで、残りは日付ゲートかunloadのどちらかで自然に空振りする
  • unload=成功と思い込まず、書き換え対象側に残るバックアップファイルのタイムスタンプのような副産物の証跡で裏取りする

Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

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