アプリの作り方

Windowsの無人ジョブは手元で緑、本番で死ぬ — タスクスケジューラ4つの罠を実測で潰す

朝、無人ジョブの記録を開くと、昨夜のタスクが 0x1 で死んでいる。同じコマンドを手元の PowerShell で打つと、何事もなく通る。ログには読める手がかりが無い。筆者は Windows のタスクスケジューラで X 自動投稿・日次の公開後検証・週次のニュース収集を無人運転していて、この「手元では緑、本番だけ赤」を何度も踏んだ。原因はコードよりも、スケジューラと Python のあいだにある仕様の隙間だった。この記事は、実測で確かめた4つの罠と、行き着いた自衛の型をまとめる。

目次
  1. 罠1: 文字1つで死ぬ — リダイレクト先は cp932
  2. リハーサルは cmd 経由で、環境変数を外して
  3. 罠2: 「失敗時に再起動」は、失敗しても再起動しない
  4. 罠3: 数字を読み違える — LastTaskResult と NextRunTime
  5. 罠4: 記録層より下で死ぬと、何も残らない
  6. 自衛の型 — スケジューラを信じる範囲を狭くする
  7. まとめ

罠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=1PYTHONIOENCODING=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 に来ない」もどうぞ。

OpenAI の画像生成が background=transparent に対応(プレビュー)— 自作の背景除去と比べる前に、判定基準を決めた前のページ

Claude Code の –restricted を9項目で実測した — 外れるツールは6つ、MCP は残る、書込は許可さえあれば通る次のページ

ピックアップ記事

  1. New Eden Intelligence Hub の作り方 — EVE Onl…

  2. 競艇予想AIの作り方 — LightGBMで「当たる順位」を学習させる実装ガイド…

  3. 高精度OCRデスクトップアプリの作り方 — PaddleOCR-VLとPyIns…

  4. Claude Mission Control の作り方 — Tauri+Pyth…

関連記事

  1. 高精度OCRデスクトップアプリの作り方 — PaddleOCR-VLとPyInstaller凍結の実践

    アプリの作り方

    高精度OCRデスクトップアプリの作り方 — PaddleOCR-VLとPyInstaller凍結の実…

    PaddleOCR-VLで撮影→解析→翻訳まで完結するOCRアプリの作…

  2. アプリの作り方

    AI編集部を作る③ 編成会議 — デスクに渡す情報を brief 1枚に絞る

    無人編集部の司令塔(デスク)は Read/Write/Glob の3ツ…

  3. アプリの作り方

    Claude Codeのトークンを削るOSS 11本 — ライセンス本文まで確かめた一覧

    GitHubで「Claude Codeのトークンを削る」と名の付くリポ…

  4. 都市開発シミュを作る③|需要・成長・経済の決定論シミュレーション(MVP完成)【Aurum City制作】

    アプリの作り方

    都市開発シミュを作る③|需要・成長・経済の決定論シミュレーション(MVP完成)【Aurum City…

    需要・成長・経済で街を動かす決定論シミュレーションの作り方。種から回す…

  5. FX Monitor Pro の作り方 — Flet で主要10通貨ペアを常時モニターするデスクトップ為替アプリ

    アプリの作り方

    FX Monitor Pro の作り方 — Flet で主要10通貨ペアを常時モニターするデスクトッ…

    見たい通貨ペアだけをデスクトップに常駐表示する為替モニターの作り方。P…

  6. 背景除去アプリを作る⑦|目視を数値化する — MAE/IoU実測・プリセット掃引・pytest回帰、そして配布【背景除去Studio制作・完結】

    アプリの作り方

    背景除去アプリを作る⑦|目視を数値化する — MAE/IoU実測・プリセット掃引・pytest回帰、…

    「綺麗に抜けた」という目視の合格を、実測座標ボックスとMAE/edge…

注目

AIで、ここまで作れる

AIで作った2D RPGを、ブラウザでそのまま遊べます。その「作り方=最後まで完成させる進め方」も実例つきで公開中。

▶ ゲームを遊ぶやり方を読む

PR

ロリポップ!レンタルサーバー(PR)

レンタルサーバ:ロリポップ!(本サイトの稼働環境・PR)

  1. Dependabot が既定3日のクールダウンを導入 — 「切る前に読む」設定と自動マージの組み合わせ方

    AI・テック動向

    Dependabot が既定3日のクールダウンを導入 — 「切る前に読む」設定と…
  2. 背景除去アプリを作る⑦|目視を数値化する — MAE/IoU実測・プリセット掃引・pytest回帰、そして配布【背景除去Studio制作・完結】

    アプリの作り方

    背景除去アプリを作る⑦|目視を数値化する — MAE/IoU実測・プリセット掃引…
  3. VS Code 1.128 のマルチチャット — 1セッションで2方針を並走する

    AI・テック動向

    VS Code 1.128 のマルチチャット — 1セッションで2方針を並走する…
  4. 背景除去アプリを作る④|BiRefNetをONNXでローカル推論する — モデル別前処理とDirectML→CPUフォールバック【背景除去Studio制作】

    アプリの作り方

    背景除去アプリを作る④|BiRefNetをONNXでローカル推論する — モデル…
  5. AI・テック動向

    GLM-5.3-Flash の実費を5回測った — 同じ入力・temperatu…
PAGE TOP

TAG CLOUD

ドラッグで回転・クリックでそのタグの記事一覧へ