朝、無人ジョブの記録を開くと、昨夜のタスクが 0x1 で死んでいる。同じコマンドを手元の PowerShell で打つと、何事もなく通る。ログには読める手がかりが無い。筆者は Windows のタスクスケジューラで X 自動投稿・日次の公開後検証・週次のニュース収集を無人運転していて、この「手元では緑、本番だけ赤」を何度も踏んだ。原因はコードよりも、スケジューラと Python のあいだにある仕様の隙間だった。この記事は、実測で確かめた4つの罠と、行き着いた自衛の型をまとめる。
目次
罠1: 文字1つで死ぬ — リダイレクト先は cp932
最初の事故は、記事タイトルに含まれる em ダッシュ(U+2014)だった。タスクスケジューラから起動した Python が print() した瞬間に落ちる。再現はこうなる。
$ cmd /c "set PYTHONUTF8=&set PYTHONIOENCODING=& python C:\temp\cp932probe.py > out.txt"
Traceback (most recent call last):
File "C:\temp\cp932probe.py", line 1, in <module>
print('�L���^�C�g�� \u2014 �e�X�g')
UnicodeEncodeError: 'cp932' codec can't encode character '\u2014'
in position 7: illegal multibyte sequence
(exit code 1)
日本語 Windows でコンソール出力をファイルへリダイレクトすると、Python の stdout エンコーディングは cp932 になる。em ダッシュは cp932 に存在しない文字で、出力しようとした行で UnicodeEncodeError が上がり、プロセスごと非ゼロ終了する。投稿処理そのものは成功していても、最後のログ出力1行で「失敗」として記録される。トレースバックの中の日本語まで文字化けしているのは、エラー表示自体も cp932 の世界で起きているからだ。
たちが悪いのは、この失敗が手元で再現しないことにある。対話シェルの環境には PYTHONUTF8=1 や PYTHONIOENCODING=utf-8 が入っていることが多く(筆者の環境ではエージェント用の設定が両方を注入していた)、同じスクリプトが素通りする。再現するときは両方を外すこと。
$ cmd /c "set PYTHONUTF8=1& python C:\temp\cp932probe.py > out.txt"
(exit code 0 / out.txt の中身: 記事タイトル — テスト)
環境変数1つの差で、回帰テストの検出力がゼロになる。
リハーサルは cmd 経由で、環境変数を外して
体育館でやる避難訓練では非常口の場所は覚えられない。無人ジョブの予行も同じで、本番と同じ舞台でやらないと意味が無かった。筆者は無人実行のリハーサル用に、こういう cmd ファイルを1枚用意している。
rem rehearsal.cmd — 本番条件の再現(スケジューラと同じ素の環境で回す)
set PYTHONUTF8=
set PYTHONIOENCODING=
python scripts\my_job.py > logs\rehearsal.log 2>&1
echo exit=%ERRORLEVEL%
恒久対策はスクリプト側に置く。出力を必ず UTF-8 で開く(io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace"))か、起動側のラッパで PYTHONUTF8=1 を明示するか。どちらでもいいが、「対話シェルで動いたから大丈夫」だけは判定として成立しない。
罠2: 「失敗時に再起動」は、失敗しても再起動しない
タスクの設定画面には「タスクが失敗した場合の再起動の間隔」という項目がある。exit 1 で終わるジョブに再試行が付くと期待して設定し、裏切られた。
実測はこうだ。cmd /c exit 1 だけを実行するタスクを RestartCount=1・間隔1分で登録して起動する。1分の再起動時刻を過ぎてからイベントログ(Microsoft-Windows-TaskScheduler/Operational)を照会しても、記録されているのは起動時の8件(106 登録・110 手動起動・100 開始・129 プロセス生成・200/201 アクション開始と完了・102 完了ほか)だけで、再起動のイベントは出ない。タスク情報は LastTaskResult = 1 を記録しながら、イベント 102 は「Task completed」と言う。2026年8月19日に105秒待った実験でも、8月26日にやり直した実験でも結果は同じだった。
この設定が言う「失敗」は、プロセスの終了コードのことではない。起動そのものができなかった・応答しなかったといった、スケジューラから見た失敗を指す。exit code の失敗に再試行が欲しければ、スクリプトの中に自分で書くしかない。筆者の X 投稿ジョブは、一時障害(タイムアウト・5xx・429)だけを最大3回・線形バックオフで再試行する層を Python 側に持っている。
スクリプト内の再試行だけでは足りない場面もある。プロセスごと落ちる失敗(罠1のエンコーディング事故がまさにそれ)は、中の再試行層まで巻き込んで消えるからだ。ここで効いたのが、トリガーの多重化で再試行を作る方法だった。筆者の X 計測ジョブは毎日1回で足りる処理だが、タスクのトリガーは「9:00 起点・20分間隔・1時間」にしてあり、9:00・9:20・9:40・10:00 の4回起動する。スクリプトの先頭で「今日の分の記録が既にあれば、ネットワークに触らず exit 0」と判定するので、1回目が成功した日は残り3回が数秒の空振りで終わる。1回目が落ちた日は、20分後の起動がそのまま再試行になる。X の API が朝の2回連続で一時障害を返した日も、この形にしてからは3回目で拾えている。
罠3: 数字を読み違える — LastTaskResult と NextRunTime
スケジューラが見せてくれる数字は少なく、しかも多義的だ。筆者が実際に読み違えた2つを挙げる。
LastTaskResult = 0 は「異常なし」の証明にならない。「対象が無くて何もしなかった」も「全部成功した」も同じ 0 になる。逆に 1 が残ったときは、ログへのリダイレクトを仕込んでいなければ、X の API が何を返したのか(認証切れか、429 か、障害か)を後から特定する手段が無い。筆者は同じ朝の連続2スロットでこれを踏み、原因を永久に失った。以後、ラッパ cmd が毎回の標準出力をログファイルへ残す。
NextRunTime から繰り返しの残り回数を推論しない。20分間隔・1時間の繰り返しを設定したタスクで、9時40分のスロット成功直後に照会した NextRunTime が翌日の 9:00 を指しているのを見て、「今日の残りは終わった」と読んだことがある(2026年8月21日)。実際にはそのあと 10:00 のスロットが普通に発火した。少なくとも筆者の環境で、この値は当日の残り回数の根拠にならない。派生値から挙動を推論すると外す。
数えたいものがあるときは、イベントログを直接数える。
# 今日そのタスクが実際に何回走ったか(id=100 が開始、201 が完了)
Get-WinEvent -FilterHashtable @{
LogName='Microsoft-Windows-TaskScheduler/Operational'
Id=100,201; StartTime=(Get-Date).Date
} | Where-Object { $_.Message -match 'タスク名' } |
Select-Object TimeCreated, Id
このログは既定で有効になっていない環境もある。無効ならイベントビューアーで該当ログ(Microsoft-Windows-TaskScheduler/Operational)を選んで有効化してから使う。無人運用を始める日に、まず1回照会して録れていることを確かめておくと、罠2や罠3で数字を疑ったときの照合先になる。
罠4: 記録層より下で死ぬと、何も残らない
筆者の無人ジョブは実行結果を自前の status.json に積み、毎朝それを確認する運用にしている。この仕組みには盲点があった。ジョブの起動に使っている uv.exe そのものが消えた夜(2026年8月3日)、記録を書くはずのプロセスが起動できないので、status.json には1行も増えない。確認手順の上では「異常なし」に見えたまま、日次の検証タスクが黙って止まっていた。
記録層は、自分より下で起きた失敗を記録できない。
対策として、起動ラッパの cmd に「uv.exe が無ければ、uv を経由しない素の python で code=9 を記録する」という一段を足した。起動失敗そのものを status.json に載せる専用コードで、この層は uv を経由しない素の python で動くよう、依存を標準ライブラリだけに絞ってある。なお uv.exe が消えた原因は特定できておらず、この対策は再発の検知であって予防ではない。そこは未解決のまま運用している。
自衛の型 — スケジューラを信じる範囲を狭くする
4つの罠に共通するのは、スケジューラの機能や表示を信じた分だけ裏切られたことだ。行き着いた型は、スケジューラには「時刻に起動する」だけを任せ、残りを自前の層に寄せる構成になる。
| 層 | 担当 | 理由 |
|---|---|---|
| タスクスケジューラ | 時刻起動のみ | 再試行・失敗判定・記録は期待どおりに動かない(罠2・3) |
| ラッパ cmd | 環境の固定・ログのリダイレクト・起動失敗の記録(code=9) | 記録層より下の死を拾う(罠4) |
| Python 本体 | 再試行・タイムスタンプ・結果の status.json 記録 | exit code の失敗に再試行を付けられるのはここだけ(罠2) |
ログのタイムスタンプは Python が ISO 8601 で書く。cmd の %date% に頼ると2つの理由で壊れる。1つは、cmd 側の書き込みがコンソールのコードページ(スケジューラ経由では cp932)で行われ、Python の UTF-8 出力と同じファイルに混ざってログ全体が読めなくなること。もう1つは値そのもので、筆者の環境の %date% は今日の実測で 2026/08/(水) を返す。曜日は入るのに、日が入らない。
冪等判定にも1つ癖がある。筆者のジョブは追記型の台帳(status.json)とは別に、その日の計測結果を日次スナップショットのファイルに残し、「今日の分が既にあるか」で空振りを判定している。この判定を「ファイルが存在するか」で書くと、強制終了で 0 バイトの JSON が残った日に二度と取り直されなくなる。判定条件は「中身がパースできること」まで含めておく。トリガー多重化の空振り判定(罠2)もこの条件で書いてあり、壊れた記録が残った日は次のトリガーが自動で取り直す。
まとめ
Windows の無人ジョブは、Linux の cron の記事を読みながら作ると、cron には無い場所で死ぬ。出力エンコーディングは本番条件(cmd 経由・PYTHONUTF8 なし)でリハーサルする。「失敗時に再起動」に exit code の再試行を期待しない。LastTaskResult と NextRunTime から挙動を推論せず、数えたいものはイベントログで数える。そして記録層より下の死を拾う一段を、起動ラッパに置く。スケジューラに任せるのは時刻だけにして、残りは自分のコードに寄せるのが、筆者が1か月の障害対応で行き着いた形だ。
設定項目の正式な仕様は Microsoft Learn の Task Scheduler ドキュメント、PYTHONUTF8 の挙動は Python 公式ドキュメント が一次情報になる。ジョブから呼ぶ処理を MCP サーバー化する設計は「ローカルMCPサーバーのジョブ実行パターン」、Windows 固有機能の見分け方は「セッション間メッセージングはネイティブ Windows に来ない」もどうぞ。