Windows 计划任务故障排查
Windows 计划任务故障排查
计划任务的“成功”至少有三层:启动请求送到了调度器,动作进程真正启动,业务完成并产生本次结果。schtasks /run 返回 SUCCESS,只能沿着这条链继续查,不能就此结束。
下面以 Windows Server 2022 和 Windows 10 LTSC 2021 为例,依次检查工作目录、实例策略、登录类型、HKCU 和映射盘。每个分支都从任务的实际执行上下文取样,再与手动运行对比。
1. 检查任务定义
先确认完整 TaskPath 和内部名称;同名任务可以放在不同文件夹。读取实际动作、主体、策略与最近结果:
$name = 'NAD0820261009'
$taskPath = '\'
$t = Get-ScheduledTask -TaskName $name -TaskPath $taskPath -ErrorAction Stop
$t.Actions | Select-Object Execute,Arguments,WorkingDirectory
$t.Principal | Select-Object UserId,LogonType,RunLevel
$t.Settings | Select-Object MultipleInstances,ExecutionTimeLimit,StartWhenAvailable
Get-ScheduledTaskInfo -TaskName $name -TaskPath $taskPath |
Select-Object LastRunTime,LastTaskResult,NumberOfMissedRuns
[xml]$xml = Export-ScheduledTask -TaskName $name -TaskPath $taskPath
$xml.Task.Principals.Principal
$xml.Task.Settings.MultipleInstancesPolicy主要比较四件事:程序与参数是否分开、工作目录是什么、执行身份及登录类型是什么、已有实例运行时如何处理新请求。PowerShell 版本与位数也要记录;管理员窗口里解析到的 PATH 不一定就是后台进程的环境。
| 记录 | 正确读法 |
|---|---|
| schtasks /run 退出 0 | 请求调用成功,继续确认实例 |
| 0x41303 / 267011 | 尚未运行,不是应用退出码 |
| 0x41301 | 任务正在运行 |
| 0x41305 | 没有更多计划运行,不是“密码错误” |
| 应用退出 0 | 该程序约定下的成功,仍核对业务产物 |
| 0x80070569 | 请求的登录类型未获授予,优先查主体和批处理登录权 |
返回值要保留所在字段和原始十六进制/十进制表示,不把所有非零值都套进一个密码失败解释。Task Scheduler 常量
2. 工作目录
Server 2022 的 NAD0820261009 以 SYSTEM 运行 job.ps1。绝对 -File 路径没有让脚本的 Get-Content '.\input.txt' 自动改为从脚本目录读取。
| 场景 | Get-Location | 结果 | 新业务输出 |
|---|---|---|---|
| 手动子进程指定目录 | C:\NAD0820261009 | 退出 0 | quantity=7 |
| 任务配置同一 WorkingDirectory | 同上 | LastTaskResult=0 | 存在 |
| 只清空 WorkingDirectory | C:\Windows\system32 | LastTaskResult=2 | 不存在 |
| 补回工作目录 | C:\NAD0820261009 | LastTaskResult=0 | 恢复 |
故障日志为 PathNotFound,脚本 catch 后明确 exit 2。因此这里的 2 是业务脚本退出约定,不是仅凭“找不到文件”字面就认定 powershell.exe 没启动。
同一实例 {cf1df54c-0b8e-4e1c-a933-d0d3d08722f0} 有 100/200 启动、201 动作结束、102 任务结束。201 的 ResultCode=2147942402,即 0x80070002,而任务信息记录为 2;两处原值与应用日志一起保存,不将所有事件码都假定为未经包装的进程退出码。
任务程序、输入、身份和脚本哈希没有变化,只改工作目录即可恢复。界面上的“起始于”对应 WorkingDirectory;程序路径与“添加参数”是另外两项。动作参数说明
下面保留原 Execute、Arguments,只修改确认过的单个 Exec 动作:
# For this confirmed single Exec action only; do not replace a multi-action task.
$name = 'NAD0820261009'; $taskPath = '\'
$t = Get-ScheduledTask -TaskName $name -TaskPath $taskPath
if ($t.State -eq 'Running') { throw 'Wait for the current run to finish.' }
if (@($t.Actions).Count -ne 1 -or -not $t.Actions[0].Execute) {
throw 'Review this task separately; expected one Exec action.'
}
$backup = Join-Path $env:TEMP ('task-before-' + [guid]::NewGuid() + '.xml')
Export-ScheduledTask -TaskName $name -TaskPath $taskPath |
Set-Content -LiteralPath $backup -Encoding Unicode
$old = $t.Actions[0]
$args = @{Execute=$old.Execute;WorkingDirectory='C:\NAD0820261009'}
if ($old.Arguments) { $args.Argument = $old.Arguments }
if ($old.Id) { $args.Id = $old.Id }
$a = New-ScheduledTaskAction @args
Set-ScheduledTask -TaskName $name -TaskPath $taskPath -Action $a | Out-Null
(Get-ScheduledTask -TaskName $name -TaskPath $taskPath).Actions |
Select-Object Execute,Arguments,WorkingDirectory多动作任务不能用一个新动作覆盖整个数组。含密码的任务更新还可能要求重新提供凭据,应在任务属性页或相应注册接口处理,XML 备份不包含密码。
业务如果永远读取脚本相邻文件,可以直接使用 Join-Path $PSScriptRoot 'input.txt';这与工作目录是两个设计选择。外部程序仍可能使用进程工作目录,所以只修一条 Get-Content 不代表所有相对路径都改好了。原实验采用补任务配置,保持脚本不变。
3. 实例策略
另一个 8 秒任务 KBEW0320261011 每轮记录 GUID、PID、UTC 起止时间。空闲时能正常完成;已有实例运行约一秒时再发一次请求,只产生首轮业务记录。
XML 中是 IgnoreNew,日志的 322 明确写出已有实例仍在运行。第二次请求虽然出现 325、110,却没有新动作进程与新业务记录。最近 LastTaskResult 仍可能是前一次的 0,不能替被忽略的请求证明成功。
把同一个任务改为 Queue 后,两次请求对应两个进程 3944、1544。出现 324 的排队说明,前一实例 201 结束后约两毫秒出现后续进程的 129,业务起始与前轮结束相差约 0.093 秒;两轮都正常完成。
| 策略 | 已有实例时的新请求 | 业务需要考虑 |
|---|---|---|
| IgnoreNew | 不启动新实例 | 重复触发允许合并/丢弃时适用 |
| Queue | 等已有实例完成后再执行 | 积压、任务持续时间及停机恢复 |
| Parallel | 并发启动 | 输入、输出、锁和幂等性 |
| StopExisting | 停旧再启新 | 中断可能留下半成品 |
这些是调度准入策略,不是带业务确认与持久化语义的可靠消息队列。每个业务请求都必须保留时,调度器可以唤醒消费者,待处理工作应由持久化队列或业务状态管理。MultipleInstancesPolicy
原实验的配置修改如下,先备份,回读 XML,再进行同样两次请求的对照:
$name = 'KBEW0320261011'; $taskPath = '\'
$backup = Join-Path $env:TEMP ('task-policy-' + [guid]::NewGuid() + '.xml')
Export-ScheduledTask -TaskName $name -TaskPath $taskPath |
Set-Content -LiteralPath $backup -Encoding Unicode
$t = Get-ScheduledTask -TaskName $name -TaskPath $taskPath
if ($t.State -eq 'Running') { throw 'Change after the current run completes.' }
$t.Settings.MultipleInstances = 1 # Queue
Set-ScheduledTask -InputObject $t | Out-Null
[xml]$x = Export-ScheduledTask -TaskName $name -TaskPath $taskPath
$x.Task.Settings.MultipleInstancesPolicy不要仅把 LastTaskResult=0 当成两轮都完成;要看独立实例及新增的两份结果。
4. 登录类型
LTSC 2021 的 KBTask 创建后主体为 Interactive,XML 是 InteractiveToken,界面勾选“只在用户登录时运行”。kbtask 当时没有交互式会话,schtasks /run 返回尝试启动成功,但结果保持 0x41303,没有文件。

同账号改为 Password 登录方式后,在无人登录时能执行并输出 password-run。该对照还改变了运行级别为 Highest,因此它不是“只有密码一个变量”的完整隔离;定位交互式前提的直接证据是主体、会话状态与动作从未启动。
不能推广成“省略 /rp 一定创建 InteractiveToken”,要看命令参数、交互提示、API 与最终定义。无人值守也不一定必须普通账号保存密码:SYSTEM/合适服务账号、托管账号或受限 S4U 等可用于不同需求。S4U 不提供同样的网络及加密文件访问能力。选择最小所需权限,Highest 不应成为所有后台任务的默认修复。
检查“作为批处理作业登录”与“拒绝作为批处理作业登录”,以及管理它的计算机 GPO。另一个测试出现事件 104、0x80070569,授予准确账号 SeBatchLogonRight 后才能真正启动。不要覆盖整份权利列表,也不要把某台测试机的默认允许组当作所有机器标准。
5. HKCU 配置
Server 2022 的配置实验使用同一个 app.exe、相同 auto/explicit 参数逻辑。用户 KBEW0820261011 的 HKCU 有 ConfigPath 种子键,SYSTEM 的对应分支没有:
| 身份 | 模式 | 退出 | 读到的内容 |
|---|---|---|---|
| SYSTEM | auto,从 HKCU 找路径 | 2 | 无 |
| 专用用户 | auto | 0 | limit=50 |
| SYSTEM | explicit,直接给用户配置路径 | 0 | limit=50 |
| 专用用户 | explicit | 0 | limit=50 |
SYSTEM 明确路径可读,失败仅发生在自动查种子键;这组结果排除了这次目标文件读取的 NTFS 拒绝,不是证明所有用户目录都可读。
随后为 SYSTEM 自己的配置分支初始化独立路径,auto 读到 limit=200,退出变 0;删除测试种子键再次返回 2。配置初始化与运行身份一致,是本次修复。
把下面这类探针放到实际动作里,把输出保存在该身份可写的专用日志目录,而不是在管理员窗口猜它看到什么:
# Add equivalent diagnostics to the real action, using a dedicated writable log folder.
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
[pscustomobject]@{
TimeUtc = [datetime]::UtcNow.ToString('o')
PID = $PID
Identity = $id.Name
SID = $id.User.Value
PowerShellLocation = [string](Get-Location)
ProcessDirectory = [Environment]::CurrentDirectory
UserProfile = $env:USERPROFILE
AppData = $env:APPDATA
PSVersion = [string]$PSVersionTable.PSVersion
Is64Bit = [Environment]::Is64BitProcess
} | ConvertTo-Json
Get-ItemProperty 'HKCU:\Software\KBEW0820261011' -ErrorAction SilentlyContinue
net.exe use环境变量不是 HKCU 正确加载的充分证据。服务、任务和模拟身份的 profile 加载行为不同;这次批处理任务确实观察到了用户 profile,不应照搬“所有非交互场景都不加载”。模拟线程还涉及预定义 HKCU 句柄缓存,应使用适当用户注册表 API,不能只改 USERPROFILE 字符串。
集中配置可用明确路径,按账号隔离配置则在实际运行身份下初始化。不要批量把管理员 HKCU 复制给所有身份。需要访问 HKU 时可用 Registry::HKEY_USERS,没有 HKU: 驱动器本身不是配置单元不存在。
6. 提权与映射盘
映射盘实验在 Windows 10 21H2、EnableLUA=1 下比较同一用户关联的受限/完整令牌。普通 LogonId 为 0:0x83ab6,提升后为 0:0x83a95。普通窗口能读 R:\marker.txt,提升窗口没有 R:;提升窗口改用同一文件的 UNC 立即读到 MAPPED_DATA_OK,普通窗口原映射仍可读。

该实验只用了本机 127.0.0.1 共享,没有验证真实域服务账号跨机认证。因此 UNC 修复的是本次路径命名空间问题;后台任务改 UNC 后仍要用实际身份验证网络凭据、共享与 NTFS 两层权限。不能凭“同一个用户名”假设一定继承桌面映射。微软映射盘说明
能接受 UNC 的脚本优先写准确 UNC;必须盘符的旧应用在实际执行上下文建立那一个映射,检查占用后再运行。EnableLinkedConnections 改变全机行为,也不能覆盖所有重新凭据登录产生的上下文,不作为此处首选。
7. 实例与业务结果
按完整 TaskName 查 Operational 日志,保留两种可能的实例字段:
$fullName = $taskPath + $name
$log = 'Microsoft-Windows-TaskScheduler/Operational'
(Get-WinEvent -ListLog $log).IsEnabled
Get-WinEvent -FilterHashtable @{
LogName=$log;StartTime=(Get-Date).AddHours(-1)
} | ForEach-Object {
$e = [xml]$_.ToXml(); $d = @{}
foreach ($item in $e.Event.EventData.Data) {
$d[[string]$item.Name] = [string]$item.'#text'
}
if ($d.TaskName -eq $fullName) {
[pscustomobject]@{
Time=$_.TimeCreated;Id=$_.Id;RecordId=$_.RecordId
InstanceId=$d.InstanceId;TaskInstanceId=$d.TaskInstanceId
ProcessId=$d.ProcessId;Action=$d.ActionName
ResultCode=$d.ResultCode;Message=$_.Message
}
}
} | Sort-Object Time,RecordId | Format-List日志未启用的历史不能事后补齐。动作日志至少记录开始/结束 UTC、唯一 RunId、PID、实际身份、解析后的路径、退出码和业务结果标识。调度器实例 ID 与业务 RunId 都保留,避免把另一次运行的输出贴到本次请求上。
Start-ScheduledTask 是异步调用。开始前记 LastRunTime、现有实例和业务日志位置;之后确认新实例确实产生,再等待它结束。只轮询 State 不为 Running 有竞态:请求尚未启动也会是 Ready,短任务还可能在两次采样之间结束。并发任务更不能仅靠单个“最近结果”证明每轮都完成。
修复验收仍用相同输入、同一业务身份:路径分支看新解析路径与新输出;身份分支看准确 SID/HKCU;登录分支看新实例与运行结果;实例策略分支看对应数量的独立业务完成记录。恢复从已导出的 XML 核对原字段,只还原本次改动;密码重新提供,保留已有业务结果与历史事件。测试任务空闲并确认子进程退出后,再清理专用对象。
