Files
awoooi/scripts/reboot-recovery/agent99-live-preflight.ps1

1316 lines
55 KiB
PowerShell

[CmdletBinding()]
param(
[ValidateSet("Verify")]
[string]$Mode = "Verify",
[string]$AgentRoot = "C:\Wooo\Agent99",
[ValidateSet("FullRuntime", "PromotionReserve", "PostPromotionMaintenance")]
[string]$ReadinessScope = "FullRuntime",
[ValidateRange(0, 20)]
[int]$MinimumEvidenceFreshnessReserveMinutes = 0
)
$ErrorActionPreference = "Stop"
function Get-AgentLivePreflightDecision {
param(
[bool]$AgentRootPresent,
[object]$Manifest,
[bool]$ManifestCheckRequired,
[int]$ExpectedRuntimeFileCount,
[int]$TaskFailureCount,
[int]$RelayListenerCount,
[int]$RequiredEvidenceStaleCount,
[int]$RequiredEvidenceReserveFailureCount,
[int]$RequiredEvidenceParseFailureCount,
[int]$RequiredEvidenceContentFailureCount,
[string]$SelfHealthSeverity,
[int]$SelfHealthPerformanceIssueCount,
[int]$StaleAlertExecutionCount,
[int]$AlertIncomingCount,
[int]$StaleAlertIncomingCount,
[int]$PendingCommandBlockingCount,
[int]$PromotionDeferredCommandCount
)
$blockingReasons = @()
$warningReasons = @()
$manifestExists = [bool]($Manifest -and $Manifest.exists)
$manifestParseReady = [bool](
$manifestExists -and
-not ([string]$Manifest.parseError)
)
$manifestIdentityReady = [bool](
$manifestParseReady -and
[string]$Manifest.sourceRevision -match "^[a-fA-F0-9]{40}$" -and
[int]$Manifest.fileCount -eq $ExpectedRuntimeFileCount
)
$manifestContentReady = [bool](
$manifestParseReady -and
$Manifest.runtimeMatched -eq $true -and
[int]$Manifest.mismatchCount -eq 0
)
if (-not $AgentRootPresent) { $blockingReasons += "agent_root_missing" }
if ($ManifestCheckRequired) {
if (-not $manifestExists) {
$blockingReasons += "runtime_manifest_missing"
} elseif (-not $manifestParseReady) {
$blockingReasons += "runtime_manifest_parse_failed"
} else {
if (-not $manifestIdentityReady) {
$blockingReasons += "runtime_manifest_identity_invalid"
}
if (-not $manifestContentReady) {
$blockingReasons += "runtime_manifest_mismatch"
}
}
}
if ($TaskFailureCount -gt 0) { $blockingReasons += "scheduled_task_unhealthy" }
if ($RelayListenerCount -lt 1) { $blockingReasons += "sre_alert_relay_not_listening" }
if ($RequiredEvidenceStaleCount -gt 0) { $blockingReasons += "required_evidence_stale" }
if ($RequiredEvidenceReserveFailureCount -gt 0) { $blockingReasons += "required_evidence_freshness_reserve_insufficient" }
if ($RequiredEvidenceParseFailureCount -gt 0) { $blockingReasons += "required_evidence_parse_failed" }
if ($RequiredEvidenceContentFailureCount -gt 0) { $blockingReasons += "required_evidence_content_unhealthy" }
$normalizedSelfHealth = ([string]$SelfHealthSeverity).Trim().ToLowerInvariant()
switch ($normalizedSelfHealth) {
"ok" {}
"warning" {
$warningReasons += "self_health_warning"
if ($SelfHealthPerformanceIssueCount -gt 0) {
$warningReasons += "performance_warning"
}
}
# Self-health also aggregates external host performance and Telegram
# delivery. Keep the preflight exit degraded, but emit the receiver's
# canonical external-health reason so unrelated asset pressure cannot be
# mistaken for a runtime-plane integrity failure.
"critical" { $blockingReasons += "self_health_not_ok" }
default { $blockingReasons += "self_health_unclassified" }
}
if ($StaleAlertExecutionCount -gt 0) { $blockingReasons += "stale_alert_execution" }
if ($StaleAlertIncomingCount -gt 0 -or $PendingCommandBlockingCount -gt 0) {
$blockingReasons += "pending_work_before_reboot"
} elseif ($AlertIncomingCount -gt 0) {
$warningReasons += "fresh_alert_pending_next_inbox_tick"
}
if ($PromotionDeferredCommandCount -gt 0) {
$warningReasons += "maintenance_pending_commands_deferred_until_post_promotion"
}
return [pscustomobject]@{
deploymentEligible = [bool]($blockingReasons.Count -eq 0)
blockingReasons = @($blockingReasons)
warningReasons = @($warningReasons)
warningCount = [int]$warningReasons.Count
manifestCheckRequired = $ManifestCheckRequired
manifestIdentityReady = $manifestIdentityReady
manifestContentReady = $manifestContentReady
selfHealthClassification = $normalizedSelfHealth
}
}
function Get-TaskReadback {
param(
[string]$TaskPath,
[string]$TaskName,
[string[]]$RequiredArgumentMarkers
)
try {
$tasks = @(Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction Stop)
if ($tasks.Count -ne 1) { throw "exact_root_task_count_invalid" }
$task = $tasks[0]
$info = Get-ScheduledTaskInfo -TaskPath $TaskPath -TaskName $TaskName -ErrorAction Stop
$lastResult = [int64]$info.LastTaskResult
$state = [string]$task.State
$actions = @($task.Actions)
$actionDefinitionReady = [bool](
$actions.Count -eq 1 -and
[string]$actions[0].Execute -match '(?i)\\WindowsPowerShell\\v1\.0\\powershell\.exe$'
)
if ($actionDefinitionReady) {
foreach ($marker in @($RequiredArgumentMarkers)) {
if (
-not $marker -or
([string]$actions[0].Arguments).IndexOf(
$marker,
[StringComparison]::OrdinalIgnoreCase
) -lt 0
) {
$actionDefinitionReady = $false
break
}
}
}
$principalReady = [bool](
[string]$task.Principal.UserId -match '(?i)(^|\\)Administrator$' -and
[string]$task.Principal.LogonType -eq "S4U" -and
[string]$task.Principal.RunLevel -eq "Highest"
)
$definitionReady = [bool]($actionDefinitionReady -and $principalReady)
$healthy = [bool](
$task.Settings.Enabled -and
$definitionReady -and
($lastResult -in @(0, 267009, 267011) -or $state -eq "Running")
)
return [pscustomobject]@{
name = $TaskName
taskPath = $TaskPath
exists = $true
enabled = [bool]$task.Settings.Enabled
state = $state
lastTaskResult = $lastResult
lastRunTime = if ($info.LastRunTime.Year -gt 2000) { $info.LastRunTime.ToString("o") } else { "" }
nextRunTime = if ($info.NextRunTime.Year -gt 2000) { $info.NextRunTime.ToString("o") } else { "" }
actionDefinitionReady = $actionDefinitionReady
principalReady = $principalReady
definitionReady = $definitionReady
healthy = $healthy
}
} catch {
return [pscustomobject]@{
name = $TaskName
taskPath = $TaskPath
exists = $false
enabled = $false
state = "Missing"
lastTaskResult = $null
lastRunTime = ""
nextRunTime = ""
actionDefinitionReady = $false
principalReady = $false
definitionReady = $false
healthy = $false
}
}
}
function Get-DirectoryReadback {
param(
[string]$RelativePath,
[switch]$RootQueueFilesOnly
)
$path = Join-Path $AgentRoot $RelativePath
$exists = [bool](Test-Path -LiteralPath $path -PathType Container)
$count = 0
$staleCount = 0
$oldest = $null
$newest = $null
if ($exists) {
foreach ($candidatePath in [IO.Directory]::EnumerateFiles(
$path,
"*.json",
[IO.SearchOption]::TopDirectoryOnly
)) {
$name = [IO.Path]::GetFileName($candidatePath)
if ($RootQueueFilesOnly -and $name -match "^(processed|failed|running)-") {
continue
}
$file = [IO.FileInfo]::new($candidatePath)
$count += 1
if (-not $oldest -or $file.LastWriteTimeUtc -lt $oldest.LastWriteTimeUtc) {
$oldest = $file
}
if (-not $newest -or $file.LastWriteTimeUtc -gt $newest.LastWriteTimeUtc) {
$newest = $file
}
if (((Get-Date) - $file.LastWriteTime).TotalMinutes -gt 5) {
$staleCount += 1
}
}
}
return [pscustomobject]@{
relativePath = $RelativePath
exists = $exists
count = [int]$count
oldest = if ($oldest) { $oldest.LastWriteTime.ToString("o") } else { "" }
newest = if ($newest) { $newest.LastWriteTime.ToString("o") } else { "" }
staleOverFiveMinutes = [int]$staleCount
}
}
function Get-AgentBoundedNewestFiles {
param(
[string]$DirectoryPath,
[string]$Pattern,
[ValidateRange(1, 256)]
[int]$CandidateWindow = 32
)
# Evidence freshness is authoritative from filesystem UTC mtime. Some
# evidence names are stable aliases and are not chronologically sortable.
# Keep only the newest bounded window in a native .NET set so a large
# evidence directory never becomes a PowerShell object-sort workload.
$selected = [Collections.Generic.SortedSet[string]]::new(
[StringComparer]::OrdinalIgnoreCase
)
$enumeratedCount = 0
if (Test-Path -LiteralPath $DirectoryPath -PathType Container) {
foreach ($candidatePath in [IO.Directory]::EnumerateFiles(
$DirectoryPath,
$Pattern,
[IO.SearchOption]::TopDirectoryOnly
)) {
$enumeratedCount += 1
$candidate = [IO.FileInfo]::new($candidatePath)
$sortKey = (
$candidate.LastWriteTimeUtc.Ticks.ToString(
"D19",
[Globalization.CultureInfo]::InvariantCulture
) + "|" + $candidate.FullName
)
$null = $selected.Add($sortKey)
if ($selected.Count -gt $CandidateWindow) {
$null = $selected.Remove($selected.Min)
}
}
}
$selectedKeys = [string[]]@($selected)
[Array]::Reverse($selectedKeys)
return [pscustomobject]@{
files = @($selectedKeys | ForEach-Object {
[IO.FileInfo]::new($_.Substring(20))
})
enumeratedCount = [int]$enumeratedCount
candidateWindow = [int]$CandidateWindow
windowTruncated = [bool]($enumeratedCount -gt $selected.Count)
}
}
function Get-AgentControlLoopMaintenanceReadback {
$evidenceDir = Join-Path $AgentRoot "evidence"
$selection = Get-AgentBoundedNewestFiles `
$evidenceDir `
"agent99-control-loop-maintenance-2*.json" `
8
$file = @($selection.files | Select-Object -First 1)
$receipt = $null
$intent = $null
$parseError = ""
if ($file.Count -eq 1) {
try {
$receipt = Get-Content -LiteralPath $file[0].FullName -Raw | ConvertFrom-Json
} catch {
$parseError = $_.Exception.GetType().Name
}
}
$intentRef = if ($receipt -and $receipt.PSObject.Properties["operationIntentRef"]) {
[string]$receipt.operationIntentRef
} else { "" }
$intentRefSafe = [bool](
$intentRef -match "^agent99-control-loop-maintenance-intent-[0-9]{8}-[0-9]{6}-[0-9]{3}-[0-9a-f]{8}\.json$"
)
$intentPath = if ($intentRefSafe) { Join-Path $evidenceDir $intentRef } else { "" }
if ($intentPath -and (Test-Path -LiteralPath $intentPath -PathType Leaf)) {
try {
$intent = Get-Content -LiteralPath $intentPath -Raw | ConvertFrom-Json
} catch {
$parseError = $_.Exception.GetType().Name
}
}
$legacyPropertySet = @(
"blockers", "controlStopped", "expectedControlCreated", "expectedControlPid",
"expectedRecoverCreated", "expectedRecoverParentPid", "expectedRecoverPid",
"hostRebootPerformed", "leaseMetadataRemoved", "mode", "precheckPassed",
"recoverStopped", "runtimeWritePerformed", "schemaVersion", "secretValueRead",
"taskEnabled", "taskName", "terminal", "timestamp", "vmPowerChangePerformed",
"vmwareVmxProcessTerminated", "vmxLockDeleted"
)
$receiptPropertySet = if ($receipt) {
@($receipt.PSObject.Properties.Name | Sort-Object)
} else { @() }
$legacyPropertySetMatches = [bool](
$receiptPropertySet.Count -eq $legacyPropertySet.Count -and
(@($receiptPropertySet) -join ",") -eq (@($legacyPropertySet | Sort-Object) -join ",")
)
$legacyIdentityFieldsValid = [bool](
$receipt -and
$receipt.expectedControlPid -is [int] -and [int]$receipt.expectedControlPid -gt 0 -and
$receipt.expectedRecoverPid -is [int] -and [int]$receipt.expectedRecoverPid -gt 0 -and
$receipt.expectedRecoverParentPid -is [int] -and [int]$receipt.expectedRecoverParentPid -gt 0 -and
[string]$receipt.expectedControlCreated -match "^[0-9]{4}-[0-9]{2}-[0-9]{2}T" -and
[string]$receipt.expectedRecoverCreated -match "^[0-9]{4}-[0-9]{2}-[0-9]{2}T" -and
[string]$receipt.timestamp -match "^[0-9]{4}-[0-9]{2}-[0-9]{2}T"
)
$legacyReceipt = [bool](
$receipt -and
-not $receipt.PSObject.Properties["operationIntentRef"] -and
$receipt.PSObject.Properties["taskEnabled"] -and
$legacyPropertySetMatches -and
$legacyIdentityFieldsValid -and
@($receipt.blockers).Count -eq 0
)
$ageMinutes = if ($file.Count -eq 1) {
[math]::Round(((Get-Date) - $file[0].LastWriteTime).TotalMinutes, 1)
} else { $null }
$activeControlProcesses = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue |
Where-Object {
([string]$_.CommandLine) -match '(?i)agent99-run\.ps1"?\s+-Mode\s+ControlTick(\s|$)' -or
([string]$_.CommandLine) -match '(?i)agent99-run\.ps1"?\s+-Mode\s+Recover\s+-ControlledApply(\s|$)'
})
$leasePresent = [bool](Test-Path -LiteralPath (Join-Path $AgentRoot "state\recovery-transport-lease.json") -PathType Leaf)
$recoveryMutexActive = $true
$mutex = $null
$mutexAcquired = $false
try {
$mutex = [Threading.Mutex]::new($false, "Global\WoooAgent99RecoveryTransportV1")
try {
$mutexAcquired = [bool]$mutex.WaitOne(0)
} catch [Threading.AbandonedMutexException] {
$mutexAcquired = $true
}
$recoveryMutexActive = [bool](-not $mutexAcquired)
} finally {
if ($mutexAcquired -and $mutex) { try { $mutex.ReleaseMutex() } catch {} }
if ($mutex) { $mutex.Dispose() }
}
$commonReceiptFieldsValid = [bool](
$receipt -and
[string]$receipt.schemaVersion -eq "agent99_control_loop_maintenance_v2" -and
[string]$receipt.mode -eq "Apply" -and
[string]$receipt.taskName -eq "Wooo-Agent99-Control-Loop" -and
[string]$receipt.terminal -eq "control_loop_maintenance_frozen" -and
$receipt.precheckPassed -is [bool] -and $receipt.precheckPassed -and
$receipt.controlStopped -is [bool] -and $receipt.controlStopped -and
$receipt.recoverStopped -is [bool] -and $receipt.recoverStopped -and
$receipt.leaseMetadataRemoved -is [bool] -and $receipt.leaseMetadataRemoved -and
$receipt.runtimeWritePerformed -is [bool] -and $receipt.runtimeWritePerformed -and
$receipt.vmPowerChangePerformed -is [bool] -and -not $receipt.vmPowerChangePerformed -and
$receipt.vmwareVmxProcessTerminated -is [bool] -and -not $receipt.vmwareVmxProcessTerminated -and
$receipt.vmxLockDeleted -is [bool] -and -not $receipt.vmxLockDeleted -and
$receipt.hostRebootPerformed -is [bool] -and -not $receipt.hostRebootPerformed -and
$receipt.secretValueRead -is [bool] -and -not $receipt.secretValueRead
)
$modernReceiptFieldsValid = [bool](
$commonReceiptFieldsValid -and
-not $legacyReceipt -and
$receipt.taskEnabledAfter -is [bool] -and -not $receipt.taskEnabledAfter
)
$legacyReceiptFieldsValid = [bool](
$commonReceiptFieldsValid -and
$legacyReceipt -and
$receipt.taskEnabled -is [bool] -and $receipt.taskEnabled
)
$receiptFieldsValid = [bool]($modernReceiptFieldsValid -or $legacyReceiptFieldsValid)
$intentFieldsValid = [bool](
$legacyReceipt -or
(
$intent -and
[string]$intent.schemaVersion -eq "agent99_control_loop_maintenance_intent_v1" -and
[string]$intent.taskName -eq "Wooo-Agent99-Control-Loop" -and
$intent.vmPowerChangePerformed -is [bool] -and -not $intent.vmPowerChangePerformed -and
$intent.vmwareVmxProcessTerminated -is [bool] -and -not $intent.vmwareVmxProcessTerminated -and
$intent.secretValueRead -is [bool] -and -not $intent.secretValueRead -and
(@($intent.plannedActions) -join ",") -eq "disable_control_task,stop_exact_control_tree,stop_exact_recover_tree,remove_exact_recovery_lease"
)
)
$valid = [bool](
-not $parseError -and
$file.Count -eq 1 -and
$null -ne $ageMinutes -and
$ageMinutes -ge 0 -and
$ageMinutes -le $(if ($legacyReceipt) { 360 } else { 720 }) -and
($legacyReceipt -or $intentRefSafe) -and
$receiptFieldsValid -and
$intentFieldsValid -and
$activeControlProcesses.Count -eq 0 -and
-not $leasePresent -and
-not $recoveryMutexActive
)
return [pscustomobject]@{
valid = $valid
receiptFile = if ($file.Count -eq 1) { $file[0].Name } else { "" }
ageMinutes = $ageMinutes
parseError = $parseError
intentRefPresent = $intentRefSafe
contractVersion = if ($legacyReceipt) { "legacy_v2_exact_upgrade_bridge" } else { "intent_bound_v2" }
receiptTimestamp = if ($receipt) { [string]$receipt.timestamp } else { "" }
receiptLastWriteTime = if ($file.Count -eq 1) { $file[0].LastWriteTime.ToString("o") } else { "" }
legacyPropertySetMatches = $legacyPropertySetMatches
legacyIdentityFieldsValid = $legacyIdentityFieldsValid
receiptFieldsValid = $receiptFieldsValid
intentFieldsValid = $intentFieldsValid
activeControlProcessCount = [int]$activeControlProcesses.Count
recoveryLeasePresent = $leasePresent
recoveryMutexActive = $recoveryMutexActive
secretValueRead = $false
remoteWritePerformed = $false
}
}
function Get-AgentPreflightSha256Hex {
param([string]$Text)
$sha = [Security.Cryptography.SHA256]::Create()
try {
$bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes([string]$Text))
return [BitConverter]::ToString($bytes).Replace("-", "").ToLowerInvariant()
} finally {
$sha.Dispose()
}
}
function Convert-AgentPreflightGuidToNetworkBytes {
param([guid]$Guid)
[byte[]]$bytes = $Guid.ToByteArray()
return [byte[]]@(
$bytes[3], $bytes[2], $bytes[1], $bytes[0],
$bytes[5], $bytes[4], $bytes[7], $bytes[6],
$bytes[8], $bytes[9], $bytes[10], $bytes[11],
$bytes[12], $bytes[13], $bytes[14], $bytes[15]
)
}
function New-AgentPreflightUuidV5 {
param(
[guid]$Namespace,
[string]$Name
)
[byte[]]$namespaceBytes = Convert-AgentPreflightGuidToNetworkBytes $Namespace
[byte[]]$nameBytes = [Text.Encoding]::UTF8.GetBytes($Name)
$sha1 = [Security.Cryptography.SHA1]::Create()
try {
[byte[]]$hash = $sha1.ComputeHash($namespaceBytes + $nameBytes)
} finally {
$sha1.Dispose()
}
$hash[6] = [byte](($hash[6] -band 0x0f) -bor 0x50)
$hash[8] = [byte](($hash[8] -band 0x3f) -bor 0x80)
$hex = -join @($hash[0..15] | ForEach-Object { $_.ToString("x2") })
return [guid]::Parse((
"{0}-{1}-{2}-{3}-{4}" -f
$hex.Substring(0, 8),
$hex.Substring(8, 4),
$hex.Substring(12, 4),
$hex.Substring(16, 4),
$hex.Substring(20, 12)
))
}
function Test-AgentCanonicalPendingCommandIdentity {
param([object]$Payload)
if (-not $Payload) { return $false }
try {
$projectId = ([string]$Payload.projectId).Trim()
$incidentId = ([string]$Payload.incidentId).Trim()
$sourceFingerprint = ([string]$Payload.sourceFingerprint).Trim()
$routeId = ([string]$Payload.dispatchRouteId).Trim()
$executionGeneration = ([string]$Payload.executionGeneration).Trim()
$approvalId = ([string]$Payload.approvalId).Trim()
$workItemId = ([string]$Payload.workItemId).Trim()
if (
-not $projectId -or
-not $incidentId -or
-not $sourceFingerprint -or
-not $routeId -or
-not $executionGeneration -or
-not $workItemId
) { return $false }
$normalized = [ordered]@{
approval_id = $approvalId
execution_generation = $executionGeneration
incident_id = $incidentId
project_id = $projectId
route_id = $routeId
source_fingerprint = $sourceFingerprint
work_item_id = $workItemId
}
$canonical = $normalized | ConvertTo-Json -Compress -Depth 4
$digest = Get-AgentPreflightSha256Hex $canonical
$singleFlightCanonical = [ordered]@{
incident_id = $incidentId
project_id = $projectId
route_id = $routeId
source_fingerprint = $sourceFingerprint
} | ConvertTo-Json -Compress -Depth 4
$singleFlightDigest = Get-AgentPreflightSha256Hex $singleFlightCanonical
$runId = (New-AgentPreflightUuidV5 `
-Namespace ([guid]::Parse("6ba7b811-9dad-11d1-80b4-00c04fd430c8")) `
-Name "agent99-controlled-dispatch:$canonical").ToString()
$traceId = "00-$($digest.Substring(0, 32))-$($digest.Substring(32, 16))-01"
$idempotencyKey = "agent99-controlled:$digest"
$singleFlightKey = "agent99-controlled-flight:$singleFlightDigest"
$public = [ordered]@{
approval_id = $approvalId
execution_generation = $executionGeneration
idempotency_key = $idempotencyKey
incident_id = $incidentId
lock_owner = $runId
project_id = $projectId
route_id = $routeId
run_id = $runId
schema_version = "agent99_controlled_dispatch_identity_v1"
single_flight_key = $singleFlightKey
source_fingerprint = $sourceFingerprint
trace_id = $traceId
work_item_id = $workItemId
}
$expected = [ordered]@{
approvalId = $approvalId
automationRunId = $runId
canonicalDigest = Get-AgentPreflightSha256Hex (
$public | ConvertTo-Json -Compress -Depth 4
)
dispatchIdentitySchemaVersion = "agent99_controlled_dispatch_identity_v1"
dispatchRouteId = $routeId
executionGeneration = $executionGeneration
idempotencyKey = $idempotencyKey
incidentId = $incidentId
lockOwner = $runId
projectId = $projectId
singleFlightKey = $singleFlightKey
sourceFingerprint = $sourceFingerprint
traceId = $traceId
workItemId = $workItemId
}
foreach ($field in $expected.Keys) {
if ([string]$Payload.$field -cne [string]$expected[$field]) {
return $false
}
}
return $true
} catch {
return $false
}
}
function Get-AgentPendingCommandPromotionReadback {
param(
[object]$MaintenanceReadback,
[ValidateSet("FullRuntime", "PromotionReserve", "PostPromotionMaintenance")]
[string]$Scope
)
$queueRoot = Join-Path $AgentRoot "queue"
$files = @()
if (Test-Path -LiteralPath $queueRoot -PathType Container) {
$files = @([IO.Directory]::EnumerateFiles(
$queueRoot,
"*.json",
[IO.SearchOption]::TopDirectoryOnly
) | Where-Object {
[IO.Path]::GetFileName($_) -notmatch "^(processed|failed|running)-"
})
}
$observedCount = [int]$files.Count
$result = [ordered]@{
scope = $Scope
observedCount = $observedCount
deferredCount = 0
blockingCount = $observedCount
eligibleMaintenanceAutoRecoverCount = 0
boundedLimit = 8
contractValid = [bool]($observedCount -eq 0)
reason = if ($observedCount -eq 0) { "queue_empty" } else { "pending_commands_require_runtime" }
items = @()
rawInstructionStored = $false
secretValueRead = $false
remoteWritePerformed = $false
}
if ($observedCount -eq 0) {
return [pscustomobject]$result
}
if (
-not $MaintenanceReadback -or
-not $MaintenanceReadback.receiptFieldsValid -or
-not $MaintenanceReadback.intentFieldsValid
) {
$result.reason = "maintenance_contract_invalid"
return [pscustomobject]$result
}
if (
$Scope -in @("PromotionReserve", "PostPromotionMaintenance") -and
-not $MaintenanceReadback.valid
) {
$result.reason = "maintenance_current_state_invalid"
return [pscustomobject]$result
}
if ($observedCount -gt $result.boundedLimit) {
$result.reason = "maintenance_pending_command_limit_exceeded"
return [pscustomobject]$result
}
$maintenanceTimestamp = [datetimeoffset]::MinValue
$maintenanceTimestampValid = [datetimeoffset]::TryParse(
[string]$MaintenanceReadback.receiptTimestamp,
[ref]$maintenanceTimestamp
)
if (-not $maintenanceTimestampValid) {
$result.reason = "maintenance_timestamp_invalid"
return [pscustomobject]$result
}
$requiredPropertySet = @(
"approvalId", "automationRunId", "canonicalDigest", "controlledApply",
"correlationKey", "createdAt", "dispatchIdentitySchemaVersion",
"dispatchRouteId", "executionGeneration", "externalId", "id",
"idempotencyKey", "incidentId", "instruction", "lockOwner", "mode",
"observation", "projectId", "reason", "requestedBy", "singleFlightKey",
"source", "sourceFingerprint", "traceId", "workItemId"
) | Sort-Object
$safeItems = @()
$allValid = $true
foreach ($path in $files) {
$file = [IO.FileInfo]::new($path)
$payload = $null
$parseError = ""
$sha256 = ""
try {
$bytes = [IO.File]::ReadAllBytes($path)
$sha = [Security.Cryptography.SHA256]::Create()
try {
$sha256 = -join @($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") })
} finally {
$sha.Dispose()
}
$jsonOffset = if (
$bytes.Length -ge 3 -and
$bytes[0] -eq 0xef -and
$bytes[1] -eq 0xbb -and
$bytes[2] -eq 0xbf
) { 3 } else { 0 }
$jsonText = [Text.Encoding]::UTF8.GetString(
$bytes,
$jsonOffset,
$bytes.Length - $jsonOffset
)
$payload = $jsonText | ConvertFrom-Json
} catch {
$parseError = $_.Exception.GetType().Name
}
$propertySet = if ($payload) {
@($payload.PSObject.Properties.Name | Sort-Object)
} else { @() }
$propertySetMatches = [bool](
$propertySet.Count -eq $requiredPropertySet.Count -and
(@($propertySet) -join ",") -eq (@($requiredPropertySet) -join ",")
)
$createdAt = [datetimeoffset]::MinValue
$createdAtValid = [bool](
$payload -and
[datetimeoffset]::TryParse([string]$payload.createdAt, [ref]$createdAt)
)
$ageMinutes = if ($createdAtValid) {
[math]::Round(([datetimeoffset]::Now - $createdAt).TotalMinutes, 1)
} else { $null }
$fileBaseName = [IO.Path]::GetFileNameWithoutExtension($file.Name)
$nativeStringFields = @(
"approvalId", "automationRunId", "canonicalDigest", "correlationKey",
"createdAt", "dispatchIdentitySchemaVersion", "dispatchRouteId",
"executionGeneration", "externalId", "id", "idempotencyKey",
"incidentId", "instruction", "lockOwner", "mode", "projectId",
"reason", "requestedBy", "singleFlightKey", "source",
"sourceFingerprint", "traceId", "workItemId"
)
$nativeStringTypesValid = [bool](
$payload -and
@($nativeStringFields | Where-Object { $payload.$_ -isnot [string] }).Count -eq 0
)
$allowedReason = [bool](
$payload -and
[string]$payload.reason -in @(
"host_reboot_detected",
"host_reboot_recovery_pending",
"host_unreachable_detected",
"host112_guest_not_ready",
"host188_edge_services_not_ready"
)
)
$identityValid = [bool](
-not $parseError -and
$propertySetMatches -and
$nativeStringTypesValid -and
$payload.controlledApply -is [bool] -and $payload.controlledApply -and
$payload.observation -is [pscustomobject] -and
[string]$payload.id -eq $fileBaseName -and
[string]$payload.id -match "^auto-recover-[0-9]{8}-[0-9]{6}-[0-9a-f]{8}$" -and
[string]$payload.mode -eq "Recover" -and
[string]$payload.source -eq "agent99-recovery-observer" -and
[string]$payload.dispatchIdentitySchemaVersion -eq "agent99_controlled_dispatch_identity_v1" -and
[string]$payload.dispatchRouteId -eq "agent99_local_observer" -and
[string]$payload.projectId -eq "awoooi" -and
[string]$payload.requestedBy -eq "Agent99" -and
$allowedReason -and
[string]$payload.externalId -eq [string]$payload.reason -and
[string]$payload.approvalId -eq "" -and
[string]$payload.automationRunId -match "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" -and
[string]$payload.lockOwner -eq [string]$payload.automationRunId -and
[string]$payload.executionGeneration -match "^[1-9][0-9]*$" -and
[string]$payload.canonicalDigest -match "^[0-9a-f]{64}$" -and
[string]$payload.sourceFingerprint -match "^[0-9a-f]{64}$" -and
[string]$payload.correlationKey -match "^agent99-controlled:[0-9a-f]{64}$" -and
[string]$payload.idempotencyKey -eq [string]$payload.correlationKey -and
[string]$payload.singleFlightKey -match "^agent99-controlled-flight:[0-9a-f]{64}$" -and
[string]$payload.incidentId -match "^agent99-auto-[0-9a-f]{20}$" -and
[string]$payload.workItemId -eq "agent99-auto:$($payload.incidentId):$($payload.reason)" -and
[string]$payload.traceId -match "^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$" -and
(Test-AgentCanonicalPendingCommandIdentity $payload) -and
-not [string]::IsNullOrWhiteSpace([string]$payload.instruction) -and
$createdAtValid -and
$createdAt -ge $maintenanceTimestamp -and
$ageMinutes -ge 0 -and
$ageMinutes -le 120
)
if (-not $identityValid) { $allValid = $false }
$safeItems += [pscustomobject]@{
file = $file.Name
id = if ($payload) { [string]$payload.id } else { "" }
mode = if ($payload) { [string]$payload.mode } else { "" }
source = if ($payload) { [string]$payload.source } else { "" }
workItemId = if ($payload) { [string]$payload.workItemId } else { "" }
createdAt = if ($payload) { [string]$payload.createdAt } else { "" }
ageMinutes = $ageMinutes
sha256 = $sha256
identity = if ($payload -and $sha256) { "$([string]$payload.id)|$sha256" } else { "" }
parseError = $parseError
propertySetMatches = $propertySetMatches
identityValid = $identityValid
instructionContentReadback = $false
}
}
$result.items = $safeItems
if ($allValid) {
$result.eligibleMaintenanceAutoRecoverCount = $observedCount
$result.contractValid = $true
if ($Scope -in @("PromotionReserve", "PostPromotionMaintenance")) {
$result.deferredCount = $observedCount
$result.blockingCount = 0
$result.reason = if ($Scope -eq "PromotionReserve") {
"bounded_maintenance_auto_recover_deferred_until_post_promotion"
} else {
"bounded_maintenance_auto_recover_deferred_until_control_loop_restore"
}
} else {
$result.reason = "full_runtime_requires_maintenance_auto_recover_to_drain"
}
} else {
$result.reason = "maintenance_pending_command_contract_invalid"
}
return [pscustomobject]$result
}
function Get-EvidenceReadback {
param(
[string]$Name,
[string]$Pattern,
[int]$MaxAgeMinutes,
[bool]$Required,
[int]$MinimumFreshnessReserveMinutes = 0
)
$evidenceDir = Join-Path $AgentRoot "evidence"
$candidateSelection = Get-AgentBoundedNewestFiles $evidenceDir $Pattern
$candidates = @($candidateSelection.files)
$deferredCandidates = @()
$file = $null
foreach ($candidate in $candidates) {
$candidateDeferred = $false
try {
$candidateEvidence = Get-Content -LiteralPath $candidate.FullName -Raw | ConvertFrom-Json
$candidateSchemaVersion = if ($candidateEvidence.PSObject.Properties["schemaVersion"]) {
[string]$candidateEvidence.schemaVersion
} else {
""
}
$candidateDeferred = [bool](
($candidateEvidence.PSObject.Properties["deferred"] -and [bool]$candidateEvidence.deferred) -or
$candidateSchemaVersion -in @(
"agent99_background_mode_deferred_v1",
"agent99_recovery_transport_deferred_v1"
)
)
} catch {
# A malformed newest non-deferred candidate must remain visible and fail closed.
$file = $candidate
break
}
if ($candidateDeferred) {
$deferredCandidates += $candidate
continue
}
$file = $candidate
break
}
$candidateWindowExhausted = [bool](
-not $file -and
$candidateSelection.windowTruncated -and
$deferredCandidates.Count -eq $candidates.Count
)
$ageMinutes = if ($file) { [math]::Round(((Get-Date) - $file.LastWriteTime).TotalMinutes, 1) } else { $null }
$fresh = [bool](
$file -and
$ageMinutes -ge 0 -and
$ageMinutes -le $MaxAgeMinutes
)
$freshnessHeadroomMinutes = if ($file) { [math]::Round($MaxAgeMinutes - $ageMinutes, 1) } else { $null }
$freshnessReserveReady = [bool](
$fresh -and
$null -ne $freshnessHeadroomMinutes -and
$freshnessHeadroomMinutes -ge $MinimumFreshnessReserveMinutes
)
$safeSummary = $null
$parseError = if ($candidateWindowExhausted) { "EvidenceCandidateWindowExhausted" } else { "" }
$contentHealthy = $true
if ($file) {
try {
$rawEvidence = Get-Content -Path $file.FullName -Raw | ConvertFrom-Json
$hostRows = if ($rawEvidence.PSObject.Properties["hosts"]) { @($rawEvidence.hosts | ForEach-Object {
[pscustomobject]@{ host = [string]$_.host; reachable = [bool]$_.ping }
}) } else { @() }
$vmRows = if ($rawEvidence.PSObject.Properties["vmResults"]) { @($rawEvidence.vmResults | ForEach-Object {
[pscustomobject]@{
alias = if ($_.PSObject.Properties["alias"]) { [string]$_.alias } else { "" }
ok = [bool]$_.ok
skipped = [bool]$_.skipped
}
}) } else { @() }
$publicRows = if ($rawEvidence.PSObject.Properties["public"]) { @($rawEvidence.public) } else { @() }
$performanceRows = if ($rawEvidence.PSObject.Properties["performance"]) { @($rawEvidence.performance) } else { @() }
$telegramRows = if ($rawEvidence.PSObject.Properties["telegram"]) { @($rawEvidence.telegram) } else { @() }
$selfHealthPerformance = if (
$rawEvidence.PSObject.Properties["selfHealth"] -and
$rawEvidence.selfHealth -and
$rawEvidence.selfHealth.PSObject.Properties["latestPerformance"]
) { $rawEvidence.selfHealth.latestPerformance } else { $null }
$selfHealthPerformanceSeverity = if ($selfHealthPerformance) {
([string]$selfHealthPerformance.severity).Trim().ToLowerInvariant()
} else { "" }
if ($selfHealthPerformanceSeverity -notin @("ok", "warning", "critical")) {
$selfHealthPerformanceSeverity = "unknown"
}
$selfHealthPerformanceRows = if ($selfHealthPerformance) {
@($selfHealthPerformance.hosts | ForEach-Object {
$safeHost = ([string]$_.host).Trim()
if ($safeHost -notmatch "^[A-Za-z0-9.:-]{1,128}$") {
$safeHost = "unknown"
}
$safeSeverity = ([string]$_.severity).Trim().ToLowerInvariant()
if ($safeSeverity -notin @("ok", "warning", "critical", "missing")) {
$safeSeverity = "unknown"
}
$safeReasons = @($_.reasons | ForEach-Object {
$reason = ([string]$_).Trim().ToLowerInvariant()
if ($reason -match "^[a-z0-9_]{1,80}$") { $reason }
})
[pscustomobject]@{
host = $safeHost
severity = $safeSeverity
reasons = $safeReasons
}
})
} else { @() }
$safeSummary = if ($Name -eq "sre_alert_inbox") {
[pscustomobject]@{
ok = if ($rawEvidence.PSObject.Properties["ok"]) { [bool]$rawEvidence.ok } else { $false }
queuedCount = if ($rawEvidence.PSObject.Properties["queuedCount"]) { [int]$rawEvidence.queuedCount } else { 0 }
ignoredCount = if ($rawEvidence.PSObject.Properties["ignoredCount"]) { [int]$rawEvidence.ignoredCount } else { 0 }
duplicateSuppressedCount = if ($rawEvidence.PSObject.Properties["duplicateSuppressedCount"]) { [int]$rawEvidence.duplicateSuppressedCount } else { 0 }
failedCount = if ($rawEvidence.PSObject.Properties["failedCount"]) { [int]$rawEvidence.failedCount } else { 0 }
runNow = if ($rawEvidence.PSObject.Properties["runNow"]) { [bool]$rawEvidence.runNow } else { $false }
controlExitCode = if ($rawEvidence.PSObject.Properties["controlExitCode"]) { [int]$rawEvidence.controlExitCode } else { $null }
}
} elseif ($Name -eq "telegram_inbox") {
[pscustomobject]@{
ok = if ($rawEvidence.PSObject.Properties["ok"]) { [bool]$rawEvidence.ok } else { $false }
reason = if ($rawEvidence.PSObject.Properties["reason"]) { [string]$rawEvidence.reason } else { "" }
synthetic = if ($rawEvidence.PSObject.Properties["synthetic"]) { [bool]$rawEvidence.synthetic } else { $false }
updatesSeen = if ($rawEvidence.PSObject.Properties["updatesSeen"]) { [int]$rawEvidence.updatesSeen } else { 0 }
processedCount = if ($rawEvidence.PSObject.Properties["processedCount"]) { [int]$rawEvidence.processedCount } else { 0 }
alertedCount = if ($rawEvidence.PSObject.Properties["alertedCount"]) { [int]$rawEvidence.alertedCount } else { 0 }
ignoredCount = if ($rawEvidence.PSObject.Properties["ignoredCount"]) { [int]$rawEvidence.ignoredCount } else { 0 }
}
} else {
[pscustomobject]@{
mode = if ($rawEvidence.PSObject.Properties["mode"]) { [string]$rawEvidence.mode } else { "" }
controlledApply = if ($rawEvidence.PSObject.Properties["controlledApply"]) { [bool]$rawEvidence.controlledApply } else { $false }
hostCount = $hostRows.Count
hostReachableCount = [int]@($hostRows | Where-Object { $_.reachable }).Count
hosts = $hostRows
vmCount = $vmRows.Count
vmOkCount = [int]@($vmRows | Where-Object { $_.ok -or $_.skipped }).Count
vms = $vmRows
harborReady = [bool](
$rawEvidence.PSObject.Properties["harbor"] -and
$rawEvidence.harbor -and
$rawEvidence.harbor.coreHealthy -and
$rawEvidence.harbor.redisUp -and
$rawEvidence.harbor.registryHealthy
)
awoooiReady = [bool](
$rawEvidence.PSObject.Properties["awoooi"] -and
$rawEvidence.awoooi -and
$rawEvidence.awoooi.deployReady -and
-not $rawEvidence.awoooi.imagePullFailure -and
-not $rawEvidence.awoooi.crashFailure
)
publicCount = $publicRows.Count
publicOkCount = [int]@($publicRows | Where-Object { $_.ok }).Count
performanceCount = $performanceRows.Count
performanceIssueCount = [int]@($performanceRows | Where-Object { $_.severity -in @("warning", "critical") }).Count
selfHealthSeverity = if ($rawEvidence.PSObject.Properties["selfHealth"] -and $rawEvidence.selfHealth) { [string]$rawEvidence.selfHealth.severity } else { "" }
selfHealthPerformanceSeverity = $selfHealthPerformanceSeverity
selfHealthPerformanceIssueCount = [int]@($selfHealthPerformanceRows | Where-Object { $_.severity -ne "ok" }).Count
selfHealthPerformanceIssues = @($selfHealthPerformanceRows | Where-Object { $_.severity -ne "ok" })
backupHealthSeverity = if ($rawEvidence.PSObject.Properties["backupHealth"] -and $rawEvidence.backupHealth) { [string]$rawEvidence.backupHealth.severity } else { "" }
controlProcessedCount = if ($rawEvidence.PSObject.Properties["controlTick"] -and $rawEvidence.controlTick) { [int]$rawEvidence.controlTick.processedCount } else { 0 }
controlPendingCount = if ($rawEvidence.PSObject.Properties["controlTick"] -and $rawEvidence.controlTick) { [int]$rawEvidence.controlTick.pendingCount } else { 0 }
telegramAttemptCount = $telegramRows.Count
telegramSentCount = [int]@($telegramRows | Where-Object { $_.sent }).Count
recovery = if ($rawEvidence.PSObject.Properties["recoverySlo"] -and $rawEvidence.recoverySlo) {
[pscustomobject]@{
targetMinutes = [int]$rawEvidence.recoverySlo.targetMinutes
elapsedSeconds = [double]$rawEvidence.recoverySlo.elapsedSeconds
completed = [bool]$rawEvidence.recoverySlo.completed
withinSlo = [bool]$rawEvidence.recoverySlo.withinSlo
phases = if ($rawEvidence.recoverySlo.PSObject.Properties["phases"]) { @($rawEvidence.recoverySlo.phases | ForEach-Object {
[pscustomobject]@{ name = [string]$_.name; status = [string]$_.status; elapsedSeconds = [double]$_.elapsedSeconds }
}) } else { @() }
}
} else { $null }
recoveryCoordinator = if ($rawEvidence.PSObject.Properties["recoveryCoordinator"] -and $rawEvidence.recoveryCoordinator) {
[pscustomobject]@{
verified = [bool]$rawEvidence.recoveryCoordinator.verified
requireFreshRebootWindow = [bool]$rawEvidence.recoveryCoordinator.requireFreshRebootWindow
rebootSloClaimed = [bool]$rawEvidence.recoveryCoordinator.rebootSloClaimed
scorecardStatus = [string]$rawEvidence.recoveryCoordinator.scorecardStatus
scorecardBlockerCount = [int]$rawEvidence.recoveryCoordinator.scorecardBlockerCount
readinessPercent = [int]$rawEvidence.recoveryCoordinator.readinessPercent
failedChecks = @($rawEvidence.recoveryCoordinator.failedChecks)
}
} else { $null }
}
}
if ($Name -in @("sre_alert_inbox", "telegram_inbox")) {
$contentHealthy = [bool]($safeSummary -and $safeSummary.ok)
}
} catch {
$parseError = $_.Exception.GetType().Name
$contentHealthy = $false
}
}
$parsed = [bool]($file -and -not $parseError)
$usable = [bool]($fresh -and $freshnessReserveReady -and $parsed -and $contentHealthy)
return [pscustomobject]@{
name = $Name
pattern = $Pattern
required = $Required
file = if ($file) { $file.Name } else { "" }
lastWriteTime = if ($file) { $file.LastWriteTime.ToString("o") } else { "" }
ageMinutes = $ageMinutes
maxAgeMinutes = $MaxAgeMinutes
fresh = $fresh
minimumFreshnessReserveMinutes = $MinimumFreshnessReserveMinutes
freshnessHeadroomMinutes = $freshnessHeadroomMinutes
freshnessReserveReady = $freshnessReserveReady
parsed = $parsed
contentHealthy = $contentHealthy
usable = $usable
healthy = [bool](-not $Required -or $usable)
parseError = $parseError
deferredCandidateCount = [int]$deferredCandidates.Count
latestDeferredTime = if ($deferredCandidates.Count -gt 0) { $deferredCandidates[0].LastWriteTime.ToString("o") } else { "" }
enumeratedCandidateCount = [int]$candidateSelection.enumeratedCount
candidateWindowLimit = [int]$candidateSelection.candidateWindow
candidateWindowTruncated = [bool]$candidateSelection.windowTruncated
candidateWindowExhausted = $candidateWindowExhausted
safeSummary = $safeSummary
}
}
$rootTaskPath = "\"
$requiredTasks = @(
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Startup-Recovery"; markers = @("agent99-submit-request.ps1", "-Mode Recover", "-Source agent99-startup-recovery") },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Heartbeat"; markers = @("agent99-run.ps1", "-Mode Status") },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Performance-Guard"; markers = @("agent99-run.ps1", "-Mode Perf", "-ControlledApply") },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Control-Loop"; markers = @("agent99-run.ps1", "-Mode ControlTick") },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Telegram-Inbox"; markers = @("agent99-telegram-inbox.ps1", "-RunNow") },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-SRE-Alert-Inbox"; markers = @("agent99-sre-alert-inbox.ps1", "-RunNow") },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-SRE-Alert-Relay"; markers = @("agent99-sre-alert-relay.ps1") },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Self-Health"; markers = @("agent99-run.ps1", "-Mode SelfCheck") },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Backup-Health"; markers = @("agent99-run.ps1", "-Mode BackupCheck") }
)
$maintenanceSnapshotMutex = [Threading.Mutex]::new(
$false,
"Global\WoooAgent99ControlLoopMaintenanceV1"
)
$maintenanceSnapshotMutexAcquired = $false
try {
try {
$maintenanceSnapshotMutexAcquired = [bool]$maintenanceSnapshotMutex.WaitOne(0)
} catch [Threading.AbandonedMutexException] {
$maintenanceSnapshotMutexAcquired = $true
}
if (-not $maintenanceSnapshotMutexAcquired) {
throw "maintenance_snapshot_mutex_unavailable"
}
# The control task definition/state and its maintenance receipt are one
# consistency boundary. Freeze/restore holds this same mutex, so a verifier
# can never combine task state from one side of a transition with receipt
# state from the other side.
$tasks = @($requiredTasks | ForEach-Object {
Get-TaskReadback `
-TaskPath $_.taskPath `
-TaskName $_.name `
-RequiredArgumentMarkers $_.markers
})
$controlLoopMaintenance = Get-AgentControlLoopMaintenanceReadback
$controlLoopMaintenance | Add-Member `
-NotePropertyName snapshotMutexAcquired `
-NotePropertyValue $true `
-Force
} finally {
if ($maintenanceSnapshotMutexAcquired -and $maintenanceSnapshotMutex) {
try { $maintenanceSnapshotMutex.ReleaseMutex() } catch {}
}
if ($maintenanceSnapshotMutex) { $maintenanceSnapshotMutex.Dispose() }
}
if ($ReadinessScope -eq "PromotionReserve") {
foreach ($task in $tasks) {
$runtimeHealthy = [bool]$task.healthy
$maintenanceFreezeAccepted = [bool](
$task.name -eq "Wooo-Agent99-Control-Loop" -and
$task.exists -and
$task.definitionReady -and
-not $task.enabled -and
$controlLoopMaintenance.valid
)
$promotionReady = [bool](
$task.exists -and
$task.definitionReady -and
($task.enabled -or $maintenanceFreezeAccepted)
)
$task | Add-Member -NotePropertyName runtimeHealthy -NotePropertyValue $runtimeHealthy -Force
$task | Add-Member -NotePropertyName promotionReady -NotePropertyValue $promotionReady -Force
$task | Add-Member -NotePropertyName maintenanceFreezeAccepted -NotePropertyValue $maintenanceFreezeAccepted -Force
$task.healthy = $promotionReady
}
} elseif ($ReadinessScope -eq "PostPromotionMaintenance") {
foreach ($task in $tasks) {
$runtimeHealthy = [bool]$task.healthy
$maintenanceFreezeAccepted = [bool](
$task.name -eq "Wooo-Agent99-Control-Loop" -and
$task.exists -and
$task.definitionReady -and
-not $task.enabled -and
$controlLoopMaintenance.valid
)
$postPromotionReady = [bool]($runtimeHealthy -or $maintenanceFreezeAccepted)
$task | Add-Member -NotePropertyName runtimeHealthy -NotePropertyValue $runtimeHealthy -Force
$task | Add-Member -NotePropertyName promotionReady -NotePropertyValue $postPromotionReady -Force
$task | Add-Member -NotePropertyName maintenanceFreezeAccepted -NotePropertyValue $maintenanceFreezeAccepted -Force
$task.healthy = $postPromotionReady
}
}
$manifestPath = Join-Path $AgentRoot "state\runtime-manifest.json"
$manifest = [pscustomobject]@{
exists = $false
sourceRevision = ""
runtimeMatched = $false
fileCount = 0
mismatchCount = -1
parseError = ""
}
if (Test-Path $manifestPath) {
try {
$rawManifest = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json
$manifest = [pscustomobject]@{
exists = $true
sourceRevision = [string]$rawManifest.sourceRevision
runtimeMatched = [bool]$rawManifest.runtimeMatched
fileCount = [int]$rawManifest.fileCount
mismatchCount = [int]$rawManifest.mismatchCount
parseError = ""
}
} catch {
$manifest = [pscustomobject]@{
exists = $true
sourceRevision = ""
runtimeMatched = $false
fileCount = 0
mismatchCount = -1
parseError = $_.Exception.GetType().Name
}
}
}
$listeners = @(Get-NetTCPConnection -LocalPort 8787 -State Listen -ErrorAction SilentlyContinue)
$relayListeners = @($listeners | ForEach-Object {
$process = Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue
[pscustomobject]@{
address = [string]$_.LocalAddress
port = [int]$_.LocalPort
processId = [int]$_.OwningProcess
processName = if ($process) { [string]$process.ProcessName } else { "" }
}
})
$queues = @(
(Get-DirectoryReadback "alerts\incoming"),
(Get-DirectoryReadback "alerts\running"),
(Get-DirectoryReadback "alerts\failed"),
(Get-DirectoryReadback "queue" -RootQueueFilesOnly),
(Get-DirectoryReadback "queue\failed")
)
$promotionEvidenceRequired = [bool]($ReadinessScope -ne "PromotionReserve")
$postPromotionControlMaintenanceAccepted = [bool](
$ReadinessScope -eq "PostPromotionMaintenance" -and
@($tasks | Where-Object {
[string]$_.name -eq "Wooo-Agent99-Control-Loop" -and
[bool]$_.maintenanceFreezeAccepted
}).Count -eq 1
)
$controlTickEvidenceRequired = [bool](
$promotionEvidenceRequired -and
-not $postPromotionControlMaintenanceAccepted
)
$evidence = @(
(Get-EvidenceReadback "self_check" "agent99-SelfCheck-*.json" 20 $promotionEvidenceRequired $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "status" "agent99-Status-*.json" 20 $promotionEvidenceRequired $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "control_tick" "agent99-ControlTick-*.json" 20 $controlTickEvidenceRequired $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "sre_alert_inbox" "agent99-SreAlertInbox-*.json" 20 $promotionEvidenceRequired $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "telegram_inbox" "agent99-TelegramInbox-*.json" 20 $promotionEvidenceRequired $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "performance" "agent99-Perf-*.json" 20 $promotionEvidenceRequired $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "backup" "agent99-BackupCheck-*.json" 1440 $promotionEvidenceRequired $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "recovery" "agent99-Recover-*.json" 10080 $false 0)
)
$controlTickEvidence = $evidence | Where-Object { $_.name -eq "control_tick" } | Select-Object -First 1
if ($controlTickEvidence) {
$controlTickEvidence | Add-Member `
-NotePropertyName maintenanceFreezeAccepted `
-NotePropertyValue $postPromotionControlMaintenanceAccepted `
-Force
}
$operatingSystem = Get-CimInstance Win32_OperatingSystem
$taskFailures = @($tasks | Where-Object { -not $_.healthy })
$requiredEvidenceFailures = @($evidence | Where-Object { $_.required -and -not $_.healthy })
$requiredEvidenceStale = @($evidence | Where-Object { $_.required -and -not $_.fresh })
$requiredEvidenceReserveFailures = @($evidence | Where-Object { $_.required -and $_.fresh -and -not $_.freshnessReserveReady })
$requiredEvidenceParseFailures = @($evidence | Where-Object { $_.required -and $_.fresh -and -not $_.parsed })
$requiredEvidenceContentFailures = @($evidence | Where-Object { $_.required -and $_.fresh -and $_.parsed -and -not $_.contentHealthy })
$selfCheckEvidence = $evidence | Where-Object { $_.name -eq "self_check" } | Select-Object -First 1
$selfHealthSeverity = if ($selfCheckEvidence -and $selfCheckEvidence.safeSummary) { [string]$selfCheckEvidence.safeSummary.selfHealthSeverity } else { "unknown" }
$selfHealthPerformanceSeverity = if ($selfCheckEvidence -and $selfCheckEvidence.safeSummary) { [string]$selfCheckEvidence.safeSummary.selfHealthPerformanceSeverity } else { "unknown" }
$selfHealthPerformanceIssueCount = if ($selfCheckEvidence -and $selfCheckEvidence.safeSummary) { [int]$selfCheckEvidence.safeSummary.selfHealthPerformanceIssueCount } else { 0 }
$alertIncoming = $queues | Where-Object { $_.relativePath -eq "alerts\incoming" } | Select-Object -First 1
$alertRunning = $queues | Where-Object { $_.relativePath -eq "alerts\running" } | Select-Object -First 1
$commandQueue = $queues | Where-Object { $_.relativePath -eq "queue" } | Select-Object -First 1
$pendingCommandPromotion = Get-AgentPendingCommandPromotionReadback `
-MaintenanceReadback $controlLoopMaintenance `
-Scope $ReadinessScope
$decision = Get-AgentLivePreflightDecision `
-AgentRootPresent ([bool](Test-Path $AgentRoot)) `
-Manifest $manifest `
-ManifestCheckRequired ([bool]($ReadinessScope -ne "PromotionReserve")) `
-ExpectedRuntimeFileCount 20 `
-TaskFailureCount $taskFailures.Count `
-RelayListenerCount $relayListeners.Count `
-RequiredEvidenceStaleCount $requiredEvidenceStale.Count `
-RequiredEvidenceReserveFailureCount $requiredEvidenceReserveFailures.Count `
-RequiredEvidenceParseFailureCount $requiredEvidenceParseFailures.Count `
-RequiredEvidenceContentFailureCount $requiredEvidenceContentFailures.Count `
-SelfHealthSeverity $selfHealthSeverity `
-SelfHealthPerformanceIssueCount $selfHealthPerformanceIssueCount `
-StaleAlertExecutionCount $alertRunning.staleOverFiveMinutes `
-AlertIncomingCount $alertIncoming.count `
-StaleAlertIncomingCount $alertIncoming.staleOverFiveMinutes `
-PendingCommandBlockingCount $pendingCommandPromotion.blockingCount `
-PromotionDeferredCommandCount $pendingCommandPromotion.deferredCount
$blockingReasons = @($decision.blockingReasons)
$warningReasons = @($decision.warningReasons)
$result = [pscustomobject]@{
schemaVersion = "agent99_live_preflight_v1"
mode = $Mode
readinessScope = $ReadinessScope
minimumEvidenceFreshnessReserveMinutes = $MinimumEvidenceFreshnessReserveMinutes
observedAt = (Get-Date).ToString("o")
host = $env:COMPUTERNAME
lastBootUpTime = $operatingSystem.LastBootUpTime.ToString("o")
secretValueRead = $false
remoteWritePerformed = $false
agentRoot = $AgentRoot
controlLoopMaintenance = $controlLoopMaintenance
manifest = $manifest
tasks = $tasks
relayListeners = $relayListeners
queues = $queues
pendingCommandPromotion = $pendingCommandPromotion
evidence = $evidence
summary = [pscustomobject]@{
requiredTaskCount = $requiredTasks.Count
healthyTaskCount = [int]@($tasks | Where-Object { $_.healthy }).Count
taskFailureCount = $taskFailures.Count
freshRequiredEvidenceCount = [int]@($evidence | Where-Object { $_.required -and $_.fresh }).Count
usableRequiredEvidenceCount = [int]@($evidence | Where-Object { $_.required -and $_.usable }).Count
requiredEvidenceFailureCount = $requiredEvidenceFailures.Count
requiredEvidenceStaleCount = $requiredEvidenceStale.Count
requiredEvidenceReserveFailureCount = $requiredEvidenceReserveFailures.Count
requiredEvidenceParseFailureCount = $requiredEvidenceParseFailures.Count
requiredEvidenceContentFailureCount = $requiredEvidenceContentFailures.Count
selfHealthSeverity = $selfHealthSeverity
selfHealthPerformanceSeverity = $selfHealthPerformanceSeverity
selfHealthPerformanceIssueCount = $selfHealthPerformanceIssueCount
alertIncomingCount = [int]$alertIncoming.count
alertIncomingStaleCount = [int]$alertIncoming.staleOverFiveMinutes
alertRunningCount = [int]$alertRunning.count
pendingCommandCount = [int]$commandQueue.count
pendingCommandBlockingCount = [int]$pendingCommandPromotion.blockingCount
promotionDeferredCommandCount = [int]$pendingCommandPromotion.deferredCount
relayListenerCount = $relayListeners.Count
preflightGreen = [bool]$decision.deploymentEligible
deploymentEligible = [bool]$decision.deploymentEligible
manifestCheckRequired = [bool]$decision.manifestCheckRequired
manifestIdentityReady = [bool]$decision.manifestIdentityReady
manifestContentReady = [bool]$decision.manifestContentReady
blockingReasons = $blockingReasons
warningCount = [int]$decision.warningCount
warningReasons = $warningReasons
}
}
Write-Output "AGENT99_LIVE_PREFLIGHT=$([int]$result.summary.preflightGreen)"
Write-Output "DEPLOYMENT_ELIGIBLE=$([int]$result.summary.deploymentEligible)"
Write-Output "MODE=$Mode"
Write-Output "READINESS_SCOPE=$ReadinessScope"
Write-Output "MINIMUM_EVIDENCE_FRESHNESS_RESERVE_MINUTES=$MinimumEvidenceFreshnessReserveMinutes"
Write-Output "SOURCE_REVISION=$($manifest.sourceRevision)"
Write-Output "RUNTIME_MATCHED=$([int]$manifest.runtimeMatched)"
Write-Output "TASKS_HEALTHY=$($result.summary.healthyTaskCount)/$($result.summary.requiredTaskCount)"
Write-Output "RELAY_LISTENER_COUNT=$($result.summary.relayListenerCount)"
Write-Output "ALERT_INCOMING_COUNT=$($result.summary.alertIncomingCount)"
Write-Output "ALERT_INCOMING_STALE_COUNT=$($result.summary.alertIncomingStaleCount)"
Write-Output "COMMAND_PENDING_COUNT=$($result.summary.pendingCommandCount)"
Write-Output "SELF_HEALTH_SEVERITY=$($result.summary.selfHealthSeverity)"
Write-Output "BLOCKING_REASONS=$($blockingReasons -join ',')"
Write-Output "WARNING_REASONS=$($warningReasons -join ',')"
Write-Output "SECRET_VALUE_READ=0"
Write-Output "REMOTE_WRITE_PERFORMED=0"
Write-Output "JSON_BEGIN"
$result | ConvertTo-Json -Depth 8 -Compress
Write-Output "JSON_END"
if ($result.summary.deploymentEligible) {
exit 0
}
exit 2