From f5e2d74734cb52b61cefaea914a5c86423a7c116 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 18 May 2026 21:05:56 -0400 Subject: [PATCH 1/3] fix(cluster): surface named-task lookup failures as errors instead of silent partial results Previously, Get-StmClusteredScheduledTaskInfo silently emitted partial results (TaskName-only) when a named task could not be resolved on a cluster, and Get-StmClusteredScheduledTask fired a misleading "configure your cluster" warning for what was actually a single-named-task miss. Three changes across two files: - Get-StmClusteredScheduledTask.ps1: when -TaskName is given and the cluster returns zero, write a structured ClusteredScheduledTaskNotFound error instead of a cluster-wide warning. Bulk path (no -TaskName) keeps the warning. - Get-StmClusteredScheduledTask.ps1: detect stage-B misses (cluster registry has the task but the owner node didn't return it) and write a structured ClusteredScheduledTaskOwnerLookupFailed error per missing task. Suppresses the raw cmdletization error leak from the per-name Get-ScheduledTask call. - Get-StmClusteredScheduledTaskInfo.ps1: when -TaskName is given and the inner lookup is empty, write a structured ClusteredScheduledTaskNotResolvable error. Bulk path keeps the warning. Five new Pester tests across the two .Tests.ps1 files cover both error paths and verify the preserved bulk-path warnings. Validated red-then-green: new tests fail against pristine module, pass with patches. Also validated end-to-end against a real failover cluster in the integration lab. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Public/Get-StmClusteredScheduledTask.ps1 | 61 +++++++++++++-- .../Get-StmClusteredScheduledTaskInfo.ps1 | 25 +++++- tests/Get-StmClusteredScheduledTask.Tests.ps1 | 76 +++++++++++++++++++ ...et-StmClusteredScheduledTaskInfo.Tests.ps1 | 43 +++++++++++ 4 files changed, 197 insertions(+), 8 deletions(-) diff --git a/ScheduledTasksManager/Public/Get-StmClusteredScheduledTask.ps1 b/ScheduledTasksManager/Public/Get-StmClusteredScheduledTask.ps1 index c8cc44c..c1e9bf3 100644 --- a/ScheduledTasksManager/Public/Get-StmClusteredScheduledTask.ps1 +++ b/ScheduledTasksManager/Public/Get-StmClusteredScheduledTask.ps1 @@ -162,10 +162,30 @@ Write-Verbose "Retrieving clustered scheduled tasks from cluster '$Cluster'" $clusteredScheduledTasks = Get-ClusteredScheduledTask @clusteredScheduledTasksParameters if ($clusteredScheduledTasks.Count -eq 0) { - Write-Warning ( - "No clustered scheduled tasks found on cluster '$Cluster'. " + - 'Ensure the cluster is properly configured.' - ) + if ($taskNameProvided) { + $notFoundException = [System.InvalidOperationException]::new( + "Clustered scheduled task '$TaskName' not found on cluster '$Cluster'.") + $errorRecordParameters = @{ + Exception = $notFoundException + ErrorId = 'ClusteredScheduledTaskNotFound' + ErrorCategory = [System.Management.Automation.ErrorCategory]::ObjectNotFound + TargetObject = $TaskName + Message = ( + "Clustered scheduled task '$TaskName' was not found on cluster '$Cluster'." + ) + RecommendedAction = ( + 'Verify the task name (including case) and that it is registered as a clustered task.' + ) + } + $errorRecord = New-StmError @errorRecordParameters + $PSCmdlet.WriteError($errorRecord) + } + else { + Write-Warning ( + "No clustered scheduled tasks found on cluster '$Cluster'. " + + 'Ensure the cluster is properly configured.' + ) + } return } Write-Verbose "Found $($clusteredScheduledTasks.Count) clustered scheduled task(s) on cluster '$Cluster'" @@ -208,12 +228,41 @@ # ScheduledTaskObject contains CIM instance references that depend on them $taskOwnerCimSession = New-StmCimSession -ComputerName $taskOwner -Credential $Credential Write-Verbose "Retrieving scheduled tasks from owner '$taskOwner' using CIM session" + # Suppress per-name cmdletization errors; we re-emit them as structured errors below + # so the user sees one diagnostic per missing task instead of a raw red leak. $getScheduledTaskParameters = @{ - TaskName = $taskNames - CimSession = $taskOwnerCimSession + TaskName = $taskNames + CimSession = $taskOwnerCimSession + ErrorAction = 'SilentlyContinue' } $scheduledTasksFromOwner = Get-ScheduledTask @getScheduledTaskParameters + # Diff requested vs returned: cluster registry references the task but the owner + # node didn't return it (task may have just failed over, or local copy is missing). + $foundTaskNames = @($scheduledTasksFromOwner | Select-Object -ExpandProperty 'TaskName') + foreach ($expectedTaskName in $taskNames) { + if ($foundTaskNames -notcontains $expectedTaskName) { + $ownerLookupException = [System.InvalidOperationException]::new( + "Owner '$taskOwner' did not return clustered task '$expectedTaskName'.") + $ownerLookupErrorParameters = @{ + Exception = $ownerLookupException + ErrorId = 'ClusteredScheduledTaskOwnerLookupFailed' + ErrorCategory = [System.Management.Automation.ErrorCategory]::ObjectNotFound + TargetObject = $expectedTaskName + Message = ( + "Clustered scheduled task '$expectedTaskName' is registered on cluster " + + "'$Cluster' but owner node '$taskOwner' did not return it. The task may " + + 'have just failed over, or its local copy on the owner may be missing.' + ) + RecommendedAction = ( + "Verify the task exists on '$taskOwner' and that cluster ownership is consistent." + ) + } + $ownerLookupErrorRecord = New-StmError @ownerLookupErrorParameters + $PSCmdlet.WriteError($ownerLookupErrorRecord) + } + } + if ($PSBoundParameters.ContainsKey('TaskState')) { Write-Verbose "Filtering scheduled tasks by state '$TaskState' on owner '$taskOwner'" $scheduledTasksFromOwner = $scheduledTasksFromOwner | Where-Object { $_.State -eq $TaskState } diff --git a/ScheduledTasksManager/Public/Get-StmClusteredScheduledTaskInfo.ps1 b/ScheduledTasksManager/Public/Get-StmClusteredScheduledTaskInfo.ps1 index d86619b..4522816 100644 --- a/ScheduledTasksManager/Public/Get-StmClusteredScheduledTaskInfo.ps1 +++ b/ScheduledTasksManager/Public/Get-StmClusteredScheduledTaskInfo.ps1 @@ -183,8 +183,29 @@ } process { - if ($scheduledTask.Count -eq 0) { - Write-Warning "No scheduled tasks found on cluster '$Cluster' with the specified parameters." + if ($null -eq $scheduledTask -or $scheduledTask.Count -eq 0) { + if ($PSBoundParameters.ContainsKey('TaskName')) { + $unresolvedException = [System.InvalidOperationException]::new( + "Clustered scheduled task '$TaskName' could not be resolved on cluster '$Cluster'.") + $errorRecordParameters = @{ + Exception = $unresolvedException + ErrorId = 'ClusteredScheduledTaskNotResolvable' + ErrorCategory = [System.Management.Automation.ErrorCategory]::ObjectNotFound + TargetObject = $TaskName + Message = ( + "Clustered scheduled task '$TaskName' was not found on cluster '$Cluster', " + + 'or its owner node could not return the task.' + ) + RecommendedAction = ( + 'Run Get-StmClusteredScheduledTask with -Verbose to see which stage of the lookup failed.' + ) + } + $errorRecord = New-StmError @errorRecordParameters + $PSCmdlet.WriteError($errorRecord) + } + else { + Write-Warning "No scheduled tasks found on cluster '$Cluster' with the specified parameters." + } return } Write-Verbose "Retrieving scheduled task info for $($scheduledTask.Count) tasks on cluster '$Cluster'" diff --git a/tests/Get-StmClusteredScheduledTask.Tests.ps1 b/tests/Get-StmClusteredScheduledTask.Tests.ps1 index 881f1ea..f9dd973 100644 --- a/tests/Get-StmClusteredScheduledTask.Tests.ps1 +++ b/tests/Get-StmClusteredScheduledTask.Tests.ps1 @@ -234,5 +234,81 @@ InModuleScope -ModuleName 'ScheduledTasksManager' { Should -Invoke 'Remove-CimSession' -Times 2 -Exactly } } + + Context 'When a named task is not found in the cluster (Issue 1 / stage-A miss)' { + BeforeEach { + Mock -CommandName 'Get-ClusteredScheduledTask' -MockWith { return @() } + # The outer BeforeEach mocks New-StmCimSession to return a string sentinel, + # so Remove-CimSession in the end block would fail without this mock. + Mock -CommandName 'Remove-CimSession' -MockWith {} + } + + It 'Should write a structured non-terminating error (not a warning) when -TaskName is given' { + $err = $null + $warn = $null + $namedParameters = @{ + Cluster = 'TestCluster' + TaskName = 'NoSuchTask' + ErrorVariable = 'err' + WarningVariable = 'warn' + ErrorAction = 'SilentlyContinue' + WarningAction = 'SilentlyContinue' + } + Get-StmClusteredScheduledTask @namedParameters + + $err.Count | Should -Be 1 + $err[0].FullyQualifiedErrorId | Should -Match 'ClusteredScheduledTaskNotFound' + $err[0].CategoryInfo.Category | Should -Be 'ObjectNotFound' + $err[0].TargetObject | Should -Be 'NoSuchTask' + $warn.Count | Should -Be 0 + } + + It 'Should still emit a warning (and no error) when -TaskName is omitted (bulk path)' { + $err = $null + $warn = $null + $bulkParameters = @{ + Cluster = 'TestCluster' + ErrorVariable = 'err' + WarningVariable = 'warn' + ErrorAction = 'SilentlyContinue' + WarningAction = 'SilentlyContinue' + } + Get-StmClusteredScheduledTask @bulkParameters + + $warn.Count | Should -BeGreaterThan 0 + $warn[0].Message | Should -Match 'No clustered scheduled tasks found' + $err.Count | Should -Be 0 + } + } + + Context 'When the cluster claims an owner but the owner does not return the task (Issue 1 / stage-B miss)' { + BeforeEach { + Mock -CommandName 'Get-ClusteredScheduledTask' -MockWith { + return [PSCustomObject]@{ + TaskName = 'StragglerTask' + CurrentOwner = 'OwnerNode1' + } + } + # Owner returns no tasks for the requested name — the stage-B miss scenario + Mock -CommandName 'Get-ScheduledTask' -MockWith { return @() } + Mock -CommandName 'Remove-CimSession' -MockWith {} + } + + It 'Should write a structured ClusteredScheduledTaskOwnerLookupFailed error' { + $err = $null + $stageBParameters = @{ + Cluster = 'TestCluster' + TaskName = 'StragglerTask' + ErrorVariable = 'err' + ErrorAction = 'SilentlyContinue' + } + Get-StmClusteredScheduledTask @stageBParameters + + $err.FullyQualifiedErrorId | + Should -Contain 'ClusteredScheduledTaskOwnerLookupFailed,Get-StmClusteredScheduledTask' + $matchingErr = $err | Where-Object { $_.FullyQualifiedErrorId -match 'OwnerLookupFailed' } + $matchingErr.TargetObject | Should -Be 'StragglerTask' + } + } } } diff --git a/tests/Get-StmClusteredScheduledTaskInfo.Tests.ps1 b/tests/Get-StmClusteredScheduledTaskInfo.Tests.ps1 index 67be4cc..32562b8 100644 --- a/tests/Get-StmClusteredScheduledTaskInfo.Tests.ps1 +++ b/tests/Get-StmClusteredScheduledTaskInfo.Tests.ps1 @@ -310,5 +310,48 @@ InModuleScope -ModuleName 'ScheduledTasksManager' { $verboseOutput | Should -Match "Filtering tasks by name.*TestTask" } } + + Context 'When a named task cannot be resolved (Issues 1 + 2)' { + BeforeEach { + Mock -CommandName 'Get-StmClusteredScheduledTask' -MockWith { return $null } + } + + It 'Should write a structured non-terminating error (not a warning) when -TaskName is given' { + $err = $null + $warn = $null + $namedParameters = @{ + Cluster = 'TestCluster' + TaskName = 'Missing' + ErrorVariable = 'err' + WarningVariable = 'warn' + ErrorAction = 'SilentlyContinue' + WarningAction = 'SilentlyContinue' + } + Get-StmClusteredScheduledTaskInfo @namedParameters + + $err.Count | Should -Be 1 + $err[0].FullyQualifiedErrorId | Should -Match 'ClusteredScheduledTaskNotResolvable' + $err[0].CategoryInfo.Category | Should -Be 'ObjectNotFound' + $err[0].TargetObject | Should -Be 'Missing' + $warn.Count | Should -Be 0 + } + + It 'Should still emit a warning (and no error) when -TaskName is omitted (bulk path)' { + $err = $null + $warn = $null + $bulkParameters = @{ + Cluster = 'TestCluster' + ErrorVariable = 'err' + WarningVariable = 'warn' + ErrorAction = 'SilentlyContinue' + WarningAction = 'SilentlyContinue' + } + Get-StmClusteredScheduledTaskInfo @bulkParameters + + $warn.Count | Should -BeGreaterThan 0 + $warn[0].Message | Should -Match 'No scheduled tasks found on cluster' + $err.Count | Should -Be 0 + } + } } } From c46e052884251fb7c35bf1314e80c65199809136 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 18 May 2026 22:10:22 -0400 Subject: [PATCH 2/3] fix(tests): align test expectations and cleanup probes with new error contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the stale "Should warn" unit test — its scenario is now covered by the PR's new structured-error tests (`ClusteredScheduledTaskOwnerLookupFailed`), and its old warning-based assertion no longer matches the post-#42 behavior. Switch four integration-test cleanup probes from `-EA SilentlyContinue` to `-EA Ignore`. `Ignore` does not pollute `$Error`; `SilentlyContinue` does. The polluted `$Error` was leaking across the PSRemoting boundary into psake's `$ErrorActionPreference = 'Stop'` scope and failing the build at `build.psake.ps1:266` after all 19 integration tests had passed. Probe-then-act pattern is preserved (Unregister-* is still terminating on missing tasks); collapsing to one-line idempotent cleanup is tracked in #44. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Get-StmClusteredScheduledTask.Tests.ps1 | 22 ------------------- ...usteredScheduledTask.Integration.Tests.ps1 | 8 +++---- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/tests/Get-StmClusteredScheduledTask.Tests.ps1 b/tests/Get-StmClusteredScheduledTask.Tests.ps1 index f9dd973..2d23454 100644 --- a/tests/Get-StmClusteredScheduledTask.Tests.ps1 +++ b/tests/Get-StmClusteredScheduledTask.Tests.ps1 @@ -143,28 +143,6 @@ InModuleScope -ModuleName 'ScheduledTasksManager' { } Context 'Error handling' { - It 'Should warn when no matching clustered task found for scheduled task' { - Mock -CommandName 'Get-ClusteredScheduledTask' -MockWith { - return [PSCustomObject]@{ - TaskName = 'ClusteredTask1' - CurrentOwner = 'OwnerNode1' - } - } - Mock -CommandName 'Get-ScheduledTask' -MockWith { - return [PSCustomObject]@{ - TaskName = 'DifferentTask' - State = 'Ready' - } - } - Mock -CommandName 'Write-Warning' -MockWith {} - - Get-StmClusteredScheduledTask -Cluster 'TestCluster' - - Should -Invoke 'Write-Warning' -Times 1 -ParameterFilter { - $Message -like '*No matching clustered task found*' - } - } - It 'Should write error when retrieving tasks from owner fails' { Mock -CommandName 'Get-ClusteredScheduledTask' -MockWith { return [PSCustomObject]@{ diff --git a/tests/Integration/ClusteredScheduledTask.Integration.Tests.ps1 b/tests/Integration/ClusteredScheduledTask.Integration.Tests.ps1 index a44ccd7..48120e9 100644 --- a/tests/Integration/ClusteredScheduledTask.Integration.Tests.ps1 +++ b/tests/Integration/ClusteredScheduledTask.Integration.Tests.ps1 @@ -150,7 +150,7 @@ AfterAll { $task = Get-StmClusteredScheduledTask ` -Cluster $ClusterName ` -TaskName $TaskName ` - -ErrorAction SilentlyContinue ` + -ErrorAction Ignore ` -WarningAction SilentlyContinue if ($task) { @@ -342,7 +342,7 @@ Describe 'Clustered Scheduled Task Integration Tests' -Skip:$script:SkipIntegrat Get-StmClusteredScheduledTask ` -Cluster $ClusterName ` -TaskName $TaskName ` - -ErrorAction SilentlyContinue ` + -ErrorAction Ignore ` -WarningAction SilentlyContinue } -ArgumentList @($script:LabModulePath, $script:ClusterName, $script:TestTaskName) -PassThru @@ -411,7 +411,7 @@ Describe 'Clustered Scheduled Task Integration Tests' -Skip:$script:SkipIntegrat $existing = Get-StmClusteredScheduledTask ` -Cluster $ClusterName ` -TaskName $TaskName ` - -ErrorAction SilentlyContinue ` + -ErrorAction Ignore ` -WarningAction SilentlyContinue if ($existing) { Unregister-StmClusteredScheduledTask ` @@ -546,7 +546,7 @@ Describe 'Clustered Scheduled Task Integration Tests' -Skip:$script:SkipIntegrat Get-StmClusteredScheduledTask ` -Cluster $ClusterName ` -TaskName $TaskName ` - -ErrorAction SilentlyContinue ` + -ErrorAction Ignore ` -WarningAction SilentlyContinue } -ArgumentList @($script:LabModulePath, $script:ClusterName, $script:TestTaskName) -PassThru From ccda64327e892d0acca1e299ddea7edca4aca1b8 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Mon, 18 May 2026 22:51:13 -0400 Subject: [PATCH 3/3] fix(disable): tolerate Get-* 'not found' as unregister-success in verify step Disable-StmClusteredScheduledTask's verify-after-unregister called Get-StmClusteredScheduledTask with -ErrorAction Stop, expecting a silent null when the task was successfully unregistered. After this PR introduced the new ClusteredScheduledTaskNotFound error on the named-task path, that Get call now throws terminating on the SUCCESS case (task absent), which the surrounding catch block wraps as UnregisterFailed and re-throws. In the integration test, the "Should disable" Pester test still reported as passed (AutomatedLab's Invoke-LabCommand appears to swallow terminating errors from the remote lab session), but the error accumulated in the session's error stream and surfaced ~2s after Pester finished, at the outer Invoke-Command boundary in build.psake.ps1:266 where psake's $ErrorActionPreference = 'Stop' promoted it to a build failure. Empirically verified locally: Get-StmClusteredScheduledTask with the exact splat Disable used (-EA Stop, -WA SilentlyContinue) throws terminating with FQEI ClusteredScheduledTaskNotFound when the task is missing. Switch to -ErrorAction Ignore on the verify-Get: a missing task here is the SUCCESS condition. The existing `$taskExists = $null -ne $task` check correctly drives the success/failure branch without depending on the producer's error contract. Audit of other internal Get-StmClusteredScheduledTask callers: - Import-StmClusteredScheduledTask: -EA Stop, but wraps in try/catch that treats throw as 'doesn't exist'. Robust to both old and new behavior. - Enable/Export/Set/Start/Stop/Wait/Get-Info: all expect the task to exist; surfacing 'not found' as an error is the correct behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Public/Disable-StmClusteredScheduledTask.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ScheduledTasksManager/Public/Disable-StmClusteredScheduledTask.ps1 b/ScheduledTasksManager/Public/Disable-StmClusteredScheduledTask.ps1 index 498444f..bd4b023 100644 --- a/ScheduledTasksManager/Public/Disable-StmClusteredScheduledTask.ps1 +++ b/ScheduledTasksManager/Public/Disable-StmClusteredScheduledTask.ps1 @@ -172,12 +172,15 @@ } Unregister-ClusteredScheduledTask @unregisterClusteredScheduledTaskParameters Write-Verbose "Verifying unregistration of clustered scheduled task '$TaskName'..." + # Use -EA Ignore: a missing task here means the unregister succeeded + # (Get-StmClusteredScheduledTask writes ClusteredScheduledTaskNotFound when + # -TaskName misses; with -EA Stop that would falsely flag success as failure). $taskParameters = @{ TaskName = $TaskName Cluster = $Cluster CimSession = $clusterCimSession - ErrorAction = 'Stop' - WarningAction = 'SilentlyContinue' # Suppress the warning about the task not being found + ErrorAction = 'Ignore' + WarningAction = 'SilentlyContinue' } $task = Get-StmClusteredScheduledTask @taskParameters $taskExists = $null -ne $task