exit 0 は「動いた」の証明ではない — 無音で失敗する自動化を夜の点呼で捕まえる
在庫と伸びは見ていた。「今日出るはずの1本が出ていない」だけ、誰も見ていなかった

自動化を組んでいると、いつのまにか「終了コード0」を成功の同義語として扱うようになります。タスクスケジューラの一覧が全部「前回の結果: 0」で埋まっていると、それだけで安心してしまう。ところが私は先日、全タスクが結果0のまま、その日の投稿が1本も出ていないという日を作りました。この記事は、そこから「今日ちゃんと外に出たか」を実データで数える夜の点呼を実装するまでの記録です。
事故1: 上限2時間のタスクが11時間走り続けた
深夜、動画の自動生成中に ffmpeg がハングしました。CPUを1コア食い切ったまま、出力ファイルが途中で凍結。私が気づいたのは11時間後、それも「PCが重い」と感じたからで、通知が来たからではありません。
タスク側には実行時間の上限を2時間で設定してありました。ではなぜ11時間走ったのか。理由は、スケジューラが上限で殺すのは自分が起動したプロセスだけだからです。私の構成は「スケジューラ → 隠し起動用のランチャ → バッチ → Node → ffmpeg」で、上限が来たときに死んだのはランチャだけ。その下の Node と ffmpeg は孤児プロセスとして生き残り、焼き続けました。タスク一覧の表示は、その間ずっと「準備完了」です。
さらに悪いことに、通知も飛びませんでした。プロセスごと打ち切られたので、失敗を知らせるコードに到達しなかったのです。失敗が失敗として観測されない — これがいちばん怖い形の壊れ方です。
対策は2つ。まず、ランチャに自前の期限を持たせ、期限が来たらプロセスツリーごと強制終了するようにしました (Windows なら taskkill /PID x /T /F)。次に、外部コマンドをシェル越しに呼ばず直接起動する形に変え、スクリプト自身にも全体の締切を持たせました。時間の階層はこう置きます。
スクリプト自身の締切 (100分)
< ランチャの期限 (110分)
< スケジューラの上限 (120分)
内側から順に発火させるのが肝です。外側が先に落ちると、内側は孤児になって生き残ります。
事故2: 「通知するコード」はあったが、宛先が無かった
別の日、投稿の在庫が尽きて毎日スキップに入っていたのに、その通知が1件も届いていませんでした。原因は共通の通知モジュールで、送信先をある環境変数だけから読む作りだったことです。その環境変数はどこにも設定されていませんでした。
// 悪い例: 宛先が無いと、黙って何もしない
export async function notify(text) {
const url = process.env.WEBHOOK_URL;
if (!url) return; // ← ここで静かに消える
await fetch(url, { /* ... */ });
}
しかも呼び出し側は try/catch で囲み、読み込みに失敗したときは空の関数にフォールバックしていました。つまり通知が飛ばないことが、エラーとしてすら見えない。「通知するコードがある」は「通知が届く」ではありません。今は宛先を解決できたかどうかを戻り値で返し、実際に送信して着信を確認するまでを1セットにしています。
足りなかったのは「成果物を見る監視」だった
ここで気づいたのは、私の監視が2種類しかなかったことです。1つは在庫を見るもの (残り何本あるか)。もう1つは伸びを見るもの (再生数の推移)。どちらも有用ですが、「今日出るはずの1本が出ていない」では鳴りません。在庫は十分にあり、過去の再生数も伸びているからです。
そこで、毎晩23時半に各サービスの一次情報で当日分を数えるジョブを追加しました。ローカルのログや投稿履歴は見ません。あれは「送ったつもり」の記録であって、「出た」証拠ではないからです。
実装で効いた4つの判断
1. 予約投稿があるので「アップロード時刻」で数えてはいけない
YouTube Data API で当日の投稿を数えるとき、最初は再生リスト (uploads) の publishedAt を使いました。これはアップロードした時刻で、予約投稿だと公開日と一致しません。正しくは、動画IDを集めてから videos.list を引き直し、公開状態と公開日時の実物で数えます。
const vs = await yt.videos.list({ part: ['snippet', 'status'], id: ids });
const hit = vs.data.items.filter(v =>
v.status?.privacyStatus === 'public' &&
jstDay(new Date(v.snippet.publishedAt)) === today
);
2. 取得に失敗したら「成功」にしない
API が落ちた・トークンが切れた場合、件数は0ではなく不明です。ここを0扱いにすると「投稿されていない」と誤報し、逆に成功扱いにすると壊れているのに緑という最悪の側に倒れます。私は不明を明示的に持ち、赤で鳴らすようにしました。
export function missingChannels(rows) {
return rows
.filter(r => r.expected)
.filter(r => r.count === null || r.count < 1) // null = 確認できず → 赤
.map(r => ({ ch: r.ch, why: r.count === null ? '確認できず' : '0本' }));
}
3. 独立に確認できないものは「自己申告」と明示して、合否に混ぜない
投稿先の1つは、公開ページを実ブラウザでしか読めず、APIで独立に数えられません。この枠だけは「エンジンの自己申告」と明記して別行で出し、合否判定には入れないことにしました。混ぜると、実際には確認していないものが緑の一部として計上され、緑の意味が薄まります。見ていないものを見たことにしないのが、監視を信用に足るものにする条件です。
4. 日付は必ずローカル時刻で切る
JavaScript の toISOString() は UTC に寄るので、日本時間で朝9時より前の投稿が前日扱いになります。日付で数える監視では、これだけで毎日ズレます。
const jstDay = (d = new Date()) =>
new Date(d.getTime() + 9 * 3600e3).toISOString().slice(0, 10);
いちばん大事だったのは「計測器の検証」
実装した直後、朝に走らせたら全チャンネル0本と出ました。ここで「今日はまだ投稿時刻が来ていないだけ」と解釈するのは推測です。数え方が壊れていて常に0を返している可能性と区別がつきません。
そこで、日付を指定して数え直せるオプションを付けました。昨日を数えると各チャンネルで1〜2本が正しく出る。これで「この計測器は0でない値を出せる」ことが分かり、初めて今朝の0に意味が生まれます。
node posted-today.mjs --dry --day 2026-08-17
# 日本語チャンネル: 2本 / 英語チャンネル: 2本 / クライアント案件: 1本 → 数えられている
検査を書いたら、緑と赤の両方を実際に出せることを確かめる。片方しか見ていない検査は、まだ検査ではありません。
まとめ
- 終了コード0は「起動して終わった」以上のことを証明しない。中身が一行も動かなくても0は返る
- 時間制限は内側から順に。外側だけに置くと、下のプロセスが孤児になって生き残る
- 通知は「宛先の解決」まで実物で確かめる。送信コードの存在は着信の証明ではない
- 在庫と伸びだけでは、出ていない日に気づけない。成果物を一次情報で数える監視を1本持つ
- 計測器を先に検証する。0が出たとき、それが世界の事実なのか道具の故障なのかを区別できる形にしておく
自動化は「動いている感じ」を量産します。感じを事実に変えるのは、成果物を外から数える一手間だけです。
よくある質問
タスクの実行時間上限を設定しているのに、なぜプロセスが生き残るの?
スケジューラが上限で終了させるのは、原則として自分が直接起動したプロセスだけだからです。ランチャやバッチを経由していると、その下で動いている実処理 (Node や ffmpeg など) は親が消えても孤児として走り続けます。対策は、中間のランチャに自前の期限を持たせ、期限が来たらプロセスツリーごと強制終了すること。さらにスクリプト自身にも全体の締切を持たせ、内側ほど短い期限にしておくと、必ず内側から順に落ちます。
監視は在庫や再生数を見ていれば十分では?
足りません。在庫監視は「材料が尽きそう」を、成長監視は「出したものが伸びているか」を見ますが、どちらも「今日1本も出ていない」では鳴らないからです。在庫は潤沢なまま、過去の投稿は伸び続けているので、どちらの指標も正常に見えます。出たかどうかは、投稿先のサービスから当日分を数えるしか確かめようがありません。
監視ジョブの結果が0件だったとき、まず何を疑うべき?
自分の計測器です。0という数字は「本当に無かった」と「数え方が壊れている」の両方から出ます。私は日付を指定して過去日を数え直せるオプションを用意し、昨日なら正しく1〜2本出ることを確認してから、今日の0を事実として扱うようにしました。検査を作ったら、緑と赤の両方を実際に出せることを確かめる。片側しか観測していない検査は、まだ検査になっていません。