launchd で uv が見つからない原因を env -i で切り分ける
目次
macOS の launchd に Python バッチのジョブを登録している。uv run でプロジェクトのコマンドを叩く構成だ。この手の構成で最初に踏むのが、ターミナルでは通るコマンドが launchd では見つからないという壁になる。
原因は plist の書式ではなく、launchd がジョブに渡す PATH にある。以下は、その切り分けを実際に手元で回した記録。現在の私のジョブは対処済みの状態なので、本文は「対処後の構成が正しいことを確かめる」向きで進む。検証環境は次のとおり。
$ bash -c 'sw_vers -productVersion; uname -m; uv --version; python3 -V; echo "SHELL=$SHELL"'
15.7.3
arm64
uv 0.9.16 (a63e5b62e 2025-12-06)
Python 3.14.6
SHELL=/bin/zsh
# exit=0 (23 ms)
まず出力ログの有無を見る
plist は所定の場所にある。
$ bash -c 'ls -l ~/Library/LaunchAgents/ | grep -i ai-article || echo "(no matching plist)"'
-rw-r--r--@ 1 user staff 833 Aug 5 14:28 com.ai-article-maker.market-daily.plist
# exit=0 (11 ms)
一方、plist で指定した出力先を見に行くと、ファイルどころかディレクトリごと存在しない。
$ bash -c 'ls -l /Users/user/Desktop/dev/ai-article-maker/pipeline/data/logs/ 2>&1 | head -10'
ls: /Users/user/Desktop/dev/ai-article-maker/pipeline/data/logs/: No such file or directory
# exit=0 (7 ms)
ここで分かるのは「ジョブがまだ一度も出力を書いていない」ことまでで、起動して落ちたのか、そもそも起動していないのかまでは決まらない。ちなみにこのコマンドは ls の失敗が head に飲まれて終了コードが 0 になっている。判定に終了コードを使うつもりなら、パイプの組み方に注意がいる。
ログが空なら「起動はしたが処理が落ちた」、ログが無いなら「起動そのものが成立していない」寄り。どちらにせよ、次に見るのはジョブの登録状態になる。
launchctl list は当てにならなかった
状態を確認しようとして、最初に launchctl list を grep した。何も出てこない。
$ bash -c 'launchctl list | grep ai-article-maker'
# exit=1 (8 ms)
「登録できていないのか」と一度そう結論しかけたが、これは誤りだった。
launchctl list の出力そのものを数えたら、この実行文脈では1行も返っていない。
macOS が常時動かしている com.apple 系のジョブすら出てこない。
$ bash -c 'launchctl list | wc -l; echo "--- first 3 ---"; launchctl list | head -3; echo "--- grep com.apple count ---"; launchctl list | grep -c "com.apple" '
0
--- first 3 ---
--- grep com.apple count ---
0
# exit=1 (14 ms)
つまり「ジョブが無い」のではなく「この文脈では launchctl list が何も返さない」だった。何が実行文脈を分けているのかまでは特定していないが、少なくとも
launchctl list が空でも、登録されていないとは判断できないことは確かめられた。ドメインを明示して launchctl print を使うと、同じジョブがきちんと出てくる。確認手段はこちらに統一したほうが安全だった。
launchd が渡す PATH は print に書いてある
ドメインを指定して問い合わせたら、探していた答えがそのまま出力に含まれていた。
$ bash -c 'launchctl print gui/$(id -u)/com.ai-article-maker.market-daily 2>&1 | head -20'
gui/501/com.ai-article-maker.market-daily = {
active count = 0
path = /Users/user/Library/LaunchAgents/com.ai-article-maker.market-daily.plist
type = LaunchAgent
state = not running
program = /bin/bash
arguments = {
/bin/bash
/Users/user/Desktop/dev/ai-article-maker/pipeline/scripts/daily.sh
}
stdout path = /Users/user/Desktop/dev/ai-article-maker/pipeline/data/logs/launchd.out.log
stderr path = /Users/user/Desktop/dev/ai-article-maker/pipeline/data/logs/launchd.err.log
inherited environment = {
SSH_AUTH_SOCK => /private/tmp/com.apple.launchd.UJlI45zY2M/Listeners
}
default environment = {
PATH => /usr/bin:/bin:/usr/sbin:/sbin
# exit=0 (13 ms)
読みどころが三つある。
stdout path と stderr path は、plist を開かなくても出力先が確認できる。
state は not running、active count は 0 で、いま動いてはいない。そして inherited environment に入っているのは SSH_AUTH_SOCK だけで、
default environment の PATH は /usr/bin:/bin:/usr/sbin:/sbin しかない。
ログインシェルの PATH と並べると、差がそのまま見える。ここでは PATH 全体ではなく、関係する要素だけを取り出した(全体を貼ると自分の環境に入れているツール一式を公開することになる)。
$ bash -c 'echo "$PATH" | tr ":" "\n" | grep -E "\.local/bin|homebrew" | head -3; echo "--- 総エントリ数 ---"; echo "$PATH" | tr ":" "\n" | grep -c .; command -v uv'
/opt/homebrew/bin
/Users/user/.local/bin
--- 総エントリ数 ---
18
/Users/user/.local/bin/uv
# exit=0 (6 ms)
ログインシェルの PATH は 18 個のエントリを持ち、/opt/homebrew/bin も ~/.local/bin も入っている。一方 launchd が渡すのは4つだけで、uv の実体がある ~/.local/bin は含まれていない。
plist を睨む前に launchctl print の環境変数まわりを読んだほうが早い、というのがここでの結論になる。
同じ環境を手元で再現する
推測で終わらせたくなかったので、launchd と同じ PATH を手元に作って確かめた。
env -i で環境変数を落とし、PATH だけを launchctl print で見た値と同じにする。
$ env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin HOME=/Users/user /bin/bash -c 'uv --version'
/bin/bash: uv: command not found
# exit=127 (4 ms)
command not found で終了コードは 127。そして PATH に ~/.local/bin を足すだけで、同じコマンドが通る。
$ env -i PATH=/Users/user/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin HOME=/Users/user /bin/bash -c 'uv --version'
uv 0.9.16 (a63e5b62e 2025-12-06)
# exit=0 (10 ms)
二つのコマンドの差分は PATH だけなので、
launchd と同じ PATH では uv に到達できないことはこれで確定する。ここで確かめたのは再現環境での挙動であって、launchd 経由の実行そのものではない。それでも、原因の候補を PATH に絞り込むには十分だった。
この再現手順の利点は、launchd のスケジュールを待たずに何度でも試せることにある。スケジュールされた実行を待って launchd.err.log を眺める、という遅いループから抜けられる。
対処はラッパースクリプト側に寄せてある
対処の選択肢は plist の EnvironmentVariables に PATH を書くか、ジョブが呼ぶシェルスクリプト側で export するかの二択になる。私は後者を採っている。
$ bash -c 'grep -n "export PATH" /Users/user/Desktop/dev/ai-article-maker/pipeline/scripts/daily.sh | grep -o "^[0-9]*:export PATH=\"[^:]*"; echo "--- 以降のエントリ数 ---"; grep -o "^export PATH=.*" /Users/user/Desktop/dev/ai-article-maker/pipeline/scripts/daily.sh | tr ":" "\n" | grep -c .'
6:export PATH="/Users/user/.local/bin
--- 以降のエントリ数 ---
5
# exit=0 (7 ms)
PATH 全体は載せない(自分が入れているツール一式を公開することになる)。先頭に ~/.local/bin を置き、残りは元の PATH から必要な分だけを並べている。
ラッパー側に寄せた理由は、環境まわりの面倒を一箇所に集められることだ。
PATH の明示、ログの退避、失敗時の終了コードの扱いは、どれも「起動された側」の関心事になる。
plist には「いつ何を起動するか」だけを持たせて、環境の面倒はスクリプトに集約したほうが、後から読んだときに追いやすい。
plist を書き換えた場合は読み込み直しが要る(launchctl bootout と bootstrap)が、スクリプトなら保存するだけで次回の実行に反映される、という手数の差もある。なお、この読み込み直しの挙動は今回の切り分けでは実行しておらず、確かめていない。
この構成が実際に通ることは、本番の処理を最小環境で走らせて確かめた。
$ env -i PATH=/Users/user/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin HOME=/Users/user /bin/bash -c 'cd /Users/user/Desktop/dev/ai-article-maker/pipeline && uv run market report --needs-serp --format json'
["生成AI 開発", "aiエージェント おすすめ", "python 自動化ツール", "aiエージェント 無料", "生成ai 開発ツール", "ai コーディング 比較", "ai コーディングツール", "claude code 料金", "ai コーディング 無料", "ai コーディング おすすめ", "python 自動化 本", "生成ai 開発 本", "aiエージェント 本", "MCP", "aiエージェント 作り方", "ai コーディングで障害が増加", "生成ai 開発費用", "mcpサーバーとは", "python 自動化 やり方", "ai コーディング規約"]
# exit=0 (729 ms)
uv run はプロジェクトの仮想環境を解決してからコマンドを実行するので、
uv にさえ到達できれば、その先の Python やパッケージの PATH を個別に足す必要はない。
PATH に足すのは uv の置き場所だけでよかった。
この性質は、同じ問題に当たったときの当たりをつけやすくしてくれる。
launchd から起動するコマンドが仮想環境を自前で解決してくれる種類のものなら、足すのは実行ファイル1つの置き場所で済む。逆に python を直接呼ぶ構成だと、仮想環境の bin を PATH に入れるか、絶対パスで呼ぶかを自分で決める必要がある。どちらにしても、最初に確かめるのは「起動対象の実行ファイルに到達できるか」の一点でよい。
黙って動かないときに見る順番
今回の切り分けを、そのまま手順として並べておく。上から順に潰していくと早い。
- plist で指定した出力先のファイルがあるかを見る。無ければ出力が一度も書かれていない
launchctl print gui/$(id -u)/<label>でジョブが登録されているかを見る。launchctl listは空でも判断材料にならない- 同じ出力の
stateとactive countを控える - 同じ出力の
default environmentの PATH を控える - 控えた PATH を
env -iで再現して、起動対象のコマンドを手で叩く
私の場合は 1、4、5 で決着がついた。 plist の書式を疑うのは、この5つを全部通してからで遅くない。
この構成が向かないケース
ここまでの手順は、CLI で完結する定型処理を回す場合にはよく効く。一方で向かない場面もある。
GUI やログインセッションに依存する処理は向かない。 LaunchAgent はログイン中のユーザーセッションで動く仕組みなので、画面ロック中やログアウト中の挙動を前提にはできない(この点は今回検証していない)。定刻に必ず走らせたいなら LaunchDaemon を検討することになる。そちらは実行ユーザーもホームディレクトリも別になるはずで、PATH の扱いは今回よりさらに面倒になる(LaunchDaemon は今回一度も動かしていないので、ここは確かめていない)。
逆に言えば、環境変数への依存が小さい処理ほど launchd に載せやすい。ジョブを増やす前に「このスクリプトはログインシェルの設定に何を期待しているか」を一度洗い出しておくと、同じ原因で二度止まることはなくなる。