Cuckoo

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/start when the task starts,
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/0 when the job succeeded,
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/1 (any non-zero exit code) or /fail when 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:

  • $LASTEXITCODE holds 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 the catch block.
  • try/catch turns 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 $code passes 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 -Proxy to Invoke-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 SYSTEM where 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

All guides · Compare Cuckoo with other tools

Send feedback

Ideas, bugs, questions: we read every message. Or write to hello@metastic.ch.