Windows Task Scheduler Monitoring: Result Codes, History, Alerts
Contents
To monitor Windows Task Scheduler, read each task's Last Run Result and event history for failures, and have the task check in with an external monitor so that a run that never starts also raises an alert. Task Scheduler records results but notifies no one: its email action was removed in Windows 8 and Windows Server 2012 (IEmailAction). Several defaults skip or stop runs without an error, and a PowerShell script that fails can still report success. This guide covers Windows Server 2016 through 2025 and Windows 10 and 11, with Windows PowerShell 5.1 and PowerShell 7; it is the Windows counterpart of how to know if a cron job ran.
Task Settings That Skip or Stop Runs Without an Error#
These settings skip or end runs by design. The history event in the last column is written only if task history is enabled, which a later section covers.
| Setting (console wording) | Default | Effect on a run | History event |
|---|---|---|---|
| Run only when user is logged on (General) | Selected: the task runs as its creator, only while they're signed in | Nothing runs on a server where nobody is signed in | 332 |
| Start the task only if the computer is on AC power (Conditions) | On | No start on battery; Stop if the computer switches to battery power ends a run in progress | 326, 327 |
| Run task as soon as possible after a scheduled start is missed (Settings) | Off | A start missed while the machine was off or the service was busy is never made up | 114 if on |
| Stop the task if it runs longer than (Settings) | 72 hours | The run is terminated. Runs started on demand are exempt, so clicking Run to test never reaches the limit | 111, 329 |
| If the task is already running (Settings) | Do not start a new instance | A slow or hung run silently blocks the next starts | 322 |
| Expire (trigger, Advanced settings) | Not set | After that date the trigger never fires again | 128 |
Defaults: Task Security Context, DisallowStartIfOnBatteries, StartWhenAvailable, ExecutionTimeLimit, MultipleInstancesPolicy. Events: Microsoft's Task Properties and Task Monitoring and Control lists.
Run whether user is logged on or not runs the task non-interactively with a stored password. If you also check Do not store password, Task Scheduler uses S4U logon, which can reach only local resources (Task Security Context). A stored password doesn't follow a password change; Microsoft says the credential store "requires that passwords be updated once per computer" (Help! My Scheduled Task does not run). Until someone re-enters it on each server, the task fails to log on (event 104). A group managed service account removes the stored password; Microsoft's overview names scheduled tasks among the workloads that can run under one (gMSA overview).
One more trap: when Start in is blank, a script's relative paths resolve against %SystemRoot%\System32 (the same support article).
What Last Run Result Actually Holds#
Last Run Result, LastTaskResult in PowerShell, is the result returned by the task's last run (IRegisteredTask). For a Start a program action that is the program's exit code in hex, so a script that exits with 1 shows 0x1. When Task Scheduler itself couldn't run the task, the field holds one of its own status constants (0x0004xxxx for success, 0x8004xxxx for errors) or a Windows error wrapped as an HRESULT: 0x8007 followed by the Win32 error number (HRESULT structure).
So 0x2 is the Win32 code for "file not found", but as a Last Run Result it only says the program returned 2. What 2 means is up to that program.
| Code | Meaning | What to check |
|---|---|---|
0x0 |
The action exited with 0 | Whether the job did its work (next section) |
0x1 |
The action exited with 1 | Run the exact command line as the task's account |
0x2 |
The action exited with 2 | The program's own exit codes |
0x41301 |
SCHED_S_TASK_RUNNING: the task is currently running |
A run still going long after its usual duration is hung |
0x41303 |
SCHED_S_TASK_HAS_NOT_RUN: the task has not yet run |
The trigger's start date, and whether it ever fired |
0x41306 |
SCHED_S_TASK_TERMINATED: the last run was terminated by the user |
History: 111 or 329 (time limit), 330 (stopped by a user) |
0x8004131F |
SCHED_E_ALREADY_RUNNING: an instance of this task is already running |
The If the task is already running rule |
0x80041326 |
SCHED_E_TASK_DISABLED: the task is disabled |
Who disabled it: event 142 |
0x80070002 |
ERROR_FILE_NOT_FOUND: the system cannot find the file specified |
The program path in the action and the Start in folder |
0x8007052E |
ERROR_LOGON_FAILURE: the user name or password is incorrect |
The stored password after a rotation |
0x800710E0 |
ERROR_REQUEST_REFUSED: the operator or administrator has refused the request |
Conditions and the already-running rule; history around that time |
0xE0434352 |
The exception code of a .NET (CLR) exception: the program died of an unhandled exception | Catch exceptions in Main and return an exit code |
Win32 meanings: Microsoft's system error code lists (0-499, 1300-1699, 4000-5999). A process ended by an unhandled exception exits with the exception's code (GetExitCodeProcess), and 0xE0434352 is the CLR's (Microsoft Learn).
Why a Failed Script Can Still Report 0x0#
Task Scheduler only sees the exit code of the process it started. When that's PowerShell, the code follows the rules in about_PowerShell_exe for Windows PowerShell 5.1, and the same rules in about_Pwsh for pwsh:
| How the task starts it | Process exit code |
|---|---|
powershell.exe -File job.ps1 |
The number given to exit; 1 if a script-terminating error (throw, -ErrorAction Stop) ends the script; otherwise 0 |
powershell.exe -Command "..." |
0 or 1, from $? after the last command. A called script's exit 3 becomes 1 unless the command ends with exit $LASTEXITCODE |
A .bat or .cmd file |
exit N sets the process exit code. exit /b N is documented only to set ERRORLEVEL (exit) |
The 0x0 trap is that last case under -File, and $LASTEXITCODE in about_Automatic_Variables lists the same three outcomes. Write-Error and most cmdlet failures are non-terminating, and under the default $ErrorActionPreference of Continue, PowerShell prints the error and keeps going (about_Preference_Variables). The script reaches its last line and exits 0. A native program that fails inside the script doesn't stop it either; its code waits in $LASTEXITCODE. Start scheduled scripts like this:
$ErrorActionPreference = 'Stop' # cmdlet errors now end the script with exit code 1
& 'C:\Tools\export-orders.exe' --since yesterday
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }In PowerShell 7.4 and later (what's new), $PSNativeCommandUseErrorActionPreference = $true makes a non-zero exit from a native program raise an error too. In a batch file the task runs directly, end with exit %RC%, not exit /b %RC%.
Turn On Task History Before You Need It#
The History tab and the queries below read the Microsoft-Windows-TaskScheduler/Operational event log. History is a machine-wide switch, and when it's off the tab is empty, so turn it on before the next failure. In the console, select Task Scheduler Library and click Enable All Tasks History in the Actions pane (View Task Properties and History). From an elevated prompt (wevtutil):
wevtutil sl Microsoft-Windows-TaskScheduler/Operational /e:true
wevtutil gl Microsoft-Windows-TaskScheduler/Operational # check "enabled: true" and maxSize/ms: raises the log's maximum size in bytes if you need longer history. The events that matter, from Microsoft's event lists linked above:
| Event ID | Logged when |
|---|---|
| 100, 102 | A task instance started; it finished |
| 200, 201 | An action launched; an action completed, whatever its exit code |
| 101, 103 | Task Scheduler failed to start the task (the message has the error) |
| 104 | Logon failed for the task's account |
| 203 | The action failed to launch |
| 111, 329 | The run was stopped for exceeding its time limit |
| 114 | A missed start launched late because of the catch-up setting |
| 322 | Not launched: an instance is already running |
| 326, 332 | Not launched: on batteries; the user wasn't logged on |
Event 201 is written whether the program succeeded or not. In one Windows Server 2016 report, a PowerShell action with Last Run Result 0x1 logged "return code 2147942401", which is 0x80070001: the same 1, wrapped as an HRESULT (Microsoft Q&A). Query the problem events from the last day with Get-WinEvent:
$ids = 101, 103, 104, 111, 203, 322, 326, 329, 332
Get-WinEvent -ErrorAction SilentlyContinue -FilterHashtable @{
LogName = 'Microsoft-Windows-TaskScheduler/Operational'
Id = $ids
StartTime = (Get-Date).AddDays(-1)
} | Select-Object TimeCreated, Id, Message | Format-List-ErrorAction SilentlyContinue hides the error Get-WinEvent raises when nothing matches. Add Where-Object Message -like '*\Nightly Export*' to narrow it to one task.
Check Every Task's Last Result With PowerShell#
Get-ScheduledTaskInfo returns LastRunTime, LastTaskResult, NextRunTime, and NumberOfMissedRuns for each task (Get-ScheduledTaskInfo; example output). This lists enabled tasks in the root folder whose last result is anything but success or "running":
$okResults = 0, 0x41301
Get-ScheduledTask -TaskPath '\' |
Where-Object { $_.State -ne 'Disabled' } |
Get-ScheduledTaskInfo |
Where-Object { $okResults -notcontains $_.LastTaskResult } |
Select-Object TaskName, LastRunTime, NextRunTime, NumberOfMissedRuns,
@{ Name = 'LastResult'; Expression = { '0x{0:X}' -f $_.LastTaskResult } } |
Format-Table -AutoSizePoint -TaskPath at the folder that holds your jobs, which keeps Microsoft's own tasks under \Microsoft\ out of the report. The check has two limits: running on the same server, it dies with the server, and it can't see a run that should have happened and didn't. Both need a signal from outside the machine.
Add an External Check-In to Each Task#
An external monitor expects a check-in on the task's schedule and alerts when it doesn't arrive: the dead man's switch pattern. CronRadar works this way. It doesn't discover Task Scheduler tasks, so each task reports for itself, over HTTP from a wrapper or through the .NET SDK.
A PowerShell Wrapper#
This wrapper runs under Windows PowerShell 5.1 and PowerShell 7. It reports start, runs the real program, reports complete or fail, and exits with the program's own code, so Last Run Result keeps showing the real result:
# C:\Jobs\nightly-export-monitored.ps1
$monitorUrl = 'https://cron.life/api/v1/ping/nightly-export'
$schedule = '0 2 * * *' # five-field cron, UTC
$grace = 900 # seconds
$keyFile = 'C:\ProgramData\CronRadar\api-key.txt'
$runId = 'nightly-export-{0:yyyyMMddHHmmss}-{1}' -f (Get-Date).ToUniversalTime(), $PID
$apiKey = $env:CRONRADAR_API_KEY
if (-not $apiKey -and (Test-Path -LiteralPath $keyFile)) {
$apiKey = "$(Get-Content -LiteralPath $keyFile -Raw)".Trim()
}
function Send-CheckIn([string] $Stage, [string] $Message) {
if (-not $apiKey) { return }
try {
$query = 'schedule=' + [uri]::EscapeDataString($schedule) +
"&gracePeriod=$grace&run_id=" + [uri]::EscapeDataString($runId)
if ($Message) { $query += '&message=' + [uri]::EscapeDataString($Message) }
$token = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${apiKey}:"))
Invoke-RestMethod -Method Post -Uri ('{0}/{1}?{2}' -f $monitorUrl, $Stage, $query) `
-Headers @{ Authorization = "Basic $token" } -TimeoutSec 5 -UseBasicParsing | Out-Null
} catch {
# A monitoring failure must never change the job's result.
}
}
Send-CheckIn 'start'
$exitCode = 1
try {
& 'C:\Jobs\NightlyExport\NightlyExport.exe' --output 'D:\exports'
if ($null -ne $LASTEXITCODE) { $exitCode = $LASTEXITCODE }
} catch {
Write-Error $_ # for example, the program file is missing
}
if ($exitCode -eq 0) {
Send-CheckIn 'complete'
} else {
Send-CheckIn 'fail' ('NightlyExport.exe exited with code 0x{0:X}' -f $exitCode)
}
exit $exitCodeSet the task's action to program powershell.exe, arguments -NoProfile -NonInteractive -File C:\Jobs\nightly-export-monitored.ps1, and Start in C:\Jobs\NightlyExport. -NonInteractive turns any prompt into an error instead of a hang (about_PowerShell_exe). Windows 10 and 11 default to the Restricted execution policy, which blocks scripts, and Windows Server to RemoteSigned (about_Execution_Policies). On a client, sign the script or add -ExecutionPolicy RemoteSigned. To wrap a PowerShell job, call powershell.exe -File on it in place of the .exe, so its exit code follows the rules above.
What else the wrapper does:
- The key goes in the Basic auth username, with an empty password. A machine-wide environment variable is visible to every process on the server, so on shared machines use the file, readable only by the task's account and Administrators.
- Monitoring errors are ignored. Each request has a 5-second timeout, which a slow DNS lookup can stretch to 15 seconds or more (Invoke-RestMethod). A blocked proxy or TLS failure therefore shows up only as a monitor that never appears, so run the task once with Run and confirm it does.
- Every check-in carries the schedule, so the first run creates the monitor, and a deleted monitor comes back on the next run.
- Alerts: a
failalerts immediately. A check-in that never arrives turns the monitorwarning, thencriticalonce the grace period passes. Astartwith no finish is flagged as stuck when the next expected run plus grace passes with no new check-in.
That last rule interacts with Task Scheduler's defaults. A hung run blocks later starts (event 322), so the job goes silent and CronRadar flags it. But if a time limit shorter than the schedule's interval kills the wrapper, no fail is sent, and the next run's start hides the lost one. Give the job its own timeout, which turns a hang into a failure.
curl.exe From a Batch File#
Windows 10 build 17063 and later include curl.exe (Microsoft). That covers Windows 10 version 1803 (build 17134) and Windows Server 2019 (build 17763) onward, not Windows Server 2016 (build 14393) (Windows 10 and Windows Server release information). In Windows PowerShell 5.1, curl is an alias for Invoke-WebRequest, so type curl.exe (curl on Windows). A batch job can send one heartbeat on success:
@echo off
rem C:\Jobs\nightly-export.cmd
"C:\Jobs\NightlyExport\NightlyExport.exe" --output D:\exports
set "RC=%ERRORLEVEL%"
if "%RC%"=="0" curl.exe -fsS -m 5 -o NUL -X POST -G -u "%CRONRADAR_API_KEY%:" --data-urlencode "schedule=0 2 * * *" --data-urlencode "gracePeriod=900" https://cron.life/api/v1/ping/nightly-export
exit %RC%A failed run sends nothing, so it surfaces as a missed check-in after the grace period rather than as an immediate failure. Plain exit also closes an interactive console, which doesn't matter for a file only the task runs.
A .NET Console App#
If the task runs your own .NET console app, the CronRadar .NET SDK reports for it. NuGet installs CronRadar 1.0.13 (checked September 20, 2026), added with dotnet add package CronRadar; it reads CRONRADAR_API_KEY:
// Program.cs
try
{
// The class shares its namespace's name, so qualify it fully.
await CronRadar.CronRadar.WrapAsync("nightly-export", async () =>
{
await ExportOrdersAsync();
}, schedule: "0 2 * * *");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
return 1;
}
static async Task ExportOrdersAsync()
{
// The job's real work; an exception here is reported as "fail" and rethrown.
await File.WriteAllTextAsync(@"D:\exports\orders.csv", "id,total\n");
}WrapAsync reports start, then complete or fail, and rethrows the job's exception; the catch turns it into exit code 1 instead of a 0xE0434352 crash. The SDK's own network errors never reach your code, and each request gives up after 5 seconds. .NET monitoring covers the rest of the SDK.
Translate the Trigger to a UTC Cron Schedule#
CronRadar reads every schedule as five-field cron in UTC (schedule syntax). Task Scheduler triggers run in the computer's time zone unless Synchronize across time zones is checked; Microsoft's console documentation calls that box Universal and says it makes the time relative to UTC (Triggers). The trigger's start boundary shows which you have:
(Get-ScheduledTask -TaskName 'Nightly Export').Triggers | Select-Object StartBoundaryA value with no offset, such as 2026-01-05T02:00:00, follows the machine's time zone and daylight saving rules. A value with an offset or Z, such as 2026-01-05T02:00:00-05:00, is used as written regardless of daylight saving (ITrigger::StartBoundary), so that task runs at 07:00 UTC all year: 0 7 * * *.
With no offset on a server that observes daylight saving, the task's UTC time moves by an hour twice a year. Run the server in UTC if you can. Otherwise use the earlier of the two UTC times and a grace period of at least an hour plus the job's usual duration: a 02:00 New York task becomes 0 6 * * * with gracePeriod of 3600 or more.
Repeat task every runs the task, waits the interval, and runs again from the trigger's start time (Triggers). A 15-minute repetition that started at 09:07 runs at :07, :22, :37, and :52, while */15 * * * * expects :00, :15, :30, and :45. Write the real minutes as 7/15 * * * *, or move the trigger's start to a whole hour. A grace period long enough to cover the offset also works but delays every missed-run alert. A Delay task for up to (random delay) setting needs a grace period that covers the full delay. If the repetition has a duration, restrict the hour field to that window.
| Trigger (server on UTC) | CronRadar schedule |
|---|---|
| Daily at 02:00 | 0 2 * * * |
| Weekly on Monday and Thursday at 06:30 | 30 6 * * 1,4 |
| Monthly on day 1 at 00:00 | 0 0 1 * * |
| Monthly on the first Monday at 09:00 | 0 9 1-7 * 1 (CronRadar matches both day fields) |
| Daily from 00:07, repeating every 15 minutes indefinitely | 7/15 * * * * |
| Every 2 days, every 2 weeks, or the last day of the month | No exact equivalent; run on every candidate date, let the job skip, and check in every time |
| At startup, at log on, or on an event | No clock schedule to check against |
Once the task has run, compare CronRadar's next expected run with (Get-ScheduledTaskInfo -TaskName 'Nightly Export').NextRunTime.ToUniversalTime(), and check again after the next daylight saving change. If the job belongs to a SQL Server instance, SQL Server Agent job monitoring covers that scheduler.
Frequently Asked Questions#
What does Last Run Result 0x1 mean in Task Scheduler?#
The task's program exited with code 1. For a PowerShell script started with -File, either the script called exit 1 or a script-terminating error such as throw ended it; with -Command, the last command failed. Run the action's exact command line as the task's account, and log the script's output to a file to see the error.
Why didn't my scheduled task run?#
Check that the task is enabled and its trigger hasn't expired, then look at NextRunTime and the history around the expected time. Event 332 means the account wasn't logged on, 326 means the computer was on battery, 322 means an earlier run was still going, and 104 means the stored password no longer works. These events appear only if task history is enabled.
How do I enable Task Scheduler history?#
In the console, click Enable All Tasks History in the Actions pane, or run wevtutil sl Microsoft-Windows-TaskScheduler/Operational /e:true from an elevated prompt. It's one switch for every task on the machine, and it records only from the moment you turn it on.
How do I get an email when a scheduled task fails?#
Task Scheduler's email action was removed in Windows 8 and Windows Server 2012. Microsoft's suggested replacement, Send-MailMessage, is itself marked obsolete (Send-MailMessage). An external monitor such as CronRadar sends the alert instead: a fail check-in alerts immediately, and a check-in that never arrives alerts after the grace period, by email, Slack, webhook, Discord, or PagerDuty.
Does Task Scheduler run missed tasks after the computer was off?#
Only if Run task as soon as possible after a scheduled start is missed is checked, and it's off by default. When it's on, Task Scheduler starts the task after the machine comes back, which Microsoft's Windows Server 2008 documentation says happens after a ten-minute wait (Task Settings), and logs event 114. A monitor watching the schedule alerts on the missed time once its grace period passes, and the late run's check-in then records the recovery.
Put this guide to work
Connect your .NET job to monitoring
Follow the current integration guide, check the release limitations, and verify a real run and alert delivery.
NuGet currently installs CronRadar 1.0.13. Scheduled heartbeat reporting is available; execution IDs, outcome values, and CRONRADAR_BASE_URL support in the repository reference require the pending 1.0.14 release.