Windows工作排程器監控自動化VBScriptNode.js

排程回傳 0 不代表成功:我們給 50 多條 Windows 排程做的真實成敗偵測

· · 23 分鐘閱讀
目錄
  1. 我們怎麼發現結果碼不可信?
  2. 結果碼為什麼會是 0?
  3. 不看結果碼,那看什麼?
  4. 包裝層怎麼補?
  5. 檢查程式長什麼樣?
  6. 有哪些坑會讓你白忙?
  7. 資源看門也照同一個想法
  8. 覆蓋到哪裡,還抓不到什麼?

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 只負責藏視窗、叫真正的程式。問題出在三個地方:

  1. WshShell.Run cmd, 0, False:不等子程序,VBS 叫完就結束,排程器拿到 0。
  2. WshShell.Run cmd, 0, True:有等,但最後沒寫 WScript.Quit rc,VBS 一樣回 0。上面那支看門狗就是這種。
  3. 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。

每週 AI 自動化實戰筆記

不廢話,只有能直接用的東西。Prompt 模板、自動化 SOP、技術拆解。

加入一人公司實驗室

免費資源包、每日建造日誌、可以對話的 AI Agent。一群用 AI 武裝自己的獨立開發者社群。

想自己動手試試?

UltraProbe 免費、免註冊,掃一次網站就知道 Google 與 AI 找不找得到你。