diff --git a/HOST-REBOOT-AI-AUTOMATION-SOP.md b/HOST-REBOOT-AI-AUTOMATION-SOP.md index 417b3fd90..314b8dc7c 100644 --- a/HOST-REBOOT-AI-AUTOMATION-SOP.md +++ b/HOST-REBOOT-AI-AUTOMATION-SOP.md @@ -181,7 +181,7 @@ Producer-side Agent99 bridge: - If relay/path is empty or unavailable, the bridge fails open and logs; it must not cause Alertmanager retries. - Relay safety: only LAN/local source prefixes are accepted by default; optional `AGENT99_SRE_RELAY_TOKEN` / `AGENT99_SRE_ALERT_RELAY_TOKEN` values stay in env/Secret and are never stored in repo or evidence. - Provider freshness bridge output must run direct readback, then require provider window, last seen, direct reference, and verifier readback before dashboard green is trusted. -- Provider freshness no-false-green rule: route 200 or dashboard up is not enough. The Agent99 verifier must read `https://awoooi.wooo.work/api/v1/platform/events/dossier/recurrence?project_id=awoooi&limit=20` and record `providerWindowMinutes`, `lastSeen`, `directReference`, and `verifierReadback`. +- Provider freshness no-false-green rule: route 200, dashboard up, or generic recurrence is not enough. The Agent99 verifier must read provider-scoped coverage for both `provider=sentry` and `provider=signoz`, calculate each `latest_received_at` age against `providerWindowMinutes`, and require both provider rows fresh in the same run before resolution. 2026-07-11 P0-002 source receipt, runtime deployment pending: @@ -417,6 +417,8 @@ Recovery is complete only when all of the following are captured: 2026-07-10 10:00 Asia/Taipei: +> Historical receipt only; `superseded_by=agent99_provider_freshness_readback_v2`. The generic recurrence result below cannot satisfy the current independent verifier and must not be treated as runtime closure. + - Runtime patch deployed on 99: - Script: `C:\Wooo\Agent99\bin\agent99-control-plane.ps1`. - Parser readback: `parse_ok`. diff --git a/agent99-control-plane.ps1 b/agent99-control-plane.ps1 index 501687399..53afecfff 100644 --- a/agent99-control-plane.ps1 +++ b/agent99-control-plane.ps1 @@ -710,7 +710,7 @@ function Format-AgentTelegramLegacyText { $lines += "影響 Impact: source freshness 未證明前,dashboard green 不可視為健康。" $lines += "證據命中 Evidence hits: $hitCount" if ($readback) { - $lines += "驗證 Verifier: directReadback ok=$($readback.ok), sourceEvents=$($readback.sourceEventTotal), latest=$($readback.latestReceivedAt)" + $lines += "驗證 Verifier: providerSpecific=$($readback.readbackScope), readbackOk=$($readback.ok), allProvidersFresh=$($readback.allProvidersFresh), stale=$(@($readback.staleProviders) -join ','), oldestLatest=$($readback.latestReceivedAt)" } if (@($missing).Count -gt 0) { $lines += "仍缺欄位 Still missing: $($missing -join ', ')" @@ -762,7 +762,7 @@ function Format-AgentTelegramLegacyText { default { "執行失敗 / failed" } } $actionText = switch ($modeName) { - "ProviderFreshness" { "建立 provider freshness candidate,要求 provider window、last seen、direct reference、verifier readback;不切 provider。" } + "ProviderFreshness" { "同一 run 讀回 Sentry/SignOz coverage,逐 provider 驗證 freshness age;generic recurrence 不得關閉事件,且不切 provider。" } "BackupCheck" { "讀回備份 freshness、status/log、snapshot metadata、cron contract;不讀備份內容或 secrets。" } "Perf" { "讀回主機 CPU/記憶體/磁碟;必要時只做 allowlisted 降載。" } "LoadShed" { "執行 allowlisted 降載並做 post-apply verifier。" } @@ -1042,14 +1042,19 @@ function Get-AgentIncidentCardModel { $visualKind = "backup" } elseif ($EventType -match "provider_freshness") { $missing = if ($Data -and $Data.PSObject.Properties["missingFields"]) { @($Data.missingFields) } else { @() } + $providerRows = if ($Data -and $Data.PSObject.Properties["providerReadbacks"]) { @($Data.providerReadbacks) } else { @() } + $staleProviders = if ($Data -and $Data.PSObject.Properties["staleProviders"]) { @($Data.staleProviders) } else { @() } + $allProvidersFresh = [bool]($Data -and $Data.PSObject.Properties["allProvidersFresh"] -and $Data.allProvidersFresh -eq $true) $target = [string](Get-AgentObjectValue $Data "target" "provider freshness") - $title = "Provider freshness 證據不完整" + $title = if ($allProvidersFresh) { "Provider freshness 已驗證" } else { "Provider freshness 尚未通過" } $impact = "觀測來源可能過期或 ingestion 中斷;route 200 或 dashboard up 不能視為健康。" - $action = "建立 freshness candidate,補 provider window、last seen、direct reference 與 verifier readback。" - $verification = if ($missing.Count -eq 0) { "四項 freshness evidence 已齊,等待來源事件解除。" } else { "仍缺 $($missing.Count) 項 freshness evidence,事件保持開啟。" } - foreach ($gate in @("providerWindow", "lastSeen", "directReference", "verifierReadback")) { - $gates += [pscustomobject]@{ name = $gate; passed = [bool]($gate -notin $missing) } + $action = "同一 run 執行 Sentry/SignOz provider-specific coverage readback 與 freshness-age verifier;generic recurrence 不採信。" + $verification = if ($allProvidersFresh -and $missing.Count -eq 0) { "Sentry 與 SignOz 都在 freshness window 內,等待 durable verifier/writeback receipt。" } else { "未通過 provider=$($staleProviders -join ',');事件保持開啟,不假性標綠。" } + foreach ($providerName in @("sentry", "signoz")) { + $providerRow = @($providerRows | Where-Object { ([string]$_.provider).ToLowerInvariant() -eq $providerName } | Select-Object -First 1) + $gates += [pscustomobject]@{ name = "$providerName freshness"; passed = [bool]($providerRow.Count -eq 1 -and $providerRow[0].fresh) } } + $gates += [pscustomobject]@{ name = "same-run verifier receipt"; passed = $allProvidersFresh } $visualKind = "freshness" } elseif ($EventType -match "^operator_command_") { $target = if ($targetName) { $targetName } elseif ($hostName) { $hostName } elseif ($modeName) { $modeName } else { "Agent99 task" } @@ -1076,7 +1081,7 @@ function Get-AgentIncidentCardModel { default { "Agent99 已接手來源告警並執行受控處置。" } } $action = switch ($modeName) { - "ProviderFreshness" { "補齊四項 freshness evidence;不切 provider、不呼叫付費模型、不改 env。" } + "ProviderFreshness" { "同一 run 驗證 Sentry/SignOz coverage 與 freshness age;generic recurrence 不採信,且不切 provider、不呼叫付費模型、不改 env。" } "BackupCheck" { "讀回 freshness、cron、integrity 與 restore drill receipt;不讀備份內容。" } "Perf" { "讀回 CPU、記憶體、磁碟;必要時只做 allowlisted 降載。" } "LoadShed" { "執行 allowlisted 降載並立即做 post-apply verifier。" } @@ -4960,8 +4965,41 @@ function Get-AgentOutcomeContract { } "ProviderFreshness" { $provider = $data.providerFreshness - $directOk = [bool]($provider -and $provider.status -eq "direct_readback_ok" -and @($provider.missingFields).Count -eq 0 -and $provider.lastSeen -and $provider.verifierReadback -and $provider.verifierReadback.ok -eq $true -and [int]$provider.verifierReadback.sourceEventTotal -gt 0) + $providerReadback = if ($provider -and $provider.PSObject.Properties["verifierReadback"]) { $provider.verifierReadback } else { $null } + $providerRows = if ($providerReadback -and $providerReadback.PSObject.Properties["providerReadbacks"]) { @($providerReadback.providerReadbacks) } else { @() } + $requiredProviders = @("sentry", "signoz") + $providerNames = @($providerRows | ForEach-Object { ([string]$_.provider).Trim().ToLowerInvariant() } | Select-Object -Unique) + $requiredProvidersPresent = [bool]( + $providerRows.Count -eq $requiredProviders.Count -and + @($requiredProviders | Where-Object { $_ -notin $providerNames }).Count -eq 0 + ) + $providerRowsFresh = [bool]( + $requiredProvidersPresent -and + @($providerRows | Where-Object { -not $_.readbackOk -or -not $_.fresh -or $null -eq $_.ageMinutes -or $_.ageMinutes -gt $_.providerWindowMinutes }).Count -eq 0 + ) + $providerSpecificReadback = [bool]( + $providerReadback -and + $providerReadback.schemaVersion -eq "agent99_provider_freshness_readback_v2" -and + $providerReadback.readbackScope -eq "provider_specific_coverage" -and + $providerReadback.genericRecurrenceAccepted -eq $false + ) + $directOk = [bool]( + $provider -and + $provider.status -eq "direct_readback_ok" -and + @($provider.missingFields).Count -eq 0 -and + $provider.lastSeen -and + $providerSpecificReadback -and + $providerReadback.ok -eq $true -and + $providerReadback.allProvidersFresh -eq $true -and + @($providerReadback.staleProviders).Count -eq 0 -and + $providerRowsFresh + ) $checks += New-AgentOutcomeCheck "provider_freshness_direct_readback" $directOk $true ([string]$provider.status) + $checks += New-AgentOutcomeCheck "provider_freshness_sentry_signoz_specific" $providerSpecificReadback $true "providers=$($providerNames -join ',')" + $checks += New-AgentOutcomeCheck "provider_freshness_window_verified" $providerRowsFresh $true "stale=$(@($providerReadback.staleProviders) -join ',')" + if ($directOk) { + $modeEvidenceRefs["provider_freshness_evidence_ref"] = "provider-coverage:sentry,signoz:$([string]$provider.lastSeen)" + } $modeVerifierPassed = $directOk } "SecurityTriage" { @@ -5065,21 +5103,57 @@ function Invoke-AgentOutcomeContractSelfTest { public = @([pscustomobject]@{ url = "https://example.invalid"; status = 502; non502 = $false; ok = $false }) } } - $providerEvidence = [pscustomobject]@{ - exists = $true - path = "selftest-provider.json" - lastWriteTime = (Get-Date -Format o) - parseError = $null - data = [pscustomobject]@{ - mode = "ProviderFreshness" - providerFreshness = [pscustomobject]@{ - status = "direct_readback_ok_no_source_events" - missingFields = @("lastSeen") - lastSeen = $null - verifierReadback = [pscustomobject]@{ ok = $true; sourceEventTotal = 0 } + $providerFixtureNow = [DateTimeOffset]::Parse("2026-07-16T14:00:00Z").UtcDateTime + function New-AgentProviderSelfTestResponse { + param([string]$Provider, [string]$LatestReceivedAt, [int]$SourceCount) + [pscustomobject]@{ + provider = $Provider + ok = $true + directReference = "https://example.invalid/coverage?provider=$Provider" + httpStatus = 200 + response = [pscustomobject]@{ + summary = [pscustomobject]@{ + source_count = $SourceCount + latest_received_at = $LatestReceivedAt + } } } } + function New-AgentProviderSelfTestEvidence { + param([string]$Path, [object]$Readback) + $missing = @($Readback.providerReadbacks | Where-Object { -not $_.fresh } | ForEach-Object { + "providerFreshness:$([string]$_.provider):$([string]$_.reason)" + }) + [pscustomobject]@{ + exists = $true + path = $Path + lastWriteTime = (Get-Date -Format o) + parseError = $null + data = [pscustomobject]@{ + mode = "ProviderFreshness" + providerFreshness = [pscustomobject]@{ + status = if ($Readback.ok -and $Readback.allProvidersFresh) { "direct_readback_ok" } else { "provider_freshness_degraded" } + missingFields = $missing + lastSeen = $Readback.latestReceivedAt + verifierReadback = $Readback + } + } + } + } + $providerMixedReadback = Convert-AgentProviderFreshnessCoverageReadback -ProviderResponses @( + (New-AgentProviderSelfTestResponse "sentry" "2026-07-16T13:50:00Z" 20), + (New-AgentProviderSelfTestResponse "signoz" "2026-07-16T10:00:00Z" 20) + ) -LookbackMinutes 120 -NowUtc $providerFixtureNow + $providerFreshReadback = Convert-AgentProviderFreshnessCoverageReadback -ProviderResponses @( + (New-AgentProviderSelfTestResponse "sentry" "2026-07-16T13:50:00Z" 20), + (New-AgentProviderSelfTestResponse "signoz" "2026-07-16T13:40:00Z" 20) + ) -LookbackMinutes 120 -NowUtc $providerFixtureNow + $providerGenericReadback = Convert-AgentProviderFreshnessCoverageReadback -ProviderResponses @( + (New-AgentProviderSelfTestResponse "recurrence" "2026-07-16T13:59:00Z" 999) + ) -LookbackMinutes 120 -NowUtc $providerFixtureNow + $providerMixedEvidence = New-AgentProviderSelfTestEvidence "selftest-provider-mixed.json" $providerMixedReadback + $providerFreshEvidence = New-AgentProviderSelfTestEvidence "selftest-provider-fresh.json" $providerFreshReadback + $providerGenericEvidence = New-AgentProviderSelfTestEvidence "selftest-provider-generic.json" $providerGenericReadback $securityEvidence = [pscustomobject]@{ exists = $true path = "selftest-security.json" @@ -5097,7 +5171,9 @@ function Invoke-AgentOutcomeContractSelfTest { $alertTransportFailedVerifier = Get-AgentOutcomeContract "PublicSmoke" 7 $publicOkEvidence $startedAt $true $false "" "mode_verifier_can_resolve" $failedPostCondition = Get-AgentOutcomeContract "PublicSmoke" 0 $publicFailedEvidence $startedAt $false $transportFailed = Get-AgentOutcomeContract "PublicSmoke" 7 $publicOkEvidence $startedAt $false - $providerDegraded = Get-AgentOutcomeContract "ProviderFreshness" 0 $providerEvidence $startedAt $true $false + $providerMixedDegraded = Get-AgentOutcomeContract "ProviderFreshness" 0 $providerMixedEvidence $startedAt $true $false "" "mode_verifier_can_resolve" + $providerFreshResolved = Get-AgentOutcomeContract "ProviderFreshness" 0 $providerFreshEvidence $startedAt $true $false "" "mode_verifier_can_resolve" + $providerGenericDegraded = Get-AgentOutcomeContract "ProviderFreshness" 0 $providerGenericEvidence $startedAt $true $false "" "mode_verifier_can_resolve" $securityCandidate = Get-AgentOutcomeContract "SecurityTriage" 0 $securityEvidence $startedAt $true $false $ok = [bool]( $operatorResolved.state -eq "resolved" -and $operatorResolved.verifierPassed -and @@ -5106,7 +5182,9 @@ function Invoke-AgentOutcomeContractSelfTest { $alertTransportFailedVerifier.state -eq "failed" -and -not $alertTransportFailedVerifier.sourceEventResolved -and $failedPostCondition.state -eq "degraded" -and -not $failedPostCondition.verifierPassed -and $transportFailed.state -eq "failed" -and - $providerDegraded.state -eq "degraded" -and + $providerMixedDegraded.state -eq "degraded" -and -not $providerMixedDegraded.verifierPassed -and + $providerFreshResolved.state -eq "resolved" -and $providerFreshResolved.verifierPassed -and $providerFreshResolved.sourceEventResolved -and + $providerGenericDegraded.state -eq "degraded" -and -not $providerGenericDegraded.verifierPassed -and $securityCandidate.state -eq "candidate_ready" ) $result = [pscustomobject]@{ @@ -5120,7 +5198,9 @@ function Invoke-AgentOutcomeContractSelfTest { alertTransportFailedVerifier = $alertTransportFailedVerifier failedPostCondition = $failedPostCondition transportFailed = $transportFailed - providerDegraded = $providerDegraded + providerMixedDegraded = $providerMixedDegraded + providerFreshResolved = $providerFreshResolved + providerGenericDegraded = $providerGenericDegraded securityCandidate = $securityCandidate } } @@ -6359,48 +6439,150 @@ function Invoke-AgentSecurityTriage { $triage } +function Convert-AgentProviderFreshnessCoverageReadback { + param( + [object[]]$ProviderResponses, + [int]$LookbackMinutes, + [datetime]$NowUtc = [datetime]::UtcNow + ) + + $requiredProviders = @("sentry", "signoz") + $effectiveNowUtc = $NowUtc.ToUniversalTime() + $providerReadbacks = @() + foreach ($provider in $requiredProviders) { + $entryRows = @($ProviderResponses | Where-Object { + $_ -and ([string]$_.provider).Trim().ToLowerInvariant() -eq $provider + } | Select-Object -First 1) + $entry = if ($entryRows.Count -gt 0) { $entryRows[0] } else { $null } + $response = if ($entry -and $entry.PSObject.Properties["response"]) { $entry.response } else { $null } + $summary = if ($response -and $response.PSObject.Properties["summary"]) { $response.summary } else { $null } + $latestRaw = if ($summary -and $summary.PSObject.Properties["latest_received_at"]) { [string]$summary.latest_received_at } else { "" } + $sourceCount = if ($summary -and $summary.PSObject.Properties["source_count"]) { [int]$summary.source_count } else { 0 } + $readbackOk = [bool]($entry -and $entry.ok -eq $true -and $response) + $latestUtc = $null + $latestValid = $false + if ($latestRaw) { + $parsedTimestamp = [DateTimeOffset]::MinValue + if ([DateTimeOffset]::TryParse($latestRaw, [ref]$parsedTimestamp)) { + $latestUtc = $parsedTimestamp.UtcDateTime + $latestValid = $true + } + } + $ageMinutes = if ($latestValid) { [math]::Round(($effectiveNowUtc - $latestUtc).TotalMinutes, 2) } else { $null } + $fresh = [bool]( + $readbackOk -and + $sourceCount -gt 0 -and + $latestValid -and + $ageMinutes -ge -5 -and + $ageMinutes -le $LookbackMinutes + ) + $reason = if (-not $entry) { + "provider_readback_missing" + } elseif (-not $readbackOk) { + "provider_readback_failed" + } elseif ($sourceCount -le 0) { + "provider_source_events_missing" + } elseif (-not $latestRaw) { + "latest_received_at_missing" + } elseif (-not $latestValid) { + "latest_received_at_invalid" + } elseif ($ageMinutes -lt -5) { + "latest_received_at_in_future" + } elseif ($ageMinutes -gt $LookbackMinutes) { + "provider_freshness_window_exceeded" + } else { + "fresh" + } + $directReference = if ($entry -and $entry.PSObject.Properties["directReference"] -and $entry.directReference) { + [string]$entry.directReference + } else { + "https://awoooi.wooo.work/api/v1/platform/events/dossier/coverage?project_id=awoooi&provider=$provider&limit=20" + } + $providerReadbacks += [pscustomobject]@{ + provider = $provider + readbackOk = $readbackOk + fresh = $fresh + reason = $reason + providerWindowMinutes = $LookbackMinutes + latestReceivedAt = if ($latestRaw) { $latestRaw } else { $null } + ageMinutes = $ageMinutes + sourceCount = $sourceCount + directReference = $directReference + httpStatus = if ($entry -and $entry.PSObject.Properties["httpStatus"]) { $entry.httpStatus } else { $null } + errorType = if ($entry -and $entry.PSObject.Properties["errorType"]) { [string]$entry.errorType } else { $null } + } + } + + $readbackOk = [bool]( + $providerReadbacks.Count -eq $requiredProviders.Count -and + @($providerReadbacks | Where-Object { -not $_.readbackOk }).Count -eq 0 + ) + $allProvidersFresh = [bool]( + $readbackOk -and + @($providerReadbacks | Where-Object { -not $_.fresh }).Count -eq 0 + ) + $staleProviders = @($providerReadbacks | Where-Object { -not $_.fresh } | ForEach-Object { [string]$_.provider }) + $missingProviders = @($providerReadbacks | Where-Object { $_.reason -in @("provider_readback_missing", "provider_source_events_missing", "latest_received_at_missing") } | ForEach-Object { [string]$_.provider }) + $datedReadbacks = @($providerReadbacks | Where-Object { $null -ne $_.ageMinutes } | Sort-Object ageMinutes -Descending) + $oldestLatestReceivedAt = if ($datedReadbacks.Count -gt 0) { [string]$datedReadbacks[0].latestReceivedAt } else { $null } + $sourceEventTotal = [int](($providerReadbacks | Measure-Object -Property sourceCount -Sum).Sum) + + [pscustomobject]@{ + schemaVersion = "agent99_provider_freshness_readback_v2" + ok = $readbackOk + allProvidersFresh = $allProvidersFresh + verifier = "awoooi-platform-events-provider-coverage" + readbackScope = "provider_specific_coverage" + requiredProviders = $requiredProviders + providerReadbacks = $providerReadbacks + staleProviders = $staleProviders + missingProviders = $missingProviders + providerWindowMinutes = $LookbackMinutes + latestReceivedAt = $oldestLatestReceivedAt + sourceEventTotal = $sourceEventTotal + directReferences = @($providerReadbacks | ForEach-Object { [string]$_.directReference }) + directReference = "https://awoooi.wooo.work/api/v1/platform/events/dossier/coverage?project_id=awoooi&provider={sentry|signoz}&limit=20" + checkedAt = $effectiveNowUtc.ToString("o") + genericRecurrenceAccepted = $false + } +} + function Invoke-AgentProviderFreshnessDirectReadback { param( [int]$LookbackMinutes ) - $baseUrl = "https://awoooi.wooo.work/api/v1/platform/events/dossier/recurrence?project_id=awoooi&limit=20" $startedAt = Get-Date - try { - $response = Invoke-RestMethod -Method Get -Uri $baseUrl -TimeoutSec 12 - $summary = if ($response -and $response.PSObject.Properties["summary"]) { $response.summary } else { $null } - $latest = if ($summary -and $summary.PSObject.Properties["latest_received_at"]) { $summary.latest_received_at } else { $null } - $sourceTotal = if ($summary -and $summary.PSObject.Properties["source_event_total"]) { [int]$summary.source_event_total } else { 0 } - $itemsCount = if ($response -and $response.PSObject.Properties["items"]) { @($response.items).Count } else { 0 } - return [pscustomobject]@{ - ok = $true - verifier = "awoooi-platform-events-recurrence" - directReference = $baseUrl - providerWindowMinutes = $LookbackMinutes - latestReceivedAt = $latest - sourceEventTotal = $sourceTotal - recurrenceGroupTotal = if ($summary -and $summary.PSObject.Properties["recurrence_group_total"]) { [int]$summary.recurrence_group_total } else { 0 } - itemsCount = $itemsCount - httpStatus = 200 - durationSeconds = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 2) - checkedAt = (Get-Date -Format o) - } - } catch { - return [pscustomobject]@{ - ok = $false - verifier = "awoooi-platform-events-recurrence" - directReference = $baseUrl - providerWindowMinutes = $LookbackMinutes - latestReceivedAt = $null - sourceEventTotal = 0 - recurrenceGroupTotal = 0 - itemsCount = 0 - httpStatus = $null - durationSeconds = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 2) - checkedAt = (Get-Date -Format o) - error = ($_ | Out-String).Trim() + $providerResponses = @() + foreach ($provider in @("sentry", "signoz")) { + $providerUrl = "https://awoooi.wooo.work/api/v1/platform/events/dossier/coverage?project_id=awoooi&provider=$provider&limit=20" + try { + $response = Invoke-RestMethod -Method Get -Uri $providerUrl -TimeoutSec 12 + $providerResponses += [pscustomobject]@{ + provider = $provider + ok = $true + response = $response + directReference = $providerUrl + httpStatus = 200 + errorType = $null + } + } catch { + $providerResponses += [pscustomobject]@{ + provider = $provider + ok = $false + response = $null + directReference = $providerUrl + httpStatus = $null + errorType = $_.Exception.GetType().Name + } } } + $readback = Convert-AgentProviderFreshnessCoverageReadback ` + -ProviderResponses $providerResponses ` + -LookbackMinutes $LookbackMinutes ` + -NowUtc ([datetime]::UtcNow) + $readback | Add-Member -NotePropertyName durationSeconds -NotePropertyValue ([math]::Round(((Get-Date) - $startedAt).TotalSeconds, 2)) + $readback } function Invoke-AgentProviderFreshnessTriage { @@ -6487,10 +6669,15 @@ function Invoke-AgentProviderFreshnessTriage { if (-not $latestSeen) { $missingFields += "lastSeen" } if (-not $directReference) { $missingFields += "directReference" } if (-not ($verifierReadback -and $verifierReadback.ok -eq $true)) { $missingFields += "verifierReadback" } - $status = if ($verifierReadback -and $verifierReadback.ok -eq $true -and $latestSeen) { + foreach ($providerRow in @($verifierReadback.providerReadbacks)) { + if (-not $providerRow.fresh) { + $missingFields += "providerFreshness:$([string]$providerRow.provider):$([string]$providerRow.reason)" + } + } + $status = if ($verifierReadback -and $verifierReadback.ok -eq $true -and $verifierReadback.allProvidersFresh -eq $true -and $latestSeen) { "direct_readback_ok" } elseif ($verifierReadback -and $verifierReadback.ok -eq $true) { - "direct_readback_ok_no_source_events" + "provider_freshness_degraded" } elseif (@($hits).Count -gt 0) { "candidate_created_waiting_direct_readback" } else { @@ -6510,8 +6697,11 @@ function Invoke-AgentProviderFreshnessTriage { providerWindowMinutes = $lookbackMinutes lastSeen = $latestSeen directReference = $directReference + providerReadbacks = @($verifierReadback.providerReadbacks) + staleProviders = @($verifierReadback.staleProviders) + allProvidersFresh = [bool]($verifierReadback.allProvidersFresh -eq $true) verifierReadback = $verifierReadback - requiredVerifierReadback = @("provider_window", "last_seen", "direct_reference", "source_trace_or_sentry_signoz_ref") + requiredVerifierReadback = @("sentry_provider_window", "sentry_last_seen", "signoz_provider_window", "signoz_last_seen", "provider_specific_direct_references", "independent_verifier_readback") noFalseGreenRisk = $true allowedActions = @("create_candidate", "read_existing_evidence", "provider_freshness_direct_readback", "request_direct_reference", "write_km") prohibitedActions = @("provider_switch", "paid_model_call", "env_change", "reload") @@ -8125,7 +8315,7 @@ $selfHealthCritical = if ($selfHealth -and $selfHealth.severity -eq "critical") $selfHealthWarning = if ($selfHealth -and $selfHealth.severity -eq "warning") { 1 } else { 0 } $backupCritical = if ($backupHealth -and $backupHealth.severity -eq "critical") { 1 } else { 0 } $backupWarning = if ($backupHealth -and $backupHealth.severity -eq "warning") { 1 } else { 0 } -$providerFreshnessWarning = if ($providerFreshness) { 1 } else { 0 } +$providerFreshnessWarning = if ($providerFreshness -and $providerFreshness.status -ne "direct_readback_ok") { 1 } else { 0 } $securityTriageWarning = if ($securityTriage -and $securityTriage.hitCount -gt 0) { 1 } else { 0 } $summarySeverity = if ($publicFailures -gt 0 -or $aiFailures -gt 0 -or $perfCritical -gt 0 -or $selfHealthCritical -gt 0 -or $backupCritical -gt 0) { "critical" } elseif ($perfWarning -gt 0 -or $selfHealthWarning -gt 0 -or $backupWarning -gt 0 -or $providerFreshnessWarning -gt 0 -or $securityTriageWarning -gt 0) { "warning" } else { "info" } if ($Mode -eq "Recover") { diff --git a/agent99-synthetic-tests.ps1 b/agent99-synthetic-tests.ps1 index 7f479f933..817cc2934 100644 --- a/agent99-synthetic-tests.ps1 +++ b/agent99-synthetic-tests.ps1 @@ -50,7 +50,8 @@ foreach ($functionName in @( "Get-AgentHost112TimeoutContract", "Convert-AgentHost112GuestReadback", "Invoke-AgentHost112GuestRecovery", - "Convert-AgentAwoooDeploymentReadback" + "Convert-AgentAwoooDeploymentReadback", + "Convert-AgentProviderFreshnessCoverageReadback" )) { $definition = $ast.Find({ param($node) @@ -106,6 +107,56 @@ if ($formatFunctions.ContainsKey("Format-AgentTelegramText")) { Add-SyntheticCheck "telegram:formatter" $false "Format-AgentTelegramText not found" } +if ($formatFunctions.ContainsKey("Convert-AgentProviderFreshnessCoverageReadback")) { + function New-ProviderFreshnessFixtureResponse { + param([string]$Provider, [string]$LatestReceivedAt, [int]$SourceCount) + [pscustomobject]@{ + provider = $Provider + ok = $true + directReference = "https://example.invalid/coverage?provider=$Provider" + httpStatus = 200 + response = [pscustomobject]@{ + summary = [pscustomobject]@{ + source_count = $SourceCount + latest_received_at = $LatestReceivedAt + } + } + } + } + $providerNow = [DateTimeOffset]::Parse("2026-07-16T14:00:00Z").UtcDateTime + $providerMixed = Convert-AgentProviderFreshnessCoverageReadback -ProviderResponses @( + (New-ProviderFreshnessFixtureResponse "sentry" "2026-07-16T13:50:00Z" 20), + (New-ProviderFreshnessFixtureResponse "signoz" "2026-07-16T10:00:00Z" 20) + ) -LookbackMinutes 120 -NowUtc $providerNow + Add-SyntheticCheck "provider_freshness:mixed_fresh_stale_degraded" ( + $providerMixed.ok -and + -not $providerMixed.allProvidersFresh -and + @($providerMixed.staleProviders | Where-Object { $_ -eq "signoz" }).Count -eq 1 + ) "Sentry fresh plus SignOz stale stays degraded" + + $providerFresh = Convert-AgentProviderFreshnessCoverageReadback -ProviderResponses @( + (New-ProviderFreshnessFixtureResponse "sentry" "2026-07-16T13:50:00Z" 20), + (New-ProviderFreshnessFixtureResponse "signoz" "2026-07-16T13:40:00Z" 20) + ) -LookbackMinutes 120 -NowUtc $providerNow + Add-SyntheticCheck "provider_freshness:both_fresh_resolved" ( + $providerFresh.ok -and + $providerFresh.allProvidersFresh -and + @($providerFresh.staleProviders).Count -eq 0 + ) "Sentry and SignOz must both satisfy the configured freshness window" + + $providerGeneric = Convert-AgentProviderFreshnessCoverageReadback -ProviderResponses @( + (New-ProviderFreshnessFixtureResponse "recurrence" "2026-07-16T13:59:00Z" 999) + ) -LookbackMinutes 120 -NowUtc $providerNow + Add-SyntheticCheck "provider_freshness:generic_recurrence_not_resolved" ( + -not $providerGeneric.ok -and + -not $providerGeneric.allProvidersFresh -and + $providerGeneric.genericRecurrenceAccepted -eq $false -and + @($providerGeneric.missingProviders).Count -eq 2 + ) "generic unrelated recurrence evidence cannot resolve provider freshness" +} else { + Add-SyntheticCheck "provider_freshness:provider_specific_parser" $false "Convert-AgentProviderFreshnessCoverageReadback not found" +} + if ($formatFunctions.ContainsKey("Convert-AgentRecoveryReadback")) { $fixtureEpoch = [DateTimeOffset]::Now.ToUnixTimeSeconds() $fixtureConfig = [pscustomobject]@{ diff --git a/apps/api/src/services/backup_restore_signal_automation.py b/apps/api/src/services/backup_restore_signal_automation.py index cc844095d..dc056241d 100644 --- a/apps/api/src/services/backup_restore_signal_automation.py +++ b/apps/api/src/services/backup_restore_signal_automation.py @@ -40,7 +40,7 @@ BACKUP_RESTORE_POLICY_SOURCE_HASH = ( ) _TOP_EVIDENCE_RE = re.compile( - r"\s*Top evidence\s*(?P.*?)(?:\s*建議下一步\s*|$)", + r"\s*Top evidence\s*(?P.*?)(?:\s*(?:建議下一步|AI 自動處置佇列|禁止事項)\s*|$)", re.IGNORECASE | re.DOTALL, ) _TAG_RE = re.compile(r"<[^>]+>") diff --git a/apps/api/src/services/controlled_alert_target_router.py b/apps/api/src/services/controlled_alert_target_router.py index e18e80b6b..72250be40 100644 --- a/apps/api/src/services/controlled_alert_target_router.py +++ b/apps/api/src/services/controlled_alert_target_router.py @@ -63,6 +63,10 @@ _NODE_EXPORTER_ALERTS = { "nodeexporterunhealthy", } _ALERTMANAGER_DELIVERY_ALERTS = {"alertchainbrokenalertmanager"} +_PROVIDER_FRESHNESS_ALERTS = { + "providerfreshnesssignal", + "sourceprovideringestionstale", +} _OLLAMA_LOCAL_RUNTIME_ALERTS = { "ollamalocalunavailable", "ollama111unavailable", @@ -429,6 +433,46 @@ def resolve_typed_alert_target( execution_role="single_writer_alert_chain_container_executor", ) + if ( + compact_alert in _PROVIDER_FRESHNESS_ALERTS + or "source_provider_freshness_triage" in text + or "alertchain_provider_freshness" in text + ): + provider = _text_token( + str( + labels.get("source") + or labels.get("provider") + or labels.get("service") + or "" + ) + ) + if provider not in {"sentry", "signoz"}: + provider = "sentry-signoz" + route = _typed_route( + resolution_status="resolved", + target_kind="observability_source_ingestion", + target_resource=target_resource or "source-provider-ingestion", + namespace=namespace, + canonical_asset_id=f"observability-provider:{provider}", + host=None, + executor="provider_freshness_readonly_executor", + verifier="provider_freshness_independent_verifier", + risk_class="low", + controlled_apply_allowed=True, + execution_role=( + "single_writer_observability_source_ingestion_readonly_executor" + ), + ) + route["provider_freshness_contract"] = { + "schema_version": "provider_freshness_typed_route_v1", + "execution_mode": "read_only", + "required_providers": ["sentry", "signoz"], + "allowed_actions": ["provider_specific_coverage_readback"], + "mutating_actions_allowed": False, + "generic_recurrence_can_resolve": False, + } + return route + if compact_alert in _OLLAMA_LOCAL_RUNTIME_ALERTS: return _typed_route( resolution_status="resolved", diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index da5548d1b..7b4191aa5 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -469,13 +469,13 @@ _AI_OPS_SIGNAL_SPECS: tuple[AiOpsSignalSpec, ...] = ( ), gate_tokens=("stale", "freshness", "window", "provider", "ingestion", "timeout"), impact="觀測來源可能過期或 ingestion 中斷;不能把 route 200 或 dashboard up 當健康", - next_step="建立 provider freshness candidate,要求 provider window、last seen、direct ref 與 verifier readback", + next_step="同一 run 讀回 Sentry / SignOz provider coverage、計算 freshness age,接 independent verifier / writeback", recommendation_lines=( - "├ 對齊 Sentry / SigNoz / provider freshness window 與 direct reference", - "├ 標出缺資料、過期、timeout 與 no-false-green 風險", - "└ 回寫報表資料信任度與 AgentOps readiness,不直接切 provider", + "├ Evidence:分別驗證 Sentry / SignOz provider window、last seen 與 direct reference", + "├ Verify:兩者都 fresh 才能 resolved;任一 stale / timeout 保持 degraded 並自動重試", + "└ Writeback:回寫 incident、KM/RAG/MCP/PlayBook receipt;不直接切 provider", ), - forbidden_summary="不切 provider、不呼叫付費模型、不改 env、不 reload;先補 freshness evidence。", + forbidden_summary="不切 provider、不呼叫付費模型、不改 env、不 reload;generic recurrence 不得作為 resolved 證據。", ), AiOpsSignalSpec( event_type="supply_chain_drift", @@ -596,8 +596,16 @@ def _aiops_signal_evidence_lines(text: str) -> list[str]: return lines -def format_aiops_signal_alert_card(text: str) -> str: - """把資安 / 維運 raw signal 轉成 AI 自動化事件包。""" +def format_aiops_signal_alert_card( + text: str, + *, + lifecycle: Mapping[str, object] | None = None, +) -> str: + """把 raw signal 轉成 fail-closed AI lifecycle 首卡。 + + 沒有 durable run / executor / verifier receipt 時只能顯示 partial;首卡不是 + terminal,也不得把自動化缺口包裝成人工「建議下一步」。 + """ spec = _classify_aiops_signal_alert(text) if spec is None: return text @@ -605,6 +613,82 @@ def format_aiops_signal_alert_card(text: str) -> str: target = _aiops_signal_target(text, spec) severity = _aiops_signal_severity(text, spec) evidence_lines = _aiops_signal_evidence_lines(text) + queued_action_lines = tuple( + f"├{line[1:]}" if line.startswith("└") else line + for line in spec.recommendation_lines + ) + lifecycle_data = lifecycle if isinstance(lifecycle, Mapping) else {} + provided_run_id = str(lifecycle_data.get("run_id") or "").strip() + provided_receipt_status = str( + lifecycle_data.get("receipt_status") or "" + ).strip() + provided_verifier_status = str( + lifecycle_data.get("verifier_status") or "" + ).strip() + provided_lifecycle_status = str( + lifecycle_data.get("status") or "" + ).strip() + requested_terminal = bool( + lifecycle_data.get("terminal") is True + or provided_lifecycle_status.lower() + in {"completed", "closed", "resolved", "verified_resolved"} + ) + terminal = bool( + requested_terminal + and provided_run_id + and provided_receipt_status + and "pending" not in provided_receipt_status.lower() + and provided_verifier_status + and "pending" not in provided_verifier_status.lower() + ) + lifecycle_status = str( + ( + provided_lifecycle_status + if not requested_terminal or terminal + else "partial" + ) + or "partial" + ).strip() + receipt_status = str( + ( + provided_receipt_status + if not requested_terminal or terminal + else "receipt_pending" + ) + or "receipt_pending" + ).strip() + run_id = provided_run_id or "receipt_pending" + check_status = str( + lifecycle_data.get("check_status") + or "same_run_check_running_receipt_pending" + ).strip() + provided_execution_summary = str( + lifecycle_data.get("execution_summary") or "" + ).strip() + execution_summary = str( + ( + provided_execution_summary + if not requested_terminal or terminal + else "" + ) + or ( + "已收到 terminal receipt" + if terminal + else "已建立 Run/AI check執行中" + ) + ).strip() + verifier_status = str( + ( + provided_verifier_status + if not requested_terminal or terminal + else "" + ) + or ( + "same_run_terminal_receipt_received" + if terminal + else "same_run_verifier_receipt_pending" + ) + ).strip() gate_line = ( "├ Gate:controlled_playbook_queue=readback_only / " "runtime_write_gate=0_until_verifier" @@ -622,18 +706,22 @@ def format_aiops_signal_alert_card(text: str) -> str: f"├ 事件類型:{html.escape(spec.event_type)}", f"├ Target:{html.escape(target)}", f"├ 影響:{html.escape(spec.impact)}", - "└ 產品判讀:raw_signal_normalized / controlled_runtime_candidate", + f"└ 自動化狀態:{html.escape(lifecycle_status)} / {html.escape(receipt_status)}", "", "AI 自動化判讀", f"├ Lane:{html.escape(spec.lane)}", gate_line, - f"└ 下一步:{html.escape(spec.next_step)}", + f"├ Run:{html.escape(run_id)}", + f"└ 執行:{html.escape(execution_summary)}", "", "Top evidence", *evidence_lines, "", - "建議下一步", - *spec.recommendation_lines, + "AI 自動處置佇列", + f"├ Check:{html.escape(check_status)}", + f"├ 自動後續:{html.escape(spec.next_step)}", + *queued_action_lines, + f"└ Verify / Writeback:{html.escape(verifier_status)};未收到獨立 verifier receipt 前保持 partial", "", "禁止事項", f"└ {html.escape(spec.forbidden_summary)}", diff --git a/apps/api/tests/test_agent99_control_plane_outcome_contract.py b/apps/api/tests/test_agent99_control_plane_outcome_contract.py index 1c53cd96f..249860762 100644 --- a/apps/api/tests/test_agent99_control_plane_outcome_contract.py +++ b/apps/api/tests/test_agent99_control_plane_outcome_contract.py @@ -67,6 +67,43 @@ def test_agent99_outcome_contract_has_runtime_self_test() -> None: assert '$securityCandidate.state -eq "candidate_ready"' in source +def test_agent99_provider_freshness_is_provider_specific_and_fail_closed() -> None: + source = _CONTROL_PLANE.read_text(encoding="utf-8") + converter = source[ + source.index("function Convert-AgentProviderFreshnessCoverageReadback") : + source.index("function Invoke-AgentProviderFreshnessDirectReadback") + ] + direct_readback = source[ + source.index("function Invoke-AgentProviderFreshnessDirectReadback") : + source.index("function Invoke-AgentProviderFreshnessTriage") + ] + synthetic = ( + _REPO_ROOT / "agent99-synthetic-tests.ps1" + ).read_text(encoding="utf-8") + + assert '$requiredProviders = @("sentry", "signoz")' in converter + assert 'schemaVersion = "agent99_provider_freshness_readback_v2"' in converter + assert 'readbackScope = "provider_specific_coverage"' in converter + assert "genericRecurrenceAccepted = $false" in converter + assert "ageMinutes -le $LookbackMinutes" in converter + assert "allProvidersFresh = $allProvidersFresh" in converter + assert "/dossier/coverage?" in direct_readback + assert "provider=$provider" in direct_readback + assert "/dossier/recurrence" not in direct_readback + assert '$providerReadback.allProvidersFresh -eq $true' in source + assert 'provider_freshness_evidence_ref' in source + + for case in ( + "provider_freshness:mixed_fresh_stale_degraded", + "provider_freshness:both_fresh_resolved", + "provider_freshness:generic_recurrence_not_resolved", + ): + assert case in synthetic + assert '$providerMixedDegraded.state -eq "degraded"' in source + assert '$providerFreshResolved.state -eq "resolved"' in source + assert '$providerGenericDegraded.state -eq "degraded"' in source + + def test_agent99_posts_fenced_outcome_to_production_ingestion() -> None: source = _CONTROL_PLANE.read_text(encoding="utf-8") inbox = _SRE_INBOX.read_text(encoding="utf-8") diff --git a/apps/api/tests/test_sre_typed_domain_router.py b/apps/api/tests/test_sre_typed_domain_router.py index c1a51f7bc..64c23f982 100644 --- a/apps/api/tests/test_sre_typed_domain_router.py +++ b/apps/api/tests/test_sre_typed_domain_router.py @@ -124,6 +124,31 @@ def test_other_alert_chain_domains_do_not_fall_into_alertmanager_container() -> assert route["cross_domain_fallback_allowed"] is False +def test_provider_freshness_routes_to_read_only_observability_lane() -> None: + route = resolve_typed_alert_target( + alertname="SourceProviderIngestionStale", + target_resource="source-ingestion", + namespace="monitoring", + labels={"component": "source-ingestion", "source": "signoz"}, + alert_category="alertchain_provider_freshness", + ) + + assert route["resolution_status"] == "resolved" + assert route["target_kind"] == "observability_source_ingestion" + assert route["canonical_asset_id"] == "observability-provider:signoz" + assert route["executor"] == "provider_freshness_readonly_executor" + assert route["verifier"] == "provider_freshness_independent_verifier" + assert route["risk_class"] == "low" + assert route["controlled_apply_allowed"] is True + assert route["cross_domain_fallback_allowed"] is False + assert route["agent99_bridge"]["dispatch_allowed"] is False + contract = route["provider_freshness_contract"] + assert contract["execution_mode"] == "read_only" + assert contract["required_providers"] == ["sentry", "signoz"] + assert contract["mutating_actions_allowed"] is False + assert contract["generic_recurrence_can_resolve"] is False + + def _sentry_runtime_incident() -> SimpleNamespace: return SimpleNamespace( incident_id="INC-20260714-471307", diff --git a/apps/api/tests/test_telegram_gateway_error_sanitizer.py b/apps/api/tests/test_telegram_gateway_error_sanitizer.py index b3c22ae76..1b666dcfb 100644 --- a/apps/api/tests/test_telegram_gateway_error_sanitizer.py +++ b/apps/api/tests/test_telegram_gateway_error_sanitizer.py @@ -266,6 +266,16 @@ Authorization: Bearer abcdefghijklmnopqrstuvwxyz """ card = format_aiops_signal_alert_card(raw_alert) + assert "已建立 Run/AI check執行中" in card + assert "partial" in card + assert "receipt_pending" in card + assert "same_run_check_running_receipt_pending" in card + assert "same_run_verifier_receipt_pending" in card + assert "AI 自動處置佇列" in card + assert "兩者都 fresh 才能 resolved" in card + assert "generic recurrence 不得作為 resolved 證據" in card + assert "建議下一步" not in card + payload = _agent99_alert_from_ai_automation_card( card, source="unit-test", diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index 51b0570a3..91fc24524 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -299,6 +299,13 @@ def test_aiops_signal_formatter_covers_non_host_alert_lanes( assert "ai_automation_alert_card_v1" in result assert event_type in result assert lane in result + assert "已建立 Run/AI check執行中" in result + assert "partial" in result + assert "receipt_pending" in result + assert "same_run_check_running_receipt_pending" in result + assert "same_run_verifier_receipt_pending" in result + assert "AI 自動處置佇列" in result + assert "建議下一步" not in result assert "controlled_playbook_queue" in result if event_type == "backup_restore_escrow_signal": assert "controlled_playbook_queue=readback_only" in result @@ -310,6 +317,23 @@ def test_aiops_signal_formatter_covers_non_host_alert_lanes( assert "禁止事項" in result +def test_aiops_signal_terminal_claim_requires_complete_same_run_receipts() -> None: + result = format_aiops_signal_alert_card( + 'alertname="SourceProviderIngestionStale" source="signoz" stale', + lifecycle={ + "status": "resolved", + "terminal": True, + "execution_summary": "resolved without receipts", + }, + ) + + assert "已建立 Run/AI check執行中" in result + assert "resolved without receipts" not in result + assert "partial / receipt_pending" in result + assert "same_run_verifier_receipt_pending" in result + assert "建議下一步" not in result + + @pytest.mark.asyncio async def test_send_alert_notification_normalizes_host_resource_raw_dump(monkeypatch) -> None: """send_alert_notification 是最後出口,必須自動套用 AI 自動化事件包。"""