排程回傳 0 不代表成功:我們給 50 多條 Windows 排程做的真實成敗偵測
Windows 工作排程器的「上次執行結果 0x0」,只代表排程器叫起來的那支程式正常結束。如果那支程式是幫你藏視窗的 VBS,那個 0 就只是 VBS 自己的 0。我們的主機上有五十多條排程,這週發現其中一支看門狗跑起來就是 exit 255,從來沒做過它該做的事,排程器卻一直顯示成功。現在我們完全不看結果碼:每條排程自己寫下開始和結束標記,另一支程式定時去讀,最後一輪失敗或超過時限沒有成功,就判紅。
我們怎麼發現結果碼不可信?
第一件,每天產短影片的那條排程,10 月 24 日到 11 月 1 日連續失敗 9 天。它的 log 每次都寫了 exit 1,每次也都發了告警,只是告警淹在其他訊息裡,沒人跟進。
第二件,11 月 3 日機器卡死,05:49 到 18:57 這段時間的排程全部沒跑,開機後也沒有補跑。沒跑的排程沒有新的結果碼,排程器畫面上留著的是上一次的 0。
第三件最荒謬。有一支 Telegram 看門狗,工作是發現主控的 Claude 程序死掉就把它拉起來。它的 .bat 是 LF 換行,裡面又有 UTF-8 的特殊符號,cmd 讀這種檔會吃掉每行開頭的字:REM 變成 'Telegram',set 變成 'et',tasklist 變成 sklist,最後 exit 255。它應該寫的 log 從來沒出現過,也就是說它一次都沒有重啟過任何東西。排程器那邊,結果一直是 0。
結果碼為什麼會是 0?
我們的排程幾乎都是 wscript.exe 某支.vbs,VBS 只負責藏視窗、叫真正的程式。問題出在三個地方:
WshShell.Run cmd, 0, False:不等子程序,VBS 叫完就結束,排程器拿到 0。WshShell.Run cmd, 0, True:有等,但最後沒寫WScript.Quit rc,VBS 一樣回 0。上面那支看門狗就是這種。- VBS 自己出錯。寫這篇時我們故意把 log 路徑寫成不存在的磁碟,VBS 在開檔那行發生執行期錯誤,cscript 回傳的結束碼照樣是 0。
就算三個都修好,結果碼也只記最後一次,看不出「已經三天沒跑」。所以我們決定,成敗改由排程自己寫的紀錄來判斷。
不看結果碼,那看什麼?
四種紀錄,看排程的性質選一種:
- log 標記:開始寫一行、結束寫一行,結束那行帶真實結束碼。大部分排程用這個。
- 狀態檔:每輪覆蓋同一個檔,內容只有一行
[時間] exit N。給每幾分鐘就跑一次的排程用,log 才不會越長越大。 - 修改時間:只看輸出檔多久沒更新。給只產檔、不寫成敗的排程。
- 常駐程序:查 Win32_Process 裡它在不在。給開機就啟動、不會結束的程式。
每條排程在健檢表裡佔一列:去哪個檔看、成功長什麼樣(regex)、失敗長什麼樣、多久沒成功算紅。燈號只有三種:
- 紅:最後一輪跑完是失敗;或超過時限沒有成功,包括根本沒跑。
- 黃:時限內有成功紀錄,但最新一輪開始了卻沒有結束標記;或 log 不見了、內容看不懂。
- 綠:其他。
時限抓「週期再多一點」:每天跑的給 26 小時,每週跑的給 8 天。超時判紅是整套的核心,因為最危險的失敗是根本沒跑,不會有任何一行 log 告訴你。11 月 3 日停機之後第一次跑,表上 11 條紅燈,逐條查過全是真的,都是停機那段時間該跑沒跑的。
包裝層怎麼補?
很多程式本身不寫成敗,我們不改程式,改外面那層 VBS。下面這支等子程序結束、拿到真實結束碼、寫進自己的 log,最後把結束碼交還給排程器:
' Nightly-Export.vbs: run the real command hidden, wait for it, log start/end and the real exit code
Option Explicit
Const TASK = "Nightly-Export"
Const CMDLINE = "cmd /c ""cd /d C:\ops\nightly && node export.js >> C:\ops\logs\Nightly-Export.out 2>&1"""
Const LOGDIR = "C:\ops\logs\"
Function Stamp()
Dim t : t = Now
Stamp = Year(t) & "-" & Right("0" & Month(t), 2) & "-" & Right("0" & Day(t), 2) & " " & _
Right("0" & Hour(t), 2) & ":" & Right("0" & Minute(t), 2) & ":" & Right("0" & Second(t), 2)
End Function
Sub Append(text)
Dim f
Set f = CreateObject("Scripting.FileSystemObject").OpenTextFile(LOGDIR & TASK & ".log", 8, True)
f.WriteLine "[" & Stamp() & "] " & text
f.Close
End Sub
Dim rc
Append "start " & TASK
rc = CreateObject("WScript.Shell").Run(CMDLINE, 0, True)
Append "end " & TASK & " rc=" & rc
WScript.Quit rc
每條排程寫自己的檔,免得兩條同時寫一個 log。時間格式自己組,不依賴系統地區設定。
等子程序有一個副作用:VBS 變成跟你的程式一樣久,排程設定裡的「執行超過多久就停止」和「已在執行時不啟動新的」會開始真的套用在你的程式上。有些排程我們不想改變這個行為,就改成讓 cmd 在同一行收尾,VBS 照樣叫完就走:
cmd.exe /v:on /c "<原本的指令> & echo [!date! !time!] end Nightly-Export rc=!errorlevel! >> C:\ops\logs\run.log"
這行有個一定會踩的坑:如果寫成 %errorlevel%,cmd 在執行整行之前就把它換成當下的值,那時前面的指令還沒跑。我們實測同一行指令 cmd /c exit 3,%errorlevel% 寫出 0,打開 /v:on 改用 !errorlevel! 才寫出 3。另外 !date! !time! 的格式跟著系統地區設定走,我們的機器寫出來是 2026/11/04 週三 0:35:04.92,檢查程式的時間 regex 要照你的格式改。
檢查程式長什麼樣?
下面是精簡過的骨架,規則表換成你自己的排程就能跑。有紅燈時 exit 1,方便再接到巡邏或告警;--file 可以把某條排程的 log 換成假檔,拿來測規則:
// task-health.cjs:排程的成敗不看結果碼,看它自己寫下的紀錄
// 用法:node task-health.cjs [--json] [--only 名稱] [--file "名稱=假log路徑"]
const fs = require('fs')
const { execFileSync } = require('child_process')
const H = 3600e3
const LOGS = 'C:/ops/logs'
// 一條排程一列:去哪裡看、怎樣算成功、多久沒成功算紅
const RULES = [
{ name: 'Nightly-Export', type: 'log', file: `${LOGS}/Nightly-Export.log`,
start: /\] start /, ok: /\] end .* rc=0$/, fail: /\] end .* rc=(?!0$)-?\d+$/, maxAgeH: 26, stuckH: 4 },
{ name: 'Signal-Worker', type: 'status', file: `${LOGS}/signal-worker.status`, okCodes: [0], maxAgeH: 2 },
{ name: 'Weekly-SEO', type: 'mtime', file: 'C:/ops/data/seo/last-run.log', maxAgeH: 8 * 24 },
{ name: 'Ollama', type: 'proc', exe: 'ollama.exe', cmd: /\bserve\b/ },
]
const args = process.argv.slice(2)
const opt = (k) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : null }
const only = opt('--only')
const fake = opt('--file')
if (fake) { const [n, p] = fake.split('='); const r = RULES.find((x) => x.name === n); if (r) r.file = p }
const TIME = /^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)\]/
const utf8 = new TextDecoder('utf-8')
const big5 = new TextDecoder('big5')
// 一份 log 可能混著 cmd 寫的 cp950 和 node 寫的 UTF-8:逐行解,UTF-8 解出亂碼就改用 big5
function readLines(file) {
const buf = fs.readFileSync(file).filter((b) => b !== 0) // 斷電重開會留下整塊 NUL
const out = []
let s = 0
for (let i = 0; i <= buf.length; i++) {
if (i === buf.length || buf[i] === 0x0a) {
const part = buf.subarray(s, i)
let line = utf8.decode(part)
if (line.includes('\uFFFD')) line = big5.decode(part)
out.push(line.replace(/\r$/, ''))
s = i + 1
}
}
return out
}
const ts = (line) => { const m = TIME.exec(line); return m ? new Date(m[1].replace(' ', 'T')).getTime() : null }
const ago = (t) => `${((Date.now() - t) / H).toFixed(1)}h 前`
function check(r) {
if (r.type === 'proc') {
const ps = `Get-CimInstance Win32_Process -Filter "Name='${r.exe}'" | ForEach-Object { $_.CommandLine }`
const lines = execFileSync('powershell', ['-NoProfile', '-Command', ps], { encoding: 'utf8' }).split(/\r?\n/)
return lines.some((l) => r.cmd.test(l)) ? ['green', '常駐中'] : ['red', '常駐程序沒在跑']
}
if (!fs.existsSync(r.file)) return ['yellow', 'log 不見了']
const mtime = fs.statSync(r.file).mtimeMs
if (r.type === 'mtime') return Date.now() - mtime > r.maxAgeH * H ? ['red', `超過 ${r.maxAgeH}h 沒更新`] : ['green', `更新於 ${ago(mtime)}`]
if (r.type === 'status') { // 每輪覆蓋一行:[時間] exit N
const line = readLines(r.file).find((l) => /exit -?\d+/.test(l)) || ''
const code = Number((line.match(/exit (-?\d+)/) || [])[1])
const t = ts(line) ?? mtime
if (!line) return ['yellow', '狀態檔看不懂']
if (!r.okCodes.includes(code)) return ['red', `最後一輪 exit ${code}(${ago(t)})`]
return Date.now() - t > r.maxAgeH * H ? ['red', `超過 ${r.maxAgeH}h 沒跑`] : ['green', `exit ${code}(${ago(t)})`]
}
let lastStart = null, lastOk = null, lastEnd = null, lastEndOk = null
for (const l of readLines(r.file)) { // 照行序看,最後一個結束標記說了算
const t = ts(l)
if (t == null) continue
if (r.start.test(l)) lastStart = t
if (r.ok.test(l)) { lastOk = lastEnd = t; lastEndOk = true }
else if (r.fail.test(l)) { lastEnd = t; lastEndOk = false }
}
if (lastEndOk === false) return ['red', `最後一次失敗(${ago(lastEnd)})`]
if (!lastOk || Date.now() - lastOk > r.maxAgeH * H) return ['red', lastOk ? `最後成功 ${ago(lastOk)},超過 ${r.maxAgeH}h` : '找不到任何一次成功']
if (lastStart > lastEnd && Date.now() - lastStart > r.stuckH * H) return ['yellow', `開始了沒有結束(${ago(lastStart)})`]
return ['green', `最後成功 ${ago(lastOk)}`]
}
const rows = RULES.filter((r) => !only || r.name === only).map((r) => {
try { const [level, why] = check(r); return { name: r.name, level, why } }
catch (e) { return { name: r.name, level: 'yellow', why: `檢查本身壞了:${e.message.split('\n')[0]}` } }
})
const icon = { green: '🟢', yellow: '🟡', red: '🔴' }
if (args.includes('--json')) console.log(JSON.stringify(rows, null, 1))
else for (const x of rows) console.log(`${icon[x.level]} ${x.name.padEnd(16)} ${x.why}`)
process.exitCode = rows.some((x) => x.level === 'red') ? 1 : 0
這支骨架我們寫這篇時用假 log 逐一測過:失敗、超時、開始了沒結束、檔案不見、夾 NUL 和 big5 的 log、狀態檔 exit 255、常駐程序不存在,燈號都對。第一版還抓到自己一個 bug:同一秒內先失敗再成功會被判成失敗,改成照行序,最後一個結束標記說了算。
有哪些坑會讓你白忙?
一份 log 兩種編碼。 cmd 用 !date! 寫出的「週三」是 cp950,node 寫的是 UTF-8,兩種會出現在同一個檔裡。整份用 UTF-8 解,中文就變亂碼,regex 也對不上。所以骨架是逐行解:UTF-8 解出替代字元,就改用 big5 重解。
重開機留下的 NUL。 機器卡死再強制重開,寫到一半的檔會留下整塊 NUL。log 還好,濾掉就行;狀態檔就慘了。我們每兩小時跑一次的巡邏腳本,記錄上一輪狀態的 JSON 檔重開後變成 504 bytes 的 NUL,JSON.parse 直接炸,整輪巡邏跟著死。現在的寫法是:讀不懂就當成第一輪,記一行 log 繼續跑。
新規則上線第一天全紅。 剛補上包裝的排程,log 裡還沒有任何一輪,照規則是「找不到任何一次成功」。我們給每條新規則一個起算時間,時限內還沒跑完第一輪先標綠「新納入」,過了時限還是沒有紀錄才轉紅。
告警要有節奏。 健檢結果接進每兩小時一次的巡邏:紅燈直接告警,但同一句話 24 小時內只報一次;黃燈要連兩輪都黃才報,因為「開始了還沒結束」常常只是剛好在跑。巡邏自己那條不在自己的檢查範圍裡,巡邏死了就發不出告警,這要靠別的機制兜底。
資源看門也照同一個想法
除了排程,我們還有一支資源看門,每 5 分鐘量一次可用記憶體、磁碟佇列和 claude.exe 數量。可用記憶體低於 8GB、磁碟佇列連續 3 次超過 8,或 claude.exe 超過 8 個,就寫一個暫停旗標檔,派工迴圈看到旗標就不再開新的 agent。旗標內容是 JSON,帶 by: "resource-guard",指標回到門檻內時它只刪自己插的旗,人手動放的不動。
它自己也進了健檢表:每輪覆蓋一行狀態檔,30 分鐘沒更新就判紅。看門的程式也要有人看。
覆蓋到哪裡,還抓不到什麼?
健檢表從 33 條擴到 52 條,沒覆蓋的從 22 條降到 1 條(其中 3 條是已刪除或停用的排程,直接移出名單),剩下那條是每年 12 月 1 日才跑一次的提醒,至今還沒跑過。擴完那一輪是綠 46、紅 6:5 條是 11/3 停機留下的,等各自下一輪跑完就會轉綠;另 1 條就是那支 Telegram 看門狗,現在如實顯示 exit 255。我們還沒修它,因為修好之後它會真的開始重啟程序,那是行為改變,要另外決定。
抓不到的也講清楚:
- 跑成功但什麼都沒做。 有一條抓成效數據的排程每次都 exit 0,實際上 API 回權限不足、一支影片都沒抓到。判定只看結束碼,它就是綠的。要抓這種,成功標記得帶上產出數量,這個我們還沒做。
- 有告警沒人修。 影片排程那 9 天,告警每天都有發。偵測只解決「知道」,不解決「有人去修」。
更多「服務看起來活著、其實什麼都沒做」的例子,我們整理在自動化工具的靜默失敗與偵測,那篇講的是旗標、心跳和告警去重,跟這篇的排程結果碼是同一種病。
常見問題
為什麼 Windows 工作排程器的上次執行結果是 0,程式卻沒跑成功?
因為排程器拿到的是最外層那支程式的結束碼。常見的寫法是用 VBS 把視窗藏起來再叫真正的程式,VBS 不等子程序就結束,或等了卻沒把子程序的結束碼傳回去,排程器看到的都是 VBS 自己的 0。我們實測過,VBS 本身執行期出錯,cscript 回傳的結束碼一樣是 0。
不看結果碼,要怎麼判斷排程有沒有真的成功?
讓每條排程自己寫下紀錄,再用另一支程式去讀。我們用四種來源:log 裡的開始與結束標記(結束標記帶真實結束碼)、每輪覆蓋一行的狀態檔、輸出檔的修改時間、常駐程序在不在。判定規則是最後一輪失敗判紅、超過時限沒有成功也判紅(包括根本沒跑),開始了卻沒有結束標記判黃。
cmd 裡用 & 串指令再 echo %errorlevel%,為什麼永遠是 0?
cmd 會在執行整行之前就把 %errorlevel% 換成當下的值,那時前面的指令還沒跑。要用 cmd /v:on 打開延遲展開,改寫成 !errorlevel!,才會在執行到那一段時才取值。我們實測同一行指令,%errorlevel% 拿到 0,!errorlevel! 拿到真正的 3。