Guides · Scheduled scripts
Task Scheduler Says Success but Your PowerShell Script Failed
Published · Tested with PowerShell 7.6.6 and Windows PowerShell 5.1 (build 26100) on Windows 11 Pro
A script runs every night from Task Scheduler. One morning the report is missing, but the task says the last run was fine: 0x0. Task Scheduler only sees the exit code of the process it started, and a PowerShell script that hits an error often still exits with 0. This guide measures which failures reach Task Scheduler and which do not, then gives a script template that turns every error into exit code 1 and a check that lists the tasks whose last run failed.
Before you start
- What Task Scheduler records. For a task that starts
pwsh.exeorpowershell.exe, the last run's result is the exit code of that process.Get-ScheduledTaskInforeturns it asLastTaskResult; the Task Scheduler window shows it as Last Run Result. - Some values come from Task Scheduler itself.
0x41301means “The task is currently running”,0x41303“The task has not yet run” and0x41306“The last run of the task was terminated by the user” (Microsoft Learn).Get-ScheduledTaskInfoprints them in decimal: 267009, 267011 and 267014. - How we tested. Each case was a real task, registered with
Register-ScheduledTaskfor the signed-in user (“Run only when user is logged on”), started withStart-ScheduledTaskand read withGet-ScheduledTaskInfoonce it was back to Ready. Every case ran three times, with Windows PowerShell 5.1 and with PowerShell 7. All three runs gave the same result every time. The scripts and data are invented, and every test task was deleted afterwards. - Excel jobs. If the script drives Excel, the task also has to run in a signed-in session; Run an Excel Macro from PowerShell with a Timeout covers that part.
Which failures reach Task Scheduler
The same scripts, started three ways from the task's action. Windows PowerShell 5.1 and PowerShell 7 gave identical results in every row:
| What the script did | -File script.ps1 | -Command "& 'script.ps1'" | -Command "& 'script.ps1'; exit $LASTEXITCODE" |
|---|---|---|---|
| Finished normally | 0 | 0 | 0 |
A cmdlet error (Get-Item on a missing file), then went on | 0 | 0 | 0 |
The same error inside try, with a catch that only writes a warning | 0 | 0 | 0 |
| Ran a program that exited with 3, then went on | 0 | 0 | 3 |
| The program that exited with 3 was the last line | 0 | 0 | 3 |
The same cmdlet error with $ErrorActionPreference = 'Stop' | 1 | 1 | 1 |
throw 'input file not found' | 1 | 1 | 1 |
exit 1 | 1 | 1 | 1 |
exit 3 | 3 | 1 | 3 |
The bold zeros are the failures Task Scheduler never hears about. They follow the documented rules of pwsh (Microsoft Learn, about_Pwsh):
- With
-File, “With normal termination, the exit code is always0”; anexitsets it to “the numeric argument used with theexitcommand”, and “when a script-terminating error occurs, the exit code is set to1”. A cmdlet error that is not terminating, or a native program's exit code, does not count. - With
-Command, “The exit code is0when$?is$trueor1when$?is$false”, and an exit code “other than0or1[…] is converted to1”. That is whyexit 3arrived as 1. - Adding
exit $LASTEXITCODEpasses on the last native program's code, even when the script went on and finished normally after it. It does not catch PowerShell errors, and it reports a program that uses non-zero codes for success as a failure.
When your script never ran
Some results mean the script did not start or did not finish. From the same test runs (three runs each, same result every time, except the time limit case, which ran once per version):
Task action LastTaskResult
pwsh.exe without a path (Microsoft Store install) 2147942402 (0x80070002)
powershell.exe without a path 0
-File with a wrong script path, Windows PowerShell 5.1 4294770688 (0xFFFD0000)
-File with a wrong script path, PowerShell 7 64
-Command with a wrong script path, both versions 1
Script still running at the task's 1-minute time limit 267014 (0x41306)
- Give the full path to pwsh.exe. Our PowerShell 7 was the Microsoft Store (MSIX) package, the one winget installs by default since PowerShell 7.6.0 (Microsoft Learn). A task with a bare
pwsh.exedid not start at all; 0x80070002 ends in Windows error 2, “The system cannot find the file specified” (Microsoft Learn). With the full path of the Store alias,%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe, every test ran. The MSI package installs to$Env:ProgramFiles\PowerShell\7(same Microsoft Learn page); we did not test that install. - 64 is not a network error here. Microsoft's list of Task Scheduler codes suggests
net helpmsg 64, which answers “The specified network name is no longer available”. In our test, 64 came frompwshitself when the script file did not exist. - Windows PowerShell does not report a missing script as 1. With
-Fileit gave 0xFFFD0000, a value that looks nothing like a PowerShell error. Any code other than 0 deserves a look.
A script that fails loudly
The template below makes every error reach Task Scheduler. Put your job between the two marker lines; here it reads a CSV file. Save it as C:\Jobs\Nightly-Job.ps1:
# Nightly-Job.ps1: a script for Task Scheduler that fails loudly.
# Any error stops the script, is written to the log, and ends it with exit code 1,
# which Task Scheduler shows as the task's Last Run Result.
param(
[string] $LogPath = (Join-Path $PSScriptRoot 'nightly-job.log')
)
$ErrorActionPreference = 'Stop'
# PowerShell 7.4+: a native program that exits non-zero throws as well.
$PSNativeCommandUseErrorActionPreference = $true
function Write-Log {
param([Parameter(Mandatory)] [string] $Message)
$line = '{0:yyyy-MM-dd HH:mm:ss} {1}' -f (Get-Date), $Message
Add-Content -LiteralPath $LogPath -Value $line -Encoding utf8
}
# Runs a native program and throws if it exits non-zero, naming the line that called it.
# Needed in Windows PowerShell 5.1, which ignores the preference above.
function Invoke-Native {
param(
[Parameter(Mandatory)] [string] $FilePath,
[string[]] $ArgumentList = @()
)
$PSNativeCommandUseErrorActionPreference = $false # this function checks the code itself
& $FilePath @ArgumentList
if ($LASTEXITCODE -ne 0) {
throw "$FilePath exited with code $LASTEXITCODE (called at line $($MyInvocation.ScriptLineNumber))."
}
}
try {
Write-Log "start (PowerShell $($PSVersionTable.PSVersion))"
# ---- the job ----
$rows = Import-Csv -LiteralPath 'C:\Jobs\input.csv'
Write-Log "read $($rows.Count) rows"
# ---- end of the job ----
Write-Log 'done'
exit 0
}
catch {
Write-Log "FAILED at line $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.Message)"
exit 1
}
$ErrorActionPreference = 'Stop'turns the cmdlet errors that used to slip through (the second row of the table) into errors that reachcatch.$PSNativeCommandUseErrorActionPreference: when it is$true, “native commands with non-zero exit codes issue errors according to$ErrorActionPreference” (Microsoft Learn); it was added in PowerShell 7.4. Windows PowerShell 5.1 ignores it, so in 5.1 call programs throughInvoke-Native, or check$LASTEXITCODEafter each one yourself.Invoke-Nativeswitches the preference off inside itself and does the check, so its message, with the line that called it, is the same in both versions.- Programs that use non-zero codes for success. Microsoft gives robocopy as the example and shows how to switch the preference off inside a script block and check the code yourself (same page). Do that for any such program in the job.
catchwrites one line and ends withexit 1. The log says what failed and where; the exit code tells Task Scheduler. With-Filea normal end gives 0 anyway; the explicitexit 0keeps the code right when the script is started another way, with-Commandor with&from another script, where an old$LASTEXITCODEcan otherwise remain (it happened to our check script below).
Register the task with the full path to pwsh.exe and -File. -NonInteractive makes prompts fail instead of waiting: “Any attempts to use interactive features, like Read-Host or confirmation prompts, result in statement terminating errors rather than hanging” (Microsoft Learn).
# The full path to pwsh.exe: the MSI default below, or for the Microsoft Store
# package "$env:LOCALAPPDATA\Microsoft\WindowsApps\pwsh.exe" (the one we tested).
$pwsh = 'C:\Program Files\PowerShell\7\pwsh.exe'
$action = New-ScheduledTaskAction -Execute $pwsh `
-Argument '-NoProfile -NonInteractive -File "C:\Jobs\Nightly-Job.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At '02:00'
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 1)
Register-ScheduledTask -TaskName 'Nightly-Job' -TaskPath '\Jobs\' -Action $action `
-Trigger $trigger -Principal $principal -Settings $settings
The time limit makes a job that hangs end with 0x41306 instead of running for days. Pick a limit well above the job's usual run time.
Results
The template as a task, three runs per case with each version (output of our test script; Native-Job and Native-Checked put a program that exits with 3 in place of the CSV step, without and with Invoke-Native):
Nightly-Job pwsh input.csv present LastTaskResult: 0, 0, 0
Nightly-Job ps51 input.csv present LastTaskResult: 0, 0, 0
Nightly-Job pwsh input.csv missing LastTaskResult: 1, 1, 1
Nightly-Job ps51 input.csv missing LastTaskResult: 1, 1, 1
Native-Job pwsh native exits 3, unchecked LastTaskResult: 1, 1, 1
Native-Job ps51 native exits 3, unchecked LastTaskResult: 0, 0, 0
Native-Checked pwsh native exits 3, Invoke-Native LastTaskResult: 1, 1, 1
Native-Checked ps51 native exits 3, Invoke-Native LastTaskResult: 1, 1, 1
The log tells you why (lines from nightly-job.log, one run of each case):
2026-10-07 14:07:13 start (PowerShell 7.6.6)
2026-10-07 14:07:13 read 3 rows
2026-10-07 14:07:13 done
2026-10-07 14:07:28 start (PowerShell 7.6.6)
2026-10-07 14:07:28 FAILED at line 35: Could not find file 'C:\Jobs\input.csv'.
2026-10-07 14:07:44 start (PowerShell 7.6.6)
2026-10-07 14:07:44 FAILED at line 35: Program "cmd.exe" ended with non-zero exit code: 3 (0x00000003).
2026-10-07 14:07:51 start (PowerShell 5.1.26100.9444)
2026-10-07 14:07:51 after the native command
2026-10-07 14:07:51 done
2026-10-07 14:08:06 start (PowerShell 5.1.26100.9444)
2026-10-07 14:08:06 FAILED at line 27: cmd.exe exited with code 3 (called at line 35).
The 5.1 run that logged “after the native command” and “done” is the trap the template leaves open in Windows PowerShell 5.1: an unchecked program failed and the job finished as a success. Through Invoke-Native, the same program stopped the job in both versions, with the same message; line 27 is the throw inside Invoke-Native, line 35 the job line that called it.
Check the results every day
An exit code only helps if someone reads it. On our machine the Task Scheduler event log (Microsoft-Windows-TaskScheduler/Operational) was disabled, so the History tab was empty; turning it on is a machine-wide setting that needs an administrator, and this guide does not rely on it. Get-ScheduledTaskInfo works without it:
> Get-ScheduledTask -TaskPath '\Jobs\' | Get-ScheduledTaskInfo | Format-Table TaskName, LastRunTime, LastTaskResult
TaskName LastRunTime LastTaskResult
-------- ----------- --------------
New-Job-NeverRun 11/30/1999 12:00:00 AM 267011
Nightly-Job-NoFile 10/7/2026 1:57:00 PM 1
Nightly-Job 10/7/2026 1:56:57 PM 0
Old-Job-Swallows 10/7/2026 1:57:02 PM 0
This script turns that into a yes or no. It lists the tasks in one folder whose last run failed, that never ran, or that have not run for longer than a day, and exits 1 when it finds any:
# Get-FailedTask.ps1: list the scheduled tasks in one folder whose last run failed,
# or that have not run recently. Exits 1 when it finds one, so it can feed an alert.
param(
[string] $TaskPath = '\Jobs\',
[int] $MaxAgeHours = 26
)
$ErrorActionPreference = 'Stop'
$running = 0x41301 # SCHED_S_TASK_RUNNING
$notRunYet = 0x41303 # SCHED_S_TASK_HAS_NOT_RUN
$since = (Get-Date).AddHours(-$MaxAgeHours)
$problems = foreach ($task in Get-ScheduledTask -TaskPath $TaskPath) {
$info = $task | Get-ScheduledTaskInfo
$result = $info.LastTaskResult
$problem =
if ($result -eq $running) { $null }
elseif ($result -eq $notRunYet) { 'has not run yet' }
elseif ($result -ne 0) { 'last run failed' }
elseif ($info.LastRunTime -lt $since) { "no run in $MaxAgeHours h" }
if ($problem) {
[pscustomobject]@{
Task = $task.TaskName
LastRun = $info.LastRunTime
Result = '0x{0:X}' -f $result
Problem = $problem
}
}
}
if ($problems) {
$problems | Format-Table -AutoSize | Out-String -Width 200
exit 1
}
"No problems in $TaskPath"
exit 0
Against the four test tasks above, and then against one task that ran fine (output of the test runs):
> .\Get-FailedTask.ps1
Task LastRun Result Problem
---- ------- ------ -------
New-Job-NeverRun 11/30/1999 12:00:00 AM 0x41303 has not run yet
Nightly-Job-NoFile 10/7/2026 1:57:00 PM 0x1 last run failed
exit code: 1
> .\Get-FailedTask.ps1 -MaxAgeHours 0 # the last run is older than "now"
Task LastRun Result Problem
---- ------- ------ -------
Nightly-Job 10/7/2026 1:57:48 PM 0x0 no run in 0 h
exit code: 1
> .\Get-FailedTask.ps1
No problems in \Jobs\
exit code: 0
Old-Job-Swallows is not in the list: its error was caught and dropped inside the script, so its 0 looks like any other success. Only the script can fix that. Run the check where a failure gets seen: from a monitoring tool that reads exit codes, or by hand at the start of the day. Our first version of the check ended without exit 0; called from another script after a failing check, it left the earlier exit code of 1 in $LASTEXITCODE and reported a failure that was not there.
Limits
- One machine, one kind of task. Windows 11 Pro, PowerShell 7.6.6 from the Microsoft Store and Windows PowerShell 5.1.26100.9444, tasks that run as the signed-in user only while they are logged on. We did not test “Run whether user is logged on or not”, the SYSTEM account, the MSI install of PowerShell 7 or other Windows versions.
- 0 means “no error reported”, not “the job worked”. A script that swallows its errors, or finishes with the wrong data, still exits 0. Add the checks the job needs (a row count, the date of the newest record, a file that must exist) and throw when they fail.
- The log is part of the job. If the log folder cannot be written,
Write-Logfails too; we did not test that case. Keep the log next to the script, where the task's user can write. - The check needs at least one task in the folder. With no task in
\Jobs\,Get-ScheduledTaskfails and the check ends with exit code 1 and an error message instead of the table. - The check assumes daily tasks.
-MaxAgeHours 26flags any task that has not run for a day, so a weekly task, or a disabled one, is reported every day. Keep those in their own folder with their own limit. - The check treats every other code as a failure. That includes Task Scheduler's own status values beyond the two it skips, such as
0x41325, “The Task Scheduler service has asked the task to run” (Microsoft Learn). Look a code up there before you treat it as the script's. - Interactive tasks run in the user's session. We did not test what happens when the user logs off, or closes the job's window, during a run.
- A running task is not checked.
Get-FailedTask.ps1skips tasks that are running; a job that hangs is caught by the task's time limit, and then shows up as 0x41306.
Just want the scripts? The Scripts Pack · €12 has the code of this guide and the other guides ready to run, with examples and tests. The code on this page stays free.
The code on this page was written for this guide and tested on the versions above. The test copies of the job script also set the UI culture to English, on the same line as $ErrorActionPreference, so that error messages came out in English. The data is invented.