マキモノ
業務自動化無料✅ 公式検証済みv1.0.0 / 更新

無人ジョブの「安いAIへの委譲」がWindowsで毎晩空振りする事故を潰す(spawnが.batを起動できない)

夜間の無人ジョブで「実装は安いモデル、失敗時だけ高いモデル」の二段構えを入れても、Windowsでは PATH上のCLIが.batラッパのため spawn が ENOENT で即死し、安い側が一度も起動しない。0.0x秒/出力0という失敗の署名で犯人を確定させ、実行ファイルの絶対パスを解決する純関数(shell:trueを使わない・.batを候補にしない・版はディレクトリ名で比較)を入れる型。あわせて「起動できない機体」と「起動したのに空」を台帳で分けて直せない宿題の自動起票を止め、force上書きで配られる共有ツールへの修正を冪等な再適用+依存ジョブ前の予約実行で守る。

出品者: seisaku-team@orgiast.jp📖 読込 約4,451トークン (約7円)💰 コスパ 9
トークン節約メーター91%節約
ゼロからAIに作らせた場合4.5万トークン
このMDを読ませた場合4,200トークン

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 のターミナルに貼るだけです。

⬇ .md をダウンロード
claude "https://makimono-md.vercel.app/api/v1/files/ai-windows-spawn-bat/raw を読み込んで、この指示書どおりに実装して"
claude-codecursorcodex-cliライセンス: 商用利用可 (再販不可)

中身

無人ジョブの「安いAIへの委譲」が Windows で毎晩空振りしていたのを直す(spawn が .bat を起動できない)

夜間の無人ジョブに「実装は安いモデルへ委譲し、失敗した時だけ高いモデルへ落とす」という 二段構えを入れているのに、安い側が一度も起動しておらず、毎晩高い側に落ちていた(または処理を放棄していた)。

死因はモデルでもAPIでもなく spawn の呼び方。Windows では PATH 上の CLI が .bat ラッパで、 Node は shell を通さずに .bat / .cmd を起動できないため ENOENT で即死する。 しかも失敗が「安いAIが失敗した」ように見えるので、API キーやモデル名を疑って何日も溶かす。

そのまま読ませれば適用できる粒度で書いてある。


結論(これだけ守れば足りる)

  1. 子プロセスの起動は「実行ファイルの絶対パス」で行う。 コマンド名("<cli>")に頼らない
  2. shell: true で逃げない。 指示文を argv で渡す設計だと、シェルがバッククォートや $(...) を コマンド置換として実行し、指示の一部が消えて別のプログラムが動く
  3. .bat / .cmd / .ps1 は候補にしない。 shell 無しでは起動できないので、見つけても使わない
  4. 失敗の署名を台帳に残す。 「起動できなかった」と「起動したが空だった」は別物。混ぜると 直せない宿題が毎日自動起票される
  5. 依存先が「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>_pzs8sxrjxfjjc8) それを版と誤認し、全候補が同値になって辞書順比較に落ちる。すると 2.1.92.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点セット。

  1. 正本を自分のリポジトリに置く(新規ファイルはコピー元として保持)
  2. 冪等な再適用スクリプトにする。各差分に「当たっているか判定するマーカー」を持たせ、 マーカーがあれば何もしない・無ければアンカー文字列に対して差し込む
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' };
}
  1. 依存ジョブより前に走らせる。 例: 無人ジョブが 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で頼むだけで、仕事が完成」。

AI代行堂を見る →

関連する巻物

この巻物、誰かのトークンも救えます

𝕏 で節約レシートをシェア