WindowsTask Schedulermonitoring自動化VBScriptNode.js

Exit code 0 is not success: how we check 50+ Windows scheduled tasks for real

· · 14 min read
Table of Contents
  1. How did we find out the result code was lying?
  2. Why is the result code 0?
  3. What do we look at instead?
  4. How do we add markers in the wrapper?
  5. What does the checker look like?
  6. Which traps will waste your time?
  7. The resource guard follows the same idea
  8. How far does it reach, and what does it still miss?

Task Scheduler's "Last Run Result 0x0" only means the program the scheduler launched exited cleanly. If that program is a VBS wrapper that hides the console window, the 0 belongs to the VBS. Our main machine runs over fifty scheduled tasks, and this week we found that one of them, a watchdog, exits 255 whenever it runs and has never done its job, while the scheduler kept reporting success. We no longer look at result codes at all: each task writes its own start and end markers, a checker reads them on a timer, and the last run failing, or no success within a time limit, means red.

How did we find out the result code was lying?

The first case: our daily short-video task failed 9 days in a row, October 24 to November 1. Its log said exit 1 every time and an alert went out every time; the alerts got lost among other messages and nobody followed up.

The second: on November 3 the machine froze, and none of the tasks due between 05:49 and 18:57 ran. Nothing caught up after the reboot either. A task that never runs produces no new result code, so the scheduler screen kept showing the previous 0.

The third was the most absurd. A Telegram watchdog is supposed to notice when the coordinating Claude process dies and bring it back. Its .bat file had LF line endings plus some UTF-8 symbols, and cmd reading a file like that eats the first characters of lines: REM became 'Telegram', set became 'et', tasklist became sklist, and the script ended with exit 255. The log it was supposed to write never appeared, which means it never restarted anything. On the scheduler's side the result was always 0.

Why is the result code 0?

Almost all our tasks are wscript.exe something.vbs; the VBS only hides the window and starts the real program. It goes wrong in three places:

  1. WshShell.Run cmd, 0, False: no waiting. The VBS exits right away and the scheduler gets 0.
  2. WshShell.Run cmd, 0, True: it waits, but without WScript.Quit rc the VBS still returns 0. The watchdog above was this kind.
  3. The VBS fails itself. While writing this post we pointed the log path at a drive that does not exist; the VBS hit a runtime error on the open-file line, and cscript's exit code was still 0.

Even with all three fixed, the result code only remembers the last run. It cannot tell you "this has not run for three days". So we moved the success check onto records the tasks write themselves.

What do we look at instead?

Four kinds of records, picked per task:

  • Log markers: one line at start, one at end, and the end line carries the real exit code. Most tasks use this.
  • Status file: one line, [time] exit N, overwritten every run. For tasks that run every few minutes, so the log does not grow forever.
  • Modified time: how long since an output file was updated. For tasks that only produce files and never report success.
  • Resident process: whether it shows up in Win32_Process. For programs that start at boot and never exit.

Each task is one row in the health table: which file to read, what success looks like (a regex), what failure looks like, and how long without success counts as red. There are only three colors:

  • Red: the last finished run failed, or there has been no success within the time limit, including never running.
  • Yellow: there is a success within the time limit, but the latest run has a start marker and no end marker; or the log is missing, or the checker cannot read it.
  • Green: everything else.

The time limit is "one cycle plus a bit": 26 hours for daily tasks, 8 days for weekly ones. Red on timeout is the core of the whole thing, because the most dangerous failure is not running at all, and no log line will ever tell you about it. The first time the checker ran, after the November 3 reboot, it showed 11 reds. We went through them one by one: all real, all tasks that should have run during the freeze.

How do we add markers in the wrapper?

Plenty of programs do not report success themselves. Instead of changing them, we change the VBS around them. This one waits for the child, gets the real exit code, writes it into the task's own log, and finally hands the exit code back to the scheduler:

' 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

Each task writes its own file, so two tasks never append to the same log at once. The timestamp is built by hand so it does not depend on the system's regional settings.

Waiting has a side effect: the VBS now lives as long as your program, so the task's "stop if it runs longer than" and "do not start a new instance" settings start applying to your program for real. For some tasks we did not want that change, so cmd writes the end marker on the same line and the VBS still exits right away:

cmd.exe /v:on /c "<original command> & echo [!date! !time!] end Nightly-Export rc=!errorlevel! >> C:\ops\logs\run.log"

There is a trap here that everyone falls into once. Written as %errorlevel%, cmd substitutes the value when it parses the line, before the first command has run. We ran the same line with cmd /c exit 3: %errorlevel% wrote 0, and only /v:on with !errorlevel! wrote 3. Also, !date! !time! follows the system's regional format. Ours writes 2026/11/04 週三 0:35:04.92 (a Chinese weekday in the middle), so adjust the checker's time regex to whatever your machine writes.

What does the checker look like?

Here is a trimmed skeleton. Swap the rule table for your own tasks and it runs. It exits 1 when anything is red, so a patrol or alert job can sit on top of it, and --file swaps one task's log for a fake file to test the rules:

// task-health.cjs: judge scheduled tasks by the records they write, not by their result code
// usage: node task-health.cjs [--json] [--only NAME] [--file "NAME=path-to-fake.log"]
const fs = require('fs')
const { execFileSync } = require('child_process')

const H = 3600e3
const LOGS = 'C:/ops/logs'
// one row per task: where to look, what success looks like, how long without success means red
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')
// one log can mix cp950 from cmd and UTF-8 from node: decode per line, fall back to big5
function readLines(file) {
  const buf = fs.readFileSync(file).filter((b) => b !== 0)   // a hard reboot leaves blocks of NUL bytes
  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 ago`

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', 'running'] : ['red', 'resident process not running']
  }
  if (!fs.existsSync(r.file)) return ['yellow', 'log missing']
  const mtime = fs.statSync(r.file).mtimeMs
  if (r.type === 'mtime') return Date.now() - mtime > r.maxAgeH * H ? ['red', `not updated for over ${r.maxAgeH}h`] : ['green', `updated ${ago(mtime)}`]
  if (r.type === 'status') {   // overwritten every run: [time] 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', 'status file unreadable']
    if (!r.okCodes.includes(code)) return ['red', `last run exit ${code} (${ago(t)})`]
    return Date.now() - t > r.maxAgeH * H ? ['red', `no run for over ${r.maxAgeH}h`] : ['green', `exit ${code} (${ago(t)})`]
  }
  let lastStart = null, lastOk = null, lastEnd = null, lastEndOk = null
  for (const l of readLines(r.file)) {   // go by line order: the last end marker wins
    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', `last run failed (${ago(lastEnd)})`]
  if (!lastOk || Date.now() - lastOk > r.maxAgeH * H) return ['red', lastOk ? `last success ${ago(lastOk)}, over ${r.maxAgeH}h` : 'no successful run found']
  if (lastStart > lastEnd && Date.now() - lastStart > r.stuckH * H) return ['yellow', `started but never ended (${ago(lastStart)})`]
  return ['green', `last success ${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: `check itself failed: ${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

We tested this skeleton with fake logs while writing the post: failure, timeout, started but never ended, missing file, a log with NUL bytes and big5 text, a status file with exit 255, a resident process that does not exist. Every color came out right. The first version even caught a bug of its own: a failure followed by a success in the same second was judged a failure. It now goes by line order, and the last end marker wins.

Which traps will waste your time?

Two encodings in one log. The weekday cmd writes through !date! is cp950, what node writes is UTF-8, and both end up in the same file. Decode the whole file as UTF-8 and the Chinese turns to garbage and the regex stops matching. So the skeleton decodes line by line and re-decodes as big5 when UTF-8 produces replacement characters.

NUL bytes after a reboot. After a freeze and a forced reboot, files that were mid-write end up with blocks of NUL. Logs are fine, you filter them out. State files are worse. The JSON state file of our two-hourly patrol script came back as 504 bytes of NUL, JSON.parse threw, and the whole patrol round died. Now an unreadable state file is treated as a first run: log one line and carry on.

Every new rule is red on day one. A task whose wrapper was just added has no run in its log yet, which by the rules is "no successful run found". Each new rule gets a start time: until the time limit has passed it shows green as "newly added", and only if there is still no record after that does it turn red.

Alerts need a rhythm. The health results feed into a patrol that runs every two hours. Reds alert right away, but the same sentence goes out at most once in 24 hours. Yellows must stay yellow for two rounds in a row, because "started, not finished" often just means it is running. The patrol does not check itself: a dead patrol cannot send alerts, so something else has to cover that.

The resource guard follows the same idea

Besides scheduled tasks we have a resource guard that measures free memory, disk queue length and the number of claude.exe processes every 5 minutes. Free memory under 8 GB, disk queue over 8 for 3 checks in a row, or more than 8 claude.exe, and it writes a pause flag file; the dispatch loop sees the flag and stops starting new agents. The flag is JSON with by: "resource-guard", and when the numbers come back under the limits it only removes a flag it wrote itself. A flag a person put there stays.

It is in the health table too: it overwrites a one-line status file every run, and no update for 30 minutes is red. The watcher needs watching as well.

How far does it reach, and what does it still miss?

The health table grew from 33 tasks to 52, and uncovered tasks went from 22 to 1 (3 of those were deleted or disabled tasks, simply taken off the list). The one left is a reminder that runs once a year on December 1 and has not run yet. The first full run after that was 46 green, 6 red. Five came from the November 3 freeze and turn green after their next normal run. The sixth is that Telegram watchdog, now honestly showing exit 255. We have not fixed it yet, because once fixed it will actually start restarting processes, and that behavior change is a separate decision.

What it does not catch, stated plainly:

  • Ran fine, did nothing. One task that pulls performance data exits 0 every time, while the API actually answers with missing permissions and not a single video gets fetched. The check only looks at the exit code, so it is green. Catching this needs the success marker to carry an output count, which we have not done yet.
  • Alerts nobody acts on. During those 9 days of failed video runs, an alert went out every day. Detection solves "knowing", not "someone fixing it".

We collected more cases of "the service looks alive but did nothing" in silent failures in automation and how to detect them. That post covers flags, heartbeats and alert dedup; the scheduler result code is the same disease.

FAQ

Why does Windows Task Scheduler show a last run result of 0 when the program failed?

Task Scheduler records the exit code of the outermost program it launched. A common setup is a VBS file that hides the console window and starts the real program. If the VBS does not wait for the child, or waits but never passes the child's exit code back, the scheduler only sees the VBS's own 0. We also tested a VBS that hits a runtime error itself: cscript still returned 0.

If not the result code, how do I tell whether a scheduled task really succeeded?

Have every task write its own record and let a separate checker read it. We use four sources: start and end markers in a log (the end marker carries the real exit code), a one-line status file overwritten every run, the modified time of an output file, and whether a resident process is running. The last run failing is red, no success within the time limit is also red (including never running at all), and a start marker with no end marker is yellow.

Why does echo %errorlevel% after & in a cmd one-liner always print 0?

cmd expands %errorlevel% when it parses the whole line, before the earlier command has run. Turn on delayed expansion with cmd /v:on and write !errorlevel!, which is read at the moment that part executes. In our test of the same line, %errorlevel% wrote 0 and !errorlevel! wrote the real 3.

Weekly AI Automation Playbook

No fluff — just templates, SOPs, and technical breakdowns you can use right away.

Join the Solo Lab Community

Free resource packs, daily build logs, and AI agents you can talk to. A community for solo devs who build with AI.

Want to try it yourself?

UltraProbe is free and needs no sign-up. One scan tells you whether Google and AI engines can find your site.