定期ジョブが止まっていないかを、ジョブ自身のログで確かめる方法があります。ただ、起動前に失敗するジョブでは、そのログが当てになりませんでした。

本記事で「起動前の失敗」と呼ぶのは、launchd のジョブが始まらず、ジョブ自身がログを書く前に終わってしまう状態のことです。ジョブの中身が失敗したのではなく、ジョブが始まっていません。今回の例では、launchctl print に job state = spawn failed、last exit code = 78: EX_CONFIG と出ていました。

私の個人サイトの自動 deploy は、plist が指す旧パスの実行ファイルが無い状態で起動前に失敗しました。調査と plist の差し替えは Claude が実行し、私は「ログを確認して」「直して」と頼んだだけです。ここから、この構成の deploy では、死活を成果物(gh-pages の最新コミット日時)で見るのが向いていると考えています。

何が起きたか:旧パスを指す plist と、9/26 以降ログに残らなかった run

個人サイトの deploy は、launchd のジョブが毎朝 07:10 に走らせています。ビルド結果は gh-pages ブランチへ force push します。plist の ProgramArguments は、mise の実行ファイルと run deploy という引数の並びです。ProgramArguments の先頭は、Program キーが無いときに実行ファイルとして使われます1。ジョブの出力は、plist の StandardOutPath と StandardErrorPath に指定したログファイルへ書かれます。本記事で「ジョブ自身のログ」と呼ぶのは、このファイルです。

起きたことを時系列にすると、次のとおりです(時刻は JST)。

日時起きたこと
9/25 07:11自動 deploy でビルドは成功したが、git push の途中で Connection reset by peer。ログには ERROR として残った(別原因の失敗)
9/26 03:38~/.local/bin/mise の更新日時(plist が指す /opt/homebrew/bin/mise は、調査時点で存在しない)
9/26〜9/29 の 07:10 の4回の枠ジョブ自身のログに run が残っていない
9/27 09:44gh-pages 側の Pages ビルドが走った(私が手で deploy した)
9/29 09:49Claude にログの確認を頼んだ。続く調査で spawn failed を確認
9/29 09:52Claude が plist のパス2点を差し替えて読み直した
9/30 07:19修理後、launchd 経由の最初の deploy が通った(Finished in 60.29s)

9/26 03:38 は新しいパスの実行ファイルの更新日時です。旧パスの実行ファイルがいつ、どう無くなったのかは分かりません。

調べたのは Claude です。plist の中身を読んだあと、launchctl list で終了コード 78 を見つけ、launchctl print でジョブの状態を確認し、ログファイルの最終更新を調べ、plist が指すパスが実在するかを確かめました。

launchctl print の確認は、次のコマンドです。ジョブのラベルは <label> に置き換えています。

launchctl print gui/$(id -u)/<label>

出力のうち、関係する行だけを抜粋します(<label> 以外は逐語です)。

	state = spawn scheduled
	program = /opt/homebrew/bin/mise
	runs = 4
	last exit code = 78: EX_CONFIG
	job state = spawn failed

program が旧パスの /opt/homebrew/bin/mise を指しています。そして job state = spawn failed、last exit code = 78: EX_CONFIG と出ています。見るのは job state と last exit code の2行です。runs = 4 は 9/26〜9/29 の4回の枠と数が一致します。ただし、一致するのは数だけです。ここで書くのは、今回の環境で観測した状態です(launchctl print は Claude に実行させました)。spawn failed や 78 が launchd の仕様として保証された挙動なのかどうかは、分かりません。launchctl print の出力について、man ページ自身が、API ではなくリリースごとに予告なく変わり得るものだと注記しています2。

exit 78 は、sysexits では EX_CONFIG、つまり設定の誤りを表す慣習値です3。launchd がこの値を返す理由は分かりません。今回は spawn failed と同時に観測した、という範囲にとどめます。

修理は、plist の2点の差し替えでした。差し替えた行だけを抜粋します(<user> は置き換えています)。

ファイル: ~/Library/LaunchAgents/<label>.plist(Before)

    <key>PATH</key>
    <string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    ...
    <string>/opt/homebrew/bin/mise</string>

ファイル: ~/Library/LaunchAgents/<label>.plist(After)

    <key>PATH</key>
    <string>/Users/<user>/.local/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin</string>
    ...
    <string>/Users/<user>/.local/bin/mise</string>

EnvironmentVariables の PATH の先頭に ~/.local/bin を足し、ProgramArguments の先頭を新しいパスに変えています。Claude は plutil -lint で plist を検証し、launchctl bootout と bootstrap で読み直しました。読み直した直後は last exit code = (never exited) で、spawn failed は消えていました。そのあと、9/30 07:19 の launchd 経由の run が通っています。修理のあとに run が通った、という事実を並べるところまでが、今回確認できた範囲です。

launchd が PATH をほぼ空で渡す問題は、事前に PATH の挙動を確かめる記事で扱いました。今回はそれとは別に、稼働後に止まったときの話です。

なぜ死活はジョブのログではなく成果物の更新日時で見るのか

ジョブ自身のログは、ジョブが起動できて初めて書かれます。起動前に失敗すると、「エラーが出る」のではなく「新しい行が増えない」状態になります。9/25 の失敗は ERROR としてログに残っていました。ログから見えなくなったのは、9/26 以降の run です。調査した時点で、ログに残る最後の run は 9/25 の失敗でした。

ここでいう「ログが空」は、起動前に失敗したジョブについて新しい行が増えないことです。今回のログファイルは、9/25 までの内容がそのまま残っていました。

ログに新しい行が増えないことは、何も起きていないことの証拠になりません。ログに出るのは、起動できたジョブが書いた分だけだからです。

そこで私は、原因側の痕跡ではなく、結果側で死活を見るほうがよいと考えています。原因は多様で、今回だけでも、push の切断と旧パスという別原因が続きました。結果は「次の deploy の成果物ができたか」の1つにまとまります。どちらの原因でも、自動 deploy の成果物は更新されませんでした。deploy が止まる失敗は、後から直せる失敗です。予防を積み上げるより、結果を自動で見る検収で拾うほうが合っていると考えます。これは、この構成での判断です。ただし、私の構成ではこの検収をまだ自動化していません(後述)。成果物で完了を確かめる考え方は、AI エージェントの完了報告を扱った記事でも書いています。

この構成で成果物の更新日時が使えるのは、deploy のスクリプトが毎回 git init → git commit → git push --force origin HEAD:gh-pages を実行し、root-commit を force push するからです。gh-pages の履歴は最新の1コミットだけになり、最新コミットの日時がそのまま最後の deploy の時刻になります。

gh-pages の最新コミットの日時は、GitHub の API で取れます。sha パラメータにはブランチ名を渡せます4。

gh api 'repos/<owner>/<repo>/commits?sha=gh-pages&per_page=12' \
  --jq '.[]|.commit.committer.date+" "+.sha[0:8]+" "+(.commit.message|split("\n")[0])'

結果は、<UTC日時> <sha8> Deploy: <UTC日時> の形の1行だけです。per_page=12 を指定しても、返るのは1行でした。履歴が1コミットしか無いからです。

停止は、コミット履歴ではなく Pages のビルド履歴に、穴として見えました。自動 deploy が走るたびに、Pages のビルドも走ります。次のコマンドで一覧を出すと、通常は 07:10〜07:24 ごろ(UTC の 22:10〜22:24)に並ぶ run が、9/23 22:22 UTC(JST 9/24 07:22)の次は 9/27 00:44 UTC(JST 09:44)でした。

gh run list -R <owner>/<repo> --limit 10

出力の抜粋です(リポジトリ名は置き換えています)。

completed	success	pages build and deployment	pages-build-deployment	gh-pages	dynamic	36283412822	1m5s	2026-09-27T00:44:34Z
completed	success	pages build and deployment	pages-build-deployment	gh-pages	dynamic	35927959392	1m42s	2026-09-23T22:22:17Z
completed	success	pages build and deployment	pages-build-deployment	gh-pages	dynamic	35791390288	1m2s	2026-09-22T22:15:38Z

9/23 までは 07:10〜07:24 ごろの並びで、その次が 9/27 の 09:44 です。この穴には、9/25 の push の切断と、9/26 以降の起動前の失敗の両方が入っています。原因は別でも、結果側では同じ1つの穴として見えます。履歴が1コミットだけなので、gh api …/commits では、この穴を再現できません。Pages のビルド履歴に穴があった、というところまでが、書ける事実です。

成果物が古いとき、次に見るのは launchctl print の last exit code です。今回の特定も、plist → launchctl list → launchctl print → パスの実在確認の順でした。これは運用していた手順ではなく、今回 Claude が辿った順序です。

起動前の失敗で何が見えるかを、見る場所ごとに整理します。起動前に失敗したジョブを見つける手段として、今回の構成では成果物の更新日時が向いていました。

見る場所起動前の失敗で何が見えるか前提
ジョブ自身のログ新しい行が増えない(見えるのは「無いこと」だけ)ジョブが起動できて初めて書かれる
launchctl printjob state = spawn failed、last exit code = 78 が出る(今回の観測)ジョブのラベルを知っている必要がある。出力は API ではない
成果物の更新日時(gh-pages の最新コミット)最後の deploy の時刻が止まって見える(この構成の場合)リポジトリ名があればよい。毎回成果物が変わる構成であること。手動 deploy でも更新される

「前提」の列は私の整理です。次節で、この見方が成り立たない場面を書きます。

この見方の限界と、想定される反論

  • 成果物が毎回変わる構成でしか成り立ちません。 この deploy は毎回 root-commit を force push するので、成果物の日時が必ず動きます。変更が無い日は更新されないジョブには、この見方は使えません。

  • 手動 deploy が挟まると、成果物は更新されます。 見ているのは「自動 deploy の成果物が最新か」であって、「launchd のジョブが動いたか」ではありません。実際、停止中の 9/27 に私が手で deploy しています。成果物の更新日時だけでは、launchd が動いたのか手動だったのかは区別できません。launchd の状態は launchctl print の job state で別に見ます。

  • 反論1:ジョブの末尾から成功を通知する方式(ハートビート)で足りないか。 足りるかどうかは構成によります。私の見立てでは、両者は「成功の信号が来ないことを異常とみなす」点で同じ型で、違うのは信号をどこに置くかだけです。ジョブの内側(ジョブが最後に出す通知)か、外側(成果物そのもの)か。私が成果物を選ぶのは、ジョブに手を入れず、結果の側で見られるからです。

  • 反論2:絶対パスで書くのが公式の推奨ではないか。 mise の公式ドキュメントも、launchd のエージェントには明示的な実行ファイルのパスを使い、必要な環境変数を宣言するよう述べています5。今回の失敗は、plist が指す旧パスの実行ファイルが調査時点で存在しなかったことで起きました。絶対パスにはこの弱点があり、インストール方法が変わると壊れ得ます。これとは別に、mise は、2つのインストール方法が PATH にあると古いバイナリが使われ続けることがある、と注意しています6。予防の一案として、ツールの導入方法を変えたら plist の絶対パスを洗います。たとえば次のコマンドです(出力は載せません)。

    grep -n -E '/opt/homebrew|/usr/local|\.local/bin' ~/Library/LaunchAgents/*.plist
    

    洗い出しは、導入方法を変えたときに1回の手作業で済む補助です。見つけ方の主役は、結果側の検収(成果物の更新日時)に置きます。可逆な失敗は検収で拾い、予防は補助、という整理です。

  • 今回の停止は、この見方を運用して見つけたものではありません。 成果物の更新日時を自動で確かめる仕組みは、まだ作っていません。通知もありません。停止に気づいたきっかけは、公開されているはずの記事がサイトに出ていなかったことでした。記事が下書きのままなのかと最初は疑い、そうではなかったので、GitHub Actions の Pages のビルド履歴で deploy されたかを確かめました。気づいたのも結果の側からでしたが、手で見て気づいたものです。その後の調査の中で、Claude が gh-pages の履歴と Pages のビルド履歴を確認し、Pages のビルド履歴に穴があったことが分かりました。

  • Mac のスリープと起動時刻の幅を見込む必要があります。 launchd の StartCalendarInterval は、スリープ中に過ぎた枠を起床後に1回にまとめて実行します1。修理後の最初の run が 07:10 ではなく 07:19 だったのは、このためかもしれませんが、原因は断定できません。日次の死活判断では、更新日時が少し遅れる幅を許容する必要があります。

前提が変われば結論はどう変わるか

  • 成果物が毎回は変わらない運用なら、更新日時では区別できません。その場合は別の信号が要ります。ハートビート方式や、launchctl print の last exit code の定期確認が候補になりますが、私が試した構成ではありません。
  • インストール方法は変わり得ます。mise 公式は macOS では mise.run のインストーラを推奨し、Homebrew 版は代替と位置づけています6。導入方法が変われば、plist の絶対パスは古くなる可能性があります。
  • 複数のジョブや複数台になれば、個別の成果物の日時を見る方式は手間が増えます。その規模では、外部の監視の仕組みを検討する価値が出る、というのが私の見立てです。事例は1件なので、一般化はしません。

よくある質問(FAQ)

Q. launchd のジョブが失敗しているのに、ログにエラーが残らないのはなぜですか?

A. ジョブが起動前に失敗すると、ジョブ自身がログを書く前に終わるためです。ここでの「ログが増えない」は、ジョブ自身のログファイルに新しい行が追記されない状態を指します。今回の例では、launchctl print に job state = spawn failed と出ていて、plist が指す旧パスの実行ファイルは調査時点で存在しませんでした。ログファイルは空ではなく、9/25 までの内容が残っていて、新しい行が増えない状態でした。

Q. launchd のジョブが起動前に失敗したかどうかは、どう確かめますか?

A. launchctl print gui/$(id -u)/<label> で job state と last exit code を見ます。ただし出力は API ではなく、リリースごとに変わり得ると man ページが注記しています2。手がかりとして使い、仕様として頼らないでください。

Q. launchd のジョブの exit code 78 とは何ですか?

A. sysexits では EX_CONFIG、「設定の誤り」を表す慣習値です3。launchd がこの値を返す理由は分からず、今回は spawn failed と同時に出ていました。今回は、launchctl print の program が指すパスに実行ファイルがありませんでした。原因に着いたのは、そのパスが実在するかを確かめたときでした。

まとめ

  • 起動前に失敗する launchd のジョブは、自前のログを書けません。今回の例では、ログに残る最後の run は 9/25 の失敗(別原因)で、9/26 から 9/29 までの 07:10 の4回の枠は run がログに残りませんでした。
  • 「ログに新しい行が増えない」は、「何も起きていない」ことの証拠になりません。ログに出るのは、起動できたジョブが書いた分だけです。
  • 毎回 root-commit を force push する deploy では、gh-pages の最新コミット日時が最後の deploy の時刻と一致します。この構成では、死活を成果物の更新日時で見るのが向いています。ただし手動 deploy でも更新されるので、launchd が動いたかどうかは別に見ます。
  • 成果物が古いときは、次に launchctl print の job state と last exit code を見ます。今回はこの順で原因に辿り着きました。
  • ツールの導入方法を変えたら、plist の絶対パスを洗います。これは補助で、主役は結果側の検収です。
  • この見方が成り立つのは、成果物が毎回変わる構成に限ります。

定期ジョブの運用設計や、AI エージェントを含む自動化の見直しについて、ぜひ コンタクトフォーム からご相談ください。

Footnotes

  1. launchd.plist(5) Apple の man ページ(keith.github.io のミラー)、ページ末尾の日付 30 July, 2019。ProgramArguments の先頭が Program キーの無いときに実行ファイルとして使われることは Program キーの項、StartCalendarInterval がスリープ中に過ぎた枠を起床後に1回にまとめて実行することは同キーの項。アクセス日 2026-10-02。 ↩ ↩2

  2. launchctl(1) Apple の man ページ(keith.github.io のミラー)、ページ末尾の日付 1 October, 2014。print の出力に last exit status が含まれること、およびその出力が API ではなくリリースごとに予告なく変わり得るという注記(IMPORTANT)は、いずれも print サブコマンドの項。アクセス日 2026-10-02。 ↩ ↩2

  3. sysexits(3) Apple 版の man ページ(keith.github.io のミラー)、ページ末尾の日付 March 31, 1996、Mac OS X 12。EX_CONFIG (78) は “Something was found in an unconfigured or misconfigured state.”(未設定または設定不備の状態が見つかった)。launchd がこの値を使う理由は述べていない。アクセス日 2026-10-02。 ↩ ↩2

  4. REST API endpoints for commits GitHub Docs、ページに日付なし。List commits の節で、sha パラメータが “SHA or branch to start listing commits from”(ブランチ名を渡せる)であることと、レスポンスに commit.committer.date があること。アクセス日 2026-10-02。 ↩

  5. macOS LaunchAgents mise 公式ドキュメント、ページに日付なし。launchd のエージェントは launchd の環境を受け取り、対話シェルの activation は受けないこと、そのため明示的な実行ファイルのパスを使い必要な環境変数を宣言することを述べている。アクセス日 2026-10-02。 ↩

  6. Installing mise mise 公式ドキュメント、ページに日付なし。本体の既定インストール先が ~/.local/bin/mise(MISE_INSTALL_PATH の既定)であること、macOS では mise.run のインストーラが推奨で Homebrew 版は代替であること、2つのインストール方法が PATH にあると古いバイナリが使われ続けることがあること。アクセス日 2026-10-02。 ↩ ↩2