Windows のタスクスケジューラで、AI エージェント(claude)を1日3回・無人で走らせています。
30分ごとの稼働確認も別のタスクで回しています。
その稼働確認のログを、先日はじめて全部数えました。
測ったもの(logs/health.log) |
実測 |
|---|---|
| 観測ブロック | 1,379回(2026-08-10 14:31 〜 2026-09-08 07:07) |
| 異常ありで終わった回 | 668回(48.4%) |
| 異常が出た日 | 17日 |
(30分ごとの定期観測だけを数えています。修正の直前に私が手で走らせた2回は除きました。その2回を入れると 1,381回になりますが、異常の数は 668 のままです)
いっぽう起動用の .cmd は、この668回の失敗を 1件もタスクスケジューラへ返していませんでした。
なお LastTaskResult は実行のたびに上書きされるので、当時それが何回「正常終了」と表示されたかは、いまから数えられません。
言えるのは「失敗が外へ出ていなかった」までです。
原因は .cmd の最後の1行です。
exit /b 0
監視スクリプト自身は最初から process.exitCode = 1 を立てていました。
その668回ぶんを、起動用の .cmd の最終行が全部捨てていました。
この記事は、同じ構成(タスクスケジューラ + .cmd + 長時間走るプロセス)で
「落ちる」より厄介な「落ちたように見えない」形を、実際に踏んだものだけ並べたものです。
どれも自分のPCで数分で確かめられます。
1. exit /b 0 — 失敗が外へ出ない
まず、.cmd の終了コードがどう決まるかを実測しました(Windows 10.0.26200)。
@echo off
cmd /c exit 7
<最後の行>
| 最後の行 |
.cmd の終了コード |
|---|---|
exit /b 0 |
0 |
exit /b %ERRORLEVEL% |
7 |
| (exit 文なし) | 7(最後に実行したコマンドのぶん) |
(exit 文なし + 末尾に rem を1行) |
0 |
exit /b(引数なし) |
0 |
最後の行が私も間違えていたところです。
引数なしの exit /b は「その時点の終了コードをそのまま返す」ように読めますが、exit /b 0 と同じ側でした。
自分の検査コードにその誤りをコメント付きで書いていて、テストが1件も無かったので通っていました。
最後の行も同じ罠です。rem を1行足しただけで、返る値が 7 から 0 に変わります。
「最後に実行したコマンドのぶんが返る」は、その最後がコメントでも成り立ちます。
何が起きるか
タスクスケジューラの履歴には「正常終了(0)」だけが残ります。
監視やCIで if exit==0 then 異常なし と書いていれば、中身が全部失敗していても緑です。
確かめ方
起動用の .cmd の最終行を見るだけです。ただし1本見ても足りません(→ 5)。
2. パイプが詰まる — 落ちるのではなく、終わらない
これがいちばん静かな壊れ方でした。
claude ... 2>> "%LOG%" | node "scripts\autorun-log.mjs" "%LOG%"
cmd の 2>> と、パイプの向こうのロガーが、同じパスをそれぞれ開こうとします。
Windows は2つ目の open を EBUSY で拒みます。ロガーはそこで即死し、
パイプを誰も読まなくなるので、本体は書き込みで永久に待ちます。
当時の実測は「起動2.5分の時点で claude は生きていて CPU 1.5秒」。
動いてはいるが何も進んでいません。
エラー行も終了コードも残りません。 タスクスケジューラには「実行中」のまま残るか、
実行時間の上限で終了させられた記録だけが残ります。
直し方は書き込み先を分けるだけです(実際、5分16秒後のコミットでそうしました)。
claude ... 2>> "%ERRLOG%" | node "scripts\autorun-log.mjs" "%LOG%"
ここで誤検出を作らないための注意
2>&1 を「2つ目の open」と数えてはいけません。
あれは既に開いている手を複製するだけで、奪い合いになりません。
数えると、直してある構成に「まだ壊れている」と伝えることになります。
3. コンソール窓 — 閉じられるとプロセス木ごと死ぬ
11日間、1日3枠のうち昼と夜だけが消える日が続きました。
答えは分布に出ていました。
| 枠 | 完走 / 終了した起動(〜2026-08-27) | 完走率 |
|---|---|---|
| 朝 7:05 | 16 / 18 | 88.9% |
| 夜 19:05 | 10 / 18 | 55.6% |
| 昼 13:05 | 8 / 20 | 40.0% |
同じ命令・同じスクリプト・同じマシンで、違うのは時刻だけです。
OS に「あの起動はどう終わったか」を尋ねると答えは1つでした。
Get-ScheduledTaskInfo -TaskName "タスク名" | Select-Object LastRunTime, LastTaskResult
# 3221225786 = 0xC000013A = STATUS_CONTROL_C_EXIT
0xC000013A は「Ctrl+C・コンソールを閉じる・ログオフなど、外からの中断でプロセス群が止められた」です。
記録に残っている9件は全部が昼か夜で、朝は0件でした。人が机の前にいる時間帯です。
(OS へ尋ねるようにしたのは 2026-08-21 からで、それより前に消えた回は終了コードが残っていません。この9件は「消えた回の全部」ではなく「終了コードを引き取れた回」です。)
タスクは LogonType=Interactive で動くので、起動のたびに黒い窓が最大45分開きます。閉じれば死にます。
直し方
wscript.exe を1段挟みます。GUI サブシステムなので自分の窓を持ちません。
Execute: C:\Windows\System32\wscript.exe
Arguments: //B //Nologo "C:\...\scripts\autorun-hidden.vbs" noon
' autorun-hidden.vbs -- 窓を出さずに .cmd を起動し、終了コードを待って返す
Set sh = CreateObject("WScript.Shell")
' 0 = 窓を出さない / True = 終了を待つ(待たないと終了コードがスケジューラへ届かない)
rc = sh.Run("""" & cmdPath & """ " & slot, 0, True)
WScript.Quit rc
Run の第3引数を False にすると、起動した瞬間に 0 を返して終わります。
それは 1 とまったく同じ形(失敗が外へ出ない)です。
結果 [2026-08-28→2026-09-08]: 終了した起動 38回中36回完走(94.7%)・0xC000013A は0件。
(件数は日ごとに増えるので、いつからいつまでを数えたかを書いています)
★<Hidden> を見て判定してはいけない
タスクの XML に <Hidden> が無いことは普通にあります。Windows の既定は false(=窓を出す)です。
そしてうちの4タスクは今も Hidden=False のままで、窓が出ないのは wscript を挟んだからです。
つまり <Hidden> だけを見る検査は、直してある構成を「危ない」と言い、既定で危ない構成を見逃します。
見るべきは「実際に何を Execute しているか」です。
4. 「経路=scheduled」は、何も区別していなかった
定期実行かどうかをログに残そうとして、こう書いていました。
set "AUTOMOS_TRIGGER=scheduled"
定数です。手で叩いても同じ印が付きます。
実測(収集ログ全体):
| 記録 | 回数 |
経路 の値 |
|---|---|---|
| 収集を試みた回 | 27 | 27回とも scheduled |
| 収集を止めたあとの回 | 11 | 11回とも scheduled |
| 合計 | 38 | 他の値は一度も出ていません |
しかも27回のうち 5回は 6:10 ではありません(2026/8/7 11:40 と、2026/8/10 の 18:04・18:15・18:16・18:17)。全部、私が手で流し直した回です。それでも27回とも scheduled です。
一度も違う値を出せない欄は、何も区別していません。
そして、その日のログにこれが残っています。
[collect] === 開始 2026/8/7 11:40:12 経路=scheduled ===
予定は 6:10 です。6:10 の定期実行は失敗し、5時間30分後に手で流し直したものが
「scheduled」と記録されていました。私はその日、この行を根拠に「定期実行は成功」と報告しています。
直し方
引数から作ります。
set "SLOT=%~1"
if "%SLOT%"=="" set "SLOT=manual"
走ったかどうかを見るなら、自前の印ではなく OS の記録(LastRunTime / LastTaskResult) を見ます。
5. 片方だけ直す
同じ穴が複数の .cmd に空いていて、気づいた1本だけ直す、を繰り返していました。
| 直したもの | 先に直した日 | 隣に適用した日 | 空いた |
|---|---|---|---|
終了コードの経路(exit /b 0) |
2026-08-07 06:54 | 2026-09-08 07:29 | 32.0日 |
| API キーを空にする行 | 2026-08-12 20:05 | 2026-08-21 07:24 | 8.5日 |
上の32日のうち、稼働確認そのものが動いていた 2026-08-10 以降のぶんが、冒頭の668回です(32日ちょうどではありません)。
下の8.5日間、診断用のスクリプトは毎回 Credit balance is too low で即死していました。
そのときのコミットに自分でこう書いています。
A diagnostic that does not reproduce the real run environment proves nothing.
1本ずつ見るかぎり、どのファイルも「そのファイルとしては正しい」顔をします。
隣を見ない検査は、この形を絶対に見つけられません。
6. 走っていないタスクが、いつまでも「正常終了」を返す
いま、この記事を書いているマシンで実際に返る値です。
AUTOMOS-診断 | Triggers=0 | LastRun=2026/08/21 7:11:43 | NextRun='' | LastResult=0
2026-09-09 時点で19日間1度も走っておらず、次に走る予定もありません。それでも OS は 0(正常終了)を返します。
走らなかった回は終了コードを残さないので、最後に走った日の答えがそのまま残り続けるからです。
したがって LastTaskResult を読むときは、必ず LastRunTime と NextRunTime を一緒に見ます。
その 0 が、いつのものなのかを書かない監視は、止まったタスクを緑で報告します。
見つけることより難しいのは、誤検出を作らないこと
上のような検査を15個書いてみて、設計の中心は「見つけること」ではありませんでした。
直してある構成に「まだ壊れている」と言わないことのほうが、ずっと難しかったです。
守った決まりは3つです。
① 「見に行けなかった」を「問題なし」と書かない。
読み出せなかった・場所を教わっていない・そこに無かった、は全部意味が違います。
とくに ENOENT(そこに無い)だけが「無い」と言える形で、権限エラーは「確かめられなかった」です。
② 「0件見つかった」と「0件しか見られなかった」を、同じ 0 で書かない。
このPCのタスクスケジューラの運用ログは、今日もこう返ります。
Microsoft-Windows-TaskScheduler/Operational | Enabled=False | Records=(空)
この空を「0件」と読めば、「何も起きていない」と「そもそも記録していない」が同じ顔になります。
(なおこのログが無効だと、誰がタスクを止めたのかを唯一記録している場所が消えます。
私は「原因は分かっていない」と4日間書き続けたあとで、有効かどうかを一度も確かめていなかったことに気づきました。)
③ コメント行を設定として読まない。
rem の中に残した古い書き方を拾って★を出したことが実際にあります。
15項目の一覧
実際に踏んだものだけです。思いついた注意点は入れていません。
| # | 見るもの | 踏んだ日 |
|---|---|---|
| 1 | タスクがコンソール窓を出していないか | 2026-08-28 |
| 2 | API キーが環境に残っていないか(従量課金・残高切れ) | 2026-08-11 |
| 3 | 起動用の .cmd に非ASCII文字が入っていないか(OEM コードページ) |
2026-08-10 |
| 4 | 前回の実行がどう終わったか(OS の記録) | 2026-08-21 |
| 5 | 異常終了の置き土産(残ったロック)が無いか | 2026-08-17 |
| 6 | 落ちたときに痕跡が残るか | 2026-08-21 |
| 7 | タスクスケジューラの運用ログが記録できる状態か | 2026-08-21 |
| 8 | 予定どおり走っているか(その終了コードは、いつのものか) | 2026-09-01 |
| 9 | 前回は「予定の時刻」に走ったか | 2026-08-07 |
| 10 | 失敗を外へ返せる形になっているか(exit /b 0) |
2026-08-06 |
| 11 | 走らせ方の印が定数で立てられていないか(経路) |
2026-08-07 |
| 12 | 走っている運転を止められるか(止める手段が起動前だけになっていないか) | 2026-09-03 |
| 13 | ログの書き込み口が同じファイルへ二重に向いていないか(パイプ) | 2026-08-10 |
| 14 | タスクが起動するのは、あなたが渡したファイルか | 2026-08-21 |
| 15 | 同じ場所にある他の起動ファイルに、同じ穴が残っていないか | 2026-08-07 |
14 は、うちの構成がまさにそれでした。
タスクが直接起動しているのは .cmd ではなく wscript.exe(3 の対策)で、
.cmd を渡して「異常なし」と言っても、タスクが本当に起動するファイルを見たことにはなりません。
確かめる道具
自分のPCで一度に確かめられるように、1ファイルにまとめました。
node unattended-check.mjs --task "タスク名" --launcher "起動用の.cmd" --lock "logs\autorun.lock" --settings ".claude\settings.json"
- 依存パッケージ 0。 Node.js だけで動きます
- 通信しません。 あなたのPCから外へは1バイトも送りません(ソースに通信 API が無いことをテストで固定しています)
- 書き換えません。 読むだけです
-
OK(調べて問題が無かった)と?(見に行けなかった)を分けて出します。全部?なら終了コード 1 です
この道具にできないこと
-
静的に読んでいるだけです。 実際に通った道や、
ifの分岐の先は追っていません -
.cmd/.batしか読みません(PowerShell の起動ファイルは読みません) - 起動ファイルを辿るのは1段までです。2段目があると分かったら
?を返します - 実測は Windows 10.0.26200 / Node v24.18.1 の1台だけです
まとめ
30日間で学んだことを1行にすると、こうなります。
「実行された」は「意図した結果になった」ではありません。
LastTaskResult=0・完了 成功8・published・本日取得8カテゴリ ——
どれも「実行された」しか示していないのに、私は全部を成功の証拠として読みました。
そのたびに、間違っていたのは処理ではなく読み方でした。
同じ構成で動かしている方は、まず起動用の .cmd の最終行と、
LastTaskResult の隣にある LastRunTime を見てみてください。数分で分かります。