Monitor Windows Task Scheduler Jobs
Updated
Windows Task Scheduler runs backups, exports and maintenance scripts on countless servers, and it is quiet when
they fail. The "Last Run Result" column shows 0x1 to whoever happens to open the console, a task stops running
after a password change on its account, or the server was off at the scheduled time. This guide wraps a
scheduled task in a small PowerShell script that reports every run to Cuckoo, so failures and missed runs
become alerts.
How Cuckoo sees the task
Each check has a ping URL. The wrapper calls:
https://cuckoo.metastic.ch/ping/YOUR-UUID/startwhen the task starts,https://cuckoo.metastic.ch/ping/YOUR-UUID/0when the job succeeded,https://cuckoo.metastic.ch/ping/YOUR-UUID/1(any non-zero exit code) or/failwhen it failed.
If no result arrives within the schedule plus grace time, the check goes down and you get an email, Slack, Discord or webhook alert.
Step 1: the PowerShell wrapper
Save this as C:\Jobs\Invoke-Monitored.ps1:
param(
[Parameter(Mandatory)] [string] $PingUrl,
[Parameter(Mandatory)] [string] $Command,
[string[]] $Arguments = @()
)
# Windows PowerShell 5.1 may not offer TLS 1.2 by default on older systems
if ($PSVersionTable.PSVersion.Major -lt 6) {
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
}
function Send-Ping([string] $Suffix, [string] $Body) {
for ($i = 1; $i -le 3; $i++) {
try {
if ($Body) {
$bytes = [Text.Encoding]::UTF8.GetBytes($Body)
if ($bytes.Length -gt 10000) { $bytes = $bytes[($bytes.Length - 10000)..($bytes.Length - 1)] }
Invoke-RestMethod -Uri "$PingUrl$Suffix" -Method Post -Body $bytes -ContentType 'text/plain; charset=utf-8' -TimeoutSec 10 | Out-Null
} else {
Invoke-RestMethod -Uri "$PingUrl$Suffix" -TimeoutSec 10 | Out-Null
}
return
} catch {
Start-Sleep -Seconds (2 * $i)
}
}
}
Send-Ping '/start'
$log = Join-Path $env:TEMP "job-$PID.log"
$code = 1
try {
& $Command @Arguments *> $log
$code = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 }
} catch {
$_ | Out-String | Add-Content -Path $log
$code = 1
}
$tail = if (Test-Path $log) { Get-Content $log -Tail 100 | Out-String } else { '' }
Send-Ping "/$code" $tail
Remove-Item $log -ErrorAction SilentlyContinue
exit $code
How it works:
$LASTEXITCODEholds the exit code of the last native program (.exe,robocopy,sqlcmd). It is not set by cmdlets, which is why the wrapper falls back to$?and thecatchblock.try/catchturns a terminating PowerShell error into a failure ping instead of a silent crash.- The ping function retries three times and swallows errors, so a network problem never fails the job.
- The last 100 lines of output go with the result ping as a POST body. The first 10 KB are stored.
exit $codepasses the result back, so Task Scheduler's Last Run Result stays accurate.
Watch out for tools with non-standard codes: robocopy uses 0 to 7 for success and 8 and above for
errors. Wrap it in a small script that maps those codes before reporting.
Step 2: TLS on Windows PowerShell 5.1
Windows PowerShell 5.1 runs on .NET Framework. On older Windows Server versions or unpatched .NET, it may try
only TLS 1.0 or 1.1, and the HTTPS request then fails with "The underlying connection was closed". The
SecurityProtocol line in the wrapper adds TLS 1.2 for the session. PowerShell 7 (pwsh.exe) uses modern TLS
defaults and does not need it. If you use Invoke-WebRequest on 5.1, add -UseBasicParsing, otherwise it may
fail in a non-interactive session where the Internet Explorer engine is not initialised.
Step 3: register the task
With PowerShell (run as Administrator):
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument (
'-NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\Jobs\Invoke-Monitored.ps1 ' +
'-PingUrl https://cuckoo.metastic.ch/ping/YOUR-UUID -Command C:\Jobs\backup.cmd')
$trigger = New-ScheduledTaskTrigger -Daily -At 02:30
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 2)
Register-ScheduledTask -TaskName 'Nightly Backup' -Action $action -Trigger $trigger `
-Settings $settings -User 'SYSTEM' -RunLevel Highest
Or with schtasks.exe:
schtasks /Create /TN "Nightly Backup" /SC DAILY /ST 02:30 /RU SYSTEM /RL HIGHEST /TR "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File C:\Jobs\Invoke-Monitored.ps1 -PingUrl https://cuckoo.metastic.ch/ping/YOUR-UUID -Command C:\Jobs\backup.cmd"
-StartWhenAvailable runs a missed task as soon as possible after the machine is back on, and
-ExecutionTimeLimit stops a hung task. Test it with Start-ScheduledTask -TaskName 'Nightly Backup'.
Schedule and grace advice
Create the check with a cron expression that matches the trigger, for example 30 2 * * *, and the server's
time zone (Task Scheduler uses local time unless you tick "Synchronize across time zones"). Set the grace time
to the usual runtime plus a margin. Set max runtime to match ExecutionTimeLimit, because a task stopped by the
limit cannot send its exit code.
Troubleshooting
- Last Run Result 0x1, no pings: the script did not start. Check the path, and that the task account may
read
C:\Jobs. Run the exact command line from an elevated prompt. - Pings fail only from the task: the account may use a proxy that is configured only for interactive users.
Add
-ProxytoInvoke-RestMethod, or run under an account with the right proxy settings. - Works for days, then stops: tasks running as a user with a stored password break when it changes. Use a
group managed service account or
SYSTEMwhere possible.
FAQ
Should I use Windows PowerShell 5.1 or PowerShell 7?
Both work with the wrapper. PowerShell 7 has better TLS defaults and a cleaner web cmdlet. Windows PowerShell 5.1 is preinstalled on every supported Windows version, which makes it convenient for servers.
Can I monitor a task without changing the script it runs?
Yes. The wrapper takes the existing program as -Command, so the original script or executable stays as it is.
Only the task's action changes to call the wrapper.
What about tasks that run every few minutes?
Use a period-based check, for example every 5 minutes with 5 minutes grace. Skip the /start ping if you want
fewer requests; the result ping alone is enough to detect missed runs.
Related guides
- Cron Job Start and Fail SignalsSend /start, success and /fail pings from cron jobs to measure run time and catch hung jobs. Includes a reusable bash wrapper script with trap and exit codes.
- Cron Schedule, Time Zone and Grace PeriodPick the right monitoring schedule for a cron job: period or cron expression, IANA time zones, how cron handles daylight saving time, and sizing grace time.
- Dead Man's Switch Monitoring ExplainedWhat dead man's switch monitoring is, how push-based heartbeat checks differ from pull-based uptime monitoring, and which jobs and systems benefit from each.
- How to Monitor Cron JobsLearn how to monitor cron jobs with heartbeat pings: why cron fails silently, how a dead man's switch catches missed runs, and how to set up a first check.