AIエージェントにシェルスクリプトを叩かせて無人運転させ始めると、人間が使う前提で書いた既存のCLIやスクリプトが、思わぬところで壊れる。今回はうちのM1 Max 64GB機で「GPUを1つずつ順番に使う」ための調停システム(信号機スクリプト・監視デーモン・状態表示アプリの3層構成)を実際に約1ヶ月運用して踏んだ、性質の違う3つの失敗を実話ベースで書く。共通しているのは、どれも「人間なら気づけたはずの状態変化」を、無人で動くコードが黙って見逃していたという点だ。
背景 — GPUを奪い合う3つのツールを1つずつ回す仕組み
うちのMacではOllama(ローカルLLM)・ComfyUI(画像・動画生成)・ACE-Step(音楽生成)が同じ統合メモリ上のGPUを取り合っている。2つ同時に走らせるとMetalのGPUエラーで落ちるため、調停用のシェルスクリプト(以下「信号機」)が「今どれかが生成中か」を確認してから順番に実行する設計にしている。これを補完するのが60秒おきに動く監視デーモン(以下「番人」)と、状態を常時表示するmacOSメニューバーアプリ(Swift製)だ。3層とも同じ「今GPUを使っているのは誰か」という状態を見ているはずなのに、実際には3つの独立した失敗を引き起こした。
失敗1 — 状態確認関数が「無言で失敗終了」した
信号機の状態確認関数は、ComfyUIが生成中かどうかを内部APIで判定してから青信号・赤信号を返す。当初のコードは次のような形だった。
gen_running() {
pgrep -f "ltx_pipelines|mlx_video|generate_av|stable_diffusion" ...
comfy_busy && echo "comfyui-job"
}
問題は comfy_busy && echo ... という書き方だ。ComfyUIがアイドル(生成していない)だと comfy_busy はfalseを返し、&&の右側は実行されず、関数全体がゼロ以外の終了コードで終わる。このスクリプトは set -e 付きで書かれていたため、呼び出し元の r=$(gen_running) がその瞬間に丸ごと落ちていた。結果として「ComfyUIが暇なとき」に限って信号機の状態確認コマンドが常に赤(混雑)を返し続けるという、原因の割に見つけにくいバグになった。人間が対話的に同じコマンドを打っていれば「あれ、動いてないのに落ちるぞ」とすぐ気づけたはずだが、無人で定期実行されるコードはエラーコードを見ていなかった。直し方は1行で済む。
# 修正後(現在のコード)
comfy_busy && echo "comfyui-job" || true
状態を返すだけの関数は、状態がどうであれ必ずゼロ終了させる。これが1つ目の教訓だ。
失敗2 — タイムアウトの無いシェル実行ヘルパーが129プロセスを溜め込んだ
状態表示アプリは数秒おきに ollama ps を呼んで「今LLMが常駐しているか」を確認していた。ところが信号機がGPUを他のツールに割り当てる際、Ollamaのプロセスを kill -STOP で一時停止(凍結)させる設計になっている。凍結中のOllamaサーバに ollama ps を投げると、応答が返らずハングする。当時のシェル実行ヘルパーにはタイムアウトの概念が無く、ポーリングのたびにハングしたプロセスが生まれ続け、誰にも気づかれないまま蓄積していった。実測でこの滞留が129個に達し、メモリを圧迫して「アプリが重い」「GPUが故障したのでは」という誤認を引き起こした。修正後の実装は次の2点になっている(Swiftのソースで確認済み)。
// ShellRunner.swift
static let defaultTimeout: TimeInterval = 12
...
k.arguments = ["-c", "pkill -TERM -P \(pid) 2>/dev/null; kill -TERM \(pid) 2>/dev/null"]
12秒でタイムアウトさせ、期限が来たら子プロセスも含めてまとめてkillする。さらに呼び出し側にも「ロックが立っている(=Ollamaが凍結中と分かっている)間はそもそもollama psを呼ばない」というガードを追加した。
// GpuSignal.swift
if info.lockName == nil {
// ロック中でなければ ollama ps で常駐モデルを確認
}
「外部コマンドを呼ぶ以上、応答が返らない可能性は常にある」という当たり前の前提が、対話的に使う分には気にならなくても、無人で秒単位に呼び続けるコードでは即座に実害になる。タイムアウトと子プロセスごとのkillは、AIに叩かせるCLI・ラッパーには例外なく必要だという2つ目の教訓になった。
失敗3 — 2つの独立したガードが「無言で異なる基準」を使っていた
番人には「重い生成が進行中ならOllamaを解凍(再開)しない」というガードが最初から入っていた。ただしその判定はプロセス名の pgrep だけに頼っており、ComfyUIのジョブはサーバ内部(main.py)でキューとして処理されるため、pgrepには一切映らない。一方で信号機側は先に「ComfyUIは内部APIの/queueで見るべき」と直っていたのに、番人側は据え置きのままだった。結果、番人はComfyUIが実際に生成中でもそれを検知できず、Ollamaを解凍し続け、信号機がせっかく凍結させた状態を60秒以内に打ち消していた。同じ「今誰かがGPUを使っているか」という1つの問いに対して、2つのスクリプトが別々の答えの出し方をしていたのが根本原因だ。修正はロックファイルを両者共通の基準にすることだった。
# ollama-watchdog.sh に追加した1行
[ -f /tmp/bigkiji_gpu.lock ] && exit 0
「生成中かどうか」を判定する仕組みを2箇所以上に持たせるなら、検出方法を必ず一致させる。これが3つ目の教訓だ。
3件から言える共通パターン
失敗壊れた前提直し方
状態確認関数の暗黙の失敗終了「アイドル時は何も返さなくていい」|| true で常にゼロ終了させる
タイムアウト無しの外部コマンド呼び出し「コマンドはいつか応答が返る」タイムアウト12秒+子プロセスごとkill
2つの独立したガードの検出方式不一致「同じ状態を見ているはず」ロックファイルという単一の真実の基準に統一
3件はいずれも、対話的に使っていれば人間がその場で違和感に気づけたはずの箇所だ。無人運転にした瞬間、そのフィードバックのループが消える。逆に言えば、直し方はどれも数行で済んでいる。壊れる深さと直す量が釣り合っていないというギャップこそ、AI駆動のCLI設計で持ち帰るべき点だと感じている。
まとめ — そして「1年」ではなく「26日」だった
この記事は当初「1年書き続けて踏んだ失敗」という切り口で企画していたが、書く前に自社の記録を数え直したところ、この信号機スクリプトの設置は2026-07-11、今日は2026-08-06で、経過は26日だった。1年という前提は成立しない。誇張したまま出すより、実際の期間で書くほうが、短期間でもこれだけ壊れた・直したという密度の話として成立すると考え、タイトルと本文を訂正している。数字は確認してから出す、を自分自身の記事タイトルにも適用した形だ。
参考(観測のみ・本文の主張には使っていない)
執筆時点でZennのdaily trendingに「AIフレンドリーなCLIを開発するテクニック」という記事が上位に観測できた(本文は未読のため主張の引用はしていない)。この関心の高さ自体が、今回の実話を記事にする動機になっている。
Top comments (0)