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?

⚠️ `.plist.retired`にしても止まらない—launchd引退宣言の正しい手順

0
Posted at

月10万の大学生から始まり、掛け持ちで月60万、会社都合の解雇で一度ゼロに戻り、半年でClaude Codeの自律環境を建て直して今は月商120万を超えた。その自動化基盤を維持し続けていると、「ジョブを正しく止める」という作業が思った以上に難しいことに気づきます。

ファイルを .plist.retired にリネームしても、launchdはそのジョブを止めません。

なぜこの仕組みが効くのか

launchdがジョブを識別するのはファイル名ではなく、plist内部の Label キーの値です。

たとえば私の環境で実際に .retired の状態になっている ~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired を見てください。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.shun.zenn-daily</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>~/.discord/run-and-notify.sh</string>
    <string>zenn</string>
    <string>Zenn日次公開</string>
    <string>/bin/bash</string>
    <string>~/.claude/scripts/zenn-daily-publish.sh</string>
    <string>apply</string>
  </array>
  <key>StartCalendarInterval</key>
  <array>
    <dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>30</integer></dict>
    <dict><key>Hour</key><integer>10</integer><key>Minute</key><integer>0</integer></dict>
    <dict><key>Hour</key><integer>14</integer><key>Minute</key><integer>0</integer></dict>
    <dict><key>Hour</key><integer>19</integer><key>Minute</key><integer>0</integer></dict>
  </array>
  <key>RunAtLoad</key>
  <false/>
  <key>ProcessType</key>
  <string>Background</string>
</dict>
</plist>

ファイル名は com.shun.zenn-daily.plist.retired ですが、内部の Label は com.shun.zenn-daily のままです。launchdはログイン時にこのファイルをすでに読み込み、com.shun.zenn-daily というラベルで自分のテーブルに登録しています。その後ファイル名が変わっても、テーブルのエントリは消えません。

このジョブは StartCalendarInterval に4つの時刻が指定されています。7:30、10:00、14:00、19:00。リネームされた後も、launchdはこれらの時刻になるたびに ~/.claude/scripts/zenn-daily-publish.sh apply を呼び出し続けます。ログを確認すれば、~/.claude/logs/zenn-daily.out.log と ~/.claude/logs/zenn-daily.err.log に実行の痕跡が積み上がっているはずです。

launchdが使うIDはパスでなくLabel

macOSのlaunchdは、Linuxのsystemdとは設計の思想が異なります。systemdはユニットファイルのパスとサービス名を密接に連動させていますが、launchdはplistのパスを「最初の読み込み先」として扱うだけで、その後の管理は Label の値で完結します。

これは設計の合理性から来ています。launchdはmacOSのシステム根幹に近いレイヤーにいて、同じLabelを持つジョブが複数の場所(~/Library/LaunchAgents/・/Library/LaunchAgents/・/Library/LaunchDaemons/)に散在してもコンフリクトを検知できます。逆に言えば、ファイルが LaunchAgents/ から消えても、すでにロードされているエントリを自動で削除する仕組みはありません。

なぜリネーム後も動き続けるのか

macOSは ~/Library/LaunchAgents/ をディレクトリとして監視しています。ファイルが追加されればlaunchdはそれを読んでロードし、削除されればアンロードのトリガーになります。ところがリネームは「元の名前で削除・新しい名前で追加」として通知されます。

ここがポイントです。com.shun.zenn-daily.plist が削除されたとしても、launchdがアンロードするのは「そのファイルに書かれていたLabelのジョブ」です。macOS 13以降では com.shun.zenn-daily.plist.retired という名前のファイルを「plistとして扱うべきか」という判断で、拡張子が .plist でないファイルは監視対象外として無視します。つまり:

  • com.shun.zenn-daily.plist → 削除イベント → 本来はアンロードされるべき
  • ただし、すでにロード済みのセッション中では、ファイルシステム監視によるオートアンロードが効かないケースがある

リネームの瞬間に運良くアンロードされることもありますが、ログインセッションをまたぐと確実にそのまま残ります。再起動後にファイルを .retired の状態で置いておいても、そもそもロードされないので問題が出ないように見える。でも再起動せず長期間稼働しているMacでリネームだけを行った場合、ジョブは確実に走り続けます。自動化の規模が大きくなると、このMacは再起動頻度が下がります。だから「退役したはずなのに動いている」という現象が起きやすい。

実害のパターン

私の環境で実際に発生したのは「退役したはずのレーンが別スケジューラで走り続け、作業フォルダを移した翌日に空振りした」というケースです。フォルダ移動後は実行が失敗するだけなのでログに exit 1 が積まれますが、launchd側からは「ジョブが走った(ただし失敗した)」にすぎず、アンロードはされません。静かな失敗が続くパターンです。

160本超のジョブフリートを運用していると、こういう「幽霊ジョブ」が複数混在することがあります。定期的に launchctl list | grep com.lily で棚卸しをしないと、意図せず残ったジョブが他のジョブのリソース(AVDロック・ブラウザスロット・クォータ)を消費し続けます。


全体の流れ

launchdのジョブを正しく退役させるのは3ステップです。ファイル操作はその後の話で、まず bootout を通過させることが先決です。

┌─────────────────────────────────────────────────┐
│  plist.retired にリネームした状態(NG)            │
│                                                 │
│  ~/Library/LaunchAgents/                        │
│    com.shun.zenn-daily.plist.retired  ← ファイル名変更済み │
│                                                 │
│  launchd 内部テーブル                             │
│    Label: com.shun.zenn-daily  ← まだ生きている   │
│      → 7:30 / 10:00 / 14:00 / 19:00 に発火      │
└─────────────────────────────────────────────────┘

            ↓ bootout を実行する

┌─────────────────────────────────────────────────┐
│  正しい退役後の状態(OK)                           │
│                                                 │
│  ~/Library/LaunchAgents/                        │
│    com.shun.zenn-daily.plist.retired            │
│                                                 │
│  launchd 内部テーブル                             │
│    Label: com.shun.zenn-daily  → エントリなし     │
│      → 発火しない                                 │
└─────────────────────────────────────────────────┘

ステップ1:bootout でアンロードする

launchctl bootout gui/$UID/com.shun.zenn-daily

gui/$UID はログインセッションのドメインです。$UID はそのまま使えば現在ユーザーのUIDが展開されます。No such process というエラーが返っても無視して続行してください。これはすでにアンロードされているジョブに対して bootout を呼んだだけで、異常ではありません。

重要な落とし穴があります。走行中のジョブを bootout した直後に即座に bootstrap(再ロード)しようとすると、5: Input/output error で失敗することがあります。これはサービスの消滅がカーネル側で完了する前に次のコマンドが走るレースコンディションです。私の環境では com.lily.threadspilot.engage がこれで一度消えました。再ロードが必要な場面では、最大15回・1秒間隔でリトライするスクリプトを挟むか、sleep 2 を入れてから bootstrap してください。

今回の目的は「止める」だけなので、bootstrap は不要です。

ステップ2:launchctl list で消えたか確認する

launchctl list | grep zenn-daily

出力が空であれば、launchdのテーブルからエントリが消えています。ジョブは発火しません。

ただし、launchctl list に出ないことは「退役完了」の十分条件ではありません。逆の問題がより重要です。launchctl list にジョブが出ていても、それは「登録されている」だけであって「最新のplistの定義でロードされている」とは限りません。

44本のジョブにクォータガードラッパを一括で差し込んだときに、この罠を踏みました。launchctl list では全件が登録済みに見えるのに、実際に launchctl print で中身を確認すると旧い ProgramArguments のままになっているジョブが複数ありました。launchctl print が返す live 定義だけが信頼できる状態の証拠です。

退役確認では list で消えていれば十分ですが、再ロード後の確認では必ず print を使う習慣にしてください。

# 退役確認用(消えていればOK)
launchctl list | grep com.shun.zenn-daily

# 再ロード後の定義確認用(新しいProgramArgumentsが反映されているか)
launchctl print gui/$UID/com.shun.zenn-daily

ステップ3:ファイルを整理する(オプション)

bootout が通れば、ファイルが LaunchAgents/ に残っていてもジョブは発火しません。ただし次のログイン時・再起動時に .plist.retired のファイルはlaunchdの監視対象外なので、自動でロードされることもありません。

このままでも運用上の問題はありませんが、フリートの見通しが悪くなります。私は退役済みのplistをバックアップディレクトリへ移動する方針にしています。

mkdir -p ~/content/launchagents-backup-$(date +%Y%m%d)
mv ~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired \
   ~/content/launchagents-backup-$(date +%Y%m%d)/

移動後は ls ~/Library/LaunchAgents/ | grep zenn で残骸がないことを確認します。

まとめると、退役の完全なコマンド列

# 1. アンロード(No such process は無視してよい)
launchctl bootout gui/$UID/com.shun.zenn-daily

# 2. テーブルから消えたか確認
launchctl list | grep zenn-daily
# 出力が空であればOK

# 3. plistをバックアップへ移動
BACKUP=~/content/launchagents-backup-$(date +%Y%m%d)
mkdir -p "$BACKUP"
mv ~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired "$BACKUP/"

# 4. 移動後の残骸確認
ls ~/Library/LaunchAgents/ | grep zenn

この4ステップが「.plist.retired にしたとき本当は何をやるべきだったか」の答えです。

なぜ .retired リネームが習慣として広まっているか

macOSの慣習として .plist.disabled や .plist.retired というリネームが使われる背景には、「後で復活させやすい」という意図があります。完全削除すると元のplistを再現するコストが高い。バックアップとして .retired という名前で同じ場所に置いておく発想は合理的です。

問題は、リネームが「退役の意思表示」であって「退役の実行」ではないことが、launchdの動作を知らないと伝わりにくい点です。Git管理下でplistを管理していれば git log で変更履歴を追えるのでバックアップ目的のリネームは不要ですが、~/Library/LaunchAgents/ をGit管理している人はほとんどいません。だからリネームという手法が残り続けます。

手順として正確に言えば、リネームはバックアップ。停止は bootout。この2つは別の操作です。

実装の詳細

ProgramArguments に4要素だけ足す

ジョブを「止める」だけでなく「ラッパ経由に切り替える」作業でも、bootout → bootstrap のサイクルは同じです。2026年8月、160本超のジョブフリートにクォータガードを一括挿入したとき、変更はこれだけでした。

変更前(com.shun.zenn-daily の実構造):

<key>ProgramArguments</key>
<array>
  <string>/bin/bash</string>
  <string>~/.discord/run-and-notify.sh</string>
  <string>zenn</string>
  <string>Zenn日次公開</string>
  <string>/bin/bash</string>
  <string>~/.claude/scripts/zenn-daily-publish.sh</string>
  <string>apply</string>
</array>

変更後(先頭に4要素のみ追加):

<key>ProgramArguments</key>
<array>
  <string>~/.claude/scripts/quota-guard.sh</string>
  <string>--job</string>
  <string>com.shun.zenn-daily</string>
  <string>--</string>
  <string>/bin/bash</string>
  <string>~/.discord/run-and-notify.sh</string>
  <string>zenn</string>
  <string>Zenn日次公開</string>
  <string>/bin/bash</string>
  <string>~/.claude/scripts/zenn-daily-publish.sh</string>
  <string>apply</string>
</array>

触るのは ProgramArguments だけ。 StartCalendarInterval(7:30・10:00・14:00・19:00の4枠)・ProcessType(Background)・RunAtLoad(false)・StandardOutPath・StandardErrorPath は1キーも変えません。スケジュールと出力先を同時に変えると、「ラッパの問題なのかタイミングの問題なのか」が切り分けられなくなるからです。問題が起きたときに変数を一つに絞るための設計です。

セマンティック差分チェックをPythonで書く理由

XMLをテキストdiffにかけると、属性順や空白の差だけで大量の行差分が出ます。「変えたくないキーが変わっていないか」を目で確認するのは44件規模で破綻します。正しいのはplistlib(Python標準ライブラリ)でキー単位の比較をすることです。

import plistlib
from pathlib import Path

def verify_minimal_change(original: Path, modified: Path) -> bool:
    orig = plistlib.loads(original.read_bytes())
    mod  = plistlib.loads(modified.read_bytes())

    # ProgramArguments以外のキーが変わっていないか
    for key in orig:
        if key == "ProgramArguments":
            continue
        assert orig[key] == mod.get(key), f"意図しないキー変更: {key}"

    # 既存コマンドのsuffixが保持されているか
    orig_args = orig["ProgramArguments"]
    new_args  = mod["ProgramArguments"]
    assert new_args[-len(orig_args):] == orig_args, \
        "既存ProgramArgumentsのsuffixが変わっている"

    return True

この検証を全対象ぶんかけて「変わったのは ProgramArguments だけ・既存コマンドのsuffixは完全一致」を機械的に確認してから、はじめて plutil -lint → bootout → bootstrap に進みます。

bootout → bootstrap のリトライパターン

退役(止めるだけ)は bootout で完結しますが、再ロードが必要な場面では次の形を使います。

reload_job() {
  local label="$1"
  local plist_path="$2"
  local domain="gui/$UID"

  # bootout(No such process は正常・無視してよい)
  launchctl bootout "${domain}/${label}" 2>/dev/null || true

  # bootstrap は最大15回・1秒間隔でリトライ
  # カーネル側のサービス消滅完了を待つため
  local max_retry=15
  for i in $(seq 1 $max_retry); do
    if launchctl bootstrap "$domain" "$plist_path" 2>/dev/null; then
      break
    fi
    if [ "$i" -eq "$max_retry" ]; then
      echo "RELOAD FAILED: $label" >&2
      return 1
    fi
    sleep 1
  done

  # live定義で検証(listではなくprintを使う)
  if launchctl print "${domain}/${label}" | grep -q "quota-guard"; then
    echo "RELOAD OK: $label"
  else
    echo "RELOAD WARN: live定義にラッパが見えない $label" >&2
    return 1
  fi
}

このパターンで肝になるのは非対称性です。bootout のエラーは無視してよく、bootstrap のエラーは無視してはいけません。 bootout は冪等(すでにない状態に向かう操作)ですが、bootstrap が失敗した場合はジョブがアンロードのまま消えます。ここを || true で飲んでいると、次の詰まりに落ちます。

launchctl print が唯一の実態確認手段

# 退役確認(消えていればOK)
launchctl list | grep com.shun.zenn-daily
# → 出力が空であればアンロード済み

# 再ロード後の定義確認(新しいProgramArgumentsが反映されているか)
launchctl print gui/$UID/com.shun.zenn-daily | grep "program ="
# → program = /path/to/quota-guard.sh  であればラッパが効いている

launchctl print の出力には program = という行があり、実際に起動されるバイナリのパスが書かれています。ここが旧い claude バイナリのままなら、「ファイルを書き換えたが再ロードが効いていない」状態です。ログに quota-guard の文字が出ていても、これを確認しないと「通った気になっているだけ」で終わります。


私が詰まった話

160本超のジョブを管理していると、「止めたはずが動いている」「変えたはずが反映されていない」「動いているはずが成果がゼロ」の3パターンが重なります。実際に詰まったケースを4つ、症状→原因→直し方の順で書きます。

①「退役済み」がフォルダ移動の翌日に静かに死んでいた

症状: ai-portraitsシリーズのblur・partial・swimの3ジョブが、作業フォルダを移した翌朝から一斉に 0/2(対象件数ゼロ)で落ち続けました。ログには exit 1 が積まれているだけです。launchd側からは「ジョブが走った(ただし失敗した)」にすぎず、アンロードはされません。

原因: .plist.retired へのリネームだけで止めた気になっていて、launchctl bootout をかけていませんでした。長期稼働中のMacでリネームした翌日の朝、セッションをまたいでいないためジョブはまだ生きていました。前日に作業フォルダを移動済みだったため、3ジョブとも「フォルダ不在」で即 exit 1 になります。

直し方: フォルダを移動する前に bootout を通す、という順番を守るだけです。

# フォルダを移動する前に必ずやる
for label in com.lily.ai-portraits-blur com.lily.ai-portraits-partial com.lily.ai-portraits-swim; do
  launchctl bootout gui/$UID/$label 2>/dev/null || true
done

# テーブルから消えたか全件確認
launchctl list | grep ai-portraits
# 出力が空になってからフォルダを移動する

# これが安全な順序
mv ~/dev/ai-portraits/work ~/dev/ai-portraits/work-archived

順番が逆でした。 退役の意思を示す前に、インフラの前提条件を外してしまっていました。

②bootstrap が 5: I/O error でジョブを消した

症状: com.lily.threadspilot.engage が再配線後に launchctl list から消えていました。スクリプトのログにも発火痕がありません。アンロードのまま消えた状態です。

原因: 走行中のジョブを bootout した直後に即座に bootstrap をかけたことによるレースコンディションです。カーネル側でサービスの消滅が完了していない状態で bootstrap が走り、5: Input/output error が返ります。再ロードスクリプトがこのエラーを 2>/dev/null で握り潰し、成功したかのように次のジョブへ進んでいました。結果として threadspilot.engage だけアンロードのまま登録が消えた状態になりました。

直し方: 上で示したリトライパターンを入れます。最大15回・1秒間隔のリトライと、launchctl print による live 定義の確認をセットにする。「bootout が成功して bootstrap も成功した」と言えるのは、launchctl print でprogramのパスを実際に確認できたときだけです。エラーを捨てるのは bootout 側だけにします。

③launchctl list を信じて44ジョブ全件「完了」と出力した

症状: クォータガードを44ジョブへ一括挿入した翌日、一部のジョブが相変わらず素の claude バイナリを直接呼んでいて、ガードを素通りしていることが発覚しました。クォータの使用量が想定を超えており、調査して初めて気づきました。

原因: 再ロードスクリプトが launchctl list | grep $label の出力で「登録済み」を確認してから RELOAD OK を出していました。しかし list に出ることは「登録されている」ことの証拠であって、「新しいplistの定義で動いている」ことの証拠ではありません。bootstrap が 5: I/O error で失敗したジョブが数件あり、古いセッションからの定義が残ったまま list に出ていました。

直し方: 確認コマンドを list から print に変えます。

# NG: listに出ることしか確認できない(旧い定義のままでも出る)
launchctl list | grep com.lily.some-job

# OK: live定義のprogramを確認する
launchctl print gui/$UID/com.lily.some-job \
  | grep "^  program =" \
  | grep -q "quota-guard" && echo "OK" || echo "NG: ラッパが入っていない"

全44ジョブに print をかけてラッパが program = に出ているかを機械確認するスクリプトを書き直し、旧い定義が残っていた6件を再 bootout → bootstrap しました。

④環境変数 CLAUDE_BIN をスクリプトが無条件で上書きしていた

症状: ig-autoreply-ig-2 のログに「DM判定失敗: claude exit 1」が頻発していました。クォータガードが CLAUDE_BIN=~/.claude/scripts/quota-guard.sh を子プロセスへ渡しているはずなのに、実際に呼ばれているのは素の ~/.local/bin/claude でした。

原因: ~/dev/social-autolike/scripts/run-ig-autoreply.sh を開くと冒頭にこう書いてありました。

export CLAUDE_BIN=~/.local/bin/claude

無条件に上書きしています。 launchdのplistでラッパのパスを渡しても、子シェルがこの行を実行した瞬間にガードのパスは消えます。同じパターンが run-comment-reply.sh・run-rewrite.sh・run-editor.sh にも存在しました。合計4本です。配線はplistだけで終わらない。経路上の誰かが無条件で上書きしていないかを確認する必要があります。

直し方: 全4本を :- 形式に変えます。

# 変更前(無条件上書き・ガードを殺す)
export CLAUDE_BIN=~/.local/bin/claude

# 変更後(既存値がある場合は尊重する)
export CLAUDE_BIN="${CLAUDE_BIN:-~/.local/bin/claude}"

修正後、テストに「この代入行が :- 形式で書かれているか」を完全一致でアサートするケースを追加しました。「文字列 CLAUDE_BIN が存在するか」だけを確認するテストでは、コメントアウトされた旧い行があっても通ってしまいます。実際に既存テストがそうなっていて、一度見落としました。grep で存在を確認するのではなく、代入行そのものの形式を検証する必要があります。


静かな失敗が積み重なる構造

4つの詰まりに共通するのは「エラーが見えない」という構造です。

  • .retired にして bootout を忘れる → ジョブは動き続けるが launchd のエラーログには何も出ない
  • bootstrap が 5: I/O error で失敗する → スクリプトが 2>/dev/null で捨てていれば無音
  • launchctl list が旧い定義のジョブを「登録済み」として出す → 見た目は正常、中身は古い
  • 環境変数の上書き → ガードが通っているつもりで素通り。ジョブ自体は動くので exit code は正常

160本の規模では、「動いている」と「意図どおりに動いている」は別の状態です。 ジョブが走っているログを見て安心してしまうのは、確認の深度が足りていません。

この問題を定期的に検知するために、私は月に一度ローカルで次の棚卸しを走らせています。

# launchdに登録されている自分のジョブ一覧
launchctl list | grep 'com\.lily\|com\.shun' | awk '{print $3}' | sort > /tmp/loaded.txt

# LaunchAgentsにplistとして存在するジョブのLabel一覧
for f in ~/Library/LaunchAgents/com.{lily,shun}.*.plist; do
  defaults read "$f" Label 2>/dev/null
done | sort > /tmp/files.txt

# 差分を見る
echo "=== ファイルがないのにloaded ===" && comm -23 /tmp/loaded.txt /tmp/files.txt
echo "=== plistがあるのにunloaded ===" && comm -13 /tmp/loaded.txt /tmp/files.txt

「ファイルがないのに loaded」の行が出たら、幽霊ジョブです。かつての .retired リネームで止めたつもりになっていたジョブが、セッションをまたがずに残り続けているケースがここに出ます。

com.shun.zenn-daily.plist.retired も、この棚卸しを走らせれば一発で見えます。ファイルの拡張子が .plist でないため /tmp/files.txt には出ませんが、bootout をかけていなければ /tmp/loaded.txt には出る。この差が「幽霊ジョブ」の存在を示します。

ファイルの名前を変えることと、launchdのテーブルからエントリを消すことは、別の操作です。その乖離を可視化する仕組みを持っていれば、退役の作業漏れは翌月の棚卸しで必ず捕まえられます。

つまずきポイント

詰まった4つのケースは前段で書きました。ここでは「やってしまいやすいが声に出されにくい」ポイントを網羅的に並べます。160本超のフリートを回す中で、実際に踏んだものだけを書いています。

  • plutil -lint を省いてから bootout → bootstrap した。 plistに構文エラーが入っていると、bootstrap は成功したように見えて launchctl list にも出るのにジョブが一度も発火しない。macOSは壊れたplistを黙って無視します。launchctl print でも state = waiting のまま時刻が来ても動かない。原因がわからず1時間調査して、最後に plutil -lint com.lily.something.plist を走らせてはじめてシンタックスエラーが見えた、というケースを2回踏みました。ファイルを触ったら必ず plutil -lint → bootout → bootstrap → launchctl print の順。 plutil -lint だけはどの工程よりも前です。

  • plist内のパスにチルダ(~)を書いた。 launchdはシェルではないので、~ を /Users/yourname に展開しません。ジョブは発火するのに exit 127: command not found で終わり続ける原因の大半はこれです。~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired の変更前の ProgramArguments を見ると、実際には ~/.discord/run-and-notify.sh と書かれていません。実ファイルを cat すると絶対パスが入っています。plist内は全て絶対パス。 記事や説明文ではチルダ表記を使い、実ファイルでは絶対パスを入れる、という使い分けを徹底してください。

  • ProcessType・Nice・LowPriorityIO の3キーを新規plistに入れ忘れた。 この3キーはmacOSがジョブの優先度を下げるための設定です。

    <key>ProcessType</key>
    <string>Background</string>
    <key>Nice</key>
    <integer>10</integer>
    <key>LowPriorityIO</key>
    <true/>
    

    これを省くと、バックグラウンドで回っているはずのジョブがフォアグラウンドと同等の優先度で実行されます。160本規模だと、昼間のピーク時にMacが明らかに重くなります。2026年8月に23本のジョブへbrowser-slotを配線したとき、18本でこの3キーが欠落していることが判明しました。新規plistを書くときは必ずテンプレートに含めてください。

  • StartCalendarInterval を単一dictにしたまま、Macがスリープする時間帯に発火時刻を置いた。 macOSは StartCalendarInterval の発火タイミングにMacがスリープしていると、その発火ごと消します。 cronは起動時に溜まった実行をまとめて処理しますが、launchdはそうではありません。1日1回のジョブを深夜に置いていた場合、画面をつけていない日は一度も実行されないことがあります。単一時刻のジョブは複数枠のdictArrayに変える。 com.shun.zenn-daily が7:30・10:00・14:00・19:00の4枠を持っているのは、どれか1枠がスリープで飛んでも残りで拾えるようにするためです。

  • 複数枠に変えたときに冪等ガードを入れ忘れ、1日4回投稿した。 複数枠は「どれかが通れば十分」という設計です。1枠目が成功したら残りはスキップする仕組み(当日のステートファイルを確認する等)を同時に入れないと、4枠全部が通って4回実行されます。StartCalendarInterval を単一から複数に変えることと、冪等ガードを追加することはセットです。一方だけやると実害になります。

  • スケジュールの間隔設計を「1ジョブ単体の所要時間」で決めた。 tiktok-autopost の実例が典型です。コメントジョブを15分間隔で設定していましたが、排他ロック(tiktok-avd.lock)を共有している別アカウントのジョブも同じロックを使います。1実行あたり約10分かかるジョブを、2アカウント合計で15分間隔(つまり7分半でもう1つが来る)で打ち続けると、ロックが常時埋まります。実測(2026年7月21日〜8月14日)ではrenappiが617回起動・exit 1が255回、bokuwalilyが784回起動・exit 1が458回——全部成果ゼロのまま、フォローレーンが3週間ログを更新していなかった。間隔は「排他資源を共有する全ジョブの合計占有率」で計算する。 個別ジョブの都合で設計したスケジュールは、規模が増えると必ず他のレーンを潰します。

  • 成果ゼロのジョブの頻度を下げる前に、存在意義を確認しなかった。 上のtiktokケースでは、commentActions=0 のアカウント設定のまま600回以上コメントジョブを回していました。ジョブが走っていること(exit codeが0であること)と、ジョブが意図した成果を出していることは別です。頻度の調整より先に「このジョブが今日1件でも成果を出しているか」を確認してください。

  • EnvironmentVariables を設定せず PATH が不在だった。 launchdが起動するジョブのデフォルトPATHは /usr/bin:/bin:/usr/sbin:/sbin 程度です。Homebrewのバイナリ(/opt/homebrew/bin)やnvmのNode(~/.nvm/versions/node/...)は入っていません。command not found が出るのにターミナルから同じコマンドを手動実行すると通る、という場合はこれです。plistに必ず追加します:

    <key>EnvironmentVariables</key>
    <dict>
      <key>PATH</key>
      <string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    </dict>
    
  • launchctl bootout のドメインを system/ にしてしまった。 ユーザーレベルのLaunchAgentsは gui/$UID ドメインです。system/ はLaunchDaemonsのドメインで、rootが管理します。system/com.shun.zenn-daily に対して bootout をかけても No such process で終わり、ジョブは止まりません。~/Library/LaunchAgents/ に置いたplistは必ず gui/$UID を使います。

  • ラッパスクリプトへの --log 引数を相対パスで渡した。 run-with-retry.sh のようなラッパは内部で cd することがあります。相対パスを --log に渡すと、ラッパがディレクトリを変えた後に相対パスを展開するため、ログが想定外の場所に書かれるか、ファイルが作成されずに捨てられます。--log は必ず絶対パスで渡します。

  • 新規ジョブを RunAtLoad: true にしたまま StartCalendarInterval も設定した。 RunAtLoad: true はplistをロードした瞬間(bootstrap の直後)にも1回実行します。StartCalendarInterval と組み合わせると、ロード直後に1回 + 設定時刻ごとに1回という動きになります。デバッグ目的で RunAtLoad を一時的に true にしてそのまま残した、というケースがフリートに混入していました。本番plistの RunAtLoad は原則 false。


ベストプラクティス

160本のフリートを維持してきた経験から、「これを守っていれば防げた事故」だけをまとめます。

1. plistを触ったら必ず plutil -lint → bootout → bootstrap → launchctl print の順を守る。
この順序を崩すと何が原因で動かないかが切り分けられなくなります。plutil -lint だけが唯一「ジョブを止める前」に実行できる検証です。ここを省くと壊れたplistが静かに浮き上がります。

2. bootout のエラーは無視してよく、bootstrap のエラーは無視してはいけない。
bootout は冪等です。未ロードのジョブに対して実行しても No such process が返るだけで異常ではありません。一方、bootstrap が 5: Input/output error で失敗した場合、ジョブはアンロードのまま消えます。再ロードスクリプトに 2>/dev/null を入れていいのは bootout 側だけです。

3. 確認コマンドは launchctl list ではなく launchctl print を使う。

# 退役確認(消えていればOK)
launchctl list | grep com.shun.zenn-daily

# 再ロード後の定義確認(新しい定義で動いているか)
launchctl print gui/$UID/com.shun.zenn-daily | grep "program ="

launchctl list に出ることは「登録されている」証拠であって、「最新のplistの定義でロードされている」証拠ではありません。定義の確認には launchctl print を使います。

4. ProcessType=Background / Nice=10 / LowPriorityIO=true の3キーをテンプレートに組み込む。
新規plistを書くたびに手で追加するのは漏れが出ます。自分の標準テンプレートファイルにこの3キーを入れておき、コピーして使う形にします。

5. StartCalendarInterval は単一dictでなく複数枠のdictArrayにする。
スリープによる発火消失への保険です。3枠あれば、1枠がスリープで飛んでも残り2枠のどちらかが通ります。複数枠への変更と同時に、冪等ガード(当日成功済みならスキップ)を必ず入れます。

6. スケジュール設計は排他資源の全占有率で計算する。
同じロックを使うジョブが複数あるなら、フリート全体の同時実行数と1実行あたりの所要時間から最大頻度を先に計算します。11レーンを120分に収める場合、隣接間隔の最大値は 120/11 ≒ 10.9分 です。「全区間11分以上」と「厳密に2時間おき」は数学的に両立しません。設計で妥協した場合、どちらを崩したかを明示してコメントに残します。

7. plist内は全て絶対パスで書く。
~ はlaunchdがシェルとして動くわけではないので展開されません。記事や説明文ではチルダ表記を使っても構いませんが、実ファイルは絶対パスだけにします。EnvironmentVariables でPATHを設定するときも同様です。

8. 環境変数の代入は :- 形式で書く。

# NG: ラッパが渡した値を上書きする
export CLAUDE_BIN=~/.local/bin/claude

# OK: 既存値がある場合は尊重する
export CLAUDE_BIN="${CLAUDE_BIN:-~/.local/bin/claude}"

ラッパを配線しても、経路上のシェルスクリプトが無条件代入していればガードは届きません。配線したあと grep -r 'CLAUDE_BIN' ~/dev ~/.claude/scripts で全走査して、:- 形式になっていない代入を潰します。

9. テストの LABELS 定数に件数アサートを入れる。
「このラッパが入っているはずのジョブ一覧」をテストのLABELS定数で管理し、assert len(labels) == 44 のように件数ごとアサートします。ジョブを追加したときに件数テストが落ちて「新しいジョブにラッパが入っていない」ことが機械的に検知できます。ラベルの存在だけを確認するテストは、コメントアウトされた古い定義でも通ってしまいます。

10. バックアップは日付ディレクトリへ cmp で全件一致を確認してから移動する。

BACKUP=~/content/launchagents-backup-$(date +%Y%m%d)
mkdir -p "$BACKUP"
cp ~/Library/LaunchAgents/com.shun.zenn-daily.plist "$BACKUP/"
cmp ~/Library/LaunchAgents/com.shun.zenn-daily.plist "$BACKUP/com.shun.zenn-daily.plist" \
  && echo "backup OK" || echo "backup FAILED"

cp はエラーを出さずにバイト数が違うファイルを作ることがまれにあります(iCloud書き込み遅延など)。cmp で確認してから元ファイルを触る習慣にしています。

11. 月次棚卸しスクリプトを定期実行して「幽霊ジョブ」を炙り出す。

launchctl list | grep 'com\.lily\|com\.shun' \
  | awk '{print $3}' | sort > /tmp/loaded.txt

for f in ~/Library/LaunchAgents/com.{lily,shun}.*.plist; do
  defaults read "$f" Label 2>/dev/null
done | sort > /tmp/files.txt

echo "=== ファイルなし・ロード済み(幽霊)===" && comm -23 /tmp/loaded.txt /tmp/files.txt
echo "=== plistあり・アンロード済み ===" && comm -13 /tmp/loaded.txt /tmp/files.txt

「ファイルなし・ロード済み」の行が幽霊ジョブです。.retired リネームで止めたつもりのジョブがセッションをまたいで残っている場合、ここに必ず出ます。月1回走らせれば、作業漏れは翌月の棚卸しで必ず捕まります。

12. 成果がゼロのジョブは頻度を下げる前に存在意義を確認する。
ジョブが走っている(exit 0)ことと、成果が出ている(投稿・フォロー・返信が実際に発生した)ことは別です。ログの exit 0 だけを見て「動いている」と判断した先で、3週間全件ゼロだったというケースが実際にありました。頻度の調整はその後の話で、まず「このジョブが今日1件でも実物の成果を出しているか」を実ログで確認します。


まとめ

com.shun.zenn-daily.plist.retired は今もファイルシステム上に存在します。ファイル名は変わりましたが、内部の Label は com.shun.zenn-daily のままです。launchctl bootout gui/$UID/com.shun.zenn-daily をかけていなければ、今日の7:30・10:00・14:00・19:00にもこのジョブは発火します。

退役の「意思表示」と「実行」は別の操作です。

  • リネームは意思表示。launchdのテーブルを変えません。
  • bootout が実行。これを通してはじめてテーブルからエントリが消えます。
  • launchctl print が検証。list でなく print で live 定義を確認して初めて「退役完了」です。

自動化の規模が増えると、Macを再起動する頻度は下がります。再起動しないセッションが続くほど、リネームだけで止めたつもりのジョブが長く生き残ります。160本を超えたフリートでは「止めたはずが動いている」「変えたはずが反映されていない」「動いているはずが成果がゼロ」の3パターンが同時に起きます。

月次棚卸しスクリプト1本と、bootout → list → print の確認習慣だけで、このクラスの障害はほぼ防げます。コマンドは3行です。手順より「知っているかどうか」が全てです。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


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

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

0
0
1

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?