DEV Community

OpsKit
OpsKit

Posted on

How to get an email when a Windows scheduled task fails (without babysitting Task Scheduler)

Your backup job runs at 3 a.m. At 9 a.m. your manager asks why last night's export is missing.
You open Task Scheduler and see it: Last Run Result: 0x1. The task failed hours ago and
nothing told you.

Windows used to tell you. Task Scheduler had built-in email alerts - then they were removed
in Server 2016 and never came back. Everyone reinvents the same broken workaround, so here is
the version that actually works in production.

What people usually paste (and why it breaks)

The typical Google result is a script that loops over tasks, calls
Get-ScheduledTaskInfo, and mails you when LastTaskResult -ne 0. Roughly:

Get-ScheduledTask | Where-Object { $_.TaskPath -notlike '\Microsoft\Windows*' } | ForEach-Object {
    $info = $_ | Get-ScheduledTaskInfo
    if ($info.LastRunTime -gt (Get-Date).AddHours(-24) -and $info.LastTaskResult -ne 0) {
        Send-MailMessage -To me@example.com -From taskwatch@example.com `
            -Subject "Task failed: $($_.TaskName)" -SmtpServer smtp.example.com
    }
}
Enter fullscreen mode Exit fullscreen mode

It works once. Then it burns you in one of three ways.

Trap 1: the LastRunTime seconds quirk

Get-ScheduledTaskInfo.LastRunTime has a long-standing bug where the seconds value can
mirror the minutes value. It has been reported through Microsoft Feedback Hub more than once
and it is still there. If you compute "how old is this run" with full timestamp precision,
a task that ran 5 minutes and 5 seconds ago can look like it ran 5 minutes and 55 seconds
ago - and anything with a tight window starts flapping.

The fix is boring and reliable: do age math at minute granularity and give yourself a
two-minute tolerance:

$ageMin = ((Get-Date) - $info.LastRunTime).TotalMinutes
if ($ageMin -gt ($maxAgeHours * 60) + 2) {
    # stale
}
Enter fullscreen mode Exit fullscreen mode

A rounding quirk can never wake you up at 3 a.m.

Trap 2: the email flood

The naive script has no memory. It runs every 5 minutes, the task is still broken, and you
get 12 identical emails before your coffee. Worse, when the task recovers and fails again
next week, the "already alerted" logic you bolted on suppresses the new incident.

The correct model is a tiny state file keyed per watched item:

  • new problem -> alert, store a signature of the problem
  • same problem, within the re-alert window (24h) -> stay quiet
  • same problem, past the window -> one reminder
  • problem gone -> clear the state, so the next incident alerts like new

Signatures must describe the problem, not the moment. Do not include the timestamp of
the failure in the signature or every failed run becomes a fresh alert while the task keeps
failing every cycle.

Trap 3: watching only Task Scheduler

Half the jobs that "run themselves" are not scheduled tasks - they are SQL Agent jobs,
SSIS packages, cron-style loops inside services, or a script on another box. Task Scheduler
monitoring gives you a green light while the real job rots.

The trick that covers everything is a heartbeat file: one line appended by the job on
success, one configured maximum age by the watcher.

# at the end of ANY job, scheduled or not:
Add-Content -Path C:\Scripts\hb.txt -Value (Get-Date -Format o)
Enter fullscreen mode Exit fullscreen mode

If the file is missing or older than N minutes, alert. That converts "is my job alive?"
into a filesystem check that works for anything that can write a file.

What I run in production

After getting tired of rebuilding this per server, I packaged it as TaskWatch - a local,
subscription-free watchdog:

  • FAIL - watched task exited non-zero, exit code in the alert
  • MISSED RUN - "must run every 25 hours" exceeded
  • DISABLED / MISSING - someone turned your backup task off or deleted it
  • HEARTBEAT STALE - the heartbeat trick, automated
  • alerts via SMTP email, a Teams/Slack-style webhook, or the local log
  • an HTML status page per machine, one bookmark
  • one alert per problem, re-reminder after 24h, auto-clear on recovery
  • nothing leaves your machine: no cloud, no account, no telemetry

One-command setup registers it as its own scheduled task every 5 minutes, and it can send
you a test email during install so you know alerts work before you need them:

.\Install-TaskWatch.ps1 -WatchList 'NightlyBackup','SqlMaintenance' -SmtpServer smtp.example.com -From tw@example.com -To you@example.com
Enter fullscreen mode Exit fullscreen mode

The full version is $15, one-time, no subscription
(30-day refund): xennedelan.gumroad.com/l/taskwatch

Free sample

I kept a free, MIT-licensed version for people who just want the report. It prints every
task that failed on the machine in the last 24 hours with exit codes, and exits 1 if
anything failed - so you can drop it into any existing alerting that understands exit codes:

git clone https://github.com/OpsKit-Dev/powershell-admin-scripts
powershell -ExecutionPolicy Bypass -File .\powershell-admin-scripts\TaskWatch\TaskWatch-mini.ps1
Enter fullscreen mode Exit fullscreen mode

TaskWatch-mini.ps1 on GitHub

Related

If TaskWatch saves you one "why is the export missing" conversation, it has paid for itself
several times over.

Top comments (0)