I run about thirty scheduled jobs on a single Windows box. Some are scrapers, some generate content, some are trading bots, some just check that the other jobs are alive. Most of them were written and are maintained by an AI coding agent that I let run unattended.
Over three months, every one of the failures below reported success. The scheduler said LastTaskResult = 0. The logs looked fine or didn't exist. And nothing had happened.
If you only take one thing from this post: stop checking exit codes, start checking artifacts. I'll get to why at the end. First, the seven ways I got lied to.
1. The wrapper that always returns 0
To stop console windows flashing on my desktop every few minutes, I wrapped each scheduled task in a tiny VBScript launcher:
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "cmd /c ""python job.py >> job.log 2>&1""", 0, True
0 hides the window. True waits for completion. I assumed True also meant the exit code came back. It does not. WshShell.Run used as a statement discards the return value, so wscript.exe exits 0 no matter what the child did.
I found this because a content pipeline had been dead for five days while the scheduler reported green every single day.
The fix is to call Run as a function and pass the value out:
Set WshShell = CreateObject("WScript.Shell")
exitCode = WshShell.Run("cmd /c ""python job.py >> job.log 2>&1""", 0, True)
WScript.Quit(exitCode)
Note the parentheses — required when you're taking a return value. After fixing this across
17 launchers, one task showed a non-zero result for the first time in its life. It had
been failing for weeks.
2. The last line of your batch file overwrites the exit code
Fixed the launcher, still got false greens. The next layer down was a .cmd shim:
node pipeline.js >> run.log 2>&1
echo [done] exit code %errorlevel% >> run.log
That echo is the last command, echo always succeeds, so the batch file returns its exit code — zero — regardless of what node did. The log even contained the correct non-zero errorlevel. It just never made it out.
node pipeline.js >> run.log 2>&1
set NODE_EXIT=%errorlevel%
echo [done] exit code %NODE_EXIT% >> run.log
exit /b %NODE_EXIT%
Debugging heuristic: when you fix one layer and still get false greens, assume there's
another layer. Mine was three deep: scheduler → vbs → cmd → python.
3. No console, no stdout, no error, no service
A Flask service was set to start at login through a VBS launcher using pythonw.exe (the GUI-subsystem Python with no console). The script began with a routine encoding guard:
if sys.stdout.encoding != "utf-8":
sys.stdout.reconfigure(encoding="utf-8")
Under pythonw, sys.stdout isn't a usable stream. That line raised, uncaught, before the server ever bound its port. No console existed to print the traceback to, so there was no error anywhere. The visible symptom was "the port isn't open after reboot," which sends you straight to firewall and networking — the wrong place entirely.
Fix: don't use pythonw. Use regular python.exe with output redirected to a file, so
stdout is a real stream:
exitCode = WshShell.Run("cmd /c ""python.exe app.py >> app.log 2>&1""", 0, True)
Heuristic: if a background service won't come up, check tasklist for the process
first. If it isn't there, the program died on startup — it's not a network problem.
4. Two scheduler defaults that quietly kill laptop jobs
Windows Task Scheduler ships with defaults that are reasonable for a desktop and lethal on a laptop:
| Setting | Default | What it does to you |
|---|---|---|
DisallowStartIfOnBatteries |
true | Job doesn't run at all when unplugged. No error. |
StopIfGoingOnBatteries |
true | Job dies mid-run if you unplug. |
StopOnIdleEnd |
true | Job is killed when you touch the machine |
StartWhenAvailable |
false | A missed run is skipped, not retried |
That third row cost me the most. A job would start on schedule, I'd sit down and move the mouse, idle state would end, and the scheduler would terminate the task mid-flight. The result code was "terminated," which is easy to misread as a crash in your own code.
Every new task I create now gets all four flipped:
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew
Set-ScheduledTask -TaskName $name -Settings $s
$t = Get-ScheduledTask -TaskName $name
$t.Settings.IdleSettings.StopOnIdleEnd = $false
Set-ScheduledTask -TaskName $name -Settings $t.Settings
StopOnIdleEnd isn't exposed on New-ScheduledTaskSettingsSet, which is why it needs the second, uglier step — and why it's the one people miss.
5. A comment warning about a trap became the trap
This one is my favourite, because it's so stupid.
A batch file had a comment reminding future-me about a previous bug:
rem 坑備忘:exit /b %errorlevel% 必留
(It's in Chinese — "trap memo: exit /b %errorlevel% must stay" — which matters, because that's what makes it multi-byte.)
The file is UTF-8. cmd.exe decodes batch files using the system codepage, which on this machine is CP950, not UTF-8. Multi-byte characters get re-paired at the wrong boundaries, the comment gets truncated partway through, and the remainder of the line is executed as a command.
Every morning at 08:30 a console popped up: 'errorlevel' is not recognized as an internal or external command. The pipeline itself succeeded. The exit code was 0. The only symptom was a comment's corpse trying to run.
Rule I follow now: comments in .cmd/.bat files that run unattended are ASCII-only.
If you truly need non-ASCII, chcp 65001 >nul must be the first line — it can't rescue anything above itself.
The same class of bug bites .vbs (WSH reads it as ANSI — save as UTF-16 LE if it contains non-ASCII) and .ps1 (non-ASCII comments can desync the parser so badly that source lines end up inside string variables).
6. Your OS silently blocks the CLI tool you pip-installed
A publishing job died on:
OSError: [WinError 4551] This file is blocked by application control policy
It was calling yt-dlp via subprocess. Windows Smart App Control had decided that this particular unsigned, freshly-updated standalone .exe had no reputation yet and blocked it. The queue silently backed up for a day.
The fix is not to weaken your security policy. It's to stop invoking the standalone executable at all:
subprocess.run([sys.executable, "-m", "yt_dlp", ...]) # goes through trusted python.exe
Most pip-installed CLI tools support -m invocation. Prefer it in anything unattended.
7. The audit script you don't have
Every failure above shares a property: the status channel said one thing and reality said another. So I stopped trusting the status channel.
I now run two scripts, and they answer deliberately different questions:
-
Did it run, and what happened? Enumerate every scheduled task, resolve where its log
actually goes (mine hide behind
set LOG=variables, nested vbs→cmd calls, and relative redirects), and read the tail. - Did anything come out? For each job, define the artifact it's supposed to produce and the maximum age that's acceptable. Anything staler than that is flagged, regardless of what the exit code says.
The second one is the important one. It's how I found out a video pipeline had been running "successfully" every day for weeks while producing zero files.
CHECKS = [
# (name, artifact glob, max age in hours)
("daily-report", r"C:\...\memory\daily_*.md", 26),
("product-radar", r"C:\...\data\radar_*.csv", 26),
("video-pipeline", r"C:\...\videos\*.mp4", 26),
]
Fifteen lines of config, and it catches an entire class of failure that no amount of exit-code checking will.
The pattern
Six of these seven are the same bug wearing different clothes: a layer between you and the work reported on itself instead of on the work. The wrapper reported on the wrapper. The batch file reported on its last echo. The scheduler reported that it launched something, not that the something did anything.
So the habit that actually protects you isn't better error handling. It's:
Define, in advance, the observable thing that proves the job did its work.
Then check for that, on a schedule, and alert when it goes stale.
Exit codes tell you a process ended. Artifacts tell you a job happened. Only one of those is what you actually care about at 3am when you're asleep and the machine is working.
I package the full version of this — 22 documented traps, the two audit scripts, wrapper
templates, and the memory system that stops an AI agent re-learning all of it every session — as the Claude Code Automation Playbook. But everything above is standalone; take it and go fix your own false greens.
Top comments (1)
The agent variation of this happens when the model catches its own exception, prints a summary explaining why it gave up, and exits 0. To the scheduler or parent runner, the task completed cleanly. I ran into this after watching pipelines report green while writing zero-byte files or dumping partial traces into the destination folder. The fix that held up was treating the process return as untrusted and making a separate validator check artifact timestamp, non-zero size, and schema validity before recording a successful run.