Scheduled task Last Result 2147020576 on a task that runs perfectly by hand

4 min read WindowsTask SchedulerAutomation

0x80070420 does not mean the task ran and failed. It means the trigger was missed because the machine was asleep, and Last Result is sticky, so a weekly task reports the same failure every day until its next run.

TL;DR · THE FIX

Last Result 2147020576 (0x80070420, ERROR_SERVICE_NOT_ACTIVE) on a task that runs fine manually means the trigger was MISSED, not that the task ran and failed. The PC was asleep and StartWhenAvailable was off. schtasks /query cannot tell you this; the proof is event 153 in Microsoft-Windows-TaskScheduler/Operational. And the code is sticky: it keeps reporting until the next trigger fires.

The symptom

A health report started flagging a scheduled task as failing: Last Result: -2147020576, which the same report also printed as 0x80070420, on a task that had been running for months. So I ran it by hand:

schtasks /run /tn "MorningDigest"
# SUCCESS: Attempted to run the scheduled task "MorningDigest".

It ran, produced its output, and exited clean. The next day the report flagged it again with the identical code, and kept doing so for the rest of the week. The task worked and the task was failing.

What I tried first

I went looking for what the task might be failing at, because a non-zero result code comes from something that ran. 0x80070420 decodes to ERROR_SERVICE_NOT_ACTIVE, which sent me at the wrong target. Which service? The Task Scheduler service was obviously running, or nothing would have been scheduled at all. I checked whether the script depended on something that starts late, on the theory that a boot-time race could produce a failure a manual run at 3pm would never reproduce. That is a real failure mode and it was worth an hour, and it was not that.

What broke the case open was noticing that the failure code never changed, across days. A task that fails intermittently produces varying codes, or at least varying timestamps. This one was frozen.

What was happening

Two separate things, and you need both.

First, the code does not mean what it looks like it means. Here 0x80070420 is Task Scheduler reporting that it could not launch the task. The machine was asleep at the trigger time, the task had StartWhenAvailable off, and the trigger came and went with nothing to run it and nothing to catch up afterwards. The task never happened. The proof is in the operational log rather than in schtasks:

Get-WinEvent -LogName Microsoft-Windows-TaskScheduler/Operational -MaxEvents 200 |
  Where-Object { $_.Id -in 153, 114 } |
  Select-Object TimeCreated, Id, Message |
  Format-List

Event 153 is “Task Scheduler did not launch task as it missed its schedule.” Event 114 is what you get on tasks that did catch up. Seeing 153 on the failing task and 114 on its neighbours turns a guess into a fact, and neither number is visible from schtasks /query, which is why an hour of querying got me nowhere.

Second, Last Result is sticky. It holds the outcome of the last launch attempt until the next one. For a task that runs every hour that is harmless, because the value refreshes before you can misread it. For a weekly task it means one missed Sunday is reported as a current failure every day until the following Sunday, and there is no way to tell the re-read old failure from a new one using the value alone. That is what wasted the most time, because it made a single missed trigger look like an ongoing outage.

The fix

The task-level fix is one setting:

$s = New-ScheduledTaskSettingsSet -StartWhenAvailable -WakeToRun:$false
Set-ScheduledTask -TaskName "MorningDigest" -Settings $s

StartWhenAvailable makes the task run as soon as the machine is back instead of silently skipping the window. WakeToRun is the heavier hammer and I left it off on purpose: I would rather the digest arrive late than have the machine wake itself at 08:30 every day.

The more valuable fix is to the reporting, because the code was never going to be readable on its own. A health check that prints Last Result: -2147020576 has told you nothing. The same check is useful once it prints three more things:

$t = Get-ScheduledTask -TaskName $name
$i = $t | Get-ScheduledTaskInfo
$ageDays = [int]((Get-Date) - $i.LastRunTime).TotalDays
"{0}: result 0x{1:X8}, last run {2} ({3}d ago), next run {4}" -f `
  $name, $i.LastTaskResult, $i.LastRunTime, $ageDays, $i.NextRunTime

The age of the failure, the next run time, and the code in hex so it is greppable. With the age attached, a stale weekly failure identifies itself: last run 6d ago, next run in 1d is obviously not a live incident. Without it you get a red line every morning, and what happens next is that you learn to skip the section, which is worse than not having the check.

The lesson

A status field records the last attempt, and an attempt that never started still writes to it. Any monitoring built on “read the last result” inherits that and will report a historical non-event as a current failure for exactly as long as the gap between runs.

So go to the operational log rather than the summary command, because the summary shows you a value and the log shows you what happened. And when you surface a sticky field in a report, surface its age next to it, or you have built something that cries wolf on a schedule.

There is a companion failure on Windows where tasks report Disabled while Get-ScheduledTask says Ready seconds later. That one is antivirus mass-disabling every task on the machine, and it has a completely different tell.

Related fixes

Discussion

Powered by GitHub. Sign in to leave a comment.