無人ジョブの「安いAIへの委譲」がWindowsで毎晩空振りする事故を潰す(spawnが.batを起動できない)
夜間の無人ジョブで「実装は安いモデル、失敗時だけ高いモデル」の二段構えを入れても、Windowsでは PATH上のCLIが.batラッパのため spawn が ENOENT で即死し、安い側が一度も起動しない。0.0x秒/出力0という失敗の署名で犯人を確定させ、実行ファイルの絶対パスを解決する純関数(shell:trueを使わない・.batを候補にしない・版はディレクトリ名で比較)を入れる型。あわせて「起動できない機体」と「起動したのに空」を台帳で分けて直せない宿題の自動起票を止め、force上書きで配られる共有ツールへの修正を冪等な再適用+依存ジョブ前の予約実行で守る。
約4.1万トークンの節約 (API料金換算で約61円分)。 要件定義・技術調査・試行錯誤ぶんのトークンがまるごと不要になります。※ 出品者申告とレビューに基づく推定値。モデル・タスク内容により変動します。
この巻物について
「無人ジョブの「安いAIへの委譲」がWindowsで毎晩空振りする事故を潰す(spawnが.batを起動できない)」は、業務自動化カテゴリのAI指示書(MDファイル)です。夜間の無人ジョブで「実装は安いモデル、失敗時だけ高いモデル」の二段構えを入れても、Windowsでは PATH上のCLIが.batラッパのため spawn が ENOENT で即死し、安い側が一度も起動しない。0.0x秒/出力0という失敗の署名で犯人を確定させ、実行ファイルの絶対パスを解決する純関数(shell:trueを使わない・.batを候補にしない・版はディレクトリ名で比較)を入れる型。あわせて「起動できない機体」と「起動したのに空」を台帳で分けて直せない宿題の自動起票を止め、force上書きで配られる共有ツールへの修正を冪等な再適用+依存ジョブ前の予約実行で守る。この巻物をAIに読み込ませると、ゼロから設計・調査する場合に比べて 約4.1万トークン(API料金換算で約61円)・91%のトークンを節約できます。
- カテゴリ
- 業務自動化
- 対応AI
- claude-code、cursor、codex-cli
- ライセンス
- 商用利用可 (再販不可)
- 価格
- 無料
- ゼロから開発時
- 約4.5万トークン
- この巻物使用時
- 約4,200トークン
- 節約量
- 約4.1万トークン (約61円)
- 更新日
- 2026-09-10
使い方 (AIに渡す3つの方法)
いちばん簡単なのはワンライナー。Claude Code のターミナルに貼るだけです。
claude "https://makimono-md.vercel.app/api/v1/files/ai-windows-spawn-bat/raw を読み込んで、この指示書どおりに実装して"
中身
無人ジョブの「安いAIへの委譲」が Windows で毎晩空振りしていたのを直す(spawn が .bat を起動できない)
夜間の無人ジョブに「実装は安いモデルへ委譲し、失敗した時だけ高いモデルへ落とす」という 二段構えを入れているのに、安い側が一度も起動しておらず、毎晩高い側に落ちていた(または処理を放棄していた)。
死因はモデルでもAPIでもなく spawn の呼び方。Windows では PATH 上の CLI が .bat ラッパで、
Node は shell を通さずに .bat / .cmd を起動できないため ENOENT で即死する。
しかも失敗が「安いAIが失敗した」ように見えるので、API キーやモデル名を疑って何日も溶かす。
そのまま読ませれば適用できる粒度で書いてある。
結論(これだけ守れば足りる)
- 子プロセスの起動は「実行ファイルの絶対パス」で行う。 コマンド名(
"<cli>")に頼らない shell: trueで逃げない。 指示文を argv で渡す設計だと、シェルがバッククォートや$(...)を コマンド置換として実行し、指示の一部が消えて別のプログラムが動く.bat/.cmd/.ps1は候補にしない。 shell 無しでは起動できないので、見つけても使わない- 失敗の署名を台帳に残す。 「起動できなかった」と「起動したが空だった」は別物。混ぜると 直せない宿題が毎日自動起票される
- 依存先が「force 上書きで配られる共有ディレクトリ」なら、修正は冪等な再適用スクリプトにする。 当てただけでは次の配布で黙って消える
症状の見分け方(ここだけで犯人が決まる)
実行ログ・使用量台帳に、次の3点セットが出ていれば spawn の起動失敗で確定。
| 証拠 | 意味 |
|---|---|
| 所要 0.0x 秒で失敗 | モデルに到達していない(推論は最低でも秒単位かかる) |
| 出力 0 バイト | 標準出力を1バイトも受け取っていない |
| 直後に「高いモデルへ落ちた」「実行者が無いので放棄」の行 | 二段構えの下段だけが常に効いている |
さらに API 側を単体で叩いて健全性を分離する。ここで 200 が返るなら、原因は呼び出し側にしかない。
curl -s -o /dev/null -w "%{http_code}\n" -X POST "<安いAIのエンドポイント>" \
-H "authorization: Bearer <キー>" -H "content-type: application/json" \
-d '{"model":"<モデル名>","max_tokens":16,"messages":[{"role":"user","content":"say ok"}]}'
やってはいけない切り分け: キーを再発行する / モデル名を変える / リトライを増やす。 どれも症状を動かさないまま時間だけ溶ける。
再現(30秒で確定できる)
# 委譲コマンドを最小の指示で1回だけ叩き、stderr を読む
<委譲コマンド> --prompt-file tiny.md > out.txt 2> err.txt; echo "exit=$?"; tail -c 300 err.txt
# → exit=1 / spawn <cli> ENOENT = 起動していない
Windows で PATH 上の実体を確認する(.bat なら確定)。
Get-Command <cli> -All | Select-Object Name,CommandType,Source
# 例: <cli>.bat Application <ホーム>\bin\<cli>.bat ← CommandType が Application で実体が .bat
.bat の中身はたいてい「最新版の .exe を選んで呼ぶ」だけのラッパになっている。
Node(18.20 / 20.12 以降は特に)は shell を介さずに .bat / .cmd を起動しない。
直し方: 実行ファイルを解決する純関数を1つ作る
fs を注入して純関数にすると、実ディスクを触らないテストが書ける(CI でも回る)。 解決順は「明示指定 → エディタ拡張の同梱バイナリ → アプリ版のインストール先 → ユーザーローカル → PATH」。
// resolve-cli.mjs ※ node:fs を import しない。listDir / exists は呼び出し側から渡す
import path from 'node:path';
export function versionTuple(text) { // 名前から版を取る
const m = String(text).match(/\d+(\.\d+)*/);
return m ? m[0].split('.').map(Number) : null;
}
export function compareVersionTuples(a, b) {
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
const d = (a[i] || 0) - (b[i] || 0);
if (d) return d;
}
return 0;
}
export function pickHighestVersion(names) { // 最大版を1つ選ぶ
let best = null, bestV = null;
for (const n of names) {
const v = versionTuple(n);
if (!v) continue;
if (!bestV || compareVersionTuples(v, bestV) > 0) { best = n; bestV = v; }
else if (compareVersionTuples(v, bestV) === 0 && n > best) best = n;
}
return best;
}
export function isUnspawnable(file, platform) { // shell 無しで起動できない形式
return platform === 'win32' && /\.(bat|cmd|ps1)$/i.test(file);
}
export function candidates({ platform, home, appDataLocal, pathEntries = [], listDir, exists }) {
const bin = platform === 'win32' ? '<cli>.exe' : '<cli>';
const out = [];
const add = (p) => { if (p && exists(p) && !out.includes(p)) out.push(p); };
// (a) エディタ拡張が同梱するネイティブバイナリ(複数版が並ぶので最大版だけ)
if (home) {
const extDir = path.join(home, '<拡張の置き場>');
const hit = pickHighestVersion(listDir(extDir).filter((n) => /^<拡張名の接頭辞>/i.test(n)));
if (hit) add(path.join(extDir, hit, '<拡張内の相対パス>', bin));
}
// (b) アプリ版のインストール先(版ディレクトリが並ぶ)
if (platform === 'win32' && appDataLocal) {
const pkgRoot = path.join(appDataLocal, '<パッケージ置き場>');
for (const pkg of listDir(pkgRoot).filter((n) => /^<パッケージ名の接頭辞>/i.test(n))) {
const verRoot = path.join(pkgRoot, pkg, '<アプリ内の相対パス>');
const ver = pickHighestVersion(listDir(verRoot)); // ← 版はディレクトリ名から取る
if (ver) add(path.join(verRoot, ver, bin));
}
}
// (c) ユーザーローカル / (d) PATH(.bat は積まない)
if (home) add(path.join(home, '.local', 'bin', bin));
for (const e of pathEntries) {
const p = path.join(e, bin);
if (!isUnspawnable(p, platform)) add(p);
}
return out;
}
export function resolveCli(opts = {}) {
const { env = {} } = opts;
if (env.CLI_PATH) return env.CLI_PATH; // 明示指定が最優先
return candidates(opts)[0] || '<cli>'; // POSIX は PATH 解決が効くので最後はコマンド名
}
呼び出し側は解決結果で spawn し、解決できなかった時は専用の終了コードと直し方を出す。
const exe = resolveCli({ ...args, env: process.env });
if (isUnspawnable(exe, process.platform) || (exe === '<cli>' && process.platform === 'win32')) {
console.error(`起動可能な <cli>.exe が見つかりません。CLI_PATH に絶対パスを設定してください。`);
return 4; // 「実行者が居ない」を他の失敗と区別する
}
const child = spawn(exe, args, { stdio: ['ignore', 'pipe', 'pipe'], env: childEnv });
child.on('error', (e) => console.error(`起動できません: ${e.message} (使ったパス: ${exe})`));
落とし穴(実際に踏んだもの)
- 版番号をフルパスから取ると外す。 パッケージ名に数字が混じっていると(例
<Name>_pzs8sxrjxfjjcの8) それを版と誤認し、全候補が同値になって辞書順比較に落ちる。すると2.1.9を2.1.10より新しいと判定する。 版はディレクトリ名から取り、必ずこの2つをテストに入れる。 - テストをパス区切りで書くと Windows で落ちる。 期待値は
path.joinで組む('a/b'と直書きしない)。 .cmdラッパを書くなら ASCII だけで書く。 cmd.exe はレガシーコードページで読むので、 UTF-8 の非ASCIIコメントが行を壊し、コメントがコマンドとして実行される(chcp 65001はログ出力用に別途入れる)。- 親プロセスの認証キーを子に継承させない。 安い側のトークンと親の API キーが二重に立ち、
「連携を無効化した」等の警告が出て原因究明が濁る。子 env から明示的に
deleteする。
「起動できない機体」と「起動したのに空」を台帳で分ける
自動ヘルスチェックが「出力ゼロ」を一律に不具合として扱うと、その環境では原理的に動かない実行者 (サンドボックス制約・依存ランタイム未導入など)について、毎日同じ宿題が引き継ぎファイルに自動起票される。 起票は残るが誰も直せないので、本当に直すべき項目が埋もれる。
// 台帳側: なぜゼロかを書く
if (executor === '<実行者>' && !row.out) {
row.status = cannotLaunchHere ? 'unavailable-on-host' : 'empty-output';
}
// ヘルスチェック側: unavailable は severity 低 + fixTask なし(=起票しない)
const zero = rows.filter((r) => r.provider === '<実行者>' && Number(r.out) === 0);
const unusable = zero.filter((r) => r.status === 'unavailable-on-host');
const empty = zero.filter((r) => r.status !== 'unavailable-on-host');
if (unusable.length) findings.push({ id: '<実行者>_unavailable_on_host', severity: 'low',
title: '<実行者> はこの機体で起動できない(既知・安いレーンへ回す)', evidence: [`${unusable.length}件`] });
if (empty.length) findings.push({ id: '<実行者>_empty_output', severity: 'medium',
title: '<実行者> の出力ゼロ', evidence: [`${empty.length}件`], fixTask: '原因を特定する' });
⚠️ 過去の行は書き換えない。 24時間窓で集計している場合、修正前の無印の行が窓から出るまで (最大24時間)は medium が残る。これは異常ではないので、引き継ぎに「いつ消えるか」を書いておく。
依存先が force 上書きで配られるなら、修正を冪等な再適用スクリプトにする
共有ルール・共通ツールを「配布物を展開して上書き」で配っている場合(Expand-Archive +
Copy-Item -Recurse -Force など)、手元で当てた修正は次の配布で黙って消える。
「直したのに翌週また落ちている」の正体はこれ。
対策は3点セット。
- 正本を自分のリポジトリに置く(新規ファイルはコピー元として保持)
- 冪等な再適用スクリプトにする。各差分に「当たっているか判定するマーカー」を持たせ、 マーカーがあれば何もしない・無ければアンカー文字列に対して差し込む
export function applyPatch(text, patch) {
if (text.includes(patch.marker)) return { text, changed: false, reason: 'already' };
if (!text.includes(patch.find)) return { text, changed: false, reason: 'anchor-missing' };
return { text: text.replace(patch.find, patch.replace), changed: true, reason: 'patched' };
}
- 依存ジョブより前に走らせる。 例: 無人ジョブが 03:20 なら再適用は 03:10 に予約する
schtasks /create /tn <再適用タスク名> /tr "<リポジトリ>\tools\<再適用>.cmd" /sc daily /st 03:10 /rl LIMITED /f
検証(ここまでやって初めて「直った」と言える)
--checkモードを作り、未適用なら exit 1 を返す(監視に載せられる)- 巻き戻った状態を人工的に作って再適用を実測する。配布前の版をコピーし直し、 再適用 → 差分件数を確認 → もう一度実行して 0 件になることを見る
- 依存ジョブと同一の組み立てで実走する。ラッパ経由の手打ちが通っても、 本番の呼び出し(引数の組み立て関数・実行ファイル・env)が違えば意味がない。 本番と同じ関数から argv を作って spawn し、exit 0 と成果物を確認する
チェックリスト
- 失敗の所要時間を見た(0.0x 秒なら起動していない)
- API 単体を叩いて健全性を分離した(200 が返るなら呼び出し側の問題)
-
Get-Commandで PATH 上の実体を確認した(.batなら確定) - 実行ファイルの絶対パスを解決している(
shell: trueを使っていない) -
.bat/.cmd/.ps1を候補から除いている - 版比較をディレクトリ名で行い、
2.1.9 < 2.1.10をテストしている - 子 env から親の認証キーを削っている
- 「起動できない」と「空だった」を台帳で分け、前者は自動起票しない
- 依存先が force 上書きで配られるなら、冪等な再適用+依存ジョブ前の予約実行がある
- 巻き戻し状態からの再適用と、本番と同一組み立ての実走を実測した
よくある質問
+「無人ジョブの「安いAIへの委譲」がWindowsで毎晩空振りする事故を潰す(spawnが.batを起動できない)」とは何ですか?
夜間の無人ジョブで「実装は安いモデル、失敗時だけ高いモデル」の二段構えを入れても、Windowsでは PATH上のCLIが.batラッパのため spawn が ENOENT で即死し、安い側が一度も起動しない。0.0x秒/出力0という失敗の署名で犯人を確定させ、実行ファイルの絶対パスを解決する純関数(shell:trueを使わない・.batを候補にしない・版はディレクトリ名で比較)を入れる型。あわせて「起動できない機体」と「起動したのに空」を台帳で分けて直せない宿題の自動起票を止め、force上書きで配られる共有ツールへの修正を冪等な再適用+依存ジョブ前の予約実行で守る。
+どれくらいトークン(費用)を節約できますか?
ゼロから開発すると約4.5万トークンかかりますが、この巻物を使えば約4,200トークンで済みます。差し引き約4.1万トークン(API料金換算で約61円)・91%の節約です。
+どうやって使いますか?
無料です。MDファイルを Claude Code などのAIに読み込ませるだけ。ワンライナーをターミナルに貼れば実装が始まります。要件定義や技術調査を省いて実装だけにトークンを使えます。
+どのAIツールに対応していますか?
claude-code、cursor、codex-cli に対応しています。
+商用利用できますか?
ライセンスは「商用利用可 (再販不可)」です。
🤝 自分でAIを動かすのは、まだ不安…という方へ
この巻物の内容を、AIを使うプロに丸ごと任せることもできます。姉妹サービスAI代行堂なら「LINEで頼むだけで、仕事が完成」。
関連する巻物
Google Meet 自動参加&動画配信Bot 開発指示書
指定した時刻に Google Meet へ自動参加し、動画を再生しながら画面共有する Bot を、Claude Code に一発で作らせる開発指示 MD。朝会の定例動画配信・ウェビナーの自動放送に。
受信メール添付を案件フォルダへ自動取込するパイプライン
メールを読むアプリとドライブに書くアプリが別、という現実的な構成で顧客メールの添付を案件フォルダへ無人保存する設計。権限追加を避ける理由、実行時間制限下の予算3本立て、二重の重複防止、base64url/行数上限/変換判定などの実装罠、案件と顧客のマッチング、名寄せは候補提示+人の承認にする型まで。
Gmail 自動仕分け&返信ドラフト生成MD
受信メールを AI が分類 (要返信/情報/営業/スパム) してラベル付けし、要返信メールには返信ドラフトまで自動生成する仕組みを作らせる指示書。DWD (ドメイン全体委任) 設定手順込み。
この巻物、誰かのトークンも救えます
𝕏 で節約レシートをシェア