fix(agent99): reserve pre-promotion readiness

This commit is contained in:
ogt
2026-07-16 23:47:05 +08:00
parent 59d03fe277
commit d3d53a4f69
5 changed files with 445 additions and 49 deletions

View File

@@ -2,7 +2,11 @@
param(
[ValidateSet("Verify")]
[string]$Mode = "Verify",
[string]$AgentRoot = "C:\Wooo\Agent99"
[string]$AgentRoot = "C:\Wooo\Agent99",
[ValidateSet("FullRuntime", "PromotionReserve")]
[string]$ReadinessScope = "FullRuntime",
[ValidateRange(0, 20)]
[int]$MinimumEvidenceFreshnessReserveMinutes = 0
)
$ErrorActionPreference = "Stop"
@@ -11,10 +15,12 @@ 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,
@@ -44,21 +50,24 @@ function Get-AgentLivePreflightDecision {
)
if (-not $AgentRootPresent) { $blockingReasons += "agent_root_missing" }
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 ($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" }
@@ -91,6 +100,7 @@ function Get-AgentLivePreflightDecision {
blockingReasons = @($blockingReasons)
warningReasons = @($warningReasons)
warningCount = [int]$warningReasons.Count
manifestCheckRequired = $ManifestCheckRequired
manifestIdentityReady = $manifestIdentityReady
manifestContentReady = $manifestContentReady
selfHealthClassification = $normalizedSelfHealth
@@ -98,11 +108,16 @@ function Get-AgentLivePreflightDecision {
}
function Get-TaskReadback {
param([string]$TaskName)
param(
[string]$TaskPath,
[string]$TaskName
)
try {
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
$info = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction Stop
$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
$healthy = [bool](
@@ -112,6 +127,7 @@ function Get-TaskReadback {
return [pscustomobject]@{
name = $TaskName
taskPath = $TaskPath
exists = $true
enabled = [bool]$task.Settings.Enabled
state = $state
@@ -123,6 +139,7 @@ function Get-TaskReadback {
} catch {
return [pscustomobject]@{
name = $TaskName
taskPath = $TaskPath
exists = $false
enabled = $false
state = "Missing"
@@ -165,7 +182,8 @@ function Get-EvidenceReadback {
[string]$Name,
[string]$Pattern,
[int]$MaxAgeMinutes,
[bool]$Required
[bool]$Required,
[int]$MinimumFreshnessReserveMinutes = 0
)
$evidenceDir = Join-Path $AgentRoot "evidence"
@@ -203,6 +221,12 @@ function Get-EvidenceReadback {
}
$ageMinutes = if ($file) { [math]::Round(((Get-Date) - $file.LastWriteTime).TotalMinutes, 1) } else { $null }
$fresh = [bool]($file -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 = ""
$contentHealthy = $true
@@ -345,7 +369,7 @@ function Get-EvidenceReadback {
}
$parsed = [bool]($file -and -not $parseError)
$usable = [bool]($fresh -and $parsed -and $contentHealthy)
$usable = [bool]($fresh -and $freshnessReserveReady -and $parsed -and $contentHealthy)
return [pscustomobject]@{
name = $Name
pattern = $Pattern
@@ -355,6 +379,9 @@ function Get-EvidenceReadback {
ageMinutes = $ageMinutes
maxAgeMinutes = $MaxAgeMinutes
fresh = $fresh
minimumFreshnessReserveMinutes = $MinimumFreshnessReserveMinutes
freshnessHeadroomMinutes = $freshnessHeadroomMinutes
freshnessReserveReady = $freshnessReserveReady
parsed = $parsed
contentHealthy = $contentHealthy
usable = $usable
@@ -366,18 +393,19 @@ function Get-EvidenceReadback {
}
}
$rootTaskPath = "\"
$requiredTasks = @(
"Wooo-Agent99-Startup-Recovery",
"Wooo-Agent99-Heartbeat",
"Wooo-Agent99-Performance-Guard",
"Wooo-Agent99-Control-Loop",
"Wooo-Agent99-Telegram-Inbox",
"Wooo-Agent99-SRE-Alert-Inbox",
"Wooo-Agent99-SRE-Alert-Relay",
"Wooo-Agent99-Self-Health",
"Wooo-Agent99-Backup-Health"
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Startup-Recovery" },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Heartbeat" },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Performance-Guard" },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Control-Loop" },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Telegram-Inbox" },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-SRE-Alert-Inbox" },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-SRE-Alert-Relay" },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Self-Health" },
[pscustomobject]@{ taskPath = $rootTaskPath; name = "Wooo-Agent99-Backup-Health" }
)
$tasks = @($requiredTasks | ForEach-Object { Get-TaskReadback $_ })
$tasks = @($requiredTasks | ForEach-Object { Get-TaskReadback -TaskPath $_.taskPath -TaskName $_.name })
$manifestPath = Join-Path $AgentRoot "state\runtime-manifest.json"
$manifest = [pscustomobject]@{
@@ -430,20 +458,21 @@ $queues = @(
(Get-DirectoryReadback "queue\failed")
)
$evidence = @(
(Get-EvidenceReadback "self_check" "agent99-SelfCheck-*.json" 20 $true),
(Get-EvidenceReadback "status" "agent99-Status-*.json" 20 $true),
(Get-EvidenceReadback "control_tick" "agent99-ControlTick-*.json" 20 $true),
(Get-EvidenceReadback "sre_alert_inbox" "agent99-SreAlertInbox-*.json" 20 $true),
(Get-EvidenceReadback "telegram_inbox" "agent99-TelegramInbox-*.json" 20 $true),
(Get-EvidenceReadback "performance" "agent99-Perf-*.json" 20 $true),
(Get-EvidenceReadback "backup" "agent99-BackupCheck-*.json" 1440 $true),
(Get-EvidenceReadback "recovery" "agent99-Recover-*.json" 10080 $false)
(Get-EvidenceReadback "self_check" "agent99-SelfCheck-*.json" 20 $true $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "status" "agent99-Status-*.json" 20 $true $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "control_tick" "agent99-ControlTick-*.json" 20 $true $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "sre_alert_inbox" "agent99-SreAlertInbox-*.json" 20 $true $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "telegram_inbox" "agent99-TelegramInbox-*.json" 20 $true $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "performance" "agent99-Perf-*.json" 20 $true $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "backup" "agent99-BackupCheck-*.json" 1440 $true $MinimumEvidenceFreshnessReserveMinutes),
(Get-EvidenceReadback "recovery" "agent99-Recover-*.json" 10080 $false 0)
)
$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
@@ -456,10 +485,12 @@ $commandQueue = $queues | Where-Object { $_.relativePath -eq "queue" } | Select-
$decision = Get-AgentLivePreflightDecision `
-AgentRootPresent ([bool](Test-Path $AgentRoot)) `
-Manifest $manifest `
-ManifestCheckRequired ([bool]($ReadinessScope -eq "FullRuntime")) `
-ExpectedRuntimeFileCount 16 `
-TaskFailureCount $taskFailures.Count `
-RelayListenerCount $relayListeners.Count `
-RequiredEvidenceStaleCount $requiredEvidenceStale.Count `
-RequiredEvidenceReserveFailureCount $requiredEvidenceReserveFailures.Count `
-RequiredEvidenceParseFailureCount $requiredEvidenceParseFailures.Count `
-RequiredEvidenceContentFailureCount $requiredEvidenceContentFailures.Count `
-SelfHealthSeverity $selfHealthSeverity `
@@ -474,6 +505,8 @@ $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")
@@ -492,6 +525,8 @@ $result = [pscustomobject]@{
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
@@ -504,6 +539,7 @@ $result = [pscustomobject]@{
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
@@ -515,6 +551,8 @@ $result = [pscustomobject]@{
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)"

View File

@@ -21,6 +21,12 @@ $RelayPort = 8787
$RelayStopTimeoutSeconds = 60
$RelayStartTimeoutSeconds = 360
$RelayPollIntervalSeconds = 1
$ValidateOnlyTimeoutSeconds = 300
$LiveDeployTimeoutSeconds = 300
$LivePreflightTimeoutSeconds = 120
$PromotionReadinessReserveMinutes = 15
$PromotionReadinessMaxAttempts = 2
$PromotionReadinessRetryWaitSeconds = 30
$FixedRuntimeFiles = @(
"agent99-bootstrap.ps1",
"agent99-contract-check.ps1",
@@ -289,7 +295,7 @@ function Invoke-AgentDeployChild {
"-SourceRevision", $SourceRevision
)
if ($ValidateOnly) { $arguments += "-ValidateOnly" }
$timeoutSeconds = if ($ValidateOnly) { 300 } else { 900 }
$timeoutSeconds = if ($ValidateOnly) { $ValidateOnlyTimeoutSeconds } else { $LiveDeployTimeoutSeconds }
$processResult = Invoke-AgentBoundedPowerShell -Arguments $arguments -TimeoutSeconds $timeoutSeconds
return [pscustomobject]@{
ok = [bool]($processResult.exitCode -eq 0 -and -not $processResult.timedOut)
@@ -301,10 +307,23 @@ function Invoke-AgentDeployChild {
}
function Invoke-AgentLivePreflight {
param([string]$PreflightPath)
param(
[string]$PreflightPath,
[ValidateSet("FullRuntime", "PromotionReserve")]
[string]$ReadinessScope = "FullRuntime",
[ValidateRange(0, 20)]
[int]$MinimumEvidenceFreshnessReserveMinutes = 0
)
$arguments = @("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", $PreflightPath, "-Mode", "Verify", "-AgentRoot", $AgentRoot)
$processResult = Invoke-AgentBoundedPowerShell -Arguments $arguments -TimeoutSeconds 120
$arguments = @(
"-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
"-File", $PreflightPath,
"-Mode", "Verify",
"-AgentRoot", $AgentRoot,
"-ReadinessScope", $ReadinessScope,
"-MinimumEvidenceFreshnessReserveMinutes", ([string]$MinimumEvidenceFreshnessReserveMinutes)
)
$processResult = Invoke-AgentBoundedPowerShell -Arguments $arguments -TimeoutSeconds $LivePreflightTimeoutSeconds
$text = [string]$processResult.stdout
$match = [regex]::Match($text, "(?s)JSON_BEGIN\r?\n(?<json>\{.*?\})\r?\nJSON_END")
$payload = $null
@@ -322,18 +341,75 @@ function Invoke-AgentLivePreflight {
}
[string[]]$externalBlockingReasons = @($blockingReasons | Where-Object { $_ -eq "self_health_not_ok" })
[string[]]$runtimePlaneBlockingReasons = @($blockingReasons | Where-Object { $_ -ne "self_health_not_ok" })
$safeTaskNames = @(
"Wooo-Agent99-Startup-Recovery",
"Wooo-Agent99-Heartbeat",
"Wooo-Agent99-Performance-Guard",
"Wooo-Agent99-Control-Loop",
"Wooo-Agent99-Telegram-Inbox",
"Wooo-Agent99-SRE-Alert-Inbox",
"Wooo-Agent99-SRE-Alert-Relay",
"Wooo-Agent99-Self-Health",
"Wooo-Agent99-Backup-Health"
)
$safeEvidenceNames = @(
"self_check", "status", "control_tick", "sre_alert_inbox",
"telegram_inbox", "performance", "backup", "recovery"
)
$failedTasks = if ($payload) {
@($payload.tasks | Where-Object { -not [bool]$_.healthy } | ForEach-Object {
$name = [string]$_.name
$state = [string]$_.state
[pscustomobject]@{
name = if ($name -in $safeTaskNames) { $name } else { "unknown" }
taskPath = if ([string]$_.taskPath -eq "\") { "\" } else { "invalid" }
exists = [bool]$_.exists
enabled = [bool]$_.enabled
state = if ($state -in @("Ready", "Running", "Disabled", "Queued", "Missing")) { $state } else { "Unknown" }
lastTaskResult = if ($null -ne $_.lastTaskResult) { [long]$_.lastTaskResult } else { $null }
}
})
} else { @() }
$failedEvidence = if ($payload) {
@($payload.evidence | Where-Object { [bool]$_.required -and -not [bool]$_.healthy } | ForEach-Object {
$name = [string]$_.name
[pscustomobject]@{
name = if ($name -in $safeEvidenceNames) { $name } else { "unknown" }
ageMinutes = if ($null -ne $_.ageMinutes) { [double]$_.ageMinutes } else { $null }
maxAgeMinutes = if ($null -ne $_.maxAgeMinutes) { [double]$_.maxAgeMinutes } else { $null }
minimumFreshnessReserveMinutes = if ($null -ne $_.minimumFreshnessReserveMinutes) { [double]$_.minimumFreshnessReserveMinutes } else { $null }
freshnessHeadroomMinutes = if ($null -ne $_.freshnessHeadroomMinutes) { [double]$_.freshnessHeadroomMinutes } else { $null }
fresh = [bool]$_.fresh
freshnessReserveReady = [bool]$_.freshnessReserveReady
parsed = [bool]$_.parsed
contentHealthy = [bool]$_.contentHealthy
}
})
} else { @() }
$acceptedExitCode = [bool]($processResult.exitCode -in @(0, 2))
$scopeMatched = [bool](
$payload -and
[string]$payload.readinessScope -eq $ReadinessScope -and
[int]$payload.minimumEvidenceFreshnessReserveMinutes -eq $MinimumEvidenceFreshnessReserveMinutes -and
$summary -and
[bool]$summary.manifestCheckRequired -eq [bool]($ReadinessScope -eq "FullRuntime")
)
$manifestReadyForScope = [bool](
$ReadinessScope -eq "PromotionReserve" -or
($payload -and $payload.manifest -and [bool]$payload.manifest.runtimeMatched)
)
$runtimePlaneReady = [bool](
$acceptedExitCode -and
-not $processResult.timedOut -and
$payload -and
$payload.manifest -and
[bool]$payload.manifest.runtimeMatched -and
$scopeMatched -and
$manifestReadyForScope -and
$summary -and
[int]$summary.requiredTaskCount -gt 0 -and
[int]$summary.healthyTaskCount -eq [int]$summary.requiredTaskCount -and
[int]$summary.taskFailureCount -eq 0 -and
[int]$summary.requiredEvidenceFailureCount -eq 0 -and
[int]$summary.requiredEvidenceStaleCount -eq 0 -and
[int]$summary.requiredEvidenceReserveFailureCount -eq 0 -and
[int]$summary.requiredEvidenceParseFailureCount -eq 0 -and
[int]$summary.requiredEvidenceContentFailureCount -eq 0 -and
[int]$summary.relayListenerCount -gt 0 -and
@@ -344,6 +420,9 @@ function Invoke-AgentLivePreflight {
)
return [pscustomobject]@{
ok = $runtimePlaneReady
readinessScope = $ReadinessScope
minimumEvidenceFreshnessReserveMinutes = $MinimumEvidenceFreshnessReserveMinutes
scopeMatched = $scopeMatched
exitCode = [int]$processResult.exitCode
acceptedExitCode = $acceptedExitCode
timedOut = [bool]$processResult.timedOut
@@ -354,6 +433,8 @@ function Invoke-AgentLivePreflight {
requiredTaskCount = if ($summary) { [int]$summary.requiredTaskCount } else { 0 }
taskFailureCount = if ($summary) { [int]$summary.taskFailureCount } else { -1 }
requiredEvidenceFailureCount = if ($summary) { [int]$summary.requiredEvidenceFailureCount } else { -1 }
requiredEvidenceStaleCount = if ($summary) { [int]$summary.requiredEvidenceStaleCount } else { -1 }
requiredEvidenceReserveFailureCount = if ($summary) { [int]$summary.requiredEvidenceReserveFailureCount } else { -1 }
requiredEvidenceParseFailureCount = if ($summary) { [int]$summary.requiredEvidenceParseFailureCount } else { -1 }
requiredEvidenceContentFailureCount = if ($summary) { [int]$summary.requiredEvidenceContentFailureCount } else { -1 }
relayListenerCount = if ($summary) { [int]$summary.relayListenerCount } else { 0 }
@@ -377,12 +458,60 @@ function Invoke-AgentLivePreflight {
overallPreflightGreen = [bool]($summary -and $summary.preflightGreen)
runtimePlaneReady = $runtimePlaneReady
externalDegraded = [bool]($runtimePlaneReady -and $externalBlockingReasons.Count -gt 0)
failedTaskNames = @($failedTasks | ForEach-Object { [string]$_.name })
failedTasks = $failedTasks
failedEvidenceNames = @($failedEvidence | ForEach-Object { [string]$_.name })
failedEvidence = $failedEvidence
blockingReasons = $blockingReasons
runtimePlaneBlockingReasons = $runtimePlaneBlockingReasons
externalBlockingReasons = $externalBlockingReasons
}
}
function Test-AgentPromotionReadinessRetryable {
param([object]$Readiness)
if (
-not $Readiness -or
$Readiness.ok -or
-not $Readiness.parsed -or
-not $Readiness.scopeMatched -or
[string]$Readiness.readinessScope -ne "PromotionReserve" -or
[int]$Readiness.minimumEvidenceFreshnessReserveMinutes -ne $PromotionReadinessReserveMinutes
) {
return $false
}
$allowedReasons = @(
"scheduled_task_unhealthy",
"required_evidence_stale",
"required_evidence_freshness_reserve_insufficient"
)
$reasons = @($Readiness.runtimePlaneBlockingReasons | ForEach-Object { [string]$_ })
if ($reasons.Count -eq 0 -or @($reasons | Where-Object { $_ -notin $allowedReasons }).Count -gt 0) {
return $false
}
if (
"scheduled_task_unhealthy" -in $reasons -and
@($Readiness.failedTasks).Count -eq 0
) { return $false }
if (
@($reasons | Where-Object { $_ -in @("required_evidence_stale", "required_evidence_freshness_reserve_insufficient") }).Count -gt 0 -and
@($Readiness.failedEvidence).Count -eq 0
) { return $false }
if (@($Readiness.failedTasks | Where-Object {
-not $_.exists -or
-not $_.enabled -or
$_.taskPath -ne "\" -or
$_.state -notin @("Ready", "Queued")
}).Count -gt 0) {
return $false
}
if (@($Readiness.failedEvidence | Where-Object { -not $_.parsed -or -not $_.contentHealthy }).Count -gt 0) {
return $false
}
return $true
}
function Get-AgentRelayRuntime {
$task = $null
$taskError = ""
@@ -1070,6 +1199,78 @@ try {
Write-AgentResultAndExit $receipt 2
}
$script:ReceiverOperationPhase = "pre_promotion_readiness"
$promotionReadinessAttempts = @()
$prePromotionReadiness = $null
for ($readinessAttempt = 1; $readinessAttempt -le $PromotionReadinessMaxAttempts; $readinessAttempt += 1) {
$prePromotionReadiness = Invoke-AgentLivePreflight `
(Join-Path $stagePath "agent99-live-preflight.ps1") `
-ReadinessScope "PromotionReserve" `
-MinimumEvidenceFreshnessReserveMinutes $PromotionReadinessReserveMinutes
$prePromotionReadiness | Add-Member -NotePropertyName attempt -NotePropertyValue $readinessAttempt -Force
$promotionReadinessAttempts += $prePromotionReadiness
if ($prePromotionReadiness.ok) { break }
if (
$readinessAttempt -ge $PromotionReadinessMaxAttempts -or
-not (Test-AgentPromotionReadinessRetryable $prePromotionReadiness)
) { break }
Start-Sleep -Seconds $PromotionReadinessRetryWaitSeconds
}
$promotionReadinessRetryCount = [math]::Max(0, $promotionReadinessAttempts.Count - 1)
$promotionReadinessStillRetryable = Test-AgentPromotionReadinessRetryable $prePromotionReadiness
if (-not $prePromotionReadiness.ok) {
$receipt = [pscustomobject]@{
schemaVersion = "agent99_remote_atomic_deploy_receipt_v1"
status = "blocked_pre_promotion_readiness_no_live_write"
mode = "apply"
traceId = $traceId
runId = $runId
workItemId = $workItemId
sourceRevision = $sourceRevision
manifestSha256 = $manifestDigest
stagingPath = $stagePath
canonicalReceiptPath = $receiptPath
attemptReceiptPath = $attemptReceiptPath
validateOnly = [pscustomobject]@{ ok = $validate.ok; exitCode = $validate.exitCode; timedOut = $validate.timedOut }
prePromotionReadiness = $prePromotionReadiness
promotionReadinessAttempts = $promotionReadinessAttempts
retryPolicy = [pscustomobject]@{
retryableTransientOnly = $true
automaticTaskTriggerAllowed = $false
maxAttempts = $PromotionReadinessMaxAttempts
attemptCount = $promotionReadinessAttempts.Count
retryCount = $promotionReadinessRetryCount
retryWaitSeconds = $PromotionReadinessRetryWaitSeconds
finalFailureRetryableTransient = $promotionReadinessStillRetryable
unboundedRetryAllowed = $false
}
durableReceiptWritten = $true
remoteWritePerformed = $script:ReceiverRemoteWritePerformed
liveRuntimeWritePerformed = $false
livePromotionAttempted = $false
livePromotionPerformed = $false
readinessCheckWritePerformed = $false
secretValueRead = $false
privateKeyValueRead = $false
tokenValueRead = $false
environmentSecretRead = $false
uiInteraction = $false
vmPowerChange = $false
hostReboot = $false
serviceRestart = $false
scheduledTaskRestart = $false
scheduledTaskModification = $false
nextSafeAction = if ($promotionReadinessStillRetryable) {
"allow_natural_root_task_and_evidence_refresh_then_retry_same_identity_once"
} else {
"repair_exact_reported_readiness_blockers_before_retry"
}
}
$script:ReceiverOperationPhase = "pre_promotion_readiness_failure_receipt_write"
Write-AgentRemoteDeployReceipt $attemptReceiptPath $receipt
Write-AgentResultAndExit $receipt 2
}
$beforeManifest = Get-AgentSafeRuntimeManifest
$beforeBundleDigest = Get-AgentLiveBundleDigest
$beforeConfigDigest = Get-AgentSha256File $ConfigPath
@@ -1215,6 +1416,9 @@ try {
backupPath = $backupPath
canonicalReceiptPath = $receiptPath
validateOnly = [pscustomobject]@{ ok = $validate.ok; exitCode = $validate.exitCode; timedOut = $validate.timedOut; status = [string]$validate.payload.status }
prePromotionReadiness = $prePromotionReadiness
promotionReadinessAttempts = $promotionReadinessAttempts
promotionReadinessRetryCount = $promotionReadinessRetryCount
deploy = [pscustomobject]@{ ok = $deploy.ok; exitCode = $deploy.exitCode; timedOut = $deploy.timedOut; status = [string]$deploy.payload.status }
relayReload = $relayReload
runtimeManifest = $runtimeManifest
@@ -1331,6 +1535,9 @@ try {
stagingPath = $stagePath
backupPath = $backupPath
validateOnly = [pscustomobject]@{ ok = $validate.ok; exitCode = $validate.exitCode; timedOut = $validate.timedOut }
prePromotionReadiness = $prePromotionReadiness
promotionReadinessAttempts = $promotionReadinessAttempts
promotionReadinessRetryCount = $promotionReadinessRetryCount
deploy = if ($deploy) { [pscustomobject]@{ ok = $deploy.ok; exitCode = $deploy.exitCode; timedOut = $deploy.timedOut } } else { [pscustomobject]@{ ok = $false; exitCode = -1; timedOut = $false } }
failurePhase = $failurePhase
failureType = $failureType

View File

@@ -40,9 +40,11 @@ def _base_case() -> dict[str, Any]:
"mismatchCount": 0,
"parseError": "",
},
"manifestCheckRequired": True,
"taskFailureCount": 0,
"relayListenerCount": 1,
"requiredEvidenceStaleCount": 0,
"requiredEvidenceReserveFailureCount": 0,
"requiredEvidenceParseFailureCount": 0,
"requiredEvidenceContentFailureCount": 0,
"selfHealthSeverity": "ok",
@@ -81,10 +83,12 @@ $case = @'
$parameters = @{{
AgentRootPresent = [bool]$case.agentRootPresent
Manifest = $case.manifest
ManifestCheckRequired = [bool]$case.manifestCheckRequired
ExpectedRuntimeFileCount = 16
TaskFailureCount = [int]$case.taskFailureCount
RelayListenerCount = [int]$case.relayListenerCount
RequiredEvidenceStaleCount = [int]$case.requiredEvidenceStaleCount
RequiredEvidenceReserveFailureCount = [int]$case.requiredEvidenceReserveFailureCount
RequiredEvidenceParseFailureCount = [int]$case.requiredEvidenceParseFailureCount
RequiredEvidenceContentFailureCount = [int]$case.requiredEvidenceContentFailureCount
SelfHealthSeverity = [string]$case.selfHealthSeverity
@@ -123,6 +127,10 @@ def test_warning_receipt_is_visible_without_weakening_integrity_blockers() -> No
assert '$blockingReasons += "runtime_manifest_mismatch"' in decision
assert '$blockingReasons += "scheduled_task_unhealthy"' in decision
assert '$blockingReasons += "sre_alert_relay_not_listening"' in decision
assert (
'$blockingReasons += "required_evidence_freshness_reserve_insufficient"'
in decision
)
assert "deploymentEligible = [bool]($blockingReasons.Count -eq 0)" in decision
assert "preflightGreen = [bool]$decision.deploymentEligible" in source
assert "-ExpectedRuntimeFileCount 16" in source
@@ -139,6 +147,9 @@ def test_warning_receipt_is_visible_without_weakening_integrity_blockers() -> No
assert '"agent99_background_mode_deferred_v1"' in source
assert '"agent99_recovery_transport_deferred_v1"' in source
assert "deferredCandidateCount = [int]$deferredCandidates.Count" in source
assert '$rootTaskPath = "\\"' in source
assert "Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName" in source
assert "Get-ScheduledTaskInfo -TaskPath $TaskPath -TaskName $TaskName" in source
def test_evidence_selection_skips_deferred_but_not_malformed_candidates(
@@ -224,6 +235,45 @@ Get-EvidenceReadback 'self_check' 'agent99-SelfCheck-*.json' 20 $true |
assert malformed_payload["healthy"] is False
def test_evidence_reserve_rejects_fresh_but_expiring_evidence_without_extending_slo(
tmp_path: Path,
) -> None:
powershell = shutil.which("pwsh") or shutil.which("powershell")
if powershell is None:
pytest.skip("PowerShell runtime is not installed on this macOS verifier")
evidence = tmp_path / "evidence"
evidence.mkdir()
candidate = evidence / "agent99-Status-20260716-230000.json"
candidate.write_text(json.dumps({"mode": "Status"}), encoding="utf-8")
six_minutes_ago = candidate.stat().st_mtime - (6 * 60)
os.utime(candidate, (six_minutes_ago, six_minutes_ago))
agent_root = str(tmp_path).replace("'", "''")
command = f"""
{_evidence_function_source()}
$AgentRoot = '{agent_root}'
Get-EvidenceReadback 'status' 'agent99-Status-*.json' 20 $true 15 |
ConvertTo-Json -Depth 8 -Compress
"""
result = subprocess.run(
[powershell, "-NoProfile", "-NonInteractive", "-Command", command],
check=False,
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
payload = json.loads(
next(line for line in reversed(result.stdout.splitlines()) if line.startswith("{"))
)
assert payload["fresh"] is True
assert payload["maxAgeMinutes"] == 20
assert payload["minimumFreshnessReserveMinutes"] == 15
assert payload["freshnessReserveReady"] is False
assert payload["usable"] is False
assert payload["healthy"] is False
def test_decision_behavior_when_powershell_is_available() -> None:
powershell = shutil.which("pwsh") or shutil.which("powershell")
if powershell is None:
@@ -252,6 +302,12 @@ def test_decision_behavior_when_powershell_is_available() -> None:
("scheduled_task_unhealthy",),
(),
),
(
_with_overrides(requiredEvidenceReserveFailureCount=1),
False,
("required_evidence_freshness_reserve_insufficient",),
(),
),
(
_with_overrides(manifest={"runtimeMatched": False, "mismatchCount": 1}),
False,
@@ -305,6 +361,29 @@ def test_decision_behavior_when_powershell_is_available() -> None:
assert set(_as_list(result.get("blockingReasons"))) == set(expected_blockers)
assert set(_as_list(result.get("warningReasons"))) == set(expected_warnings)
promotion_reserve = _run_decision(
powershell,
_with_overrides(
manifestCheckRequired=False,
manifest={"sourceRevision": "old", "fileCount": 15, "runtimeMatched": False},
),
)
assert promotion_reserve["deploymentEligible"] is True
assert promotion_reserve["manifestCheckRequired"] is False
promotion_reserve_task_failure = _run_decision(
powershell,
_with_overrides(
manifestCheckRequired=False,
manifest={"sourceRevision": "old", "fileCount": 15, "runtimeMatched": False},
taskFailureCount=1,
),
)
assert promotion_reserve_task_failure["deploymentEligible"] is False
assert promotion_reserve_task_failure["blockingReasons"] == [
"scheduled_task_unhealthy"
]
preflight_path = str(PREFLIGHT).replace("'", "''")
parser_command = (
"$tokens=$null; $errors=$null; "

View File

@@ -179,7 +179,10 @@ def test_live_preflight_rejects_fresh_but_unparseable_required_evidence() -> Non
preflight = read("scripts/reboot-recovery/agent99-live-preflight.ps1")
assert '$parsed = [bool]($file -and -not $parseError)' in preflight
assert '$usable = [bool]($fresh -and $parsed -and $contentHealthy)' in preflight
assert (
"$usable = [bool]($fresh -and $freshnessReserveReady -and $parsed -and "
"$contentHealthy)" in preflight
)
assert 'healthy = [bool](-not $Required -or $usable)' in preflight
assert '$_.required -and -not $_.healthy' in preflight
assert 'required_evidence_parse_failed' in preflight

View File

@@ -209,8 +209,10 @@ def test_receiver_validates_manifest_before_staging_and_validate_only_before_pro
"$deploy = Invoke-AgentDeployChild -SourceRoot $stagePath "
"-SourceRevision $sourceRevision"
)
assert manifest_check < stage_write < validate < promote
readiness = source.index('$script:ReceiverOperationPhase = "pre_promotion_readiness"')
assert manifest_check < stage_write < validate < readiness < promote
assert 'status = "validate_only_failed_no_promotion"' in source
assert 'status = "blocked_pre_promotion_readiness_no_live_write"' in source
assert "liveRuntimeWritePerformed = $false" in source
assert "livePromotionPerformed = $false" in source
@@ -236,7 +238,12 @@ def test_receiver_rolls_back_failed_deploy_or_failed_independent_post_verifier()
assert "function Invoke-AgentBoundedPowerShell" in source
assert "$process.WaitForExit($TimeoutSeconds * 1000)" in source
assert "$process.Kill()" in source
assert "$timeoutSeconds = if ($ValidateOnly) { 300 } else { 900 }" in source
assert "$ValidateOnlyTimeoutSeconds = 300" in source
assert "$LiveDeployTimeoutSeconds = 300" in source
assert (
"$timeoutSeconds = if ($ValidateOnly) { $ValidateOnlyTimeoutSeconds } "
"else { $LiveDeployTimeoutSeconds }" in source
)
assert "$livePreflight.sourceRevision -eq $sourceRevision" in source
assert "$runtimeManifest.sourceRevision -eq $sourceRevision" in source
assert '$runtimeManifest.fileCount -eq 16' in source
@@ -301,6 +308,60 @@ def test_receiver_has_single_flight_idempotency_and_no_secret_receipt_boundary()
assert "[bool]$prior.relayReload.newGenerationVerified" in source
def test_receiver_blocks_before_live_write_with_sanitized_readiness_receipt() -> None:
source = RECEIVER.read_text(encoding="utf-8")
validate = source.index(
"$validate = Invoke-AgentDeployChild -SourceRoot $stagePath "
"-SourceRevision $sourceRevision -ValidateOnly"
)
readiness = source.index('$script:ReceiverOperationPhase = "pre_promotion_readiness"')
live_write = source.index('$script:ReceiverOperationPhase = "live_deploy"')
assert validate < readiness < live_write
assert '-ReadinessScope "PromotionReserve"' in source
assert (
"-MinimumEvidenceFreshnessReserveMinutes "
"$PromotionReadinessReserveMinutes" in source
)
assert "$PromotionReadinessReserveMinutes = 15" in source
assert "$PromotionReadinessMaxAttempts = 2" in source
assert "$PromotionReadinessRetryWaitSeconds = 30" in source
assert "function Test-AgentPromotionReadinessRetryable" in source
assert '"scheduled_task_unhealthy"' in source
assert '"required_evidence_stale"' in source
assert '"required_evidence_freshness_reserve_insufficient"' in source
assert '[string]$Readiness.readinessScope -ne "PromotionReserve"' in source
assert (
"[int]$Readiness.minimumEvidenceFreshnessReserveMinutes -ne "
"$PromotionReadinessReserveMinutes" in source
)
assert '"scheduled_task_unhealthy" -in $reasons' in source
assert "@($Readiness.failedTasks).Count -eq 0" in source
assert "@($Readiness.failedEvidence).Count -eq 0" in source
assert '$_.state -notin @("Ready", "Queued")' in source
assert "automaticTaskTriggerAllowed = $false" in source
assert "unboundedRetryAllowed = $false" in source
assert "Start-ScheduledTask" not in source[readiness:live_write]
blocked = source[
source.index('$status = "blocked_pre_promotion_readiness_no_live_write"')
if '$status = "blocked_pre_promotion_readiness_no_live_write"' in source
else source.index('status = "blocked_pre_promotion_readiness_no_live_write"') : live_write
]
assert "liveRuntimeWritePerformed = $false" in blocked
assert "livePromotionAttempted = $false" in blocked
assert "livePromotionPerformed = $false" in blocked
assert "readinessCheckWritePerformed = $false" in blocked
assert "failedTaskNames" in source
assert "failedTasks" in source
assert "failedEvidenceNames" in source
assert "failedEvidence" in source
assert 'taskPath = if ([string]$_.taskPath -eq "\\") { "\\" }' in source
assert "ageMinutes = if ($null -ne $_.ageMinutes)" in source
assert "maxAgeMinutes = if ($null -ne $_.maxAgeMinutes)" in source
def test_check_and_apply_receipts_project_the_same_run_identity() -> None:
sender = SENDER.read_text(encoding="utf-8")
projector = sender[
@@ -510,6 +571,11 @@ def test_receiver_reloads_only_the_exact_existing_relay_task_and_proves_generati
assert "$RelayStopTimeoutSeconds = 60" in source
assert "$RelayStartTimeoutSeconds = 360" in source
assert "$RelayPollIntervalSeconds = 1" in source
assert "$ValidateOnlyTimeoutSeconds = 300" in source
assert "$LiveDeployTimeoutSeconds = 300" in source
assert "$LivePreflightTimeoutSeconds = 120" in source
assert "$PromotionReadinessMaxAttempts = 2" in source
assert "$PromotionReadinessRetryWaitSeconds = 30" in source
assert "Get-ScheduledTask -TaskPath $RelayTaskPath -TaskName $RelayTaskName" in source
assert "Get-ScheduledTaskInfo -TaskPath $RelayTaskPath -TaskName $RelayTaskName" in source
assert "Stop-ScheduledTask -TaskPath $RelayTaskPath -TaskName $RelayTaskName" in source
@@ -545,18 +611,21 @@ def test_receiver_reloads_only_the_exact_existing_relay_task_and_proves_generati
assert 'REMOTE_EXECUTION_TIMEOUT_SECONDS="2400"' in wrapper
wrapper_budget_seconds = 2400
validate_seconds = 300
deploy_seconds = 900
promotion_readiness_attempts = 2
promotion_readiness_retry_wait_seconds = 30
deploy_seconds = 300
live_preflight_seconds = 120
worst_case_seconds = (
validate_seconds
+ (promotion_readiness_attempts * live_preflight_seconds)
+ promotion_readiness_retry_wait_seconds
+ deploy_seconds
+ live_preflight_seconds
+ (2 * (60 + 360))
)
assert "$timeoutSeconds = if ($ValidateOnly) { 300 } else { 900 }" in source
assert "-TimeoutSeconds 120" in source
assert worst_case_seconds == 2160
assert wrapper_budget_seconds - worst_case_seconds == 240
assert "-TimeoutSeconds $LivePreflightTimeoutSeconds" in source
assert worst_case_seconds == 1830
assert wrapper_budget_seconds - worst_case_seconds == 570
deploy = source.index(
"$deploy = Invoke-AgentDeployChild -SourceRoot $stagePath "