mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d43cfe2bb3 | |||
| 78cc038426 | |||
| ff8b847dc0 | |||
| aab7ae12ff | |||
| 935f0e8c71 | |||
| 541dc32ffd | |||
| 022cc0354b | |||
| 3aa8b95676 | |||
| ec945a93d2 | |||
| b36cf8ff36 | |||
| c6ee7808f1 | |||
| b61d7023bc | |||
| e1b03de5b3 | |||
| 25bac34acb | |||
| c0be389a95 | |||
| 91f9d656ae | |||
| b1e7077d0a | |||
| d8c84f41c1 | |||
| 12b51bbc24 | |||
| a369ff99cd | |||
| 7a131af74d | |||
| e750cfe9dd | |||
| 3629728d93 | |||
| e521fd5cf4 | |||
| 0ed1e6f542 | |||
| 522911c5a7 | |||
| 0bfe1e327b | |||
| 2cf7216be5 | |||
| 587adc5ddf | |||
| 84bf2bc2fd | |||
| dcf1b1c227 | |||
| 6ebdfa41af | |||
| a2c4949473 | |||
| 81f4a55d88 | |||
| 4d3d3c56f4 |
+1
-1
Submodule .claude/commands updated: 72383edb7c...3f98090747
@@ -0,0 +1,367 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Publishes the customer-facing PowerShell scripts behind download.autopilotmonitor.com/agent/.
|
||||
|
||||
.DESCRIPTION
|
||||
Single owner of the script-publishing chain. Called from two places:
|
||||
|
||||
* publish-scripts.yml -- on every push to main that touches the sources,
|
||||
so a script fix reaches customers without waiting
|
||||
for an agent release.
|
||||
* build-agent.yml -- on a publish_as_stable run, so a release always
|
||||
lands a consistent set (ZIP + manifest + scripts).
|
||||
|
||||
Both callers run the same code. The scripts on the download alias are the live
|
||||
copy the WDP bootstrap MSI (Invoke-BootstrapDownload.ps1) downloads on every
|
||||
enrolling device, so "current" is a correctness property, not cosmetics.
|
||||
|
||||
Steps, in order:
|
||||
1. Parse $ScriptVersion from the bootstrap source.
|
||||
2. Render the -Dev variant by literal substitution of the two URL/manifest
|
||||
defaults. A missing anchor is a hard failure -- a silently un-substituted
|
||||
dev script would point the dev fleet at the stable agent.
|
||||
3. Version-bump guard: if the published bootstrap differs from the source
|
||||
but carries the same $ScriptVersion, abort. Otherwise the docs badge,
|
||||
version.json.bootstrapVersion and the portal's "your script is outdated"
|
||||
hint would all keep asserting a version that is no longer what ships.
|
||||
4. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
place), mirrored fail-soft to the legacy account.
|
||||
5. Reconcile the two version oracles: version.json.bootstrapVersion
|
||||
(read-modify-write under If-Match, so a concurrent agent release cannot
|
||||
lose its agent fields) and AdminConfiguration.LatestBootstrapV2ScriptVersion.
|
||||
6. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
|
||||
.PARAMETER SasToken
|
||||
Container SAS for the eu storage account with write permission. Leading '?' tolerated.
|
||||
|
||||
.PARAMETER LegacySasToken
|
||||
Container SAS for the legacy account. Optional -- a mirror failure warns, never fails.
|
||||
|
||||
.PARAMETER TableSasToken
|
||||
Table SAS for AdminConfiguration. Optional -- omitted or 'XXX' skips the oracle update.
|
||||
|
||||
.PARAMETER RepoRoot
|
||||
Repository root. Defaults to the parent of .github/scripts.
|
||||
|
||||
.PARAMETER DryRun
|
||||
Run every check and render, upload nothing.
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -DryRun
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -SasToken $env:SAS -TableSasToken $env:TABLE_SAS
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$SasToken,
|
||||
[string]$LegacySasToken,
|
||||
[string]$TableSasToken,
|
||||
[string]$RepoRoot,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $RepoRoot) {
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||
}
|
||||
if (-not $DryRun -and [string]::IsNullOrWhiteSpace($SasToken)) {
|
||||
throw 'SasToken is required unless -DryRun is used.'
|
||||
}
|
||||
|
||||
$ContainerUrl = 'https://autopilotmonitoreu.blob.core.windows.net/agent'
|
||||
$LegacyContainerUrl = 'https://autopilotmonitor.blob.core.windows.net/agent'
|
||||
$AliasUrl = 'https://download.autopilotmonitor.com/agent'
|
||||
$TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
|
||||
|
||||
$BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1'
|
||||
$BootstrapBlob = 'Install-AutopilotMonitor.ps1'
|
||||
$DevBlob = 'Install-AutopilotMonitor-Dev.ps1'
|
||||
$ScriptContentType = 'text/plain; charset=utf-8'
|
||||
|
||||
function Get-HttpStatus {
|
||||
param($ErrorRecord)
|
||||
if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) {
|
||||
return [int]$ErrorRecord.Exception.Response.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-Sha256 {
|
||||
param([byte[]]$Bytes)
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() }
|
||||
finally { $sha.Dispose() }
|
||||
}
|
||||
|
||||
# Raw bytes of a published blob, or $null when it does not exist yet.
|
||||
function Get-PublishedBytes {
|
||||
param([string]$Url)
|
||||
try {
|
||||
$resp = Invoke-WebRequest -Uri $Url -Method Get -UseBasicParsing
|
||||
} catch {
|
||||
if ((Get-HttpStatus $_) -eq 404) { return $null }
|
||||
throw
|
||||
}
|
||||
# Comma operator: PowerShell unrolls an array returned from a function into the output
|
||||
# stream and reassembles it as Object[], which would silently defeat every [byte[]] use
|
||||
# downstream. ',' wraps it so the byte[] survives the return.
|
||||
if ($resp.Content -is [byte[]]) { return ,$resp.Content }
|
||||
return ,[System.Text.Encoding]::UTF8.GetBytes([string]$resp.Content)
|
||||
}
|
||||
|
||||
# Deterministic publish form: CRLF, UTF-8 without BOM. Git normalises line endings on
|
||||
# checkout (a Windows runner yields CRLF, a core.autocrlf=false clone yields LF), so
|
||||
# publishing the raw working-copy bytes would emit different blobs for identical source
|
||||
# and make every byte comparison -- guard and verify alike -- environment-dependent.
|
||||
# CRLF is also what PS 5.1 on the device expects and what is published today.
|
||||
function Get-PublishBytes {
|
||||
param([string]$Text)
|
||||
$normalised = $Text.Replace("`r`n", "`n").Replace("`n", "`r`n")
|
||||
# ',' keeps this a byte[]: without it PowerShell unrolls the array into the output stream
|
||||
# and the caller receives Object[]. Invoke-RestMethod then serialises an Object[] body as
|
||||
# space-separated decimals instead of raw bytes -- a silent corruption of the upload.
|
||||
return ,(New-Object System.Text.UTF8Encoding $false).GetBytes($normalised)
|
||||
}
|
||||
|
||||
function Get-BootstrapScriptVersion {
|
||||
param([string]$Content, [string]$Origin, [switch]$Optional)
|
||||
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
|
||||
# Unreadable SOURCE is fatal. An unreadable PUBLISHED copy must not be: that is exactly
|
||||
# the state a republish is meant to repair, and a guard that blocks the repair is worse
|
||||
# than no guard.
|
||||
if ($Optional) { return $null }
|
||||
throw "Could not parse ScriptVersion from $Origin"
|
||||
}
|
||||
|
||||
# What the script actually DOES, with comments and layout removed: the PowerShell token
|
||||
# stream minus comments, newlines and line continuations. Lets the version-bump guard tell
|
||||
# a behaviour change (must be versioned -- consumers act on the version) apart from a
|
||||
# comment fix or reflow (nobody needs to know). Tokenising rather than regex-stripping
|
||||
# keeps '#' inside strings from being mistaken for a comment.
|
||||
function Get-CodeFingerprint {
|
||||
param([string]$Text, [string]$Origin)
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseInput($Text, [ref]$tokens, [ref]$errors) | Out-Null
|
||||
if ($errors -and $errors.Count -gt 0) {
|
||||
throw "Parse error in ${Origin}: $($errors[0])"
|
||||
}
|
||||
$ignored = @('Comment', 'NewLine', 'LineContinuation', 'EndOfInput')
|
||||
return (($tokens | Where-Object { $ignored -notcontains $_.Kind.ToString() } | ForEach-Object { $_.Text }) -join "`n")
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 1. source
|
||||
$sourceContent = Get-Content $BootstrapSource -Raw
|
||||
$sourceBytes = Get-PublishBytes $sourceContent
|
||||
$scriptVersion = Get-BootstrapScriptVersion -Content $sourceContent -Origin $BootstrapSource
|
||||
Write-Host "Bootstrap script version: $scriptVersion"
|
||||
|
||||
# ------------------------------------------------------------------ 2. dev render
|
||||
$stableUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableManLiteral = '$VersionJsonName = "version.json"'
|
||||
$devManLiteral = '$VersionJsonName = "version-dev.json"'
|
||||
|
||||
if ($sourceContent.IndexOf($stableUrlLiteral) -lt 0 -or $sourceContent.IndexOf($stableManLiteral) -lt 0) {
|
||||
throw "Bootstrap dev-render: anchor literal missing in $BootstrapSource"
|
||||
}
|
||||
$devContent = $sourceContent.Replace($stableUrlLiteral, $devUrlLiteral).Replace($stableManLiteral, $devManLiteral)
|
||||
Write-Host "Rendered $DevBlob (dev agent URL + version-dev.json)"
|
||||
|
||||
# ------------------------------------------------------------------ 3. bump guard
|
||||
$publishedBytes = Get-PublishedBytes "$AliasUrl/$BootstrapBlob"
|
||||
if ($null -eq $publishedBytes) {
|
||||
Write-Host 'No published bootstrap yet -- version-bump guard skipped (first publish)'
|
||||
} elseif ((Get-Sha256 $publishedBytes) -eq (Get-Sha256 $sourceBytes)) {
|
||||
Write-Host 'Published bootstrap is already byte-identical to the source'
|
||||
} else {
|
||||
$publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes)
|
||||
$publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BootstrapBlob" -Optional
|
||||
|
||||
if ($null -eq $publishedVersion) {
|
||||
Write-Host "::warning::The published $BootstrapBlob is not a readable bootstrap script (no ScriptVersion found). Republishing over it."
|
||||
} elseif ($publishedVersion -ne $scriptVersion) {
|
||||
Write-Host "Version bump: $publishedVersion -> $scriptVersion"
|
||||
} elseif ((Get-CodeFingerprint -Text $publishedText -Origin "$AliasUrl/$BootstrapBlob") -eq
|
||||
(Get-CodeFingerprint -Text $sourceContent -Origin $BootstrapSource)) {
|
||||
# Comments, typos in comments, reflow -- nothing a consumer of the version can act on.
|
||||
Write-Host "::warning::$BootstrapBlob changed in comments or formatting only; publishing under the unchanged version $scriptVersion."
|
||||
} else {
|
||||
throw ("Version-bump guard: $BootstrapBlob changed behaviour but ScriptVersion is still $scriptVersion. " +
|
||||
"Bump it in $BootstrapSource -- the docs badge, version.json.bootstrapVersion and the " +
|
||||
'portal outdated-script hint all read that value, and customers decide from it whether ' +
|
||||
'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.')
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 4. upload
|
||||
# Sources beyond the bootstrap pair, read straight from the repo.
|
||||
$extraSources = @(
|
||||
@{ Path = 'scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1'; BlobName = 'Test-ShouldBootstrapAgent.ps1' }
|
||||
# Customer-side Graph add-on grant script -- docs and admin UI link it directly.
|
||||
@{ Path = 'scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1'; BlobName = 'Grant-AutopilotMonitorAddOn.ps1' }
|
||||
)
|
||||
# Uninstall-AutopilotMonitor.ps1 is deliberately NOT published: nothing links it, and
|
||||
# an unauthenticated uninstall script on the public download host is not a feature.
|
||||
|
||||
$publishSet = [System.Collections.Generic.List[object]]::new()
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Bytes = $sourceBytes })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Bytes = (Get-PublishBytes $devContent) })
|
||||
foreach ($extra in $extraSources) {
|
||||
$extraPath = Join-Path $RepoRoot $extra.Path
|
||||
if (-not (Test-Path $extraPath)) {
|
||||
throw "Publish source missing: $extraPath"
|
||||
}
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Bytes = (Get-PublishBytes (Get-Content $extraPath -Raw)) })
|
||||
}
|
||||
|
||||
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
|
||||
$writeSas = if ($SasToken) { $SasToken.TrimStart('?') } else { '' }
|
||||
|
||||
foreach ($item in $publishSet) {
|
||||
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
|
||||
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
|
||||
# parameter coercion repairs it everywhere EXCEPT the wire.
|
||||
[byte[]]$bytes = $item.Bytes
|
||||
$item | Add-Member -NotePropertyName Sha256 -NotePropertyValue (Get-Sha256 $bytes) -Force
|
||||
|
||||
if ($DryRun) {
|
||||
# Doubles as a drift check: says per blob whether the alias already serves this source.
|
||||
$served = Get-PublishedBytes "$AliasUrl/$($item.BlobName)"
|
||||
$state = if ($null -eq $served) { 'MISSING on the alias' }
|
||||
elseif ((Get-Sha256 $served) -eq $item.Sha256) { 'already current' }
|
||||
else { 'STALE on the alias' }
|
||||
Write-Host " [dry-run] $($item.BlobName) ($($bytes.Length) bytes, sha256 $($item.Sha256)) -- $state"
|
||||
continue
|
||||
}
|
||||
|
||||
# no-cache on every blob: they all rotate in place and the download alias fronts them
|
||||
# with Front Door. Route caching is disabled there, but a stale script paired with a
|
||||
# fresh manifest would fail the bootstrap SHA check -- this keeps that class of bug
|
||||
# impossible even if caching is ever re-enabled.
|
||||
$headers = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = $ScriptContentType; 'x-ms-blob-cache-control' = 'no-cache' }
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/$($item.BlobName)?$writeSas" -Method Put -Headers $headers -Body $bytes | Out-Null
|
||||
|
||||
# Read straight back from the blob (authoritative, no CDN in the way) before touching the
|
||||
# mirror or the next file. The alias verification at the end would catch a bad body too,
|
||||
# but only after every blob was already overwritten -- these are live customer downloads.
|
||||
$written = Get-PublishedBytes "$ContainerUrl/$($item.BlobName)"
|
||||
if ($null -eq $written -or (Get-Sha256 $written) -ne $item.Sha256) {
|
||||
$writtenLength = if ($null -eq $written) { 'missing' } else { "$($written.Length) bytes" }
|
||||
throw ("Upload of $($item.BlobName) did not round-trip: sent $($bytes.Length) bytes, " +
|
||||
"blob now holds $writtenLength. Publishing stopped before the mirror and the " +
|
||||
'remaining files, so no further blob was touched.')
|
||||
}
|
||||
Write-Host " uploaded $($item.BlobName) ($($bytes.Length) bytes, round-trip verified)"
|
||||
|
||||
if ($legacySas) {
|
||||
try {
|
||||
Invoke-RestMethod -Uri "$LegacyContainerUrl/$($item.BlobName)?$legacySas" -Method Put -Headers $headers -Body $bytes | Out-Null
|
||||
Write-Host " mirrored $($item.BlobName) to legacy storage"
|
||||
} catch {
|
||||
Write-Host "::warning::legacy mirror failed for $($item.BlobName) -- $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 5. version oracles
|
||||
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
|
||||
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
|
||||
# agent release cannot lose its version/sha256 fields.
|
||||
if (-not $DryRun) {
|
||||
try {
|
||||
$manifestResp = Invoke-WebRequest -Uri "$ContainerUrl/version.json" -Method Get -UseBasicParsing
|
||||
$manifestRaw = if ($manifestResp.Content -is [byte[]]) {
|
||||
[System.Text.Encoding]::UTF8.GetString($manifestResp.Content)
|
||||
} else { [string]$manifestResp.Content }
|
||||
$manifest = $manifestRaw | ConvertFrom-Json
|
||||
|
||||
if ($manifest.bootstrapVersion -eq $scriptVersion) {
|
||||
Write-Host "version.json already reports bootstrapVersion $scriptVersion"
|
||||
} else {
|
||||
$etag = '"' + ([string]($manifestResp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"'
|
||||
$manifest | Add-Member -NotePropertyName 'bootstrapVersion' -NotePropertyValue $scriptVersion -Force
|
||||
$body = [System.Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Compress))
|
||||
$manifestHeaders = @{
|
||||
'x-ms-blob-type' = 'BlockBlob'
|
||||
'Content-Type' = 'application/json'
|
||||
'x-ms-blob-cache-control' = 'no-cache'
|
||||
'If-Match' = $etag
|
||||
}
|
||||
Invoke-RestMethod -Uri "$ContainerUrl/version.json?$writeSas" -Method Put -Headers $manifestHeaders -Body $body | Out-Null
|
||||
Write-Host "version.json bootstrapVersion -> $scriptVersion (agent fields untouched)"
|
||||
|
||||
if ($legacySas) {
|
||||
try {
|
||||
$legacyHeaders = @{ 'x-ms-blob-type' = 'BlockBlob'; 'Content-Type' = 'application/json'; 'x-ms-blob-cache-control' = 'no-cache' }
|
||||
Invoke-RestMethod -Uri "$LegacyContainerUrl/version.json?$legacySas" -Method Put -Headers $legacyHeaders -Body $body | Out-Null
|
||||
} catch {
|
||||
Write-Host "::warning::legacy mirror failed for version.json -- $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if ((Get-HttpStatus $_) -eq 412) {
|
||||
throw 'version.json changed while publishing (concurrent agent release). Re-run this workflow.'
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
$tableSas = if ($TableSasToken) { $TableSasToken.TrimStart('?') } else { '' }
|
||||
if ($DryRun) {
|
||||
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
} elseif ([string]::IsNullOrEmpty($tableSas) -or $tableSas -eq 'XXX') {
|
||||
Write-Host 'SKIPPED: AdminConfiguration update -- set AZURE_TABLE_ADMIN_SAS_TOKEN to enable the portal version oracle'
|
||||
} else {
|
||||
$tableHeaders = @{
|
||||
'Content-Type' = 'application/json'
|
||||
'Accept' = 'application/json;odata=nometadata'
|
||||
'If-Match' = '*'
|
||||
}
|
||||
$tableBody = @{ LatestBootstrapV2ScriptVersion = $scriptVersion } | ConvertTo-Json
|
||||
Invoke-RestMethod -Uri "$TableUrl`?$tableSas" -Method Merge -Headers $tableHeaders -Body $tableBody | Out-Null
|
||||
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 6. verify via alias
|
||||
if (-not $DryRun) {
|
||||
$pending = [System.Collections.ArrayList]::new()
|
||||
$publishSet | ForEach-Object { [void]$pending.Add($_) }
|
||||
|
||||
for ($attempt = 1; $attempt -le 6 -and $pending.Count -gt 0; $attempt++) {
|
||||
if ($attempt -gt 1) { Start-Sleep -Seconds 20 }
|
||||
foreach ($item in @($pending)) {
|
||||
$servedBytes = Get-PublishedBytes "$AliasUrl/$($item.BlobName)"
|
||||
if ($null -ne $servedBytes -and (Get-Sha256 $servedBytes) -eq $item.Sha256) {
|
||||
Write-Host " verified $($item.BlobName) via $AliasUrl"
|
||||
$pending.Remove($item)
|
||||
}
|
||||
}
|
||||
if ($pending.Count -gt 0) {
|
||||
Write-Host " attempt $attempt : $($pending.Count) blob(s) not current on the alias yet"
|
||||
}
|
||||
}
|
||||
|
||||
if ($pending.Count -gt 0) {
|
||||
$names = ($pending | ForEach-Object { $_.BlobName }) -join ', '
|
||||
throw ("Alias still serves stale content for: $names. The blobs were written, so this is a Front Door " +
|
||||
'cache issue -- purge with: az afd endpoint purge --resource-group rg-autopilotmonitor-prd-gwc ' +
|
||||
"--profile-name autopilotmonitor-fd --endpoint-name apm-download --content-paths '/agent/*'")
|
||||
}
|
||||
}
|
||||
|
||||
if ($env:GITHUB_STEP_SUMMARY) {
|
||||
$mode = if ($DryRun) { 'DRY RUN -- nothing uploaded' } else { 'published + verified via the download alias' }
|
||||
$summary = @(
|
||||
"### Bootstrap scripts: $mode",
|
||||
'',
|
||||
"Bootstrap script version: **$scriptVersion**",
|
||||
''
|
||||
) + ($publishSet | ForEach-Object { "- $($_.BlobName) -- sha256 $($_.Sha256)" })
|
||||
($summary -join [Environment]::NewLine) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
||||
}
|
||||
|
||||
Write-Host 'Done.'
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Bootstrap script gates
|
||||
|
||||
# Quality gate for the customer-facing PowerShell scripts, factored out so it exists
|
||||
# once and both callers run the identical checks:
|
||||
# * pester.yml -- pull requests (reports, does not block the merge button)
|
||||
# * publish-scripts.yml -- pushes to main, where it BLOCKS the upload to the
|
||||
# download alias. Devices execute these scripts as SYSTEM
|
||||
# during enrollment, so a red suite must never ship.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
|
||||
# Windows PowerShell 5.1 on purpose: that is the runtime IME uses to execute
|
||||
# platform scripts, so guard/relax logic is verified without resetting a device.
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$c = New-PesterConfiguration
|
||||
$c.Run.Path = 'scripts/Bootstrap'
|
||||
$c.Run.Exit = $true
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars."
|
||||
$failed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
|
||||
# The publish chain renders the -Dev variant by literal substitution and refuses to
|
||||
# ship when an anchor moved. Running it dry here surfaces that (and any drift against
|
||||
# the alias) on the PR instead of on the push that would have published.
|
||||
- name: Publish dry run (dev-render anchors + version-bump guard + drift)
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Publish-BootstrapScripts.ps1 -DryRun
|
||||
@@ -33,6 +33,13 @@ permissions:
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
# Shared with publish-scripts.yml: both write version.json (this workflow the agent fields,
|
||||
# the script publisher bootstrapVersion under If-Match). Serialising them keeps a script push
|
||||
# from colliding with a release. Never cancel -- a half-published set is worse than a queue.
|
||||
concurrency:
|
||||
group: agent-blob-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
LINE_MAJOR: 2
|
||||
AGENT_PROJECT: src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
|
||||
@@ -46,8 +53,6 @@ env:
|
||||
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
|
||||
STABLE_ZIP: AutopilotMonitor-Agent.zip
|
||||
STABLE_MANIFEST: version.json
|
||||
STABLE_BOOTSTRAP: Install-AutopilotMonitor.ps1
|
||||
STABLE_BOOTSTRAP_DEV: Install-AutopilotMonitor-Dev.ps1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -181,6 +186,8 @@ jobs:
|
||||
# commit/buildUtc are the same two fields the backend and MCP manifests carry —
|
||||
# additive, and the bootstrap reads this file by field name, so older installers
|
||||
# ignore them. sha256/bootstrapVersion stay: those are the customer contract.
|
||||
# bootstrapVersion is seeded here so the published manifest is never briefly
|
||||
# missing it; Publish-BootstrapScripts.ps1 owns and re-asserts the field afterwards.
|
||||
$manifest | Add-Member -NotePropertyName "sha256" -NotePropertyValue $sha256Hash -Force
|
||||
$manifest | Add-Member -NotePropertyName "commit" -NotePropertyValue '${{ github.sha }}'.Substring(0, 7) -Force
|
||||
$manifest | Add-Member -NotePropertyName "buildUtc" -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force
|
||||
@@ -190,29 +197,12 @@ jobs:
|
||||
$manifest | ConvertTo-Json -Compress | Set-Content $env:MANIFEST_NAME -Encoding UTF8
|
||||
Write-Host "$env:MANIFEST_NAME content: $(Get-Content $env:MANIFEST_NAME)"
|
||||
|
||||
# Bootstrap = single generic source. Stable variant = source 1:1.
|
||||
# Dev variant = literal-string substitution of the two URL/manifest defaults.
|
||||
# Both are uploaded only on -PublishAsStable runs (versioned namespace doesn't carry bootstrap).
|
||||
if ('${{ inputs.publish_as_stable }}' -eq 'true') {
|
||||
$stableUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrl = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableMan = '$VersionJsonName = "version.json"'
|
||||
$devMan = '$VersionJsonName = "version-dev.json"'
|
||||
|
||||
Copy-Item $env:BOOTSTRAP_SOURCE $env:STABLE_BOOTSTRAP -Force
|
||||
$content = Get-Content $env:BOOTSTRAP_SOURCE -Raw
|
||||
if ($content.IndexOf($stableUrl) -lt 0 -or $content.IndexOf($stableMan) -lt 0) {
|
||||
Write-Error "Bootstrap dev-render: anchor literal missing in $env:BOOTSTRAP_SOURCE"
|
||||
exit 1
|
||||
}
|
||||
$devContent = $content.Replace($stableUrl, $devUrl).Replace($stableMan, $devMan)
|
||||
[System.IO.File]::WriteAllText("$env:STABLE_BOOTSTRAP_DEV", $devContent, (New-Object System.Text.UTF8Encoding $false))
|
||||
}
|
||||
# The customer-facing scripts (stable + dev render, test script, grant script) are
|
||||
# published by .github/scripts/Publish-BootstrapScripts.ps1 -- see the upload step.
|
||||
|
||||
"versioned_zip_release=$versionedZip" >> $env:GITHUB_OUTPUT
|
||||
"sha256=$sha256Hash" >> $env:GITHUB_OUTPUT
|
||||
"exe_sha256=$exeSha256Hash" >> $env:GITHUB_OUTPUT
|
||||
"bootstrap_version=$bootstrapVersion" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
@@ -264,22 +254,21 @@ jobs:
|
||||
Write-Host "PUBLISH AS STABLE -- writing V2 into stable namespace (customer-facing)" -ForegroundColor Magenta
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:STABLE_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:STABLE_MANIFEST -ContentType "application/json"
|
||||
Send-Blob -Local $env:STABLE_BOOTSTRAP -BlobName $env:STABLE_BOOTSTRAP -ContentType "text/plain; charset=utf-8"
|
||||
Send-Blob -Local $env:STABLE_BOOTSTRAP_DEV -BlobName $env:STABLE_BOOTSTRAP_DEV -ContentType "text/plain; charset=utf-8"
|
||||
|
||||
if (Test-Path "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1") {
|
||||
Send-Blob -Local "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1" -BlobName "Test-ShouldBootstrapAgent.ps1" -ContentType "text/plain; charset=utf-8"
|
||||
}
|
||||
|
||||
# Customer-side Graph add-on grant script -- docs and admin UI link
|
||||
# https://download.autopilotmonitor.com/agent/Grant-AutopilotMonitorAddOn.ps1
|
||||
if (Test-Path "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1") {
|
||||
Send-Blob -Local "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1" -BlobName "Grant-AutopilotMonitorAddOn.ps1" -ContentType "text/plain; charset=utf-8"
|
||||
}
|
||||
# Customer-facing scripts: same publisher publish-scripts.yml runs on every push
|
||||
# to main, so a release lands a set that is consistent with what is already live
|
||||
# (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Runs AFTER version.json so its bootstrapVersion read sees this build's manifest.
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}'
|
||||
}
|
||||
|
||||
# Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle
|
||||
# must never point at a build that is not actually in the stable namespace.
|
||||
# LatestBootstrapV2ScriptVersion is NOT set here: it belongs to the script publisher
|
||||
# above, which also owns it on script-only pushes. One writer per field.
|
||||
$tableSasToken = "${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}".TrimStart('?')
|
||||
if ('${{ inputs.publish_as_stable }}' -ne 'true') {
|
||||
Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)"
|
||||
@@ -289,22 +278,18 @@ jobs:
|
||||
$sha256 = "${{ steps.artifact.outputs.sha256 }}"
|
||||
$exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}"
|
||||
$agentVersion = "${{ steps.version.outputs.version }}"
|
||||
$bootstrapVersion = "${{ steps.artifact.outputs.bootstrap_version }}"
|
||||
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')?$tableSasToken"
|
||||
$tableBody = @{
|
||||
LatestAgentV2Version = $agentVersion
|
||||
LatestAgentV2Sha256 = $sha256
|
||||
LatestAgentV2ExeSha256 = $exeSha256
|
||||
}
|
||||
if ($bootstrapVersion) {
|
||||
$tableBody["LatestBootstrapV2ScriptVersion"] = $bootstrapVersion
|
||||
}
|
||||
Invoke-RestMethod -Uri $tableUrl -Method Merge -Headers @{
|
||||
"Content-Type" = "application/json"
|
||||
"Accept" = "application/json;odata=nometadata"
|
||||
"If-Match" = "*"
|
||||
} -Body ($tableBody | ConvertTo-Json)
|
||||
Write-Host "AdminConfiguration updated: V2 version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256, bootstrapVersion=$bootstrapVersion"
|
||||
Write-Host "AdminConfiguration updated: V2 version=$agentVersion, sha256=$sha256, exeSha256=$exeSha256"
|
||||
}
|
||||
|
||||
# Sigstore keyless provenance: binds the ZIP digest to this workflow run (repo, ref,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
name: Build & Publish Bootstrap MSI
|
||||
|
||||
# Thin MDM LOB delivery vehicle for WDP (Autopilot v2) — see
|
||||
# src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs. The MSI carries no bootstrap
|
||||
# logic (only the runner script that downloads the CURRENT server-hosted
|
||||
# Install-AutopilotMonitor.ps1), so it is rebuilt rarely: only when the runner script
|
||||
# or the WiX package changes. Version lives in Package.wxs — bump it there before
|
||||
# dispatching so Intune LOB update detection sees a new ProductVersion.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_blob:
|
||||
description: 'Upload to blob (customer-facing via download.autopilotmonitor.com/agent/)'
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
env:
|
||||
MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi
|
||||
RUNNER_SCRIPT: src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1
|
||||
STABLE_MSI: AutopilotMonitor-Bootstrap.msi
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
name: Build Bootstrap MSI
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# The runner executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
- name: Validate runner script (parse + ASCII-only)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errs = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $env:RUNNER_SCRIPT), [ref]$null, [ref]$errs) | Out-Null
|
||||
if ($errs) { $errs | ForEach-Object { Write-Error $_.ToString() }; exit 1 }
|
||||
if ((Get-Content $env:RUNNER_SCRIPT -Raw) -match '[^\x00-\x7F]') {
|
||||
Write-Error "Runner script contains non-ASCII characters"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Runner script parse + ASCII check passed"
|
||||
|
||||
- name: Read MSI version from Package.wxs
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
|
||||
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') {
|
||||
Write-Error "Could not parse Version from Package.wxs"
|
||||
exit 1
|
||||
}
|
||||
$version = $Matches[1]
|
||||
Write-Host "MSI version (from Package.wxs): $version"
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Install WiX toolset
|
||||
run: dotnet tool install --global wix --version "5.*"
|
||||
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
run: |
|
||||
# wix resolves File/@Source relative to the working directory, not the wxs —
|
||||
# build from inside the project dir (same as the local build documented in the wxs).
|
||||
$out = Join-Path (Get-Location) $env:STABLE_MSI
|
||||
Push-Location $env:MSI_PROJECT_DIR
|
||||
wix build Package.wxs -arch x64 -o $out
|
||||
Pop-Location
|
||||
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
|
||||
$sha256 = (Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
Write-Host "SHA-256: $sha256"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
run: |
|
||||
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
|
||||
$sasToken = "${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}".TrimStart('?')
|
||||
$version = "${{ steps.version.outputs.version }}"
|
||||
|
||||
# Same Send-Blob contract as build-agent.yml: no-cache on every blob because the
|
||||
# stable name rotates in place behind the download alias. No legacy mirror — the
|
||||
# MSI channel never existed on the legacy account.
|
||||
function Send-Blob {
|
||||
param([string]$Local, [string]$BlobName)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Local)
|
||||
$headers = @{
|
||||
"x-ms-blob-type" = "BlockBlob"
|
||||
"Content-Type" = "application/octet-stream"
|
||||
"x-ms-blob-cache-control" = "no-cache"
|
||||
}
|
||||
$md5 = [System.Security.Cryptography.MD5]::Create()
|
||||
$headers["Content-MD5"] = [Convert]::ToBase64String($md5.ComputeHash($bytes))
|
||||
$md5.Dispose()
|
||||
Write-Host "Uploading $BlobName ($([math]::Round($bytes.Length / 1KB)) KB)..."
|
||||
Invoke-RestMethod -Uri "$containerUrl/$BlobName`?$sasToken" -Method Put -Headers $headers -Body $bytes
|
||||
Write-Host " uploaded $BlobName"
|
||||
}
|
||||
|
||||
# Versioned copy for provenance/rollback + stable customer-facing name.
|
||||
Send-Blob -Local $env:STABLE_MSI -BlobName "AutopilotMonitor-Bootstrap-v$version.msi"
|
||||
Send-Blob -Local $env:STABLE_MSI -BlobName $env:STABLE_MSI
|
||||
|
||||
# E2E: the Front Door route /agent/* must serve the fresh bytes on the public alias.
|
||||
- name: Verify download alias serves the MSI
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
run: |
|
||||
$localSize = (Get-Item $env:STABLE_MSI).Length
|
||||
$resp = Invoke-WebRequest -Uri "https://download.autopilotmonitor.com/agent/$env:STABLE_MSI" -Method Head -UseBasicParsing
|
||||
if ($resp.StatusCode -ne 200) { Write-Error "Alias HEAD returned $($resp.StatusCode)"; exit 1 }
|
||||
$remoteSize = [long]$resp.Headers['Content-Length'][0]
|
||||
if ($remoteSize -ne $localSize) {
|
||||
Write-Error "Size mismatch: alias serves $remoteSize bytes, local MSI is $localSize bytes"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Alias verified: https://download.autopilotmonitor.com/agent/$env:STABLE_MSI ($remoteSize bytes)"
|
||||
|
||||
# Sigstore keyless provenance, same as the agent ZIP. Verify:
|
||||
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (MSI)
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
@@ -1,16 +1,20 @@
|
||||
name: Pester (Bootstrap scripts)
|
||||
|
||||
# Runs the Pester suite for the Intune bootstrap scripts under Windows PowerShell 5.1
|
||||
# (the runtime IME uses to execute platform scripts) so guard/relax logic changes are
|
||||
# verified without resetting a device. Like ci.yml it reports rather than blocks.
|
||||
# Pull-request view of the bootstrap script gates. Like ci.yml it reports rather than
|
||||
# blocks the merge button.
|
||||
#
|
||||
# There is deliberately no push trigger: publish-scripts.yml runs the very same
|
||||
# reusable gate workflow on every push to main and blocks the upload to the download
|
||||
# alias on it. A push trigger here would only duplicate that run, with no consequence.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -21,41 +25,5 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pester:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$c = New-PesterConfiguration
|
||||
$c.Run.Path = 'scripts/Bootstrap'
|
||||
$c.Run.Exit = $true
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars."
|
||||
$failed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Publish customer scripts
|
||||
|
||||
# Keeps the scripts behind download.autopilotmonitor.com/agent/ current WITHOUT waiting
|
||||
# for an agent release. Before this workflow existed, the script blobs rotated only on a
|
||||
# publish_as_stable run of build-agent.yml, so a bootstrap fix sat unpublished until the
|
||||
# next agent cutover -- while the WDP bootstrap MSI downloads the published copy on every
|
||||
# enrolling device.
|
||||
#
|
||||
# Same publish code as the release path (.github/scripts/Publish-BootstrapScripts.ps1);
|
||||
# this workflow only supplies a second trigger for it.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Run every check and report drift, upload nothing'
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Shared with build-agent.yml: a script publish and an agent release both touch
|
||||
# version.json, and the bootstrapVersion read-modify-write must not interleave with the
|
||||
# release writing its agent fields. Never cancel -- a half-published set is worse than a queue.
|
||||
concurrency:
|
||||
group: agent-blob-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
uses: ./.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
publish:
|
||||
name: Publish to the download alias
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Publish bootstrap + customer scripts
|
||||
shell: pwsh
|
||||
run: |
|
||||
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-SasToken '${{ secrets.AZURE_BLOB_AGENT_SAS_TOKEN }}' `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-TableSasToken '${{ secrets.AZURE_TABLE_ADMIN_SAS_TOKEN }}' `
|
||||
-DryRun:$dryRun
|
||||
@@ -159,3 +159,8 @@ docs/~$Introduction-AutopilotMonitor.pptx
|
||||
# changes each run) and served at /version.json from the static export.
|
||||
src/Web/autopilot-monitor-web/public/version.json
|
||||
|
||||
|
||||
# Local IME binaries scratch used by the /ime-decompile skill (MSI + msiexec-
|
||||
# extracted payload per version, for grepping in binaries). Decompilates live in
|
||||
# the private ime-decompiles repo, MSIs in the ime-archive blob - this stays local.
|
||||
ime-files/
|
||||
|
||||
@@ -44,7 +44,7 @@ tenant-config report.
|
||||
|
||||
`RemoteConfigMerger` projects the bool onto a runtime path (same pattern as
|
||||
`EnableImeMatchLog`): `true` → `Constants.GatherRuleDebugLogPath` =
|
||||
`%ProgramData%\AutopilotMonitor\Logs\gather_rules_debug.log`; `false` → null, unless a
|
||||
`%ProgramData%\AutopilotMonitor\Logs\gather-rules-debug.log`; `false` → null, unless a
|
||||
`--gather-debug-log <path>` CLI override supplied a non-default path. The field is not
|
||||
security-sensitive and deliberately **survives the offline config cache**.
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ every line; the spool fsyncs only items flagged `RequiresImmediateFlush`.
|
||||
| Event sequence | `State\event-sequence.json` | `{ LastAssignedSequence }` | atomic | none; survives WhiteGlove Part-2 resume |
|
||||
| Spool | `Spool\spool.jsonl` | JSONL (`TelemetryItem`/line) | only `RequiresImmediateFlush` items | none — never truncated, cursor skips uploaded items |
|
||||
| Upload cursor | `Spool\upload-cursor.json` | `{ LastUploadedItemId }` | atomic | idempotent, never regresses |
|
||||
| Agent trace log | `Logs\agent_YYYYMMDD[_NNN].log` | text `[utc] [LEVEL] msg` | no | 50 MB → suffix rotation; consumed by `/diag-agent-log` |
|
||||
| Agent trace log | `Logs\agent.log` (segments `agent-NNN.log`) | text `[utc] [LEVEL] msg` | no | 50 MB → suffix rotation; consumed by `/diag-agent-log` |
|
||||
| Crash dump + record | `Crashes\<stem>.dmp` + `.json` | MiniDump + JSON | n/a (process dying) | 5 files / 7 days |
|
||||
| Startup-event gate | `State\startup-event-state.json` | JSON dict | no (atomic) | cross-reboot dedup of one-shot probes |
|
||||
| Bandwidth state | `State\bandwidth-state.json` | JSON samples/counters | no (atomic) | reboot-survivor for the DO bandwidth estimate |
|
||||
|
||||
@@ -146,7 +146,7 @@ snapshots) are executed by the `EffectRunner` — the reducer itself is pure. De
|
||||
| `\Agent\` | deployed binaries |
|
||||
| `\State\` | decision persistence (SignalLog, Journal, Snapshot), `enrollment-complete.marker`, `final-status.json`, self-update state, `.quarantine/`, `.part1-<ts>/` |
|
||||
| `\Spool\` | telemetry spool (offline queue) |
|
||||
| `\Logs\` | agent trace log, `crash_*.log`, `ime_pattern_matches.log` |
|
||||
| `\Logs\` | agent trace log (`agent.log`), `crash-*.log`, `ime-pattern-matches.log` |
|
||||
| `\Config\` | `remote-config.json` cache |
|
||||
| `\Agent-Update\`, `\Updates\` | self-update staging/download |
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
type: Concept
|
||||
title: ESP Completion Starvation — the sits-d Cloud-PC complex
|
||||
description: Why five Windows 365 Cloud PCs finished enrolling but never completed in the product — a SkipUser=true flow produces none of the signals every completion-gate evaluation site hangs off — plus the two adjacent defects fixed alongside (edge-only user-apps-settled synthesis, orphaned Hello-wizard restart replay) and what a Shell-Core replay deliberately does not do.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals
|
||||
tags:
|
||||
- agent
|
||||
- esp
|
||||
- completion
|
||||
- skip-user
|
||||
- reboot
|
||||
- recovery
|
||||
timestamp: 2026-08-20T00:00:00+02:00
|
||||
---
|
||||
|
||||
# ESP Completion Starvation — the sits-d Cloud-PC complex
|
||||
|
||||
Seven Windows 365 Cloud PCs, same tenant, first sign-in the same morning. Two sessions
|
||||
succeeded within minutes; five sat at `Completion is waiting on: hello_resolution` from
|
||||
second five onward and ran into the six-hour max-lifetime watchdog — verdict `Incomplete`
|
||||
on devices whose users were working normally the whole time.
|
||||
|
||||
The first diagnosis blamed the mid-ESP reboot (DeviceGuard/DmaGuard, see
|
||||
[MDM Reboot Coalescing](mdm-reboot-coalescing.md)): the agent is killed in
|
||||
`Stage=EspAccountSetup` and, so the theory went, loses the Shell-Core ESP exit to the
|
||||
downtime. The evidence disproved it. `8110e262` ran **six hours uninterrupted** after its
|
||||
last reboot with the Shell-Core watcher armed — and Windows never wrote a 62407 in all
|
||||
that time, because on these machines **the user ESP page never existed**.
|
||||
|
||||
# Schema
|
||||
|
||||
## The actual defect — a satisfied gate nobody evaluates
|
||||
|
||||
The five stuck machines carried `SkipUserStatusPage=True` in the enrollment `FirstSync`
|
||||
key (read 10+ times across three agent processes, `True` on every read — not a stale
|
||||
single read). Windows honours it: no user ESP page, hence
|
||||
|
||||
* no Shell-Core 62407 (`esp_exiting`) — the page that would exit does not exist,
|
||||
* no `AccountSetupCategory` provisioning registry — nothing writes categories for a page
|
||||
that never renders,
|
||||
* desktop within seconds of sign-in.
|
||||
|
||||
The reducer is *built* for this flow: **arm B** of `ShouldTransitionToAwaitingHello`
|
||||
(`SkipUserEsp == true`) declares the gate open, and the missing-prerequisites bookkeeping
|
||||
already exempts the AccountSetup gate — which is why the sessions reported only
|
||||
`hello_resolution` as missing. But the gate is a predicate, not a process. Something has
|
||||
to evaluate it, and every evaluation site hangs off a signal a skip-user flow structurally
|
||||
cannot produce:
|
||||
|
||||
| Evaluation site | Carrier signal | On SkipUser=true |
|
||||
|---|---|---|
|
||||
| `HandleEspExitingV1` | Shell-Core 62407 | never fires |
|
||||
| `HandleEspPhaseChangedV1(FinalizingSetup)` | Shell-Core 62407 via coordinator | never fires |
|
||||
| `HandleDesktopArrivedV1` fast-path | requires Hello policy **disabled** | Hello was enabled |
|
||||
| `HandleImeUserSessionCompletedV1` arm-C attempt | requires the recorded final exit | unsatisfiable |
|
||||
|
||||
No knock, no promotion, no `HelloSafety` — and the `AdvisoryCompletion` backstop is armed
|
||||
exclusively from an `EspExiting` signal, so it never armed either. The session idles to
|
||||
the watchdog with an open gate.
|
||||
|
||||
## The fix — the observed skip is the exit evidence
|
||||
|
||||
`HandleImeUserSessionCompletedV1`'s completion attempt now accepts the observed skip in
|
||||
place of the final exit (`skipUserExitEquivalent`): on a flow where no page exists, no
|
||||
exit can ever say more than the `FirstSync` read already did. Everything else stays
|
||||
mandatory — AccountSetup anchor, genuine (at-or-after-anchor) IME user-session completion,
|
||||
DAD-validated real-user desktop — which makes this knock **stricter** than the existing
|
||||
Shell-Core-carried arm-B promotion (pinned in `ClassicAwaitingHelloGuardTests`, which
|
||||
requires no IME or desktop evidence at all). Hello semantics ride the existing rails:
|
||||
enabled-or-unknown promotes to `AwaitingHello` with `HelloSafety` armed (on Cloud PCs
|
||||
reached via RDP the Hello wizard never appears, so the synthetic timeout resolves the
|
||||
wait), disabled completes directly with the synthetic `Skipped` outcome.
|
||||
|
||||
The knock deliberately lives on the IME user-session edge, not on desktop arrival:
|
||||
|
||||
* **Ordering-robust against Fix 10.** An `EspPhaseChanged(AccountSetup)` arriving on
|
||||
`AwaitingHello` bounces the stage back and cancels `HelloSafety` (the premature-promotion
|
||||
guard). The IME AccountSetup phase line lands *after* the desktop on this flow, so a
|
||||
desktop-side knock would be undone two signals later. The IME phase line always precedes
|
||||
the user-session-complete line in log order, so a bounced promotion is re-knocked by the
|
||||
very next re-emission — including the restart re-parse.
|
||||
* **Right semantics.** The IME user session completes when the required-app processing is
|
||||
done; a desktop-side knock would mark the session `Succeeded` mid-installation.
|
||||
* Device Preparation is excluded — WDP keeps its Hello+Desktop conjunction and the
|
||||
`DevicePrepCompletion` backstop.
|
||||
|
||||
`ClassicSkipUserEspUserSessionCompletionTests` replays the real `8110e262` signal ordering
|
||||
end to end (bootstrap `EspConfigDetected` → Hello enabled → desktop → AccountSetup anchor →
|
||||
IME completion → HelloSafety → `enrollment_complete`), including the Fix-10
|
||||
bounce-then-re-knock cycle, and pins the negative space (no skip observed / no desktop /
|
||||
ghost pre-anchor IME completion / WDP).
|
||||
|
||||
Why five of seven identically administered Cloud PCs carried `SkipUserStatusPage=True` is
|
||||
a tenant-side question (ESP profile assignment state at each machine's provisioning
|
||||
moment); the value did not change during any observed session, and machine age does not
|
||||
cleanly explain the split. The agent's job is to complete the flow Windows actually ran —
|
||||
which it now does.
|
||||
|
||||
## Adjacent defect — the synthesis was edge-triggered
|
||||
|
||||
This one is real but belongs to the `SkipUser=false` flow (the two sessions that
|
||||
succeeded). The user-apps-settled synthesis
|
||||
(`MaybeSynthesizeAccountSetupCompleteFromSettledUserApps`, session `caa6cf50`) ran **only**
|
||||
from `OnEspExited`. On a tenant with ~138 required apps the ESP page exits while apps are
|
||||
still in flight: the single attempt misses and nothing ever re-checks, even when every
|
||||
tracked app reaches a terminal state minutes later.
|
||||
|
||||
The ESP exit is an **edge** (happens once); settled user-ESP apps are a **level** (reached
|
||||
later). `EspAndHelloTracker` records the edge in `_espExitObserved` and exposes
|
||||
`ReevaluateUserAppsSettledSynthesis()`, chained into `ImeLogTracker.OnAppStateChanged` and
|
||||
into the post-restore `onStateRestored` callback (a live exit observed before the IME
|
||||
state restore completes would otherwise never get a second look). Gate conditions are
|
||||
unchanged — no new completion path, only a second opportunity for the existing one. Only a
|
||||
**live, confirmed post-AccountSetup** exit may be remembered
|
||||
(`IsConfirmedPostAccountSetupExit`, stricter than the `IsIntermediateDeviceEspExit`
|
||||
forward guard: unknown counts as not confirmed).
|
||||
|
||||
## Adjacent defect — the restart replay was never wired up
|
||||
|
||||
`ShellCoreTracker`'s backfill had existed since session `772fe502` with **no caller**; the
|
||||
Shell-Core `EventLogWatcher` only delivers records written after `Start()`.
|
||||
`EspAndHelloHost.Start()` now calls it as `BackfillRecentHelloWizardStart` — which is
|
||||
exactly what it does and all it does:
|
||||
|
||||
| Record | Replayed | Why |
|
||||
|---|---|---|
|
||||
| 62404 Hello-wizard start | **yes** | Conservative: vetoes a premature "Hello is disabled" skip, can never complete a session by itself. The observation `772fe502` was about. |
|
||||
| 62407 ESP exit | **no** | Cannot be placed in time — see below. |
|
||||
| 62407 ESP failure | **no** | Re-injecting a historic failure as fresh can fail a session that recovered on retry (`ANALYZE-ESP-006`). |
|
||||
|
||||
A replayed exit is unusable, and every candidate ordering mechanism fails independently:
|
||||
|
||||
1. Windows writes the **identical** description `CommercialOOBE_ESPProgress_Page_Exiting`
|
||||
for the intermediate DeviceSetup→AccountSetup transition and the final exit.
|
||||
2. Every post-hoc ordering probe (AccountSetup registry, settled-apps) reads state as it
|
||||
is **now**, not as it was at the event's time.
|
||||
3. The reducer orders exits by **ingest ordinal**, not timestamp
|
||||
(`IsPostAccountSetupFinalExit`) — a historic exit replayed today gets a fresher ordinal
|
||||
than reality and reads as post-AccountSetup by construction.
|
||||
4. `HandleEspExitingV1` passes `espFinalExitInFlight: true` for every arriving exit; with
|
||||
restored state, arm C would open on a historic intermediate exit.
|
||||
|
||||
`ClassicEspExitingOnRestoredStateTests` pins that the reducer *does* open on any arriving
|
||||
exit; `ShellCoreTrackerReplayScopeTests` pins that the replay never produces one. Skipped
|
||||
62407 records are counted and reported once as an `agent_trace`
|
||||
(`reason: replayed_62407_not_orderable`) — silently dropping evidence is how this class of
|
||||
bug survives.
|
||||
|
||||
**Residual gap, named honestly:** a `SkipUser=false` session whose final exit falls
|
||||
exactly into an agent downtime *and* whose AccountSetup category never resolves
|
||||
(`1ec8f4c6` shape) still has no completion carrier after the restart. The normal case is
|
||||
covered — `ProvisioningStatusTracker` reads the current registry at startup, so a
|
||||
category that resolved during the downtime fires arm A. The residual shape is rare,
|
||||
surfaced by `ANALYZE-ESP-005` via the `session_stalled` interim trigger, and deliberately
|
||||
not patched with a replay that cannot be ordered.
|
||||
|
||||
### Sizing the replay window
|
||||
|
||||
A constant is provably wrong: the scheduled task has a `BootTrigger` only
|
||||
(`Program.InstallMode.BuildScheduledTaskXml`) — after an `exception_crash` the agent does
|
||||
not return until the next boot. `ResolveEspExitBackfillLookbackMinutes` reaches back to
|
||||
the last moment the previous run is known to have been alive (max of `snapshot.json`
|
||||
mtime and `LastBootUtc` for kill-type exits), floored at 5 minutes, capped at 360, off on
|
||||
`first_run`, and immune to cross-reboot clock skew (a future timestamp is ignored).
|
||||
|
||||
# Examples
|
||||
|
||||
Tenant `sits-d.cloud`, 2026-08-19, seven Windows 365 Cloud PCs, first sign-ins the same
|
||||
morning. The discriminator is a single registry value:
|
||||
|
||||
| Session | SkipUser | `esp_exiting` | AccountSetup registry | Reboots | Outcome |
|
||||
|---|---|---|---|---|---|
|
||||
| `08fc6bda` | False | yes | yes | 0 | Succeeded (10 min) |
|
||||
| `3b9291aa` | False | yes | yes | 0 | Succeeded (2.1 h) |
|
||||
| `8110e262` | True | — | — | 2 | Incomplete (6 h watchdog) |
|
||||
| `a89aac2d` | True | — | — | 1 | Incomplete |
|
||||
| `e7ba63c9` | True | — | — | 1 | Incomplete |
|
||||
| `cb4a485a` | True | — | — | 2 | Incomplete |
|
||||
| `3d6278fb` | True | — | — | 1 | Incomplete |
|
||||
|
||||
The reboot correlation was a red herring: `8110e262` ran six hours live after its final
|
||||
reboot and would have seen every signal. `8110e262` and `a89aac2d` both reached
|
||||
`138/138 completed, 0 failed` and a genuine IME user-session completion — with the fix,
|
||||
all five complete a few minutes after their IME user session settles.
|
||||
|
||||
`08fc6bda` exercises the edge-level fix on the same day: ESP page exited normally while
|
||||
`categorySucceeded` never confirmed — completed via the user-apps-settled synthesis.
|
||||
`3b9291aa` shows the independent starvation visibility gap: 99 `app_install_starved`
|
||||
events on a session recorded as `Succeeded` — surfaced from now on by `ANALYZE-APP-016`.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs` — `HandleImeUserSessionCompletedV1`, `skipUserExitEquivalent`
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs` — `ShouldTransitionToAwaitingHello` (arm B)
|
||||
* `src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicSkipUserEspUserSessionCompletionTests.cs` — end-to-end replay of the 8110e262 ordering
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ShellCoreTracker.cs` — `BackfillRecentHelloWizardStart`, `ReplayBackfillRecords`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs` — `_espExitObserved`, `ReevaluateUserAppsSettledSynthesis`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs` — lookback sizing, `OnAppStateChanged` chaining
|
||||
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) — the policy attribution for the reboots themselves
|
||||
* [Decision Engine](decision-engine.md) — the AccountSetup gate and completion arms
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
type: concept
|
||||
title: WDP Scenario Gates & Completion Backstop
|
||||
description: How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation (WDP) sessions and how a WDP session resolves without any ESP signal.
|
||||
resource: agent
|
||||
tags: [wdp, device-preparation, decision-engine, collectors, completion]
|
||||
timestamp: 2026-08-18
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Windows Device Preparation (Autopilot v2, "WDP") has **no deployment profile and no ESP**.
|
||||
Before the afee7ae0 audit (2026-08-18) the agent ran the full Classic apparatus anyway:
|
||||
misleading `autopilot_profile_missing` warnings, a ZTD event-log query + HTTP probe,
|
||||
`esp_config_detected` "unknown/unknown" noise, an Autopilot-channel noise flood
|
||||
(EventIDs 100/417/418/1005, 900+ occurrences per session), a subtree registry watcher
|
||||
woken by every WDP progress write, and — worst — no completion path when Hello never
|
||||
resolved (park until the 360-min watchdog).
|
||||
|
||||
## Detection: deterministic vs. fallback
|
||||
|
||||
`EnrollmentRegistryDetector` classifies v2 through three rules. Only the first is
|
||||
**deterministic WDP**:
|
||||
|
||||
1. `AutopilotSettings\DevicePreparation\BootstrapperAgent` subkey with non-empty
|
||||
`ExecutionContext` → the WDP policy orchestration tree, present before the agent
|
||||
starts. Exposed as `EnrollmentRegistryDetector.IsDeterministicDevicePreparation()`
|
||||
and the `DevicePreparationExecutionContextRule` constant.
|
||||
2./3. `CloudAssignedDeviceRegistration == 2` / `CloudAssignedEspEnabled == 0` —
|
||||
profile-derived legacy signals. They imply a REAL Autopilot profile (just ESP-less),
|
||||
so every gate below deliberately ignores them: **gates require the deterministic
|
||||
marker and fail toward Classic behavior.**
|
||||
|
||||
## Agent-side gates (all keyed to the deterministic marker)
|
||||
|
||||
* `DeviceInfoCollector.CollectAutopilotProfile`: no `autopilot_profile` event, no
|
||||
`autopilot_profile_missing` warning, no ZTD event-log query, no ZTD HTTP probe.
|
||||
`enrollment_type_detected` still fires. (WDP devices DO carry an
|
||||
`AutopilotPolicyCache` with `ProfileAvailable=0` / error 807 `ZtdDeviceIsNotRegistered`
|
||||
— Windows runs the ZTD download regardless — so the probe fired for real before.)
|
||||
* `CollectEspConfigurationLocked`: `esp_config_detected` is emitted only when at least
|
||||
one FirstSync value or ESP tracking list exists (generic null-gate, mirrors the
|
||||
orchestrator bootstrap's signal gate) — fixes "unknown/unknown" for WDP AND plain
|
||||
Entra joins.
|
||||
* `DefaultComponentFactory`: `EspPolicyProviderStallHost` (60-s poll of the ESP-only
|
||||
`EnrollmentStatusTracking` key) is not created on WDP.
|
||||
* `EspAndHelloTracker.Start`: `ProvisioningStatusTracker` is not started on WDP — its
|
||||
subtree watcher sits on `AutopilotSettings`, exactly where WDP's BootstrapperAgent
|
||||
writes progress, so it woke on every write only to read three ESP category values
|
||||
that never exist there.
|
||||
* `ModernDeploymentTracker`: `DevicePreparationNoiseEventIds` {100, 417, 418, 1005}
|
||||
are suppressed entirely on WDP (before `FormatDescription()`); Critical (level 1)
|
||||
always passes; genuine WDP diagnostics (e.g. 408) keep flowing. The Autopilot error
|
||||
backfill (807/809/815/908 replay) is skipped — 807 is the EXPECTED state on WDP.
|
||||
The watcher itself stays on: WDP's provisioning stack logs into the same channels.
|
||||
* `StallProbeCollector`: skips the ESP category registry scan on WDP and unions the
|
||||
noise IDs into its harmless set; the channel scans remain (they carry genuine WDP
|
||||
diagnostics during a stall).
|
||||
* `EnrollmentTerminationHandler`: the `app_install_starved` terminal sweep is skipped
|
||||
on WDP — post-desktop installs are the DESIGNED order there (only DPP-policy apps
|
||||
run during provisioning); per-app data still travels via `app_tracking_summary` and
|
||||
final-status.json.
|
||||
|
||||
## DecisionCore: WDP completion without ESP facts
|
||||
|
||||
* **Seeding**: `EnrollmentFactsObserved` carries `enrollmentTypeDeterministic`;
|
||||
the deterministic marker seeds `DevicePreparation` at **High** confidence (same
|
||||
registry-determinism standard as the self-deploying OobeConfig bits), the fallback
|
||||
rules stay Medium. The three explicit downgrade guards from d0ab3eee remain as
|
||||
defence in depth.
|
||||
* **Arm D** (`ShouldTransitionToAwaitingHello`): on WDP the DAD-validated real-user
|
||||
desktop is a legitimate promotion basis — arms A–C are unsatisfiable by construction
|
||||
(no provisioning categories, no Shell-Core 62407 final exit). With a disabled Hello
|
||||
policy the fast-path now completes at desktop arrival.
|
||||
* **Prerequisites**: `completion_waiting` never lists
|
||||
`account_setup_provisioning_complete` on WDP (unsatisfiable; mirrors the
|
||||
`skipUserEsp` exemption).
|
||||
* **Backstop**: `DeadlineNames.DevicePrepCompletion` (30 min, replay-safe base) is
|
||||
armed by `HandleDesktopArrivedV1` when a WDP session parks desktop-first with Hello
|
||||
pending. On fire it mirrors the HelloSafety timeout: synthetic
|
||||
`HelloOutcome=Timeout` + completion through Finalizing. This closes the
|
||||
"no Hello → park until watchdog" hole; a timer surviving into a terminal stage is
|
||||
swallowed by the post-terminal dispatch guard.
|
||||
* **Tripwire**: `DecisionStepProcessor.IsParkedWithoutDeadline` has a WDP branch
|
||||
(dead-end zone = desktop arrived) so a regression in the backstop becomes visible
|
||||
as `session_parked_without_deadline`.
|
||||
|
||||
# Examples
|
||||
|
||||
Session afee7ae0 (the audit trigger): Succeeded in 9:32 min, but its timeline carried
|
||||
`autopilot_profile` (807), an `autopilot_profile_missing` WARN, `esp_config_detected`
|
||||
unknown/unknown, 30 `modern_deployment_log` rollup events and 10 `app_install_starved`
|
||||
entries — all structurally meaningless on WDP. After the gates, the same session shape
|
||||
emits none of these; completion rode Hello-skipped + desktop as before.
|
||||
|
||||
Deliberately NOT changed (open follow-ups, see the WDP audit topic memory): the ESP
|
||||
phase vocabulary (`esp_phase_changed`/stage names ride the Classic rails on WDP),
|
||||
subscription to the `BootstrapperAgentServiceLogProvider` event channel, and
|
||||
`registry_app_state` as the primary WDP app source.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs` — deterministic rule + helper
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs` — profile/ESP emission gates
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs` — `DevicePreparationNoiseEventIds`
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.DevicePreparation.cs` — backstop deadline
|
||||
* `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs` — High/Medium seeding
|
||||
* Tests: `DevicePreparationEngineTests`, `ModernDeploymentTrackerDevicePrepTests`, `EnrollmentTerminationHandlerTests.StarvedApps_skipped_on_device_preparation`, `DeviceInfoCollectorEspRefreshTests.NoEspEvidenceAtAll_SuppressesTheEvent`
|
||||
@@ -58,6 +58,21 @@ timestamp runs backward are dropped (`ClockSkewDropped`).
|
||||
| Complete, `desktop_arrived` event | `desktop_handoff` |
|
||||
| Failed | attribution ends — tail stays unattributed |
|
||||
|
||||
## Quality flags (AttributionVersion 2)
|
||||
|
||||
Duration-critical flags (`ClockSkewDropped`, `PartialObservation`,
|
||||
`WhiteGloveAnchorsIncomplete`, `PriorEnrollmentResidue`) exclude a breakdown from fleet
|
||||
segment statistics with a disclosed count; the blocking-set flags (`BlockingSetUnknown`,
|
||||
`BlockingSetTruncated`) only limit per-app evidence and never gate fleet stats.
|
||||
|
||||
`PriorEnrollmentResidue` (v2) fires on `historic_ime_replay_detected`: the IME log on disk
|
||||
predates the enrollment, so the device was re-enrolled without a wipe — pre-installed apps
|
||||
complete as instant detections and pull the phase anchors ahead of the real ESP page
|
||||
(session f475e697: AccountSetup anchor 6.5 min early, 457 s misattributed to
|
||||
`identity_hello`). `registry_app_baseline` successes are deliberately NOT a trigger — they
|
||||
are by-design normal for Windows Device Preparation (DPP Batch-1 apps install before the
|
||||
agent exists) and would starve that class's aggregates.
|
||||
|
||||
## ESP-blocking apps (positive evidence only)
|
||||
|
||||
Per-app intervals use EVENT timestamps (first started/download event → LAST terminal
|
||||
@@ -117,7 +132,7 @@ gap before the first part-2 phase declaration is unattributed, not guessed.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 1).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Helpers/TimeAttributionCalculator.cs` — calculator + semantics (AttributionVersion 2).
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.TimeAttribution.cs` — persistence.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs` — sweep + aggregation.
|
||||
* `src/Backend/AutopilotMonitor.Functions.Tests/TimeAttributionCalculatorTests.cs` — golden fixtures pinning the invariant.
|
||||
|
||||
@@ -13,6 +13,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
* [Architecture Guide](architecture.md) - High-level architecture overview and solution layout for contributors.
|
||||
* [Version Contract](versioning.md) - One version shape for agent, backend and MCP: the ETag-CAS counter blob that mints build numbers, and the manifests published only after a deploy is verified live.
|
||||
* [Customer Script Publishing](script-publishing.md) - How the customer-facing PowerShell scripts reach download.autopilotmonitor.com/agent/, why publishing is decoupled from agent releases, and the guards keeping the published copy, the version badge and the portal's version oracle in agreement.
|
||||
* [URL Registry](url-registry.md) - Every well-known host lives in one registry file per component (Constants.cs / utils/config.ts / src/config.ts), enforced by guard tests; includes the agent-download migration to the download alias and its deploy-order constraint.
|
||||
* [Drift Guards](drift-guards.md) - The 2026-08 fragility-audit follow-up contracts: SignalR message-name catalog (typed on both ends), the inverted-tick RowKey codec, tombstone table tagging, the shrink-only OkAsync baseline, per-section config PATCH with the section field map, and the web's scoped-endpoint builders + sidebar visibility matrix.
|
||||
|
||||
@@ -23,9 +24,11 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
* [ESP Completion Starvation — the sits-d Cloud-PC complex](agent/post-reboot-completion-recovery.md) - Why a SkipUser=true flow (no user ESP page → no Shell-Core 62407, no AccountSetup registry) left the completion gate satisfied but never evaluated — five Cloud PCs idled six hours at "waiting on: hello_resolution" — and the fix: the observed skip stands in for the final-exit evidence in the IME user-session completion attempt. Plus the two adjacent defects fixed alongside: the edge-only user-apps-settled synthesis (now level-triggered) and the never-wired Shell-Core restart replay (now Hello-wizard-only, with the reasoning why a replayed 62407 must never reach the reducer).
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
|
||||
* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm.
|
||||
* [WDP Scenario Gates & Completion Backstop](agent/wdp-scenario-gates.md) - How the agent gates Autopilot/ESP-only machinery off on Windows Device Preparation sessions (deterministic ExecutionContext marker, fail-toward-Classic), which noise it suppresses (ZTD probe, esp_config unknown, ModernDeployment 100/417/418/1005, starved sweep), and the WDP completion model: High-confidence seed, desktop-based arm D, and the 30-min device_prep_completion backstop.
|
||||
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
|
||||
|
||||
# Rules
|
||||
|
||||
+16
@@ -1,7 +1,23 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-20
|
||||
|
||||
* **Update**: `agent/post-reboot-completion-recovery.md` — retitled to "ESP Completion Starvation — the sits-d Cloud-PC complex" and reframed on the evidence: the reboot was a red herring. The five stuck Cloud PCs carried `SkipUserStatusPage=True` (read 10+ times across three agent processes, `True` every time) and Windows honoured it — no user ESP page, hence no Shell-Core 62407 and no AccountSetup provisioning registry, verified over a six-hour uninterrupted post-reboot run with the watcher armed. Arm B of `ShouldTransitionToAwaitingHello` was open from the bootstrap onward, but every evaluation site hangs off a signal a skip-user flow cannot produce, and the `AdvisoryCompletion` backstop arms exclusively from an `EspExiting` signal — an open gate nobody knocks on. Fix: `HandleImeUserSessionCompletedV1` accepts the observed skip in place of the final-exit evidence (`skipUserExitEquivalent`; anchor + genuine IME completion + real-user desktop stay mandatory, WDP excluded). The knock deliberately lives on the IME edge, not desktop arrival — ordering-robust against Fix 10's AccountSetup bounce-back and semantically "user-phase work finished" rather than mid-install. `ClassicSkipUserEspUserSessionCompletionTests` replays the real 8110e262 ordering end to end. Docs and comments no longer claim an ESP-exit recovery that does not exist (stray duplicate summary in `ShellCoreTracker`, `ImeLogHost` restore-callback comment); the residual `SkipUser=false` exit-in-downtime gap is named honestly and surfaced by `ANALYZE-ESP-005` via `session_stalled` instead of being patched with an unorderable replay.
|
||||
|
||||
## 2026-08-19
|
||||
|
||||
* **New**: `agent/post-reboot-completion-recovery.md` — a forced mid-ESP reboot (DeviceGuard/DmaGuard) used to cost the session its AccountSetup completion permanently. Two defects: `ShellCoreTracker.BackfillRecentEspExitEvents()` existed but had no caller, so every agent restart dropped an ESP exit (62407) written while it was down; and the user-apps-settled synthesis was edge-triggered on `EspExited` only, so apps settling afterwards never re-opened the gate. `EspAndHelloHost.Start()` now backfills with a lookback sized to the downtime (`previousExit=reboot_kill` → since `LastBootUtc`, clamped to 360 min), and `ReevaluateUserAppsSettledSynthesis()` is chained into `ImeLogTracker.OnAppStateChanged`. No new completion path — the gate conditions are unchanged. Evidence: sits-d Cloud PCs, five `Incomplete` sessions on healthy devices, two of them at `138/138 apps, 0 failed`. Hardened over two review rounds: the remembered edge must be a **live** exit that is **confirmed** post-AccountSetup (a replay carries no evidence of its own position — Shell-Core writes the same `CommercialOOBE_ESPProgress_Page_Exiting` for the intermediate and the final exit, and the registry check reads *now*, not the event's time); the replay hands over the **newest** exit rather than the oldest; the replay is narrowed to the Hello-wizard start (62404) alone — a conservative fact that can only make the agent wait longer. A replayed ESP exit is never re-raised at all: Windows writes the identical description for the intermediate and the final exit, the reducer orders exits by ingest ordinal (fresher than reality for a replay) and `HandleEspExitingV1` passes `espFinalExitInFlight: true` for every arriving exit, so arm C of `ShouldTransitionToAwaitingHello` would open on a historic handoff once the restored state carries AccountSetupEntered + IME user session + desktop. A replayed ESP failure is likewise dropped (it could fail a session that recovered). Skipped records are reported as an `agent_trace`. The window is **measured** from the previous run's last known liveness (snapshot mtime, widened by `LastBootUtc`) instead of a constant — the scheduled task has a BootTrigger only and no restart-on-failure, so a crashed agent returns hours later and five minutes would lose the exit every time. On `first_run` the replay stays off, leaving the happy path untouched.
|
||||
|
||||
* **Update**: `rules/analyze-rule-triggers.md` — `on_event:session_stalled` documented as an established interim trigger for the never-terminal stuck session (one emission per session, no throttle needed). `ANALYZE-ESP-005` carries it: both its conditions are monotonic and it has no terminal precondition, and it fires at confidence 95 on the sits-d stalled stream. `ANALYZE-APP-015` deliberately does NOT — it is the worked example of the terminal-precondition trap (lint 1) — and `ANALYZE-ESP-001` does not either, because its `phase_duration` gate on a still-open phase is unverified mid-run. New `ANALYZE-APP-016` ("Required Apps Never Started — ESP Apps Gate Starved") turns the existing `app_install_starved` events into a customer-facing finding and, unlike `ANALYZE-APP-015`, fires even when the session succeeded — session `3b9291aa` was recorded `Succeeded` with 99 required apps that never started installing.
|
||||
|
||||
* **New**: `script-publishing.md` — the customer script publishing chain. `.github/scripts/Publish-BootstrapScripts.ps1` becomes the single owner (dev render, version-bump guard, uploads, version oracles, alias verification), called both by the new `publish-scripts.yml` (push to `main` on `scripts/Bootstrap/**` and `scripts/CustomerSetup/**`) and by the `publish_as_stable` branch of `build-agent.yml`. Script publishing no longer waits for an agent release. Blobs are published in a deterministic CRLF/UTF-8-no-BOM form so byte comparisons do not depend on the checkout; the Pester + ASCII gates move into the reusable `bootstrap-script-gates.yml` and block the upload; `AdminConfiguration.LatestBootstrapV2ScriptVersion` now has exactly one writer. The version-bump guard compares a code fingerprint (PowerShell token stream without comments, newlines and line continuations), so only behaviour changes require a `$ScriptVersion` bump — comment and formatting changes publish with a warning.
|
||||
|
||||
## 2026-08-18
|
||||
|
||||
* **New**: `agent/wdp-scenario-gates.md` — WDP scenario gates (afee7ae0 audit): deterministic ExecutionContext marker as the only gate key (fail-toward-Classic), suppressed Autopilot/ESP-only emissions and hosts (autopilot_profile*/ZTD probe, esp_config unknown null-gate, EspPolicyProviderStallHost, ProvisioningStatusTracker, ModernDeployment noise IDs 100/417/418/1005 + error-backfill skip, starved sweep), and the WDP completion model (High-confidence v2 seed via `enrollmentTypeDeterministic`, desktop-based arm D, 30-min `device_prep_completion` backstop, parked-tripwire WDP branch).
|
||||
|
||||
* **Update**: `backend/time-attribution.md` — AttributionVersion 1 → 2: new duration-critical quality flag `PriorEnrollmentResidue` (fires on `historic_ime_replay_detected`; a re-enrollment without wipe pulls the phase anchors ahead of the real ESP page, so the segments are excluded from fleet stats; `registry_app_baseline` successes deliberately not a trigger — by-design normal for WDP). Added the quality-flags section.
|
||||
* **Update**: `agent/logs-and-persistence.md`, `agent/overview.md`, `agent/gather-rule-debug-log.md` — log files renamed to unified kebab-case: `agent.log` (date rotation dropped; size segments `agent-NNN.log`), `bootstrap-script.log` (was `bootstrap_agent.log`), `bootstrap-msi.log` (was `msi-bootstrap.log`), `crash-*.log`, `gather-rules-debug.log`, `ime-pattern-matches.log`. Old names still appear in archives from pre-rename agents.
|
||||
* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes.
|
||||
|
||||
## 2026-08-17
|
||||
|
||||
@@ -101,6 +101,10 @@ Interim evaluation changes what a rule's conditions mean mid-flight. Three new l
|
||||
- WG battery rule (sits-d): `evaluateOn: ["enrollment_end", "whiteglove_sealed"]` — technician gets the channel alert at the bench; Part-2 terminal pass finalizes or resolves the finding.
|
||||
- Hybrid-affinity stuck (esa): ANALYZE-ID-004 gets `evaluateOn: ["enrollment_end", "on_event:hybrid_login_pending"]` — the second overdue-login event (threshold 65) produces a live finding on the still-InProgress session. ANALYZE-APP-015 stays `enrollment_end`-only until its conditions are hardened (lint 1).
|
||||
|
||||
- Stuck after a forced mid-ESP reboot (sits-d Cloud PCs, 2026-08-19): `on_event:session_stalled` is the established trigger for the never-terminal stuck session. The agent emits `session_stalled` exactly once per session (60-minute stall probe), so the trigger is rare by construction and needs no throttle. Five Cloud PCs sat at `InProgress` with **zero findings** for hours — a mid-ESP reboot had killed the agent in `Stage=EspAccountSetup` — before the max-lifetime watchdog eventually terminalised them as `Incomplete`. ANALYZE-ESP-005 carries the trigger and fires on that stream at confidence 95 (verified against session `8110e262`): both its conditions (`mdm_policy_reboot_required.firstRebootUri` exists, `system_reboot_detected` count ≥ 1) are **monotonic** and it has no terminal preconditions, so lint 1 does not apply. ANALYZE-APP-016 is interim-safe for the same reason (`app_install_starved` count ≥ 1 only ever grows).
|
||||
|
||||
Two rules were considered and deliberately **not** given the trigger: ANALYZE-APP-015 is the worked example of lint 1 above and stays `enrollment_end`-only; ANALYZE-ESP-001 gates on `phase_duration` of a phase that is still open mid-run, and that evaluation is not pinned by a test — an unverified trigger is not worth a possible false positive on every slow DeviceSetup.
|
||||
|
||||
# Product Decisions (settled 2026-08-14)
|
||||
|
||||
1. Notify-on-resolve (a "finding healed" message)? **No** (v1) — resolution is visible in UI/MCP.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Customer Script Publishing — download.autopilotmonitor.com/agent/
|
||||
description: How the customer-facing PowerShell scripts reach the download alias, why publishing is decoupled from agent releases, and the guards that keep the published copy, the version badge and the portal's version oracle in agreement.
|
||||
resource: /.github/scripts/Publish-BootstrapScripts.ps1
|
||||
tags:
|
||||
- build
|
||||
- ci
|
||||
- release
|
||||
- bootstrap
|
||||
- download-alias
|
||||
timestamp: 2026-08-19T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Overview
|
||||
|
||||
Four PowerShell scripts are served to customers from `https://download.autopilotmonitor.com/agent/`:
|
||||
|
||||
| Blob | Source | Consumed by |
|
||||
| --- | --- | --- |
|
||||
| `Install-AutopilotMonitor.ps1` | `scripts/Bootstrap/Install-AutopilotMonitor.ps1` | Intune platform script (customer uploads a copy); downloaded live on every device by the WDP bootstrap MSI |
|
||||
| `Install-AutopilotMonitor-Dev.ps1` | rendered from the same source | dev fleet |
|
||||
| `Test-ShouldBootstrapAgent.ps1` | `scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1` | customer dry run via `irm ... \| iex` |
|
||||
| `Grant-AutopilotMonitorAddOn.ps1` | `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` | optional Graph permission grant |
|
||||
|
||||
`Uninstall-AutopilotMonitor.ps1` is deliberately not published — nothing links it, and an
|
||||
unauthenticated uninstall script on the public download host is not a feature.
|
||||
|
||||
The alias is a Front Door route in front of the `agent` container; see
|
||||
[url-registry.md](url-registry.md) for the host registry and the migration to the alias.
|
||||
|
||||
# Publishing chain
|
||||
|
||||
`.github/scripts/Publish-BootstrapScripts.ps1` is the single owner. Two workflows call it
|
||||
with identical arguments:
|
||||
|
||||
* **`publish-scripts.yml`** — on every push to `main` touching `scripts/Bootstrap/**`,
|
||||
`scripts/CustomerSetup/**` or the publisher itself, plus `workflow_dispatch` with a
|
||||
`dry_run` input.
|
||||
* **`build-agent.yml`** — inside the `publish_as_stable` branch, after `version.json` is
|
||||
uploaded, so a release lands a set consistent with what is already live.
|
||||
|
||||
Before this split, the script blobs rotated **only** on a stable agent release. A bootstrap
|
||||
fix therefore sat unpublished until the next agent cutover — while the WDP MSI downloads the
|
||||
published copy on every enrolling device. Script publishing is now decoupled from the agent
|
||||
release cadence; the release path still publishes, so it can never fall behind either.
|
||||
|
||||
Both workflows share the concurrency group `agent-blob-publish` (`cancel-in-progress: false`).
|
||||
Both write `version.json`, and the publisher's `bootstrapVersion` read-modify-write must not
|
||||
interleave with a release writing the agent fields.
|
||||
|
||||
# Publisher steps
|
||||
|
||||
1. **Parse** `$ScriptVersion` from the bootstrap source.
|
||||
2. **Dev render** — literal substitution of the two `$AgentDownloadUrl` / `$VersionJsonName`
|
||||
defaults. A missing anchor literal is a hard failure: a silently un-substituted dev script
|
||||
would point the dev fleet at the stable agent.
|
||||
3. **Version-bump guard** — if the published bootstrap changed *behaviour* under an unchanged
|
||||
`$ScriptVersion`, the run aborts. Comment and formatting changes publish with a warning.
|
||||
See [Version oracles](#version-oracles).
|
||||
4. **Upload** every blob with `Cache-Control: no-cache` (they all rotate in place), mirrored
|
||||
fail-soft to the legacy storage account.
|
||||
5. **Reconcile the version oracles.**
|
||||
6. **Verify through the alias** — re-download each blob and compare SHA-256, six attempts
|
||||
20 s apart. A persistent mismatch means blobs were written but Front Door serves stale
|
||||
content, and the error names the `az afd endpoint purge` command.
|
||||
|
||||
## Deterministic publish form
|
||||
|
||||
Every blob is published as **CRLF, UTF-8 without BOM**, normalized by the publisher rather
|
||||
than taken from the working copy. Git normalizes line endings on checkout — a Windows runner
|
||||
yields CRLF, a `core.autocrlf=false` clone yields LF — so publishing raw working-copy bytes
|
||||
would emit different blobs for identical source and make every byte comparison, guard and
|
||||
verification alike, depend on where the workflow ran. CRLF is also what PS 5.1 expects on the
|
||||
device and what has always been published.
|
||||
|
||||
The ASCII-only rule for `scripts/Bootstrap/*.ps1` (enforced by the gates below) is unrelated
|
||||
but adjacent: PS 5.1 reads BOM-less files as ANSI and corrupts multi-byte characters.
|
||||
|
||||
## Version oracles
|
||||
|
||||
Three places state the bootstrap script version, and all three are derived from
|
||||
`$ScriptVersion` in the source:
|
||||
|
||||
* `version.json.bootstrapVersion` — read by the docs badge. Written by the publisher as a
|
||||
read-modify-write under `If-Match`, so a concurrent agent release keeps its own fields.
|
||||
`build-agent.yml` seeds the field when it builds the manifest so the published manifest is
|
||||
never briefly missing it; the publisher owns and re-asserts it afterwards.
|
||||
* `AdminConfiguration.LatestBootstrapV2ScriptVersion` — read by the portal to tell a customer
|
||||
their uploaded copy is outdated. **Only** the publisher writes it; `build-agent.yml` writes
|
||||
the `LatestAgentV2*` fields and nothing else. One writer per field.
|
||||
* The `Bootstrap script version: v<x>` marker the script logs, parsed out of captured platform
|
||||
script stdout by `utils/bootstrapVersion.ts`.
|
||||
|
||||
The version-bump guard exists because those three would otherwise keep asserting a version
|
||||
that no longer matches what customers download: a behaviour change under an unchanged version
|
||||
is invisible to every consumer, and customers decide from that version whether to re-upload
|
||||
their Intune copy.
|
||||
|
||||
"Changed" means changed **behaviour**, matching what the source file's own comment on
|
||||
`$ScriptVersion` already asks for ("bump on meaningful changes"). The guard compares a code
|
||||
fingerprint — the PowerShell token stream with comments, newlines and line continuations
|
||||
removed — so comment fixes, typo corrections in comments and reflow publish with a
|
||||
`::warning::` instead of failing. Tokenising rather than regex-stripping keeps a `#` inside a
|
||||
string literal from being mistaken for a comment.
|
||||
|
||||
# Gates
|
||||
|
||||
`.github/workflows/bootstrap-script-gates.yml` is a `workflow_call` workflow holding the
|
||||
checks once:
|
||||
|
||||
* Pester suite under **Windows PowerShell 5.1** — the runtime IME uses for platform scripts.
|
||||
* ASCII-only scan over `scripts/Bootstrap/*.ps1`.
|
||||
* `Publish-BootstrapScripts.ps1 -DryRun` — exercises the dev-render anchors and the
|
||||
version-bump guard, and reports per blob whether the alias is current, stale or missing.
|
||||
|
||||
Two callers: `pester.yml` on pull requests (reports, does not block the merge button), and
|
||||
`publish-scripts.yml` on pushes to `main`, where the gates job is a `needs:` dependency of the
|
||||
publish job and therefore **blocks** the upload. `pester.yml` deliberately has no push trigger
|
||||
— it would duplicate the gate run with no consequence.
|
||||
|
||||
# Operating it
|
||||
|
||||
* Dry run and drift check, no credentials needed for the read paths:
|
||||
`pwsh .github/scripts/Publish-BootstrapScripts.ps1 -DryRun`
|
||||
* Republish without a code change: `workflow_dispatch` on **Publish customer scripts** with
|
||||
`dry_run=false`.
|
||||
* Front Door serving stale content after a successful upload:
|
||||
`az afd endpoint purge --resource-group rg-autopilotmonitor-prd-gwc --profile-name autopilotmonitor-fd --endpoint-name apm-download --content-paths '/agent/*'`
|
||||
|
||||
# Citations
|
||||
|
||||
* `.github/scripts/Publish-BootstrapScripts.ps1`
|
||||
* `.github/workflows/publish-scripts.yml`, `.github/workflows/bootstrap-script-gates.yml`, `.github/workflows/pester.yml`
|
||||
* `.github/workflows/build-agent.yml` (the `publish_as_stable` branch of the upload step)
|
||||
* `src/Agent/AutopilotMonitor.BootstrapMsi/Invoke-BootstrapDownload.ps1`
|
||||
* `src/Web/autopilot-monitor-web/utils/bootstrapVersion.ts`
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-016",
|
||||
"title": "Required Apps Never Started — ESP Apps Gate Starved",
|
||||
"description": "One or more required user-ESP apps never started installing while the ESP AccountSetup apps gate waited on them — the agent observed the ESP page exit with those apps still in a non-terminal, never-started state. Unlike ANALYZE-APP-015 (which reports that completion was DEFERRED because of a pending app, and stays silent once the session reaches a terminal verdict) this rule reports the delivery gap itself and fires regardless of the final outcome: an enrollment that Windows called done while required apps never ran is a successful enrollment with missing software, and the operator must see it.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:session_stalled"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 70,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "starved_apps_present",
|
||||
"source": "event_count",
|
||||
"eventType": "app_install_starved",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "many_starved_apps",
|
||||
"source": "event_count",
|
||||
"eventType": "app_install_starved",
|
||||
"operator": "count_gte",
|
||||
"value": "5",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appName",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_starved",
|
||||
"dataField": "appName",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "state",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_starved",
|
||||
"dataField": "state",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "many_starved_apps",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"signal": "appName",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "At least one **required** app assigned to the user ESP never started installing. The Intune Management Extension never moved it out of its initial state (`{{state}}`) — it did not download, did not install, and did not fail — while the ESP AccountSetup apps gate was waiting on it. First app observed: `{{appName}}`.\n\nEvery starved app has its own `app_install_starved` timeline event carrying `appId`, `appName` and `state` — open the timeline and filter on that event type for the complete list.\n\nThis is a **delivery** problem, not an install error, and it is invisible in the Intune console: the app simply never appears. Typical causes:\n\n- far more required apps targeted at the ESP than the gate can service in the available window,\n- requirement rules that are never met on this device (OS / architecture / registry / script checks),\n- an unsatisfied dependency or supersedence chain,\n- an app that needs a context unavailable during provisioning (e.g. a VPN client waiting on a portal sign-in),\n- a mid-ESP reboot that cut the gate short — check whether the mid-ESP reboot advisory also fired for this session.\n\nNote that this finding is independent of the session verdict. A session can be marked **Succeeded** and still be missing every app listed here.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Shrink the ESP blocking set",
|
||||
"steps": [
|
||||
"Open the Enrollment Status Page profile and review the blocking app list — every required app there must finish before the user reaches the desktop.",
|
||||
"Move apps that do not have to be present at first logon from *required* to *available*, or remove them from the ESP's blocking apps selection.",
|
||||
"Prefer a small, deliberately chosen blocking set (security agent, VPN, management tooling) over the full application catalogue."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Unblock the individual apps",
|
||||
"steps": [
|
||||
"Collect the app names from the `app_install_starved` timeline events in this session.",
|
||||
"For each app, review its requirement rules and dependencies in Intune — IntuneManagementExtension.log records every requirement evaluation.",
|
||||
"Verify the assignment actually targets the signed-in user (user-targeted apps need an established Entra user affinity; check the hybrid user-affinity finding if it also fired).",
|
||||
"Re-run an enrollment and confirm the apps now reach a terminal state before the ESP exits."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Rule out a truncated gate",
|
||||
"steps": [
|
||||
"Check whether a mid-ESP reboot was observed for this session — a forced restart during AccountSetup cuts the apps gate short and can starve everything still queued.",
|
||||
"If so, reassign the reboot-forcing device policies to user groups so the restart lands after AccountSetup."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page — blocking apps",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/intune-service/enrollment/windows-enrollment-status"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Win32 app requirement rules",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/intune-service/apps/apps-win32-add"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"apps",
|
||||
"esp",
|
||||
"starvation",
|
||||
"assignment",
|
||||
"blocking",
|
||||
"delivery-gap"
|
||||
]
|
||||
}
|
||||
@@ -5,12 +5,13 @@
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
"whiteglove_sealed",
|
||||
"on_event:session_stalled"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
|
||||
+115
-2
@@ -831,6 +831,118 @@
|
||||
"pending"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-APP-016",
|
||||
"title": "Required Apps Never Started — ESP Apps Gate Starved",
|
||||
"description": "One or more required user-ESP apps never started installing while the ESP AccountSetup apps gate waited on them — the agent observed the ESP page exit with those apps still in a non-terminal, never-started state. Unlike ANALYZE-APP-015 (which reports that completion was DEFERRED because of a pending app, and stays silent once the session reaches a terminal verdict) this rule reports the delivery gap itself and fires regardless of the final outcome: an enrollment that Windows called done while required apps never ran is a successful enrollment with missing software, and the operator must see it.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed",
|
||||
"on_event:session_stalled"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 70,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "starved_apps_present",
|
||||
"source": "event_count",
|
||||
"eventType": "app_install_starved",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "many_starved_apps",
|
||||
"source": "event_count",
|
||||
"eventType": "app_install_starved",
|
||||
"operator": "count_gte",
|
||||
"value": "5",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "appName",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_starved",
|
||||
"dataField": "appName",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "state",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_starved",
|
||||
"dataField": "state",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "many_starved_apps",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
},
|
||||
{
|
||||
"signal": "appName",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "At least one **required** app assigned to the user ESP never started installing. The Intune Management Extension never moved it out of its initial state (`{{state}}`) — it did not download, did not install, and did not fail — while the ESP AccountSetup apps gate was waiting on it. First app observed: `{{appName}}`.\n\nEvery starved app has its own `app_install_starved` timeline event carrying `appId`, `appName` and `state` — open the timeline and filter on that event type for the complete list.\n\nThis is a **delivery** problem, not an install error, and it is invisible in the Intune console: the app simply never appears. Typical causes:\n\n- far more required apps targeted at the ESP than the gate can service in the available window,\n- requirement rules that are never met on this device (OS / architecture / registry / script checks),\n- an unsatisfied dependency or supersedence chain,\n- an app that needs a context unavailable during provisioning (e.g. a VPN client waiting on a portal sign-in),\n- a mid-ESP reboot that cut the gate short — check whether the mid-ESP reboot advisory also fired for this session.\n\nNote that this finding is independent of the session verdict. A session can be marked **Succeeded** and still be missing every app listed here.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Shrink the ESP blocking set",
|
||||
"steps": [
|
||||
"Open the Enrollment Status Page profile and review the blocking app list — every required app there must finish before the user reaches the desktop.",
|
||||
"Move apps that do not have to be present at first logon from *required* to *available*, or remove them from the ESP's blocking apps selection.",
|
||||
"Prefer a small, deliberately chosen blocking set (security agent, VPN, management tooling) over the full application catalogue."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Unblock the individual apps",
|
||||
"steps": [
|
||||
"Collect the app names from the `app_install_starved` timeline events in this session.",
|
||||
"For each app, review its requirement rules and dependencies in Intune — IntuneManagementExtension.log records every requirement evaluation.",
|
||||
"Verify the assignment actually targets the signed-in user (user-targeted apps need an established Entra user affinity; check the hybrid user-affinity finding if it also fired).",
|
||||
"Re-run an enrollment and confirm the apps now reach a terminal state before the ESP exits."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Rule out a truncated gate",
|
||||
"steps": [
|
||||
"Check whether a mid-ESP reboot was observed for this session — a forced restart during AccountSetup cuts the apps gate short and can starve everything still queued.",
|
||||
"If so, reassign the reboot-forcing device policies to user groups so the restart lands after AccountSetup."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page — blocking apps",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/intune-service/enrollment/windows-enrollment-status"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Win32 app requirement rules",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/intune-service/apps/apps-win32-add"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"apps",
|
||||
"esp",
|
||||
"starvation",
|
||||
"assignment",
|
||||
"blocking",
|
||||
"delivery-gap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-CORR-003",
|
||||
"title": "Proxy Configuration Causing Download Failure",
|
||||
@@ -2069,12 +2181,13 @@
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
"whiteglove_sealed",
|
||||
"on_event:session_stalled"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
|
||||
+1
-1
@@ -629,7 +629,7 @@
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
|
||||
"action": "imeTokenFailure",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). This line appears ROUTINELY during the device phase (no user token exists yet), so the agent holds the failure pending: any IME-TOKEN-SUCCESS match clears it, and the ime_token_failure Warning fires only when no success follows for the whole 30-min grace window (then deduped to one Warning per distinct errorCode). A genuine auth outage (WAM broken, conditional access, network) runs long enough to hit the window. Verified against IME 1.97 and 1.104 decompiled sources.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-08-18 v2.4 Renamed log file bootstrap_agent.log -> bootstrap-script.log
|
||||
(unified kebab-case log naming across the agent log directory).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
@@ -126,13 +128,13 @@ param(
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.3"
|
||||
$ScriptVersion = "2.4"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent.log"
|
||||
$LogFile = "$AgentLogPath\bootstrap-script.log"
|
||||
|
||||
# Write-Host (NOT Write-Output): log calls happen inside functions that return
|
||||
# decision objects; Write-Output would leak every log line into those return values.
|
||||
|
||||
+35
@@ -61,6 +61,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
|
||||
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoEspEvidenceAtAll_SuppressesTheEvent()
|
||||
{
|
||||
// afee7ae0 (WDP / plain Entra join): FirstSync has no values and the ESP tracking
|
||||
// key does not exist — an event saying only "SkipUser=unknown, SkipDevice=unknown"
|
||||
// is noise and must not be emitted (mirrors the orchestrator bootstrap's null-gate).
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var collector = new DeviceInfoCollector(
|
||||
"session-nullgate", "tenant-1",
|
||||
new InformationalEventPost(ingress, clock), logger,
|
||||
signalIngress: null, clock: clock,
|
||||
startupGate: new StartupEventGate(tmp.Path, logger));
|
||||
|
||||
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
|
||||
_ => new EspFirstSyncSnapshot(
|
||||
skipUser: null, skipDevice: null,
|
||||
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => default))
|
||||
{
|
||||
collector.CollectEspConfiguration();
|
||||
}
|
||||
|
||||
Assert.Empty(EspConfigEvents(ingress));
|
||||
|
||||
// A later refresh where real evidence appeared must still emit normally.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsDevice);
|
||||
}
|
||||
Assert.Single(EspConfigEvents(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
|
||||
{
|
||||
|
||||
+25
-12
@@ -17,14 +17,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan §EspExiting Adapter — wire-through verification of the production EspExiting path:
|
||||
/// <c>ShellCoreTracker.HandleBackfillRecord</c> → <c>EspExited</c> event → inner re-raise on
|
||||
/// <c>EspAndHelloTracker.EspExited</c> → <c>EspAndHelloTrackerAdapter</c> → ingress → engine
|
||||
/// reducer. This is the path the live V2-Host uses; without the coordinator forward the
|
||||
/// EspExiting signal would never reach the engine in production.
|
||||
/// <c>ShellCoreTracker.ProcessEvent</c> (live Shell-Core 62407) → <c>EspExited</c> event →
|
||||
/// inner re-raise on <c>EspAndHelloTracker.EspExited</c> → <c>EspAndHelloTrackerAdapter</c> →
|
||||
/// ingress → engine reducer. Without the coordinator forward the EspExiting signal would
|
||||
/// never reach the engine in production.
|
||||
/// <para>
|
||||
/// The first test isolates the tracker's <c>EspExited</c> contract (source-event timestamp
|
||||
/// preserved through backfill); the coordinator tests drive the full production chain through
|
||||
/// to the <c>HelloSafety</c> deadline when AccountSetup is already entered.
|
||||
/// preserved rather than collapsed to wall-clock-now); the coordinator tests drive the full
|
||||
/// production chain through to the <c>HelloSafety</c> deadline when AccountSetup is already
|
||||
/// entered.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It used to drive that first test through <c>HandleBackfillRecord</c> because the replay is
|
||||
/// easier to call than the event-log watcher — but the replay no longer raises
|
||||
/// <c>EspExited</c> at all (a replayed 62407 cannot be placed in time; see
|
||||
/// <c>ShellCoreTrackerReplayScopeTests</c>), and it never was the production path: the replay
|
||||
/// had no caller when this test was written.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ShellCoreToEngineEspExitingTests
|
||||
@@ -34,12 +42,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
"CommercialOOBE_ESPProgress_Page_Exiting"; // matches EspExitingPattern in ShellCoreTracker
|
||||
|
||||
[Fact]
|
||||
public void Tracker_backfill_record_raises_EspExited_with_source_timestamp()
|
||||
public void Tracker_live_record_raises_EspExited_with_source_timestamp()
|
||||
{
|
||||
// Tracker-level contract: HandleBackfillRecord raises EspExited carrying the source
|
||||
// event time (not Clock.UtcNow). The EspExited→EspExiting signal mapping and SourceOrigin
|
||||
// are covered by the coordinator tests below; here we isolate the backfill→event link
|
||||
// that the production coordinator depends on.
|
||||
// Tracker-level contract: the live 62407 path raises EspExited carrying the source
|
||||
// event time (record.TimeCreated, not Clock.UtcNow). The EspExited→EspExiting signal
|
||||
// mapping and SourceOrigin are covered by the coordinator tests below; here we isolate
|
||||
// the event-log→event link that the production coordinator depends on.
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var clock = new VirtualClock(ClockNow);
|
||||
@@ -57,7 +65,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
// Source time IS in the past relative to the clock — verify the tracker carries the
|
||||
// historical timestamp on the event rather than collapsing to Clock.UtcNow.
|
||||
var sourceTime = ClockNow.AddMinutes(-2);
|
||||
tracker.HandleBackfillRecord(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspExitingDescription, sourceTime);
|
||||
tracker.ProcessEvent(
|
||||
eventId: ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
description: EspExitingDescription,
|
||||
timestamp: sourceTime,
|
||||
providerName: "Microsoft-Windows-Shell-Core",
|
||||
isBackfill: false);
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(sourceTime, captured!.OccurredAtUtc);
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
/// </summary>
|
||||
public sealed class AgentLoggerTests
|
||||
{
|
||||
private static string TodayBaseName => $"agent_{DateTime.Now:yyyyMMdd}";
|
||||
private const string BaseName = "agent";
|
||||
|
||||
[Fact]
|
||||
public void Log_writes_to_unsuffixed_base_file_when_dir_is_empty()
|
||||
@@ -25,7 +25,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
|
||||
logger.Info("hello");
|
||||
|
||||
var basePath = Path.Combine(tmp.Path, TodayBaseName + ".log");
|
||||
var basePath = Path.Combine(tmp.Path, BaseName + ".log");
|
||||
Assert.True(File.Exists(basePath), $"expected base file at {basePath}");
|
||||
Assert.Contains("hello", File.ReadAllText(basePath));
|
||||
}
|
||||
@@ -39,17 +39,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
|
||||
// Each line ~80 bytes after timestamp/severity envelope; 30 lines clears 1 KB
|
||||
// even at the smallest payload. The first lines fill the base file; once the
|
||||
// FileInfo.Length read sees >= 1024 the next call rotates to _002.
|
||||
// FileInfo.Length read sees >= 1024 the next call rotates to -002.
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
logger.Info($"line-{i:D4}-padded-with-some-extra-context-to-cross-1KB-quickly");
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(tmp.Path, TodayBaseName + "*.log").OrderBy(s => s).ToArray();
|
||||
var files = Directory.GetFiles(tmp.Path, BaseName + "*.log").OrderBy(s => s).ToArray();
|
||||
Assert.True(files.Length >= 2, $"expected at least 2 segments, got: {string.Join(",", files.Select(Path.GetFileName))}");
|
||||
|
||||
// The unsuffixed file is the first segment; rotated segments use _002 onward.
|
||||
var rotated = files.FirstOrDefault(p => Path.GetFileNameWithoutExtension(p).EndsWith("_002"));
|
||||
// The unsuffixed file is the first segment; rotated segments use -002 onward.
|
||||
var rotated = files.FirstOrDefault(p => Path.GetFileNameWithoutExtension(p).EndsWith("-002"));
|
||||
Assert.NotNull(rotated);
|
||||
}
|
||||
|
||||
@@ -64,8 +64,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
logger.Info($"pad-{i:D4}-content-content-content-content-content-content");
|
||||
}
|
||||
|
||||
var rotatedPath = Path.Combine(tmp.Path, TodayBaseName + "_002.log");
|
||||
Assert.True(File.Exists(rotatedPath), "expected agent_YYYYMMDD_002.log to exist");
|
||||
var rotatedPath = Path.Combine(tmp.Path, BaseName + "-002.log");
|
||||
Assert.True(File.Exists(rotatedPath), "expected agent-002.log to exist");
|
||||
var first = File.ReadAllLines(rotatedPath).FirstOrDefault();
|
||||
Assert.NotNull(first);
|
||||
Assert.Contains("AgentLogger: rotated", first);
|
||||
@@ -75,23 +75,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
public void Log_continues_existing_rotation_on_restart_without_overwriting()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
// Pre-seed: looks like an earlier process already rotated up to _003.
|
||||
File.WriteAllText(Path.Combine(tmp.Path, TodayBaseName + ".log"), "older-base\n");
|
||||
File.WriteAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log"), "older-002\n");
|
||||
File.WriteAllText(Path.Combine(tmp.Path, TodayBaseName + "_003.log"), "older-003\n");
|
||||
// Pre-seed: looks like an earlier process already rotated up to -003.
|
||||
File.WriteAllText(Path.Combine(tmp.Path, BaseName + ".log"), "older-base\n");
|
||||
File.WriteAllText(Path.Combine(tmp.Path, BaseName + "-002.log"), "older-002\n");
|
||||
File.WriteAllText(Path.Combine(tmp.Path, BaseName + "-003.log"), "older-003\n");
|
||||
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
|
||||
logger.Info("after-restart");
|
||||
|
||||
// New entries must land in the highest existing segment (_003), not in a fresh
|
||||
// New entries must land in the highest existing segment (-003), not in a fresh
|
||||
// base file. Older content in _003 must be preserved.
|
||||
var resumed = File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_003.log"));
|
||||
var resumed = File.ReadAllText(Path.Combine(tmp.Path, BaseName + "-003.log"));
|
||||
Assert.Contains("older-003", resumed);
|
||||
Assert.Contains("after-restart", resumed);
|
||||
|
||||
// Older segments are left alone.
|
||||
Assert.Equal("older-base\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + ".log")));
|
||||
Assert.Equal("older-002\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log")));
|
||||
Assert.Equal("older-base\n", File.ReadAllText(Path.Combine(tmp.Path, BaseName + ".log")));
|
||||
Assert.Equal("older-002\n", File.ReadAllText(Path.Combine(tmp.Path, BaseName + "-002.log")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -105,7 +105,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
// as plain ASCII, while genuine non-ASCII payload stays untouched.
|
||||
logger.Info("check failed \u2014 range 1\u20132 \u2192 done\u2026 \u2018a\u2019 \u201Cb\u201D nb\u00A0sp f\u00FCr M\u00FCller");
|
||||
|
||||
var text = File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + ".log"));
|
||||
var text = File.ReadAllText(Path.Combine(tmp.Path, BaseName + ".log"));
|
||||
Assert.Contains("check failed - range 1-2 -> done... 'a' \"b\" nb sp f\u00FCr M\u00FCller", text);
|
||||
}
|
||||
|
||||
@@ -115,8 +115,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
|
||||
|
||||
// Hold an exclusive lock on today's file to force AppendAllText to throw.
|
||||
var lockedPath = Path.Combine(tmp.Path, TodayBaseName + ".log");
|
||||
// Hold an exclusive lock on the log file to force AppendAllText to throw.
|
||||
var lockedPath = Path.Combine(tmp.Path, BaseName + ".log");
|
||||
File.WriteAllText(lockedPath, ""); // ensure exists
|
||||
using var holder = new FileStream(lockedPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
|
||||
|
||||
|
||||
+48
@@ -170,5 +170,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
Assert.Empty(f.StarvedPosts());
|
||||
Assert.Empty(coordinator.StarvedAppsReported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reevaluate_does_not_re_emit_starved_warnings()
|
||||
{
|
||||
// sits-d Cloud-PC fix (2026-08-19): the synthesis re-check now runs on EVERY IME
|
||||
// app-state transition. It must not turn the one-shot starvation warning into a
|
||||
// per-transition stream — the re-check path suppresses the emission entirely
|
||||
// (the per-appId dedupe would swallow the duplicates, but walking the probe
|
||||
// hundreds of times on the IME hot path is waste, not safety).
|
||||
using var f = new Fixture();
|
||||
using var coordinator = f.BuildCoordinator(
|
||||
() => false,
|
||||
() => new[] { StarvedApp("app-1", "Contoso Backgrounds") });
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.TriggerEspExitedForTest(Fixed);
|
||||
var afterExit = f.StarvedPosts().Count;
|
||||
Assert.Equal(1, afterExit);
|
||||
|
||||
for (var i = 0; i < 10; i++) coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
Assert.Equal(afterExit, f.StarvedPosts().Count);
|
||||
Assert.Single(coordinator.StarvedAppsReported);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Starved_apps_that_settle_later_still_complete_the_enrollment()
|
||||
{
|
||||
// The real sits-d shape: the ESP exits with apps still starved (warning emitted),
|
||||
// the apps then all settle, and the re-check must open the gate — the warning
|
||||
// already on the timeline is history, not a permanent veto.
|
||||
using var f = new Fixture();
|
||||
var settled = false;
|
||||
using var coordinator = f.BuildCoordinator(
|
||||
() => settled,
|
||||
() => new[] { StarvedApp("app-1", "Contoso Backgrounds") });
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.TriggerEspExitedForTest(Fixed);
|
||||
Assert.Single(f.StarvedPosts());
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete);
|
||||
|
||||
settled = true;
|
||||
coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
Assert.Equal(1, f.Ingress.Posted.Count(p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete));
|
||||
Assert.Single(f.StarvedPosts());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+182
-2
@@ -35,14 +35,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
public Fixture() { Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Debug); }
|
||||
|
||||
public EspAndHelloTracker BuildCoordinator(Func<bool> settledProbe)
|
||||
=> BuildCoordinator(settledProbe, accountSetupActivityProbe: () => true, skipUserEsp: false);
|
||||
|
||||
/// <summary>
|
||||
/// Codex review P1 (2026-08-19): the remembered exit edge is gated on positive evidence
|
||||
/// that the exit is post-AccountSetup, so these two probes decide whether the deferred
|
||||
/// re-check may fire at all.
|
||||
/// </summary>
|
||||
public EspAndHelloTracker BuildCoordinator(
|
||||
Func<bool> settledProbe,
|
||||
Func<bool> accountSetupActivityProbe,
|
||||
bool? skipUserEsp)
|
||||
{
|
||||
return new EspAndHelloTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: new InformationalEventPost(TrackerPostSink, Clock),
|
||||
logger: Logger,
|
||||
skipConfigProbe: () => ((bool?)false, (bool?)false),
|
||||
accountSetupActivityProbe: () => true,
|
||||
skipConfigProbe: () => (skipUserEsp, (bool?)false),
|
||||
accountSetupActivityProbe: accountSetupActivityProbe,
|
||||
userEspAppsSettledProbe: settledProbe);
|
||||
}
|
||||
|
||||
@@ -159,5 +170,174 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
var posted = Assert.Single(f.Ingress.Posted);
|
||||
Assert.Equal(DecisionSignalKind.EspExiting, posted.Kind);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// sits-d Cloud-PC fix (2026-08-19) — the synthesis is no longer edge-only.
|
||||
// Sessions 8110e262 / a89aac2d: the ESP page exited while 138 required user-ESP apps
|
||||
// were still in flight, they all reached a terminal state (0 failed) minutes later,
|
||||
// and because nothing re-checked, the enrollment never completed.
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Reevaluate_after_apps_settle_synthesizes_without_a_second_exit()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
var settled = false;
|
||||
using var coordinator = f.BuildCoordinator(() => settled);
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.TriggerEspExitedForTest(Fixed);
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete);
|
||||
|
||||
// The apps settle later; only the IME app-state callback fires — there is NO second
|
||||
// ESP exit on a real device, which is exactly why the edge-only version starved.
|
||||
settled = true;
|
||||
coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
Assert.Equal(1, f.Ingress.Posted.Count(p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete));
|
||||
Assert.True(coordinator.UserAppsSettledSynthesisFiredForTest);
|
||||
|
||||
var info = Assert.Single(f.TrackerPostSink.Posted);
|
||||
var data = Assert.IsAssignableFrom<System.Collections.Generic.IReadOnlyDictionary<string, object>>(info.TypedPayload);
|
||||
Assert.Equal("esp_exited_user_apps_settled_category_unresolved", data["fallbackReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reevaluate_without_an_observed_esp_exit_never_synthesizes()
|
||||
{
|
||||
// The gate needs BOTH facts. Settled apps alone must never open it — otherwise every
|
||||
// quiet moment during AccountSetup would look like completion.
|
||||
using var f = new Fixture();
|
||||
using var coordinator = f.BuildCoordinator(() => true);
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
Assert.Empty(f.Ingress.Posted);
|
||||
Assert.Empty(f.TrackerPostSink.Posted);
|
||||
Assert.False(coordinator.UserAppsSettledSynthesisFiredForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reevaluate_is_idempotent_after_the_synthesis_fired()
|
||||
{
|
||||
// The IME callback fires per app-state transition — on these tenants that is 138+
|
||||
// times. The synthesis must stay fire-once.
|
||||
using var f = new Fixture();
|
||||
using var coordinator = f.BuildCoordinator(() => true);
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.TriggerEspExitedForTest(Fixed);
|
||||
for (var i = 0; i < 20; i++) coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
Assert.Equal(1, f.Ingress.Posted.Count(p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete));
|
||||
Assert.Single(f.TrackerPostSink.Posted);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Codex review P1 (2026-08-19): Shell-Core raises 62407 at EVERY ESP phase transition.
|
||||
// Remembering the intermediate DeviceSetup→AccountSetup exit would let the deferred
|
||||
// re-check open the strong AccountSetup gate as soon as the last user app settles —
|
||||
// while the AccountSetup page is still up and its other subcategories still running.
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Intermediate_device_exit_is_not_remembered_as_the_edge()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
var settled = false;
|
||||
// Classic user-driven enrollment (SkipUser=false) that has NOT reached AccountSetup:
|
||||
// this 62407 is the DeviceSetup→AccountSetup transition, not the final exit.
|
||||
using var coordinator = f.BuildCoordinator(
|
||||
() => settled, accountSetupActivityProbe: () => false, skipUserEsp: false);
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.TriggerEspExitedForTest(Fixed);
|
||||
|
||||
// Apps settle afterwards — the level is reached, but the edge was never a valid one.
|
||||
settled = true;
|
||||
for (var i = 0; i < 10; i++) coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete);
|
||||
Assert.False(coordinator.UserAppsSettledSynthesisFiredForTest);
|
||||
Assert.Empty(f.TrackerPostSink.Posted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_skip_user_is_treated_as_unconfirmed()
|
||||
{
|
||||
// Erring strict costs at worst today's stall; erring loose costs a premature Succeeded.
|
||||
using var f = new Fixture();
|
||||
var settled = false;
|
||||
using var coordinator = f.BuildCoordinator(
|
||||
() => settled, accountSetupActivityProbe: () => false, skipUserEsp: null);
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.TriggerEspExitedForTest(Fixed);
|
||||
settled = true;
|
||||
coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
Assert.False(coordinator.UserAppsSettledSynthesisFiredForTest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skip_user_profile_confirms_the_device_exit_as_final()
|
||||
{
|
||||
// SkipUser=true means there is no user ESP at all — the Device-ESP exit IS the final
|
||||
// one and no second exit is coming, so the edge is legitimately remembered.
|
||||
using var f = new Fixture();
|
||||
var settled = false;
|
||||
using var coordinator = f.BuildCoordinator(
|
||||
() => settled, accountSetupActivityProbe: () => false, skipUserEsp: true);
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.TriggerEspExitedForTest(Fixed);
|
||||
settled = true;
|
||||
coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
Assert.Equal(1, f.Ingress.Posted.Count(p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Account_setup_activity_confirms_the_edge_for_the_deferred_recheck()
|
||||
{
|
||||
// The real reboot shape: AccountSetup activity is visible in the registry, the final
|
||||
// exit lands, the apps settle a moment later.
|
||||
using var f = new Fixture();
|
||||
var settled = false;
|
||||
using var coordinator = f.BuildCoordinator(
|
||||
() => settled, accountSetupActivityProbe: () => true, skipUserEsp: false);
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.TriggerEspExitedForTest(Fixed);
|
||||
Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete);
|
||||
|
||||
settled = true;
|
||||
coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
Assert.Equal(1, f.Ingress.Posted.Count(p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete));
|
||||
}
|
||||
|
||||
// NOTE: the "replayed exit" cases that used to live here are gone by construction —
|
||||
// ShellCoreTracker never re-raises a 62407 from the replay any more, so no backfilled exit
|
||||
// can reach this handler at all. That contract is pinned one layer up, in
|
||||
// ShellCoreTrackerReplayScopeTests, together with the reducer-side proof of why it matters
|
||||
// (ClassicEspExitingOnRestoredStateTests).
|
||||
|
||||
[Fact]
|
||||
public void Reevaluate_stays_silent_while_apps_are_still_unsettled()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var coordinator = f.BuildCoordinator(() => false);
|
||||
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||
|
||||
coordinator.TriggerEspExitedForTest(Fixed);
|
||||
for (var i = 0; i < 5; i++) coordinator.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
var posted = Assert.Single(f.Ingress.Posted);
|
||||
Assert.Equal(DecisionSignalKind.EspExiting, posted.Kind);
|
||||
Assert.False(coordinator.UserAppsSettledSynthesisFiredForTest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private string LogPath => Path.Combine(_tmp.Path, "sub", "gather_rules_debug.log");
|
||||
private string LogPath => Path.Combine(_tmp.Path, "sub", "gather-rules-debug.log");
|
||||
|
||||
[Fact]
|
||||
public void Write_produces_pipe_delimited_line_and_creates_directory_lazily()
|
||||
@@ -73,7 +73,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
for (int i = 0; i < 20; i++)
|
||||
log.Write("RULE-1", GatherRuleDebugLog.StageExec, new string('x', 100));
|
||||
|
||||
var oldPath = Path.Combine(Path.GetDirectoryName(LogPath), "gather_rules_debug.old.log");
|
||||
var oldPath = Path.Combine(Path.GetDirectoryName(LogPath), "gather-rules-debug.old.log");
|
||||
Assert.True(File.Exists(oldPath), "expected one .old rotation generation");
|
||||
Assert.True(File.Exists(LogPath));
|
||||
Assert.Contains("-- rotated", File.ReadAllText(LogPath) + File.ReadAllText(oldPath));
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
public GatherRuleExecutorDebugLogTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather-rules-debug.log");
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant", _ => { }, logger,
|
||||
debugLogPath: _debugLogPath)
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
Directory.CreateDirectory(_outsideUsers);
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather_rules_debug.log");
|
||||
_debugLogPath = Path.Combine(_tmp.Path, "gather-rules-debug.log");
|
||||
_context = new GatherRuleContext(
|
||||
logger, "sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
|
||||
+36
@@ -56,6 +56,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
Assert.Equal(1, loaded.PeakDo.DownloadMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_then_load_roundtrips_terminal_metadata()
|
||||
{
|
||||
// Session bc803955 — CompletedAtUtc/VersionReached feed the summary dialog's
|
||||
// synthetic Office row (duration + version).
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path));
|
||||
var completedAt = new DateTime(2026, 6, 8, 12, 3, 22, DateTimeKind.Utc);
|
||||
|
||||
sut.Save(new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateCompleted,
|
||||
CompletedAtUtc = completedAt,
|
||||
VersionReached = "16.0.20228.20190",
|
||||
});
|
||||
|
||||
var loaded = sut.Load();
|
||||
Assert.Equal(completedAt, loaded!.CompletedAtUtc);
|
||||
Assert.Equal("16.0.20228.20190", loaded.VersionReached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_tolerates_state_files_from_older_agents_without_terminal_metadata()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path));
|
||||
// Pre-terminal-metadata wire format (verbatim shape from a live 2.0.1404 session).
|
||||
File.WriteAllText(sut.StateFilePath,
|
||||
"{\"State\":\"Completed\",\"StartedAtUtc\":\"2026-08-18T07:56:16.8208141Z\",\"StartedTrigger\":\"registry\"}");
|
||||
|
||||
var loaded = sut.Load();
|
||||
Assert.Equal(OfficeInstallStateData.StateCompleted, loaded!.State);
|
||||
Assert.Null(loaded.CompletedAtUtc);
|
||||
Assert.Null(loaded.VersionReached);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Save_overwrites_a_previous_state()
|
||||
{
|
||||
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope of the Shell-Core replay: it recovers the Hello-wizard start and NOTHING else.
|
||||
/// <para>
|
||||
/// The replay had existed since session 772fe502 with no caller. Wiring it up (2026-08-19)
|
||||
/// activated three rails at once, and only one of them is safe to replay:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>62404 Hello-wizard start — replayed.</b> A conservative fact: it vetoes a
|
||||
/// premature "Hello is disabled" skip and can never by itself complete a session, so
|
||||
/// replaying it can only make the agent wait longer, never finish early.</item>
|
||||
/// <item><b>62407 ESP exit — never replayed.</b> Windows writes the identical description
|
||||
/// for the intermediate DeviceSetup-to-AccountSetup transition and the final exit, so the
|
||||
/// record carries no evidence of its own position; the reducer orders exits by ingest
|
||||
/// ordinal, which for a replay is fresher than reality; and arm C of
|
||||
/// ShouldTransitionToAwaitingHello opens on any arriving exit once the restored state
|
||||
/// carries AccountSetupEntered + a genuine IME user session + desktop arrival. See
|
||||
/// ClassicEspExitingOnRestoredStateTests for the reducer half of that proof.</item>
|
||||
/// <item><b>62407 ESP failure — never replayed.</b> Re-injecting a historic failure as
|
||||
/// fresh can fail a session that recovered (ANALYZE-ESP-006).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ShellCoreTrackerReplayScopeTests
|
||||
{
|
||||
private static readonly DateTime ClockNow = new DateTime(2026, 8, 19, 8, 43, 16, DateTimeKind.Utc);
|
||||
|
||||
// The REAL Shell-Core wording. Byte-identical for the intermediate DeviceSetup-to-
|
||||
// AccountSetup transition and for the final post-AccountSetup exit — that identity is the
|
||||
// whole reason a replayed exit cannot be classified.
|
||||
private const string EspExitDescription = "CommercialOOBE_ESPProgress_Page_Exiting";
|
||||
private const string EspFailureDescription = "CommercialOOBE_ESPProgress_Failure";
|
||||
private const string AadHelloDescription = "CloudExperienceHost web app activity: AADHello";
|
||||
private const string OtherWebAppDescription = "CloudExperienceHost web app activity: SomethingElse";
|
||||
|
||||
private sealed class Fixture : IDisposable
|
||||
{
|
||||
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||
public FakeSignalIngressSink PostSink { get; } = new FakeSignalIngressSink();
|
||||
public VirtualClock Clock { get; } = new VirtualClock(ClockNow);
|
||||
|
||||
public ShellCoreTracker Build() => new ShellCoreTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: new InformationalEventPost(PostSink, Clock),
|
||||
logger: new AgentLogger(Tmp.Path, AgentLogLevel.Info),
|
||||
helloTracker: null);
|
||||
|
||||
public List<FakeSignalIngressSink.PostedSignal> AgentTraces() =>
|
||||
PostSink.Posted.Where(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == "agent_trace").ToList();
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
private static (int, string, DateTime) Record(int id, string description, DateTime at)
|
||||
=> (id, description, at);
|
||||
|
||||
// ------------------------------------------------------------------ the wizard rail
|
||||
|
||||
[Fact]
|
||||
public void The_hello_wizard_start_is_replayed_with_its_original_source_time()
|
||||
{
|
||||
// Session 772fe502: an agent restarted while the user sits inside the Hello wizard
|
||||
// would otherwise never learn the wizard ran. This is the observation the replay
|
||||
// exists for.
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
var order = new List<string>();
|
||||
HelloWizardStartedEventArgs? captured = null;
|
||||
tracker.HelloWizardStarted += (_, args) => { order.Add("wizard"); captured = args; };
|
||||
tracker.FinalizingSetupPhaseTriggered += (_, reason) => order.Add($"finalizing:{reason}");
|
||||
|
||||
var duringDowntime = ClockNow.AddMinutes(-37);
|
||||
tracker.ReplayBackfillRecords(new List<(int, string, DateTime)>
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, AadHelloDescription, duringDowntime),
|
||||
});
|
||||
|
||||
Assert.Equal(new[] { "wizard", "finalizing:hello_wizard_started" }, order);
|
||||
Assert.NotNull(captured);
|
||||
// The historical time must survive the replay — collapsing to now would date the
|
||||
// wizard after the restart and skew every duration derived from it.
|
||||
Assert.Equal(duringDowntime, captured!.OccurredAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_wizard_replay_is_single_shot_across_repeated_records()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
var starts = 0;
|
||||
tracker.HelloWizardStarted += (_, __) => starts++;
|
||||
|
||||
tracker.ReplayBackfillRecords(new List<(int, string, DateTime)>
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, AadHelloDescription, ClockNow.AddMinutes(-30)),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, AadHelloDescription, ClockNow.AddMinutes(-20)),
|
||||
});
|
||||
|
||||
Assert.Equal(1, starts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_non_hello_web_app_start_is_ignored()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
var starts = 0;
|
||||
tracker.HelloWizardStarted += (_, __) => starts++;
|
||||
|
||||
tracker.ReplayBackfillRecords(new List<(int, string, DateTime)>
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, OtherWebAppDescription, ClockNow.AddMinutes(-5)),
|
||||
});
|
||||
|
||||
Assert.Equal(0, starts);
|
||||
Assert.Empty(f.AgentTraces());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ the exit rail
|
||||
|
||||
[Fact]
|
||||
public void An_intermediate_only_window_raises_nothing_that_can_reach_the_reducer()
|
||||
{
|
||||
// THE regression this whole review chain is about: restart, and the only 62407 in the
|
||||
// window is the historic DeviceSetup-to-AccountSetup handoff. Nothing may leave the
|
||||
// tracker on a rail the decision engine listens to.
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
var espExits = 0;
|
||||
var finalizing = new List<string>();
|
||||
var failures = 0;
|
||||
tracker.EspExited += (_, __) => espExits++;
|
||||
tracker.FinalizingSetupPhaseTriggered += (_, reason) => finalizing.Add(reason);
|
||||
tracker.EspFailureDetected += (_, __) => failures++;
|
||||
|
||||
tracker.ReplayBackfillRecords(new List<(int, string, DateTime)>
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspExitDescription, ClockNow.AddMinutes(-52)),
|
||||
});
|
||||
|
||||
Assert.Equal(0, espExits);
|
||||
Assert.Empty(finalizing);
|
||||
Assert.Equal(0, failures);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Even_several_exits_in_the_window_stay_out_of_the_decision_stream()
|
||||
{
|
||||
// "Newest exit wins" was an earlier attempt at this problem. It does not help: if the
|
||||
// final exit has not happened yet, the newest record in the window is still the
|
||||
// intermediate one. Picking a record is the wrong lever — none of them is usable.
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
var espExits = 0;
|
||||
tracker.EspExited += (_, __) => espExits++;
|
||||
|
||||
tracker.ReplayBackfillRecords(new List<(int, string, DateTime)>
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspExitDescription, ClockNow.AddMinutes(-52)),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspExitDescription, ClockNow.AddMinutes(-9)),
|
||||
});
|
||||
|
||||
Assert.Equal(0, espExits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_historic_esp_failure_is_never_re_injected()
|
||||
{
|
||||
// A session that failed and then recovered on retry (ANALYZE-ESP-006) must not be
|
||||
// failed again by its own history.
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
var failures = new List<string>();
|
||||
tracker.EspFailureDetected += (_, failureType) => failures.Add(failureType);
|
||||
|
||||
tracker.ReplayBackfillRecords(new List<(int, string, DateTime)>
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspFailureDescription, ClockNow.AddMinutes(-40)),
|
||||
});
|
||||
|
||||
Assert.Empty(failures);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ visibility
|
||||
|
||||
[Fact]
|
||||
public void Skipped_records_are_reported_once_as_an_agent_trace()
|
||||
{
|
||||
// Dropping evidence silently is how this class of bug survives. The gap has to stay
|
||||
// visible to whoever debugs the session later — as an informational event, which is
|
||||
// decision-neutral by construction.
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
tracker.ReplayBackfillRecords(new List<(int, string, DateTime)>
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspExitDescription, ClockNow.AddMinutes(-52)),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspFailureDescription, ClockNow.AddMinutes(-40)),
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppEvent, EspExitDescription, ClockNow.AddMinutes(-9)),
|
||||
});
|
||||
|
||||
var trace = Assert.Single(f.AgentTraces());
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(trace.TypedPayload);
|
||||
Assert.Equal(2, data["skippedEspExits"]);
|
||||
Assert.Equal(1, data["skippedEspFailures"]);
|
||||
Assert.Equal("replayed_62407_not_orderable", data["reason"]);
|
||||
Assert.Equal(ClockNow.AddMinutes(-52).ToString("o"), data["oldestSkippedUtc"]);
|
||||
Assert.Equal(ClockNow.AddMinutes(-9).ToString("o"), data["newestSkippedUtc"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_wizard_only_window_reports_nothing_as_skipped()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
tracker.ReplayBackfillRecords(new List<(int, string, DateTime)>
|
||||
{
|
||||
Record(ShellCoreTracker.EventId_ShellCore_WebAppStarted, AadHelloDescription, ClockNow.AddMinutes(-30)),
|
||||
});
|
||||
|
||||
Assert.Empty(f.AgentTraces());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_batch_is_a_no_op()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
var raised = 0;
|
||||
tracker.EspExited += (_, __) => raised++;
|
||||
tracker.HelloWizardStarted += (_, __) => raised++;
|
||||
|
||||
tracker.ReplayBackfillRecords(new List<(int, string, DateTime)>());
|
||||
|
||||
Assert.Equal(0, raised);
|
||||
Assert.Empty(f.AgentTraces());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ live path untouched
|
||||
|
||||
[Fact]
|
||||
public void The_live_path_still_raises_the_exit_normally()
|
||||
{
|
||||
// Narrowing the REPLAY must not narrow live observation — that is the agent's primary
|
||||
// signal source and the one the completion recovery actually runs on.
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
var exits = new List<DateTime>();
|
||||
tracker.EspExited += (_, args) => exits.Add(args.OccurredAtUtc);
|
||||
|
||||
tracker.ProcessEvent(
|
||||
eventId: ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
description: EspExitDescription,
|
||||
timestamp: ClockNow,
|
||||
providerName: "Microsoft-Windows-Shell-Core",
|
||||
isBackfill: false);
|
||||
|
||||
Assert.Equal(ClockNow, Assert.Single(exits));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_live_path_still_raises_esp_failures()
|
||||
{
|
||||
using var f = new Fixture();
|
||||
using var tracker = f.Build();
|
||||
|
||||
var failures = new List<string>();
|
||||
tracker.EspFailureDetected += (_, failureType) => failures.Add(failureType);
|
||||
|
||||
tracker.ProcessEvent(
|
||||
eventId: ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
description: EspFailureDescription,
|
||||
timestamp: ClockNow,
|
||||
providerName: "Microsoft-Windows-Shell-Core",
|
||||
isBackfill: false);
|
||||
|
||||
Assert.Single(failures);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ window clamp
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(-42, 1)]
|
||||
[InlineData(5, 5)]
|
||||
[InlineData(ShellCoreTracker.BackfillLookbackMaxMinutes, ShellCoreTracker.BackfillLookbackMaxMinutes)]
|
||||
[InlineData(ShellCoreTracker.BackfillLookbackMaxMinutes + 1, ShellCoreTracker.BackfillLookbackMaxMinutes)]
|
||||
[InlineData(int.MaxValue, ShellCoreTracker.BackfillLookbackMaxMinutes)]
|
||||
public void Lookback_is_clamped_to_a_sane_window(int requested, int expected)
|
||||
{
|
||||
// int.MaxValue matters: the query multiplies minutes by 60_000, so an unclamped value
|
||||
// would overflow into a negative timediff and silently match nothing.
|
||||
Assert.Equal(expected, ShellCoreTracker.ClampLookbackMinutes(requested));
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
@@ -59,6 +59,71 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void ClassifyEnforcementState_bands(int state, string expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1000, "installed")]
|
||||
[InlineData(1999, "installed")]
|
||||
[InlineData(3000, "failed")]
|
||||
[InlineData(2000, "notApplicable")]
|
||||
[InlineData(2999, "notApplicable")]
|
||||
[InlineData(0, null)]
|
||||
[InlineData(999, null)]
|
||||
[InlineData(3001, null)]
|
||||
[InlineData(4000, null)]
|
||||
public void ClassifyStatusServiceStatus_bands(int status, string? expected)
|
||||
=> Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyStatusServiceStatus(status));
|
||||
|
||||
[Fact]
|
||||
public void TerminalOutcome_prefers_enforcement_state_then_status_service()
|
||||
{
|
||||
// Enforcement state alone decides when terminal.
|
||||
Assert.Equal("success", ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 1000)));
|
||||
Assert.Equal("error", ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 5003)));
|
||||
// Non-terminal enforcement bands defer to the StatusService pillar.
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 2009)));
|
||||
Assert.Equal("error", ImeRegistryAppStateObserver.TerminalOutcome(
|
||||
Entry(enforcementState: 2009, statusServiceStatus: 3000)));
|
||||
Assert.Equal("success", ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 1000)));
|
||||
Assert.Equal("error", ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 3000)));
|
||||
// requirementsNotMet / notApplicable / empty entries are not judgeable.
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(enforcementState: 3000)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry(statusServiceStatus: 2500)));
|
||||
Assert.Null(ImeRegistryAppStateObserver.TerminalOutcome(Entry()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedFields_reports_each_field_and_ignores_espPhase_casing()
|
||||
{
|
||||
var prev = Entry(enforcementState: 2009, errorCode: null, exitCode: null,
|
||||
statusServiceStatus: null, espTracked: false, espPhase: "DevicePreparation");
|
||||
var next = Entry(enforcementState: 1000, errorCode: 0, exitCode: 0,
|
||||
statusServiceStatus: 1000, espTracked: true, espPhase: "devicepreparation");
|
||||
|
||||
var changed = ImeRegistryAppStateObserver.ChangedFields(prev, next);
|
||||
|
||||
Assert.Equal(
|
||||
new[] { "enforcementState", "errorCode", "exitCode", "statusServiceStatus", "espTracked" },
|
||||
changed);
|
||||
// Case-only espPhase difference is not a change; identity diffs to empty.
|
||||
Assert.DoesNotContain("espPhase", changed);
|
||||
Assert.Empty(ImeRegistryAppStateObserver.ChangedFields(next, next));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangedFields_null_previous_reports_populated_fields_of_new_entry()
|
||||
{
|
||||
var fresh = Entry(enforcementState: 2000, espTracked: true, espPhase: "AccountSetup");
|
||||
|
||||
var changed = ImeRegistryAppStateObserver.ChangedFields(null, fresh);
|
||||
|
||||
Assert.Contains("enforcementState", changed);
|
||||
Assert.Contains("espTracked", changed);
|
||||
Assert.Contains("espPhase", changed);
|
||||
// Fields that are null on both sides are not "changes" for a brand-new entry.
|
||||
Assert.DoesNotContain("errorCode", changed);
|
||||
Assert.DoesNotContain("exitCode", changed);
|
||||
Assert.DoesNotContain("statusServiceStatus", changed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiffSnapshots_reports_only_changed_fields()
|
||||
{
|
||||
@@ -119,6 +184,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.Equal("success", evt.Payload["enforcementClass"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NonEmpty_baseline_emits_one_summary_with_counts()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = Snapshot((App1, 1000, 0), (App2, 5003, 101));
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
observer.Tick("periodic"); // summary is one-shot, not per tick
|
||||
}
|
||||
|
||||
var summary = Assert.Single(Events(sink, SharedEventTypes.RegistryAppBaseline));
|
||||
Assert.Equal("2", summary.Payload!["totalApps"]);
|
||||
Assert.Equal("1", summary.Payload["successCount"]);
|
||||
Assert.Equal("1", summary.Payload["errorCount"]);
|
||||
// The baseline stays silent on the diff rail.
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_baseline_emits_no_summary()
|
||||
{
|
||||
var (sink, clock, observer) = CreateObserver(trackerApps: null);
|
||||
var current = new ImeRegistrySnapshot();
|
||||
|
||||
using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current))
|
||||
{
|
||||
observer.Tick("baseline");
|
||||
}
|
||||
|
||||
Assert.Empty(Events(sink, SharedEventTypes.RegistryAppBaseline));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unchanged_snapshot_emits_nothing()
|
||||
{
|
||||
@@ -230,6 +329,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
return (sink, clock, observer);
|
||||
}
|
||||
|
||||
private static AppRegistryEntry Entry(
|
||||
int? enforcementState = null, long? errorCode = null, int? exitCode = null,
|
||||
int? statusServiceStatus = null, bool espTracked = false, string? espPhase = null)
|
||||
{
|
||||
var entry = new AppRegistryEntry($"{ImeRegistrySnapshot.DeviceContext}|{App1}", ImeRegistrySnapshot.DeviceContext, App1);
|
||||
entry.EnforcementState = enforcementState;
|
||||
entry.ErrorCode = errorCode;
|
||||
entry.ExitCode = exitCode;
|
||||
entry.StatusServiceStatus = statusServiceStatus;
|
||||
entry.EspTracked = espTracked;
|
||||
entry.EspPhase = espPhase;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static ImeRegistrySnapshot Snapshot(params (string appId, int enforcementState, long? errorCode)[] apps)
|
||||
{
|
||||
var snapshot = new ImeRegistrySnapshot();
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session afee7ae0 (2026-08-18) — Device Preparation structural-noise suppression.
|
||||
/// A nine-minute WDP session shipped 900+ occurrences of WIL error 1005 plus 400+
|
||||
/// duplicate-workload 417/418 records and "Autopilot policy not found" 100s — all
|
||||
/// structural on WDP (no deployment profile exists; the BootstrapperAgent's SLDM
|
||||
/// progress poll produces the 417/418 chatter). On WDP those IDs are suppressed
|
||||
/// entirely (<see cref="ModernDeploymentTracker.DevicePreparationNoiseEventIds"/>);
|
||||
/// genuine diagnostics (e.g. 408 provisioning warnings) and Critical records keep
|
||||
/// flowing, and Classic sessions keep the harmless-downgrade + rollup path.
|
||||
/// </summary>
|
||||
public sealed class ModernDeploymentTrackerDevicePrepTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 8, 18, 9, 10, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public ModernDeploymentTrackerDevicePrepTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ModernDeploymentTracker BuildTracker(bool isDevicePreparation) =>
|
||||
new ModernDeploymentTracker(
|
||||
sessionId: "sess-wdp-noise",
|
||||
tenantId: "tenant-wdp-noise",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: _logger,
|
||||
backfillEnabled: false,
|
||||
isDevicePreparation: isDevicePreparation);
|
||||
|
||||
private void Process(ModernDeploymentTracker tracker, int eventId, int level, string shortName, string channel)
|
||||
{
|
||||
tracker.ProcessEvent(
|
||||
eventId: eventId,
|
||||
level: level,
|
||||
levelDisplayName: level == 1 ? "Critical" : level == 2 ? "Error" : "Warning",
|
||||
providerName: "ModernDeployment-Diagnostics-Provider",
|
||||
timeCreatedUtc: At,
|
||||
formattedDescription: $"EventID {eventId} record.",
|
||||
shortName: shortName,
|
||||
channelName: channel,
|
||||
isBackfill: false);
|
||||
}
|
||||
|
||||
private List<FakeSignalIngressSink.PostedSignal> PostedModernDeployment() =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et.StartsWith("modern_deployment", StringComparison.Ordinal)).ToList();
|
||||
|
||||
[Theory]
|
||||
[InlineData(100, 3, "Autopilot")] // policy not found — no profile exists on WDP
|
||||
[InlineData(417, 3, "Autopilot")] // duplicate workload — SLDM poll chatter
|
||||
[InlineData(418, 3, "Autopilot")] // duplicate batch — SLDM poll chatter
|
||||
[InlineData(1005, 2, "ManagementService")] // WIL error storm
|
||||
public void WdpNoiseIds_AreFullySuppressed_OnDevicePreparation(int eventId, int level, string shortName)
|
||||
{
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
var channel = shortName == "Autopilot"
|
||||
? ModernDeploymentTracker.AutopilotChannel
|
||||
: ModernDeploymentTracker.ManagementChannel;
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
Process(tracker, eventId, level, shortName, channel);
|
||||
}
|
||||
|
||||
Assert.Empty(PostedModernDeployment());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenuineWdpWarning_408_KeepsFlowing_OnDevicePreparation()
|
||||
{
|
||||
// 408 "Autopilot Provisioning reported a warning" carries the real WDP session
|
||||
// context — it is NOT in the noise set and must keep flowing.
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
|
||||
Process(tracker, 408, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
|
||||
|
||||
var posted = Assert.Single(PostedModernDeployment());
|
||||
Assert.Equal("modern_deployment_warning", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CriticalRecords_AreNeverSuppressed_EvenForNoiseIds()
|
||||
{
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
|
||||
Process(tracker, 1005, level: 1, shortName: "ManagementService", channel: ModernDeploymentTracker.ManagementChannel);
|
||||
|
||||
var posted = Assert.Single(PostedModernDeployment());
|
||||
Assert.Equal("modern_deployment_error", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicSessions_KeepTheHarmlessDowngradePath_ForTheSameIds()
|
||||
{
|
||||
// Classic regression guard: without the WDP flag EventID 100 still flows through
|
||||
// the harmless-downgrade path (individual Debug emissions up to the rollup limit).
|
||||
var tracker = BuildTracker(isDevicePreparation: false);
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
Process(tracker, 100, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
|
||||
}
|
||||
|
||||
Assert.Equal(2, PostedModernDeployment().Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -117,6 +118,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
hosts,
|
||||
"AadJoinHost wires its onRealUserJoined callback to the already-constructed DesktopArrivalHost.");
|
||||
|
||||
// Codex review P2 (2026-08-19): EspAndHelloHost.Start() replays the Shell-Core
|
||||
// ESP exit, but ImeLogTracker only restores its persisted app states in its own
|
||||
// Start() — which happens later. Pin the order that makes ImeLogHost's
|
||||
// onStateRestored nudge necessary, so a future reshuffle either keeps the nudge
|
||||
// meaningful or fails here loudly.
|
||||
AssertBefore<EspAndHelloHost, ImeLogHost>(
|
||||
hosts,
|
||||
"the ESP-exit replay runs before the IME app-state restore, which is why " +
|
||||
"ImeLogHost re-triggers the user-apps-settled synthesis after LoadState.");
|
||||
|
||||
// Closure-latch sanity (factory lines ~139/297): the EspAndHelloHost probes close
|
||||
// over the ImeLogHost reference, which is assigned during the same factory call —
|
||||
// the host must therefore exist in the returned list.
|
||||
@@ -130,5 +141,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ime_app_state_callback_is_chained_into_the_user_apps_settled_recheck()
|
||||
{
|
||||
// sits-d Cloud-PC fix (2026-08-19): the AccountSetup synthesis is re-evaluated on every
|
||||
// terminal IME app transition, which only works while the factory keeps this chain
|
||||
// wired. It is five lines that a refactor can drop silently and whose absence costs a
|
||||
// whole class of sessions their completion — so pin it here.
|
||||
var surfaces = CreateSurfaces();
|
||||
var hosts = surfaces.Hosts;
|
||||
try
|
||||
{
|
||||
var imeHost = hosts.OfType<ImeLogHost>().Single();
|
||||
var handler = imeHost.Tracker.OnAppStateChanged;
|
||||
|
||||
Assert.NotNull(handler);
|
||||
|
||||
// Invoking it must reach the re-check without throwing. The re-check is a no-op
|
||||
// here (no ESP exit observed), which is the point — it must stay cheap and silent
|
||||
// on the IME hot path until both facts are in.
|
||||
var pkg = new AppPackageState("app-1", listPos: 0);
|
||||
var ex = Record.Exception(() => handler!(
|
||||
pkg,
|
||||
AppInstallationState.Installing,
|
||||
AppInstallationState.Installed));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// The Shell-Core ESP-exit replay is RESTART RECOVERY: it re-reads observations the agent could
|
||||
/// not make because no agent process was running. The window is therefore measured against the
|
||||
/// previous run's last known liveness, not guessed from a constant.
|
||||
/// <para>
|
||||
/// Why a constant is not enough (Codex review P2): the scheduled task carries a BootTrigger
|
||||
/// only, with no restart-on-failure. A crashed agent does not come back until the NEXT BOOT —
|
||||
/// possibly hours later, long after Windows wrote the final ESP exit. Five minutes would lose
|
||||
/// it every time.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class EspExitBackfillLookbackPolicyTests
|
||||
{
|
||||
private static readonly DateTime Now = new DateTime(2026, 8, 19, 8, 43, 16, DateTimeKind.Utc);
|
||||
private const int Default = ShellCoreTracker.BackfillLookbackMinutes;
|
||||
|
||||
private static int Resolve(string? exitType, DateTime? lastAlive = null, DateTime? bootUtc = null)
|
||||
=> DefaultComponentFactory.ResolveEspExitBackfillLookbackMinutes(exitType, bootUtc, lastAlive, Now);
|
||||
|
||||
// ---- no previous run => nothing to recover ---------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("first_run")]
|
||||
[InlineData("FIRST_RUN")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void No_previous_run_means_no_replay(string? exitType)
|
||||
{
|
||||
Assert.Equal(0, Resolve(exitType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_run_stays_off_even_with_timestamps_available()
|
||||
{
|
||||
// The exit type decides, not the presence of a timestamp.
|
||||
Assert.Equal(0, Resolve("first_run", lastAlive: Now.AddHours(-3), bootUtc: Now.AddHours(-3)));
|
||||
}
|
||||
|
||||
// ---- the default is a floor, never a ceiling --------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("clean")]
|
||||
[InlineData("hard_kill")]
|
||||
[InlineData("exception_crash")]
|
||||
[InlineData("reboot_kill")]
|
||||
public void Without_any_timestamp_the_default_applies(string exitType)
|
||||
{
|
||||
Assert.Equal(Default, Resolve(exitType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restart_that_returned_within_seconds_keeps_the_default()
|
||||
{
|
||||
Assert.Equal(Default, Resolve("clean", lastAlive: Now.AddSeconds(-20)));
|
||||
}
|
||||
|
||||
// ---- the crash case P2 is about ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void A_crash_recovered_only_at_the_next_boot_gets_the_whole_gap()
|
||||
{
|
||||
// BootTrigger-only task: the agent was gone for three hours while the ESP finished.
|
||||
Assert.Equal(181, Resolve("exception_crash", lastAlive: Now.AddHours(-3)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_mtime_carries_the_window_when_no_boot_time_exists()
|
||||
{
|
||||
// LastBootUtc is only populated for hard_kill / reboot_kill — exception_crash and
|
||||
// clean have nothing else to go on.
|
||||
Assert.Equal(46, Resolve("clean", lastAlive: Now.AddMinutes(-45)));
|
||||
}
|
||||
|
||||
// ---- reboot: both inputs available, wider wins ------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void The_wider_of_the_two_inputs_wins()
|
||||
{
|
||||
// Snapshot last written 40 min ago, boot 12 min ago — the gap started at the snapshot.
|
||||
Assert.Equal(41, Resolve("reboot_kill", lastAlive: Now.AddMinutes(-40), bootUtc: Now.AddMinutes(-12)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Boot_time_alone_still_covers_a_reboot_without_a_snapshot()
|
||||
{
|
||||
Assert.Equal(41, Resolve("reboot_kill", bootUtc: Now.AddMinutes(-40)));
|
||||
}
|
||||
|
||||
// ---- robustness -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void A_timestamp_in_the_future_is_ignored()
|
||||
{
|
||||
// Clock skew across a reboot must never produce a negative window.
|
||||
Assert.Equal(Default, Resolve("reboot_kill", lastAlive: Now.AddMinutes(5), bootUtc: Now.AddMinutes(9)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_future_timestamp_does_not_suppress_a_valid_one()
|
||||
{
|
||||
Assert.Equal(31, Resolve("reboot_kill", lastAlive: Now.AddMinutes(9), bootUtc: Now.AddMinutes(-30)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_very_long_gap_is_clamped_by_the_tracker()
|
||||
{
|
||||
// The policy may propose more than the cap; ClampLookbackMinutes is the backstop, so
|
||||
// the two together can never produce an unbounded event-log query.
|
||||
var proposed = Resolve("exception_crash", lastAlive: Now.AddHours(-20));
|
||||
Assert.True(proposed > ShellCoreTracker.BackfillLookbackMaxMinutes);
|
||||
Assert.Equal(ShellCoreTracker.BackfillLookbackMaxMinutes,
|
||||
ShellCoreTracker.ClampLookbackMinutes(proposed));
|
||||
}
|
||||
|
||||
// ---- the snapshot probe itself ----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Missing_snapshot_reads_as_null_not_as_an_exception()
|
||||
{
|
||||
using var tmp = new Harness.TempDirectory();
|
||||
Assert.Null(DefaultComponentFactory.ReadPreviousRunLastAliveUtc(tmp.Path));
|
||||
Assert.Null(DefaultComponentFactory.ReadPreviousRunLastAliveUtc(string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_existing_snapshot_reports_its_write_time()
|
||||
{
|
||||
using var tmp = new Harness.TempDirectory();
|
||||
var path = System.IO.Path.Combine(tmp.Path, "snapshot.json");
|
||||
System.IO.File.WriteAllText(path, "{}");
|
||||
|
||||
var read = DefaultComponentFactory.ReadPreviousRunLastAliveUtc(tmp.Path);
|
||||
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal(DateTimeKind.Utc, read!.Value.Kind);
|
||||
Assert.True((DateTime.UtcNow - read.Value).Duration() < TimeSpan.FromMinutes(5));
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Codex review P2 (2026-08-19) — <see cref="ImeLogHost"/> starts AFTER <c>EspAndHelloHost</c>,
|
||||
/// and <c>ImeLogTracker.Start()</c> restores the persisted package states silently (LoadState
|
||||
/// raises no <c>OnAppStateChanged</c>). On an agent restart the Shell-Core ESP-exit replay
|
||||
/// therefore runs against an empty app list. Without the post-restore nudge the exact reboot
|
||||
/// case the completion recovery exists for — apps already terminal BEFORE the reboot, only the
|
||||
/// final exit lost to the downtime — would still never complete.
|
||||
/// </summary>
|
||||
public sealed class ImeLogHostStateRestoredCallbackTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ImeLogHost CreateHost(Action? onStateRestored)
|
||||
=> new ImeLogHost(
|
||||
sessionId: "s",
|
||||
tenantId: "t",
|
||||
logger: new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
ingress: new FakeSignalIngressSink(),
|
||||
clock: new VirtualClock(new DateTime(2026, 8, 19, 8, 43, 16, DateTimeKind.Utc)),
|
||||
imeLogPathOverride: _tmp.Path,
|
||||
imeMatchLogPath: null,
|
||||
imePatterns: null,
|
||||
stateDirectory: _tmp.Path,
|
||||
whiteGloveSealingPatternIds: null,
|
||||
onStateRestored: onStateRestored);
|
||||
|
||||
[Fact]
|
||||
public void Start_invokes_the_state_restored_callback_exactly_once()
|
||||
{
|
||||
var calls = 0;
|
||||
using var host = CreateHost(() => calls++);
|
||||
|
||||
host.Start();
|
||||
try { Assert.Equal(1, calls); }
|
||||
finally { host.Stop(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_throwing_callback_does_not_break_host_start()
|
||||
{
|
||||
// Fail-soft like every other co-wired callback in the orchestration layer: a broken
|
||||
// re-check must never take the IME log host — the session's primary signal source —
|
||||
// down with it.
|
||||
using var host = CreateHost(() => throw new InvalidOperationException("recheck boom"));
|
||||
|
||||
var ex = Record.Exception(() => host.Start());
|
||||
try { Assert.Null(ex); }
|
||||
finally { host.Stop(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_callback_is_a_valid_configuration()
|
||||
{
|
||||
using var host = CreateHost(null);
|
||||
|
||||
var ex = Record.Exception(() => host.Start());
|
||||
try { Assert.Null(ex); }
|
||||
finally { host.Stop(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
using var tmp = new TempDirectory();
|
||||
var logDir = Path.Combine(tmp.Path, "logs");
|
||||
Directory.CreateDirectory(logDir);
|
||||
var crashPath = Path.Combine(logDir, "crash_20260421_100000.log");
|
||||
var crashPath = Path.Combine(logDir, "crash-20260421-100000.log");
|
||||
File.WriteAllText(crashPath, "[2026-04-21T10:00:00Z] FATAL: InvalidOperationException: simulated crash");
|
||||
|
||||
var summary = AutopilotMonitor.Agent.V2.Program.DetectPreviousExit(tmp.Path, logDir);
|
||||
@@ -312,7 +312,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
|
||||
AutopilotMonitor.Agent.V2.Program.WriteCrashLog(logDir, new InvalidOperationException("boom"));
|
||||
|
||||
var files = Directory.GetFiles(logDir, "crash_*.log");
|
||||
var files = Directory.GetFiles(logDir, "crash-*.log");
|
||||
Assert.Single(files);
|
||||
var content = File.ReadAllText(files[0]);
|
||||
Assert.Contains("FATAL:", content);
|
||||
|
||||
+20
-4
@@ -479,16 +479,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImeTokenFailure_tracker_dedups_per_error_code()
|
||||
public void ImeTokenFailure_grace_window_emits_only_without_token_success()
|
||||
{
|
||||
// "Failed to get AAD token" appears routinely during the device phase (no user
|
||||
// token yet) — the Warning must fire only when NO success follows for the whole
|
||||
// grace window.
|
||||
using var f = new ImeLogTrackerAdapterFixture();
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var t0 = DateTime.UtcNow;
|
||||
|
||||
// Failure resolved by a token success: never emitted, not even after the window.
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line a");
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line b"); // same code — suppressed
|
||||
f.Tracker.HandleImeTokenFailureForTest("200", "line c"); // new code — emitted
|
||||
f.Tracker.NoteTokenSuccessForTest();
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + ImeLogTracker.TokenFailureGraceWindow + TimeSpan.FromMinutes(1));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
|
||||
Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeTokenFailure).Count);
|
||||
// Unresolved failure: silent before the window, one Warning after it.
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line b");
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + TimeSpan.FromMinutes(5));
|
||||
Assert.Empty(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + ImeLogTracker.TokenFailureGraceWindow + TimeSpan.FromHours(1));
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
|
||||
// Same code again: re-armed window expires but the per-code dedup suppresses.
|
||||
f.Tracker.HandleImeTokenFailureForTest("100", "line c");
|
||||
f.Tracker.CheckPendingTokenFailure(t0 + TimeSpan.FromHours(3));
|
||||
Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+111
-1
@@ -165,6 +165,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
public IReadOnlyCollection<string> StarvedAlreadyReportedOverride { get; set; } = Array.Empty<string>();
|
||||
public List<string> TerminationActionLog { get; } = new List<string>();
|
||||
public TimeSpan SpoolDrainPeriodOverride { get; set; } = TimeSpan.Zero;
|
||||
// Session bc803955 — DevicePrep app-wave settle wait. Zero (default) disables the
|
||||
// wait so the existing timing-sensitive tests stay untouched; the dedicated settle
|
||||
// tests opt in with a real budget.
|
||||
public TimeSpan DevicePrepAppSettleWaitOverride { get; set; } = TimeSpan.Zero;
|
||||
// WG Part-1 straggler-ordering fix — the production wiring passes
|
||||
// orchestrator.StopCollectorHosts, whose collectors emit their one-shot
|
||||
// stop-time stragglers (network_bandwidth_estimate, …) through Post during
|
||||
@@ -210,7 +214,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
stopPeripheralCollectors: StopPeripheralCollectorsHook,
|
||||
// Zero out the timing ceremony for tests — production paths are covered by
|
||||
// the dedicated V1-parity tests below which opt back in via their own Rig.
|
||||
lateEventGracePeriod: TimeSpan.Zero);
|
||||
lateEventGracePeriod: TimeSpan.Zero,
|
||||
devicePrepAppSettleWait: DevicePrepAppSettleWaitOverride);
|
||||
}
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
@@ -317,6 +322,91 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.True(File.Exists(statusPath), "final-status.json should be written even when ShowEnrollmentSummary=false");
|
||||
}
|
||||
|
||||
// ---- Session bc803955 — DevicePrep app-wave settle wait ----
|
||||
|
||||
private static DecisionState DevicePrepState(SessionStage stage) =>
|
||||
new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1"))
|
||||
{
|
||||
Stage = stage,
|
||||
ScenarioProfile = new EnrollmentScenarioProfile(
|
||||
mode: EnrollmentMode.DevicePreparation,
|
||||
joinMode: EnrollmentJoinMode.Unknown,
|
||||
espConfig: EspConfig.Unknown,
|
||||
preProvisioningSide: PreProvisioningSide.None,
|
||||
confidence: ProfileConfidence.Medium,
|
||||
evidenceOrdinal: 1,
|
||||
reason: "test"),
|
||||
}.Build();
|
||||
|
||||
[Fact]
|
||||
public void Handle_devicePrep_settle_wait_catches_late_app_wave_for_dialog()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = DevicePrepState(SessionStage.Completed);
|
||||
rig.DevicePrepAppSettleWaitOverride = TimeSpan.FromSeconds(10);
|
||||
|
||||
// Simulate the IME user-context sync landing AFTER termination handling began
|
||||
// (AppWorkload.log flush latency) — the wave arrives 500ms in, well before the
|
||||
// 10s budget; the settle loop must pick it up and the dialog must render it.
|
||||
var waveThread = new Thread(() =>
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
var app = new AppPackageState("app-late", 0);
|
||||
app.UpdateState(AppInstallationState.Downloading);
|
||||
TestHelpers.SetTargeted(app, AppTargeted.User);
|
||||
rig.Packages.Add(app);
|
||||
});
|
||||
waveThread.Start();
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
sw.Stop();
|
||||
waveThread.Join();
|
||||
|
||||
var statusJson = File.ReadAllText(Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName));
|
||||
Assert.Contains("\"totalApps\": 1", statusJson);
|
||||
// Early exit: the wave arrived at ~500ms — the handler must not sit out the full 10s.
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(8),
|
||||
$"settle wait should exit early once packages arrive (took {sw.Elapsed.TotalSeconds:0.0}s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_devicePrep_settle_wait_is_bounded_when_no_apps_arrive()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = DevicePrepState(SessionStage.Completed);
|
||||
rig.DevicePrepAppSettleWaitOverride = TimeSpan.FromMilliseconds(600);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
sw.Stop();
|
||||
|
||||
// Budget elapsed (>=600ms) and the dialog still rendered — just without app data.
|
||||
Assert.True(sw.Elapsed >= TimeSpan.FromMilliseconds(600),
|
||||
$"bounded wait should consume its budget when no data arrives (took {sw.Elapsed.TotalMilliseconds:0}ms)");
|
||||
var statusJson = File.ReadAllText(Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName));
|
||||
Assert.Contains("\"totalApps\": 0", statusJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_non_devicePrep_does_not_wait_even_with_empty_packages()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
// Classic/unknown mode + empty package list — the settle wait must not engage.
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
|
||||
rig.DevicePrepAppSettleWaitOverride = TimeSpan.FromSeconds(10);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(8),
|
||||
$"non-DevicePrep termination must not settle-wait (took {sw.Elapsed.TotalSeconds:0.0}s)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_writes_enrollment_complete_marker_on_non_whiteglove_terminal()
|
||||
{
|
||||
@@ -1407,6 +1497,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Assert.DoesNotContain("app_install_starved", rig.EmittedEventTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StarvedApps_skipped_on_device_preparation()
|
||||
{
|
||||
// afee7ae0: WDP has no ESP apps gate — post-desktop installs are the designed
|
||||
// order, so the sweep would report every pending app as noise (10+ events on a
|
||||
// normal session). The per-app data still travels via app_tracking_summary.
|
||||
using var rig = new Rig();
|
||||
rig.State = DevicePrepState(SessionStage.Completed);
|
||||
rig.StarvedAppsOverride = new[]
|
||||
{
|
||||
BuildStarvedApp("app-pending-1", "Contoso Backgrounds"),
|
||||
BuildStarvedApp("app-pending-2", "Contoso Tools"),
|
||||
};
|
||||
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
|
||||
Assert.DoesNotContain("app_install_starved", rig.EmittedEventTypes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromoteActiveInstalls_skipped_on_effect_infrastructure_failure()
|
||||
{
|
||||
|
||||
+117
@@ -1,8 +1,10 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Termination;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
@@ -508,6 +510,121 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
|
||||
Assert.Null(status.SignalTimestamps);
|
||||
}
|
||||
|
||||
// ---- Session bc803955 — synthetic Office row from the C2R detector's persisted state ----
|
||||
|
||||
private static FinalStatus BuildWithOffice(OfficeInstallStateData? officeState, IReadOnlyList<AppPackageState>? packages = null) =>
|
||||
FinalStatusBuilder.Build(StateWith(SessionStage.Completed),
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed),
|
||||
packageStates: packages, agentStartTimeUtc: StartUtc, officeState: officeState);
|
||||
|
||||
[Fact]
|
||||
public void Build_office_completed_adds_device_row_with_duration_and_counts()
|
||||
{
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateCompleted,
|
||||
StartedAtUtc = StartUtc.AddMinutes(1),
|
||||
CompletedAtUtc = StartUtc.AddMinutes(4).AddSeconds(22),
|
||||
VersionReached = "16.0.20228.20190",
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
var row = Assert.Single(status.PackageStatesByPhase["Device"]);
|
||||
Assert.Equal("Microsoft 365 Apps (16.0.20228.20190)", row.AppName);
|
||||
Assert.Equal("Installed", row.State);
|
||||
Assert.True(row.IsCompleted);
|
||||
Assert.False(row.IsError);
|
||||
Assert.Equal(202, row.DurationSeconds);
|
||||
Assert.Equal(office.StartedAtUtc.Value.ToString("o"), row.StartedAt);
|
||||
|
||||
Assert.Equal(1, status.AppSummary.TotalApps);
|
||||
Assert.Equal(1, status.AppSummary.CompletedApps);
|
||||
Assert.Equal(0, status.AppSummary.ErrorCount);
|
||||
Assert.Equal(1, status.AppSummary.AppsByPhase["Device"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_office_active_row_is_installing_without_completion()
|
||||
{
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateActive,
|
||||
StartedAtUtc = StartUtc.AddMinutes(1),
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
var row = Assert.Single(status.PackageStatesByPhase["Device"]);
|
||||
Assert.Equal("Microsoft 365 Apps", row.AppName);
|
||||
Assert.Equal("Installing", row.State);
|
||||
Assert.False(row.IsCompleted);
|
||||
Assert.Null(row.DurationSeconds);
|
||||
Assert.Equal(1, status.AppSummary.TotalApps);
|
||||
Assert.Equal(0, status.AppSummary.CompletedApps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_office_failed_row_counts_device_error()
|
||||
{
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateFailed,
|
||||
StartedAtUtc = StartUtc.AddMinutes(1),
|
||||
CompletedAtUtc = StartUtc.AddMinutes(2),
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
var row = Assert.Single(status.PackageStatesByPhase["Device"]);
|
||||
Assert.Equal("Error", row.State);
|
||||
Assert.True(row.IsError);
|
||||
Assert.True(row.IsCompleted); // Error is terminal — mirrors IsCompleted()
|
||||
Assert.Equal(1, status.AppSummary.ErrorCount);
|
||||
Assert.Equal(1, status.AppSummary.DeviceErrors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(OfficeInstallStateData.StatePreinstalled)]
|
||||
[InlineData("garbage")]
|
||||
[InlineData(null)]
|
||||
public void Build_office_preinstalled_or_unknown_adds_no_row(string? state)
|
||||
{
|
||||
var office = state == null ? null : new OfficeInstallStateData { State = state };
|
||||
|
||||
var status = BuildWithOffice(office);
|
||||
|
||||
Assert.Empty(status.PackageStatesByPhase);
|
||||
Assert.Equal(0, status.AppSummary.TotalApps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_office_row_is_prepended_before_ime_device_apps()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var list = new AppPackageStateList(logger);
|
||||
var imeApp = new AppPackageState("app-1", 0);
|
||||
imeApp.UpdateState(AppInstallationState.Installed);
|
||||
TestHelpers.SetTargeted(imeApp, AppTargeted.Device);
|
||||
list.Add(imeApp);
|
||||
|
||||
var office = new OfficeInstallStateData
|
||||
{
|
||||
State = OfficeInstallStateData.StateCompleted,
|
||||
StartedAtUtc = StartUtc,
|
||||
CompletedAtUtc = StartUtc.AddMinutes(3),
|
||||
};
|
||||
|
||||
var status = BuildWithOffice(office, list);
|
||||
|
||||
Assert.Equal(2, status.PackageStatesByPhase["Device"].Count);
|
||||
Assert.StartsWith("Microsoft 365 Apps", status.PackageStatesByPhase["Device"][0].AppName);
|
||||
Assert.Equal(2, status.AppSummary.TotalApps);
|
||||
Assert.Equal(2, status.AppSummary.CompletedApps);
|
||||
Assert.Equal(2, status.AppSummary.AppsByPhase["Device"]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mini helper for reflection-based field setters — AppPackageState.Targeted is a private-set property.</summary>
|
||||
|
||||
+71
@@ -144,6 +144,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Equal(1 + 2 + 3, snap.TotalBytesUp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Captures_bytes_down_via_buffering_when_content_length_is_missing()
|
||||
{
|
||||
// AutomaticDecompression strips Content-Length on the backend's gzipped JSON —
|
||||
// the pre-fix handler recorded 0 for every such (healthy) response. A non-seekable
|
||||
// stream body reproduces the headerless shape.
|
||||
var metrics = new NetworkMetrics();
|
||||
var inner = new RecordingHttpMessageHandler();
|
||||
var payload = Encoding.UTF8.GetBytes("{\"ok\":true}"); // 11 bytes
|
||||
inner.QueueResponse(() => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StreamContent(new NonSeekableReadStream(payload)),
|
||||
});
|
||||
using var client = BuildClient(metrics, inner);
|
||||
|
||||
using var resp = await client.SendAsync(NewPost(""));
|
||||
|
||||
var snap = metrics.GetSnapshot();
|
||||
Assert.Equal(payload.Length, snap.TotalBytesDown);
|
||||
// Buffering must not consume the body away from the caller.
|
||||
Assert.Equal("{\"ok\":true}", await resp.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bytes_down_fails_soft_to_zero_when_buffering_throws()
|
||||
{
|
||||
var metrics = new NetworkMetrics();
|
||||
var inner = new RecordingHttpMessageHandler();
|
||||
inner.QueueResponse(() => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ThrowingContent(),
|
||||
});
|
||||
using var client = BuildClient(metrics, inner);
|
||||
|
||||
// ResponseHeadersRead keeps HttpClient's own post-pipeline buffering out of the
|
||||
// picture — the test isolates the handler's fallback catch (which must swallow
|
||||
// the serialization failure and record 0, never throw).
|
||||
using var resp = await client.SendAsync(NewPost(""), HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
var snap = metrics.GetSnapshot();
|
||||
Assert.Equal(1, snap.RequestCount);
|
||||
Assert.Equal(0, snap.TotalBytesDown);
|
||||
}
|
||||
|
||||
/// <summary>Read-only, non-seekable stream — CanSeek=false keeps StreamContent from
|
||||
/// computing a Content-Length, mirroring a decompression-wrapped response body.</summary>
|
||||
private sealed class NonSeekableReadStream : System.IO.Stream
|
||||
{
|
||||
private readonly System.IO.MemoryStream _inner;
|
||||
public NonSeekableReadStream(byte[] data) { _inner = new System.IO.MemoryStream(data); }
|
||||
public override bool CanRead => true;
|
||||
public override bool CanSeek => false;
|
||||
public override bool CanWrite => false;
|
||||
public override long Length => throw new NotSupportedException();
|
||||
public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); }
|
||||
public override void Flush() { }
|
||||
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
|
||||
public override long Seek(long offset, System.IO.SeekOrigin origin) => throw new NotSupportedException();
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
protected override void Dispose(bool disposing) { if (disposing) _inner.Dispose(); base.Dispose(disposing); }
|
||||
}
|
||||
|
||||
/// <summary>Content whose serialization always fails — drives the fallback's catch path.</summary>
|
||||
private sealed class ThrowingContent : HttpContent
|
||||
{
|
||||
protected override Task SerializeToStreamAsync(System.IO.Stream stream, System.Net.TransportContext? context)
|
||||
=> throw new System.IO.IOException("connection dropped mid-body");
|
||||
protected override bool TryComputeLength(out long length) { length = 0; return false; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_rejects_null_metrics()
|
||||
{
|
||||
|
||||
+11
@@ -40,6 +40,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues an arbitrary response — for shapes <see cref="QueueStatus"/> can't build,
|
||||
/// e.g. content without a Content-Length header (what AutomaticDecompression leaves
|
||||
/// behind on real compressed responses).
|
||||
/// </summary>
|
||||
public RecordingHttpMessageHandler QueueResponse(Func<HttpResponseMessage> factory)
|
||||
{
|
||||
_script.Enqueue(_ => factory());
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -221,6 +221,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
Assert.Equal(1, System.Threading.Volatile.Read(ref signalled));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_tracks_enqueue_and_shrinks_to_zero_when_all_uploaded()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(0, spool.PendingBytes);
|
||||
|
||||
spool.Enqueue(EventDraft("a"));
|
||||
spool.Enqueue(EventDraft("b"));
|
||||
spool.Enqueue(EventDraft("c"));
|
||||
|
||||
// PendingBytes must equal the full file size while nothing is uploaded yet
|
||||
// (every line written by Enqueue is still pending).
|
||||
Assert.Equal(spool.SpoolFileSizeBytes, spool.PendingBytes);
|
||||
|
||||
spool.MarkUploaded(2);
|
||||
Assert.Equal(0, spool.PendingBytes);
|
||||
// File keeps its append-only size — the two measures MUST diverge here,
|
||||
// otherwise the pressure trigger regresses to the file-size false positive.
|
||||
Assert.True(spool.SpoolFileSizeBytes > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_after_partial_upload_equals_remaining_tail_line_bytes()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var spool = new TelemetrySpool(tmp.Path, Clock());
|
||||
spool.Enqueue(EventDraft("a"));
|
||||
spool.Enqueue(EventDraft("b"));
|
||||
spool.Enqueue(EventDraft("c-longer-rowkey-for-distinct-length"));
|
||||
|
||||
spool.MarkUploaded(1);
|
||||
|
||||
// Expected = on-disk byte length of the last line incl. its trailing \n.
|
||||
var lines = File.ReadAllLines(Path.Combine(tmp.Path, "spool.jsonl"), Encoding.UTF8);
|
||||
var expected = Encoding.UTF8.GetByteCount(lines[2]) + 1;
|
||||
Assert.Equal(expected, spool.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_is_rehydrated_on_restart_from_pending_tail_only()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("a"));
|
||||
s1.Enqueue(EventDraft("b"));
|
||||
s1.Enqueue(EventDraft("c"));
|
||||
s1.MarkUploaded(1);
|
||||
var expectedPendingBytes = s1.PendingBytes;
|
||||
Assert.True(expectedPendingBytes > 0);
|
||||
|
||||
// Restart: rehydrated PendingBytes must match the pre-restart value exactly
|
||||
// (uploaded prefix excluded), not the full file size.
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(expectedPendingBytes, s2.PendingBytes);
|
||||
Assert.True(s2.PendingBytes < s2.SpoolFileSizeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingBytes_rehydrate_ignores_corrupt_tail_line()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var s1 = new TelemetrySpool(tmp.Path, Clock());
|
||||
s1.Enqueue(EventDraft("a"));
|
||||
var cleanPendingBytes = s1.PendingBytes;
|
||||
|
||||
File.AppendAllText(
|
||||
Path.Combine(tmp.Path, "spool.jsonl"),
|
||||
"{\"Kind\":\"Event\",\"PartitionKey\":\"corrupt-no-",
|
||||
Encoding.UTF8);
|
||||
|
||||
var s2 = new TelemetrySpool(tmp.Path, Clock());
|
||||
Assert.Equal(cleanPendingBytes, s2.PendingBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enqueue_without_RequiresImmediateFlush_does_not_raise_ImmediateFlushRequested()
|
||||
{
|
||||
|
||||
@@ -34,9 +34,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
|
||||
private readonly long _maxFileSizeBytes;
|
||||
private readonly string _logDirectory;
|
||||
private readonly string _logFileBaseName; // "agent_20260425"
|
||||
private string _logFilePath; // mutable — rotates to "_002", "_003" ...
|
||||
private int _rotationSuffix; // 0 = base file, 2+ = "_002" etc.
|
||||
private readonly string _logFileBaseName; // "agent"
|
||||
private string _logFilePath; // mutable — rotates to "-002", "-003" ...
|
||||
private int _rotationSuffix; // 0 = base file, 2+ = "-002" etc.
|
||||
|
||||
/// <summary>
|
||||
/// When true, log entries are also written to Console.Out (same format as the log file).
|
||||
@@ -55,14 +55,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
Directory.CreateDirectory(logDirectory);
|
||||
}
|
||||
|
||||
_logFileBaseName = $"agent_{DateTime.Now:yyyyMMdd}";
|
||||
// One canonical name, no date: the agent only lives for the duration of an
|
||||
// enrollment, and a date-based split rotates at local midnight — which can fall
|
||||
// mid-enrollment (local time may still be on the OOBE default timezone). Size
|
||||
// rotation below bounds pathological growth. Legacy `agent_YYYYMMDD*.log` files
|
||||
// from a pre-rename agent are ignored by the suffix probe and left in place.
|
||||
_logFileBaseName = "agent";
|
||||
_rotationSuffix = ProbeNextRotationSuffix(logDirectory, _logFileBaseName);
|
||||
_logFilePath = BuildLogFilePath();
|
||||
}
|
||||
|
||||
// Picks up the highest existing rotation suffix on the same day so a process restart
|
||||
// doesn't overwrite previous segments. Returns 0 when no log file exists yet — the
|
||||
// first segment uses the unsuffixed name `agent_YYYYMMDD.log`.
|
||||
// Picks up the highest existing rotation suffix so a process restart doesn't
|
||||
// overwrite previous segments. Returns 0 when no log file exists yet — the
|
||||
// first segment uses the unsuffixed name `agent.log`.
|
||||
private static int ProbeNextRotationSuffix(string dir, string baseName)
|
||||
{
|
||||
if (!Directory.Exists(dir)) return 0;
|
||||
@@ -80,7 +85,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
continue;
|
||||
}
|
||||
if (name.Length > baseName.Length + 1 &&
|
||||
name.StartsWith(baseName + "_", StringComparison.OrdinalIgnoreCase) &&
|
||||
name.StartsWith(baseName + "-", StringComparison.OrdinalIgnoreCase) &&
|
||||
int.TryParse(name.Substring(baseName.Length + 1), out var suffix) &&
|
||||
suffix > max)
|
||||
{
|
||||
@@ -97,7 +102,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
{
|
||||
var name = _rotationSuffix < 2
|
||||
? $"{_logFileBaseName}.log"
|
||||
: $"{_logFileBaseName}_{_rotationSuffix:D3}.log";
|
||||
: $"{_logFileBaseName}-{_rotationSuffix:D3}.log";
|
||||
return Path.Combine(_logDirectory, name);
|
||||
}
|
||||
|
||||
|
||||
+52
-7
@@ -25,25 +25,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
private DateTime _impersonationLastRollupUtc = DateTime.MinValue;
|
||||
private static readonly TimeSpan ImpersonationRollupInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
// IME retries token acquisition on every check-in — dedup per distinct error
|
||||
// code so an outage yields one Warning, not a timeline flood.
|
||||
// "Failed to get AAD token" is logged ROUTINELY during the device phase (no user
|
||||
// token exists yet — field-confirmed: the line appears en masse in every enrollment,
|
||||
// Oliver 2026-08-18). A failure is therefore only meaningful when NO token success
|
||||
// follows: the failure is held pending, any IME-TOKEN-SUCCESS match clears it, and
|
||||
// the Warning fires only after the grace window elapses without a success. Genuine
|
||||
// auth outages (WAM broken, conditional access, network) run long enough to hit it.
|
||||
// Not persisted across restarts — failures keep arriving, the window re-arms.
|
||||
internal static readonly TimeSpan TokenFailureGraceWindow = TimeSpan.FromMinutes(30);
|
||||
|
||||
private sealed class PendingTokenFailure
|
||||
{
|
||||
public string Code = "unknown";
|
||||
public string Excerpt = string.Empty;
|
||||
public DateTime FirstFailureUtc;
|
||||
}
|
||||
|
||||
private PendingTokenFailure _pendingTokenFailure;
|
||||
// Dedup per distinct error code so a persistent outage yields one Warning total.
|
||||
private readonly HashSet<string> _reportedTokenFailureCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private void HandleImeTokenFailure(string errorCode, string message)
|
||||
{
|
||||
var code = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode.Trim();
|
||||
if (!_reportedTokenFailureCodes.Add(code)) return;
|
||||
if (_pendingTokenFailure != null) return; // earliest unresolved failure wins
|
||||
|
||||
var code = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode.Trim();
|
||||
var excerpt = message ?? string.Empty;
|
||||
if (excerpt.Length > 300) excerpt = excerpt.Substring(0, 300);
|
||||
|
||||
_logger.Warning($"ImeLogTracker: IME token acquisition failure (errorCode={code})");
|
||||
OnImeTokenFailure?.Invoke(code, excerpt);
|
||||
_pendingTokenFailure = new PendingTokenFailure
|
||||
{
|
||||
Code = code,
|
||||
Excerpt = excerpt,
|
||||
// Source-line time so a late-starting agent replaying an ongoing outage
|
||||
// ages the window correctly; a success later in the same log still clears
|
||||
// before the expiry check runs (lines are drained first, expiry after).
|
||||
FirstFailureUtc = LastMatchedLogTimestamp ?? DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
// Test seam: drives HandleImeTokenFailure without the regex pipeline.
|
||||
private void ClearPendingTokenFailure()
|
||||
{
|
||||
if (_pendingTokenFailure == null) return;
|
||||
_logger.Debug($"ImeLogTracker: token success observed — pending token failure (errorCode={_pendingTokenFailure.Code}) cleared");
|
||||
_pendingTokenFailure = null;
|
||||
}
|
||||
|
||||
/// <summary>Called once per poll cycle after the log drain (and testable directly).</summary>
|
||||
internal void CheckPendingTokenFailure(DateTime nowUtc)
|
||||
{
|
||||
var pending = _pendingTokenFailure;
|
||||
if (pending == null) return;
|
||||
if (nowUtc - pending.FirstFailureUtc < TokenFailureGraceWindow) return;
|
||||
|
||||
_pendingTokenFailure = null; // next failure line re-arms a fresh window
|
||||
if (!_reportedTokenFailureCodes.Add(pending.Code)) return;
|
||||
|
||||
_logger.Warning($"ImeLogTracker: IME token acquisition failing for >= {TokenFailureGraceWindow.TotalMinutes:0} min without a success (errorCode={pending.Code})");
|
||||
OnImeTokenFailure?.Invoke(pending.Code, pending.Excerpt);
|
||||
}
|
||||
|
||||
// Test seams: drive the pending/clear/expiry cycle without the regex pipeline.
|
||||
internal void HandleImeTokenFailureForTest(string errorCode, string message)
|
||||
=> HandleImeTokenFailure(errorCode, message);
|
||||
internal void NoteTokenSuccessForTest() => ClearPendingTokenFailure();
|
||||
|
||||
private void HandleImeImpersonation(string user)
|
||||
{
|
||||
|
||||
+8
@@ -244,6 +244,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
return;
|
||||
}
|
||||
|
||||
// A token success resolves any pending token failure (grace-window model, see
|
||||
// HandleImeTokenFailure). Matched on our own stable pattern ID — the line also
|
||||
// drives espPhaseDetected, whose handler must stay unaware of token semantics.
|
||||
// Placed after the staleness gate so a success replayed from a previous
|
||||
// enrollment's log cannot clear a genuine current outage.
|
||||
if (!isStaleReplayLine && string.Equals(pattern.PatternId, "IME-TOKEN-SUCCESS", StringComparison.OrdinalIgnoreCase))
|
||||
ClearPendingTokenFailure();
|
||||
|
||||
try
|
||||
{
|
||||
var id = match.Groups["id"]?.Value;
|
||||
|
||||
@@ -607,6 +607,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
// line never arrived within the grace period. Runs on this same loop
|
||||
// thread (no locking) so it observes the buffer right after parsing.
|
||||
FlushStalePlatformScriptResults(DateTime.UtcNow);
|
||||
|
||||
// Token-failure grace window: fires only when the failure stayed
|
||||
// unresolved (no IME-TOKEN-SUCCESS) for the whole window. Runs after
|
||||
// the drain so a success later in the same batch clears first.
|
||||
CheckPendingTokenFailure(DateTime.UtcNow);
|
||||
}
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex)
|
||||
|
||||
+135
-21
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -37,6 +38,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly int _modernDeploymentBackfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly int[] _modernDeploymentHarmlessEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
private readonly Func<(bool? skipUser, bool? skipDevice)> _skipConfigProbe;
|
||||
private readonly Func<bool> _accountSetupActivityProbe;
|
||||
private readonly Func<bool> _userEspAppsSettledProbe;
|
||||
@@ -45,7 +47,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
// Session caa6cf50 gate-starvation fix (2026-06-11) — fire-once guard for the
|
||||
// user-ESP-apps-settled AccountSetup synthesis (see MaybeSynthesizeAccountSetupComplete).
|
||||
private bool _userAppsSettledSynthesisFired;
|
||||
// Interlocked rather than a plain bool: since the sits-d Cloud-PC fix the synthesis is
|
||||
// ALSO re-evaluated from the IME app-state-change thread, so two threads can reach the
|
||||
// guard concurrently (Shell-Core watcher thread + IME log thread).
|
||||
private int _userAppsSettledSynthesisFired;
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19) — set once a CONFIRMED post-AccountSetup Shell-Core
|
||||
// exit has been observed (live OR backfill). The synthesis needs two facts: the ESP exit
|
||||
// is an EDGE that happens once, the settled user-ESP apps are a LEVEL that may only be
|
||||
// reached minutes later. Recording the edge lets ReevaluateUserAppsSettledSynthesis
|
||||
// re-check the level afterwards instead of losing the completion for the rest of the
|
||||
// session.
|
||||
//
|
||||
// Codex review P1 (2026-08-19): "confirmed" is load-bearing. Shell-Core raises 62407 at
|
||||
// EVERY ESP phase transition, DeviceSetup→AccountSetup included. Remembering that
|
||||
// intermediate exit would let the deferred re-check open the strong AccountSetup gate the
|
||||
// moment the last user app settles — while the AccountSetup page is still up and its other
|
||||
// subcategories are still running. See IsConfirmedPostAccountSetupExit.
|
||||
private volatile bool _espExitObserved;
|
||||
|
||||
// Liveness plan PR3 — one-shot-per-appId dedupe for app_install_starved emissions.
|
||||
// Written from the Shell-Core watcher thread (esp_exited path), read at termination by
|
||||
@@ -194,7 +213,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetup contains a SaveWhiteGloveSuccessResult subcategory with state "succeeded".
|
||||
/// This is a definitive WhiteGlove (Pre-Provisioning) confirmation signal from the ESP registry.
|
||||
/// NOT a WhiteGlove indicator on Win11 25H2+ — Windows writes it in every enrollment
|
||||
/// (see <see cref="ProvisioningStatusTracker.HasSaveWhiteGloveSuccessResult"/>). Observability only.
|
||||
/// </summary>
|
||||
public bool HasSaveWhiteGloveSuccessResult => _provisioningTracker?.HasSaveWhiteGloveSuccessResult ?? false;
|
||||
|
||||
@@ -210,6 +230,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
int modernDeploymentBackfillLookbackMinutes = 30,
|
||||
string stateDirectory = null,
|
||||
int[] modernDeploymentHarmlessEventIds = null,
|
||||
bool isDevicePreparation = false,
|
||||
Func<(bool? skipUser, bool? skipDevice)> skipConfigProbe = null,
|
||||
Func<bool> accountSetupActivityProbe = null,
|
||||
Func<bool> userEspAppsSettledProbe = null,
|
||||
@@ -227,6 +248,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_modernDeploymentBackfillLookbackMinutes = modernDeploymentBackfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_modernDeploymentHarmlessEventIds = modernDeploymentHarmlessEventIds;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
// Test seam: allow injection of a fake skip-config reader; defaults to the real
|
||||
// registry probe so production never has to think about this parameter.
|
||||
_skipConfigProbe = skipConfigProbe ?? (() => EspSkipConfigurationProbe.Read(_logger));
|
||||
@@ -310,11 +332,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
public void ResetForEspResumption() => _helloTracker?.ResetForEspResumption();
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Backfills recent ESP exit and failure events from Shell-Core log on startup.
|
||||
/// Secondary recovery mechanism when state persistence is unavailable.
|
||||
/// Replays the recent Shell-Core Hello-wizard start with an explicit lookback window.
|
||||
/// Called by <c>EspAndHelloHost</c> right after <see cref="Start"/> — see the host for why
|
||||
/// the window is sized to the previous run's downtime, and
|
||||
/// <see cref="ShellCoreTracker.ReplayBackfillRecords"/> for why the ESP exit is not part
|
||||
/// of the replay.
|
||||
/// </summary>
|
||||
public void BackfillRecentEspExitEvents() => _shellCoreTracker?.BackfillRecentEspExitEvents();
|
||||
public void BackfillRecentHelloWizardStart(int lookbackMinutes)
|
||||
=> _shellCoreTracker?.BackfillRecentHelloWizardStart(lookbackMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// sits-d Cloud-PC fix (2026-08-19) — re-check the user-apps-settled AccountSetup
|
||||
/// synthesis after the ESP exit has already been observed. Wired to the IME tracker's
|
||||
/// app-state-change callback in <c>DefaultComponentFactory</c>.
|
||||
/// <para>
|
||||
/// Before this, the synthesis ran ONLY on the <c>EspExited</c> edge. When the ESP page
|
||||
/// exits while user-ESP apps are still in flight (the normal case on a tenant with a
|
||||
/// large required-app set), the single attempt misses and nothing re-checks — even once
|
||||
/// every tracked app has reached a terminal state minutes later. Sessions 8110e262 and
|
||||
/// a89aac2d reached 138/138 apps with 0 failed and still never completed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Adds NO new completion path: the gate conditions inside the synthesis are unchanged
|
||||
/// (real ESP exit + every required user-ESP app terminal + zero failures). This only
|
||||
/// grants the existing, deliberately conservative check a second look.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void ReevaluateUserAppsSettledSynthesis()
|
||||
{
|
||||
if (!_espExitObserved) return;
|
||||
if (Volatile.Read(ref _userAppsSettledSynthesisFired) != 0) return;
|
||||
MaybeSynthesizeAccountSetupCompleteFromSettledUserApps(emitStarvedOnMiss: false);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Lifecycle
|
||||
@@ -347,16 +398,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_shellCoreTracker.HelloWizardStarted += OnHelloWizardStarted;
|
||||
_shellCoreTracker.Start();
|
||||
|
||||
_provisioningTracker = new ProvisioningStatusTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_post,
|
||||
_logger,
|
||||
packageStatesProbe: _packageStatesProbe);
|
||||
_provisioningTracker.EspFailureDetected += OnProvisioningEspFailureDetected;
|
||||
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
|
||||
_provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete;
|
||||
_provisioningTracker.Start();
|
||||
// WDP gate (afee7ae0, 2026-08-18): Device Preparation writes its BootstrapperAgent
|
||||
// progress under the very AutopilotSettings key this tracker subtree-watches, so on
|
||||
// WDP the watcher woke up on every provisioning write only to read three ESP
|
||||
// category values that never exist there. The ESP categories are the tracker's
|
||||
// ONLY subject — skip it entirely. All consumers already null-guard the tracker.
|
||||
if (!_isDevicePreparation)
|
||||
{
|
||||
_provisioningTracker = new ProvisioningStatusTracker(
|
||||
_sessionId,
|
||||
_tenantId,
|
||||
_post,
|
||||
_logger,
|
||||
packageStatesProbe: _packageStatesProbe);
|
||||
_provisioningTracker.EspFailureDetected += OnProvisioningEspFailureDetected;
|
||||
_provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete;
|
||||
_provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete;
|
||||
_provisioningTracker.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("EspAndHelloTracker: Device Preparation session — ProvisioningStatusTracker (ESP categories) not started");
|
||||
}
|
||||
|
||||
if (_modernDeploymentWatcherEnabled)
|
||||
{
|
||||
@@ -369,7 +432,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_modernDeploymentBackfillEnabled,
|
||||
_modernDeploymentBackfillLookbackMinutes,
|
||||
_stateDirectory,
|
||||
_modernDeploymentHarmlessEventIds);
|
||||
_modernDeploymentHarmlessEventIds,
|
||||
_isDevicePreparation);
|
||||
_modernDeploymentTracker.Start();
|
||||
}
|
||||
}
|
||||
@@ -519,6 +583,40 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return skipUser == false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Codex review P1 (2026-08-19) — may THIS <c>esp_exiting</c> be remembered as the edge the
|
||||
/// deferred user-apps-settled re-check builds on?
|
||||
/// <para>
|
||||
/// Deliberately STRICTER than <c>!IsIntermediateDeviceEspExit()</c>: that helper forwards
|
||||
/// on an unknown SkipUser (forwarding a signal early is cheap), whereas remembering the
|
||||
/// wrong edge here can open a completion gate minutes later with no second chance to take
|
||||
/// it back. So this one demands positive evidence and treats "unknown" as "not confirmed":
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item>the provisioning tracker has seen AccountSetup activity — the page that just
|
||||
/// tore down is demonstrably the AccountSetup one; or</item>
|
||||
/// <item>SkipUser is explicitly <c>true</c> — the profile has no user ESP at all, so the
|
||||
/// Device-ESP exit IS the final one and no second exit is coming.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Erring strict costs at worst today's behaviour (the session stalls as it did before the
|
||||
/// fix); erring loose costs a premature Succeeded, which is unrecoverable.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private bool IsConfirmedPostAccountSetupExit()
|
||||
{
|
||||
bool accountSetupSeen;
|
||||
try { accountSetupSeen = _accountSetupActivityProbe(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"EspAndHelloTracker: account-setup-activity probe threw: {ex.Message}");
|
||||
accountSetupSeen = false;
|
||||
}
|
||||
if (accountSetupSeen) return true;
|
||||
|
||||
return GetSkipUserEspCached() == true;
|
||||
}
|
||||
|
||||
private bool? GetSkipUserEspCached()
|
||||
{
|
||||
lock (_skipConfigLock)
|
||||
@@ -658,6 +756,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
try { EspExited?.Invoke(this, args); }
|
||||
catch (Exception ex) { _logger.Error("Error forwarding EspExited", ex); }
|
||||
|
||||
// Every exit reaching this handler is one the agent observed LIVE: the Shell-Core
|
||||
// replay never re-raises 62407 (see ShellCoreTracker.ReplayBackfillRecords — a
|
||||
// replayed exit cannot be placed in time and is therefore kept out of the decision
|
||||
// stream entirely). That is what makes the AccountSetup read below a valid
|
||||
// ordering fact: the agent was continuously observing up to this instant.
|
||||
//
|
||||
// Record the edge BEFORE the synthesis attempt so a later app-state change can
|
||||
// re-run it even when the apps are not settled at this instant.
|
||||
if (IsConfirmedPostAccountSetupExit())
|
||||
_espExitObserved = true;
|
||||
|
||||
// Session caa6cf50 gate-starvation fix: a Shell-Core normal exit while IME's
|
||||
// user-ESP app tracking is fully settled is alternative evidence that
|
||||
// AccountSetup completed. Raised AFTER the EspExited forward so the reducer
|
||||
@@ -687,9 +796,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// conservative stall behaviour.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void MaybeSynthesizeAccountSetupCompleteFromSettledUserApps()
|
||||
private void MaybeSynthesizeAccountSetupCompleteFromSettledUserApps(bool emitStarvedOnMiss = true)
|
||||
{
|
||||
if (_userAppsSettledSynthesisFired) return;
|
||||
if (Volatile.Read(ref _userAppsSettledSynthesisFired) != 0) return;
|
||||
|
||||
bool settled;
|
||||
try { settled = _userEspAppsSettledProbe(); }
|
||||
@@ -705,11 +814,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// operator with an anonymous "session hangs in AccountSetup". One-shot per
|
||||
// appId; apps that are alive (Downloading/Installing) or failed are excluded
|
||||
// by the probe itself.
|
||||
EmitStarvedUserEspApps(trigger: "esp_exited_user_apps_not_settled");
|
||||
// Only on the ESP-exit edge: the re-evaluation path passes false so a repeated
|
||||
// miss cannot re-walk the probe on every single app-state change (the per-appId
|
||||
// dedupe would swallow the events anyway — this keeps the work off the hot path).
|
||||
if (emitStarvedOnMiss)
|
||||
EmitStarvedUserEspApps(trigger: "esp_exited_user_apps_not_settled");
|
||||
return;
|
||||
}
|
||||
|
||||
_userAppsSettledSynthesisFired = true;
|
||||
// Claim the single synthesis slot; a concurrent caller that loses the race returns.
|
||||
if (Interlocked.CompareExchange(ref _userAppsSettledSynthesisFired, 1, 0) != 0) return;
|
||||
_logger.Warning(
|
||||
"EspAndHelloTracker: ESP exited normally with all tracked user-ESP apps terminal (0 failed) " +
|
||||
"but AccountSetupCategory.Status never confirmed categorySucceeded — treating AccountSetup as complete " +
|
||||
@@ -803,6 +917,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
// Test seam for the user-apps-settled synthesis — drives OnEspExited with the live
|
||||
// handler signature, mirroring TriggerEspExitedForTest's contract.
|
||||
internal bool UserAppsSettledSynthesisFiredForTest => _userAppsSettledSynthesisFired;
|
||||
internal bool UserAppsSettledSynthesisFiredForTest => Volatile.Read(ref _userAppsSettledSynthesisFired) != 0;
|
||||
}
|
||||
}
|
||||
|
||||
+67
-1
@@ -119,9 +119,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
if (_last == null)
|
||||
{
|
||||
// Silent baseline — see class doc.
|
||||
// Silent baseline for the DIFF stream — see class doc. But the baseline
|
||||
// itself is valuable evidence: on WDP the DPP-policy apps install in
|
||||
// Batch 1 BEFORE the agent (bootstrap = Batch 2), so their terminal
|
||||
// states exist ONLY here. One capped summary event closes that
|
||||
// pre-agent gap without any log backfill (Oliver, 2026-08-18).
|
||||
_last = snapshot;
|
||||
_logger?.Info($"RegistryAppState: baseline captured ({snapshot.Entries.Count} app entries, trigger={reason})");
|
||||
if (snapshot.Entries.Count > 0)
|
||||
EmitBaselineSummary(snapshot, nowUtc);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -181,6 +187,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var entry = snapshot.GetOrAdd(userName, appId);
|
||||
entry.ExitCode = TryReadInt(appKey.GetValue("ExitCode"));
|
||||
entry.Intent = TryReadInt(appKey.GetValue("Intent"));
|
||||
entry.LastUpdatedUtc = appKey.GetValue("LastUpdatedTimeUtc") as string;
|
||||
|
||||
using var esm = appKey.OpenSubKey("EnforcementStateMessage");
|
||||
var json = esm?.GetValue("EnforcementStateMessage") as string;
|
||||
@@ -452,6 +459,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
occurredAtUtc: nowUtc);
|
||||
}
|
||||
|
||||
// Baseline entries in the summary's typed payload are capped — a long-lived device
|
||||
// with many historical Win32Apps entries must not produce an oversized event.
|
||||
internal const int MaxBaselineSummaryApps = 50;
|
||||
|
||||
private void EmitBaselineSummary(ImeRegistrySnapshot snapshot, DateTime nowUtc)
|
||||
{
|
||||
int success = 0, error = 0, inProgress = 0, other = 0;
|
||||
var apps = new List<Dictionary<string, string>>();
|
||||
foreach (var entry in snapshot.Entries.Values)
|
||||
{
|
||||
var cls = entry.EnforcementState is int es ? ClassifyEnforcementState(es) : null;
|
||||
switch (cls)
|
||||
{
|
||||
case "success": success++; break;
|
||||
case "error": error++; break;
|
||||
case "inProgress": inProgress++; break;
|
||||
default: other++; break;
|
||||
}
|
||||
|
||||
if (apps.Count >= MaxBaselineSummaryApps) continue;
|
||||
var app = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["appId"] = entry.AppId,
|
||||
["userContext"] = entry.UserContext,
|
||||
};
|
||||
if (entry.EnforcementState is int state)
|
||||
{
|
||||
app["enforcementState"] = state.ToString(CultureInfo.InvariantCulture);
|
||||
app["enforcementClass"] = ClassifyEnforcementState(state);
|
||||
}
|
||||
if (entry.ErrorCode is long ec) app["errorCode"] = ec.ToString(CultureInfo.InvariantCulture);
|
||||
if (entry.ExitCode is int xc) app["exitCode"] = xc.ToString(CultureInfo.InvariantCulture);
|
||||
if (!string.IsNullOrEmpty(entry.LastUpdatedUtc)) app["lastUpdatedUtc"] = entry.LastUpdatedUtc!;
|
||||
if (entry.EspTracked) app["espTracked"] = "true";
|
||||
apps.Add(app);
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["totalApps"] = snapshot.Entries.Count.ToString(CultureInfo.InvariantCulture),
|
||||
["successCount"] = success.ToString(CultureInfo.InvariantCulture),
|
||||
["errorCount"] = error.ToString(CultureInfo.InvariantCulture),
|
||||
["inProgressCount"] = inProgress.ToString(CultureInfo.InvariantCulture),
|
||||
["otherCount"] = other.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
if (snapshot.Entries.Count > MaxBaselineSummaryApps)
|
||||
data["appListTruncated"] = "true";
|
||||
|
||||
_post.Emit(
|
||||
eventType: SharedEventTypes.RegistryAppBaseline,
|
||||
source: SourceLabel,
|
||||
message: $"Registry app baseline at agent start: {snapshot.Entries.Count} apps ({success} success, {error} error, {inProgress} in progress) — state that predates this agent run",
|
||||
severity: EventSeverity.Info,
|
||||
data: data,
|
||||
occurredAtUtc: nowUtc,
|
||||
typedPayload: new { apps });
|
||||
}
|
||||
|
||||
private void TrackTerminalTransitions(ImeRegistrySnapshot snapshot, DateTime nowUtc)
|
||||
{
|
||||
foreach (var kv in snapshot.Entries)
|
||||
@@ -552,6 +617,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
public string Key { get; }
|
||||
public string UserContext { get; }
|
||||
public string AppId { get; }
|
||||
public string? LastUpdatedUtc { get; set; }
|
||||
public int? EnforcementState { get; set; }
|
||||
public long? ErrorCode { get; set; }
|
||||
public int? ExitCode { get; set; }
|
||||
|
||||
+38
-2
@@ -66,6 +66,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
internal const string AutopilotErrorBackfillStateFileName = "autopilot-error-backfill.json";
|
||||
|
||||
/// <summary>
|
||||
/// EventIDs that are STRUCTURAL noise on a Device Preparation session and are
|
||||
/// suppressed there entirely (afee7ae0: 900+ occurrences of 1005 plus 400+ of
|
||||
/// 417/418 in a nine-minute session, all expected): 100 "Autopilot policy not
|
||||
/// found" (no deployment profile exists on WDP by design), 417/418 "duplicate
|
||||
/// deployment workload/batch" (BootstrapperAgent SLDM progress-poll chatter),
|
||||
/// 1005 ManagementService WIL error storm. Genuine WDP diagnostics (e.g. 408
|
||||
/// provisioning warnings) keep flowing; Critical (level 1) records are never
|
||||
/// suppressed. Classic sessions are unaffected — they keep the configurable
|
||||
/// harmless-downgrade + rollup path instead.
|
||||
/// </summary>
|
||||
internal static readonly HashSet<int> DevicePreparationNoiseEventIds = new HashSet<int> { 100, 417, 418, 1005 };
|
||||
|
||||
/// <summary>
|
||||
/// Per-EventId emission caps for harmless-downgraded events (session 8bc1180f,
|
||||
/// 2026-06-12). Windows can dump hundreds of identical EventID-100 "Autopilot policy
|
||||
@@ -93,6 +106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly HashSet<int> _harmlessEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
|
||||
private EventLogWatcher _autopilotWatcher;
|
||||
private EventLogWatcher _managementWatcher;
|
||||
@@ -131,7 +145,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 30,
|
||||
string stateDirectory = null,
|
||||
int[] harmlessEventIds = null)
|
||||
int[] harmlessEventIds = null,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -140,6 +155,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_logLevelMax = logLevelMax;
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_harmlessEventIds = harmlessEventIds != null && harmlessEventIds.Length > 0
|
||||
? new HashSet<int>(harmlessEventIds)
|
||||
@@ -166,7 +182,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (_backfillEnabled)
|
||||
{
|
||||
BackfillTargetedEvents();
|
||||
BackfillAutopilotErrors();
|
||||
// WDP gate (afee7ae0): the error backfill replays ZTD registration /
|
||||
// attestation errors (807/809/815/908) — on Device Preparation the 807
|
||||
// ZtdDeviceIsNotRegistered answer is the EXPECTED state, not a failure.
|
||||
if (!_isDevicePreparation)
|
||||
{
|
||||
BackfillAutopilotErrors();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("ModernDeployment: Device Preparation session — Autopilot error backfill skipped (ZTD errors are structural there)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -393,6 +419,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
var effectiveLevel = level ?? 4;
|
||||
|
||||
// WDP structural-noise gate — see DevicePreparationNoiseEventIds. Sits before
|
||||
// the severity mapping so suppressed records also skip FormatDescription().
|
||||
// Critical (1) and LogAlways (0) records always pass.
|
||||
if (_isDevicePreparation
|
||||
&& effectiveLevel >= 2
|
||||
&& DevicePreparationNoiseEventIds.Contains(eventId))
|
||||
{
|
||||
return EventVerdict.Suppress;
|
||||
}
|
||||
switch (effectiveLevel)
|
||||
{
|
||||
case 1:
|
||||
|
||||
+13
-8
@@ -107,7 +107,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private bool _deviceSetupProvisioningCompleteFired;
|
||||
// Fire-once guard for AccountSetupProvisioningComplete event (session 330f73f3 fix)
|
||||
private bool _accountSetupProvisioningCompleteFired;
|
||||
// WhiteGlove confirmation: DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded
|
||||
// DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded. NOT a WhiteGlove
|
||||
// indicator on Win11 25H2+ (see HasSaveWhiteGloveSuccessResult) — observability only.
|
||||
private bool _saveWhiteGloveSuccessResultSeen;
|
||||
// Track SaveWhiteGloveSuccessResult state transitions for observability (null → notStarted → succeeded)
|
||||
private string _lastSaveWhiteGloveState;
|
||||
@@ -226,8 +227,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>
|
||||
/// True when DeviceSetup registry JSON contains a SaveWhiteGloveSuccessResult property
|
||||
/// with subcategoryState=succeeded. This is a definitive WhiteGlove (Pre-Provisioning)
|
||||
/// confirmation signal — Windows only writes this property during White Glove flows.
|
||||
/// with subcategoryState=succeeded. NOT a WhiteGlove indicator: on Win11 25H2 (build
|
||||
/// 26200+) Windows writes this property as a trivially-succeeding step in EVERY
|
||||
/// enrollment (validated 2026-08-18 — user-driven sessions f475e697/c601a24b/4219203f
|
||||
/// all carry it). Kept for observability only (whiteglove_signal_* raw dumps); must not
|
||||
/// be used for classification. Reliable WhiteGlove anchors are ModernDeployment
|
||||
/// EventID 509 and the Shell-Core WhiteGlove-success events.
|
||||
/// </summary>
|
||||
public bool HasSaveWhiteGloveSuccessResult
|
||||
{
|
||||
@@ -891,10 +896,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string categoryStatusMessage = SafeGetString(root, "categoryStatusMessage");
|
||||
var subcategories = ParseSubcategories(root);
|
||||
|
||||
// Detect WhiteGlove signal in DeviceSetup category.
|
||||
// SaveWhiteGloveSuccessResult is NOT a *Subcategory-suffixed property, so
|
||||
// ParseSubcategories skips it. We scan the raw JSON explicitly.
|
||||
// Track state transitions for full observability (notStarted → succeeded).
|
||||
// Track the SaveWhiteGloveSuccessResult transitions in the DeviceSetup
|
||||
// category (observability only — NOT a WhiteGlove indicator on 25H2+, see
|
||||
// HasSaveWhiteGloveSuccessResult). It is NOT a *Subcategory-suffixed
|
||||
// property, so ParseSubcategories skips it; we scan the raw JSON explicitly.
|
||||
if (string.Equals(categoryLabel, "DeviceSetup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var prop in root.EnumerateObject())
|
||||
@@ -919,7 +924,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
_saveWhiteGloveSuccessResultSeen = true;
|
||||
_logger.Info("ProvisioningStatusTracker: SaveWhiteGloveSuccessResult=succeeded " +
|
||||
"— WhiteGlove confirmation signal");
|
||||
"(observability only — not a WhiteGlove indicator on 25H2+)");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
+150
-46
@@ -34,6 +34,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
internal const int EventId_ShellCore_WebAppEvent = 62407;
|
||||
internal const int BackfillLookbackMinutes = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound for the caller-supplied backfill lookback. Matches the agent's max
|
||||
/// lifetime (360 min) — a window wider than the agent can ever live would only widen
|
||||
/// the blast radius of a stale Shell-Core record without recovering anything the
|
||||
/// current enrollment could still act on.
|
||||
/// </summary>
|
||||
internal const int BackfillLookbackMaxMinutes = 360;
|
||||
|
||||
private static readonly HashSet<int> TrackedShellCoreEventIds = new HashSet<int>
|
||||
{
|
||||
EventId_ShellCore_WebAppStarted,
|
||||
@@ -350,22 +358,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// Backfill (public — called by coordinator)
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>Clamps a caller-supplied lookback into [1, <see cref="BackfillLookbackMaxMinutes"/>].</summary>
|
||||
internal static int ClampLookbackMinutes(int lookbackMinutes)
|
||||
{
|
||||
if (lookbackMinutes < 1) return 1;
|
||||
if (lookbackMinutes > BackfillLookbackMaxMinutes) return BackfillLookbackMaxMinutes;
|
||||
return lookbackMinutes;
|
||||
}
|
||||
|
||||
public void BackfillRecentHelloWizardStart() => BackfillRecentHelloWizardStart(BackfillLookbackMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// Backfills recent ESP exit, ESP failure and Hello-wizard-start events from the
|
||||
/// Shell-Core log on startup. Secondary recovery mechanism when state persistence is
|
||||
/// unavailable — an agent restart while the user sits inside the Hello wizard would
|
||||
/// otherwise lose the wizard-start observation (session 772fe502).
|
||||
/// Same recovery with a caller-chosen lookback, so a restart can reach back over its whole
|
||||
/// downtime (after a mid-ESP reboot the agent is gone from the forced restart until the
|
||||
/// post-reboot logon relaunches it; after a crash, until the next boot — the scheduled task
|
||||
/// has a BootTrigger only). Clamped to [1, <see cref="BackfillLookbackMaxMinutes"/>].
|
||||
/// <para>
|
||||
/// The 62407 records in the window are read but deliberately NOT replayed — see
|
||||
/// <see cref="ReplayBackfillRecords"/>. They are counted and reported once as an
|
||||
/// <c>agent_trace</c> so the gap stays visible in the timeline without becoming
|
||||
/// decision-relevant.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void BackfillRecentEspExitEvents()
|
||||
public void BackfillRecentHelloWizardStart(int lookbackMinutes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = BackfillLookbackMinutes * 60 * 1000;
|
||||
var lookbackMs = ClampLookbackMinutes(lookbackMinutes) * 60 * 1000;
|
||||
var query = new EventLogQuery(
|
||||
ShellCoreEventLogChannel,
|
||||
PathType.LogName,
|
||||
$"*[System[(EventID=62404 or EventID=62407) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]");
|
||||
|
||||
var records = new List<(int Id, string Description, DateTime OccurredAtUtc)>();
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
for (EventRecord record = reader.ReadEvent(); record != null; record = reader.ReadEvent())
|
||||
@@ -377,20 +402,135 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// (EspAndHelloTrackerAdapter) can stamp signals with the source time
|
||||
// rather than collapsing to wall-clock-now.
|
||||
var timestamp = (record.TimeCreated ?? DateTime.UtcNow).ToUniversalTime();
|
||||
HandleBackfillRecord(record.Id, description, timestamp);
|
||||
records.Add((record.Id, description, timestamp));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReplayBackfillRecords(records);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"ESP exit/failure event backfill failed: {ex.Message}");
|
||||
_logger.Warning($"Shell-Core replay failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal backfill record handler — extracted for testability and to keep the
|
||||
/// backfill loop free of direct event-processing logic. The
|
||||
/// Replays a chronological batch of Shell-Core records. <b>Only the Hello-wizard start
|
||||
/// (62404) is replayed.</b> ESP exits and ESP failures (62407) are counted and reported,
|
||||
/// never re-raised.
|
||||
/// <para>
|
||||
/// Why the exit is excluded — the reasoning is the whole point of this method, so it lives
|
||||
/// here rather than in a commit message:
|
||||
/// </para>
|
||||
/// <list type="number">
|
||||
/// <item>Windows writes the IDENTICAL description
|
||||
/// (<c>CommercialOOBE_ESPProgress_Page_Exiting</c>) for the intermediate
|
||||
/// DeviceSetup→AccountSetup transition and for the final post-AccountSetup exit, so a
|
||||
/// replayed record carries no evidence of its own position.</item>
|
||||
/// <item>Everything that could order it after the fact — the AccountSetup registry
|
||||
/// probe, the settled-apps probe — reads state as it is NOW, not as it was at the
|
||||
/// event's time.</item>
|
||||
/// <item>The reducer orders exits by INGEST ORDINAL, not by timestamp
|
||||
/// (<c>IsPostAccountSetupFinalExit</c>). A replayed historic exit is assigned a fresher
|
||||
/// ordinal than reality, so it looks post-AccountSetup by construction.</item>
|
||||
/// <item><c>HandleEspExitingV1</c> passes <c>espFinalExitInFlight: true</c> for every
|
||||
/// arriving exit. With restored state (AccountSetupEntered + a genuine IME user
|
||||
/// session + desktop arrived) arm C of <c>ShouldTransitionToAwaitingHello</c> then
|
||||
/// opens on a historic intermediate exit — a completion built on a fact that never
|
||||
/// happened.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// There is therefore no honest way to classify a replayed exit, so it must not enter the
|
||||
/// decision stream at all. The same applies to a replayed ESP FAILURE, for the opposite
|
||||
/// reason: re-injecting a historic failure as fresh can fail a session that recovered
|
||||
/// (see ANALYZE-ESP-006, "ESP Failure Recovered After User Retry").
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The Hello-wizard start is different in kind and is the observation this replay was
|
||||
/// written for (session 772fe502): it is a CONSERVATIVE fact. It vetoes a premature
|
||||
/// "Hello is disabled" skip and can never by itself complete a session, so replaying it
|
||||
/// can only make the agent wait longer, never finish early.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal void ReplayBackfillRecords(
|
||||
IReadOnlyList<(int Id, string Description, DateTime OccurredAtUtc)> records)
|
||||
{
|
||||
if (records == null || records.Count == 0) return;
|
||||
|
||||
var skippedExits = 0;
|
||||
var skippedFailures = 0;
|
||||
DateTime? oldestSkipped = null;
|
||||
DateTime? newestSkipped = null;
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
var description = record.Description ?? string.Empty;
|
||||
|
||||
if (record.Id == EventId_ShellCore_WebAppStarted)
|
||||
{
|
||||
HandleBackfillRecord(record.Id, description, record.OccurredAtUtc);
|
||||
continue;
|
||||
}
|
||||
|
||||
var isFailure = HasEspFailurePattern(description);
|
||||
var isExit = !isFailure && EspExitingPattern.IsMatch(description);
|
||||
if (!isFailure && !isExit) continue;
|
||||
|
||||
if (isFailure) skippedFailures++; else skippedExits++;
|
||||
if (oldestSkipped == null || record.OccurredAtUtc < oldestSkipped.Value)
|
||||
oldestSkipped = record.OccurredAtUtc;
|
||||
if (newestSkipped == null || record.OccurredAtUtc > newestSkipped.Value)
|
||||
newestSkipped = record.OccurredAtUtc;
|
||||
}
|
||||
|
||||
if (skippedExits > 0 || skippedFailures > 0)
|
||||
EmitSkippedShellCoreRecords(skippedExits, skippedFailures, oldestSkipped, newestSkipped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One informational <c>agent_trace</c> naming the 62407 records the replay deliberately
|
||||
/// did not re-raise. Decision-neutral by construction (informational events are exempt
|
||||
/// from the dispatch guard) — its only job is to keep the blind window visible to whoever
|
||||
/// debugs a session later, instead of the replay silently dropping evidence.
|
||||
/// </summary>
|
||||
private void EmitSkippedShellCoreRecords(
|
||||
int skippedExits, int skippedFailures, DateTime? oldestUtc, DateTime? newestUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
EventType = Constants.EventTypes.AgentTrace,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "ShellCoreTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message =
|
||||
$"Shell-Core replay skipped {skippedExits} ESP exit(s) and {skippedFailures} ESP failure(s) " +
|
||||
"from the window in which no agent process was running — a replayed 62407 cannot be placed " +
|
||||
"in time and must not reach the decision engine.",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "skippedEspExits", skippedExits },
|
||||
{ "skippedEspFailures", skippedFailures },
|
||||
{ "oldestSkippedUtc", oldestUtc?.ToString("o") ?? string.Empty },
|
||||
{ "newestSkippedUtc", newestUtc?.ToString("o") ?? string.Empty },
|
||||
{ "reason", "replayed_62407_not_orderable" },
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Debug($"ShellCoreTracker: skipped-records trace emit failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal backfill record handler — extracted for testability and to keep the replay
|
||||
/// loop free of direct event-processing logic. Handles the Hello-wizard start ONLY; see
|
||||
/// <see cref="ReplayBackfillRecords"/> for why 62407 is never replayed. The
|
||||
/// <paramref name="occurredAtUtc"/> is the original Shell-Core event time
|
||||
/// (<c>record.TimeCreated</c>); subscribers read it via
|
||||
/// <see cref="LastEventOccurredAtUtc"/> during their synchronous event handler.
|
||||
@@ -427,42 +567,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return;
|
||||
}
|
||||
|
||||
if (EspExitingPattern.IsMatch(description))
|
||||
{
|
||||
bool shouldNotify = false;
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (!_espExitDetected)
|
||||
{
|
||||
_espExitDetected = true;
|
||||
shouldNotify = true;
|
||||
}
|
||||
}
|
||||
if (shouldNotify)
|
||||
{
|
||||
_helloTracker?.NotifyEspExited();
|
||||
_logger.Info($"Backfill: ESP exit event found in recent Shell-Core logs (originalAt={occurredAtUtc:o})");
|
||||
LastEventOccurredAtUtc = occurredAtUtc;
|
||||
try
|
||||
{
|
||||
try { FinalizingSetupPhaseTriggered?.Invoke(this, "esp_exiting"); }
|
||||
catch (Exception ex) { _logger.Error("Backfill: FinalizingSetupPhaseTriggered handler failed", ex); }
|
||||
try { EspExited?.Invoke(this, new EspExitedEventArgs(occurredAtUtc)); }
|
||||
catch (Exception ex) { _logger.Error("Backfill: EspExited handler failed", ex); }
|
||||
}
|
||||
finally { LastEventOccurredAtUtc = null; }
|
||||
}
|
||||
}
|
||||
|
||||
if (HasEspFailurePattern(description))
|
||||
{
|
||||
var failureType = ExtractEspFailureType(description);
|
||||
_logger.Info($"Backfill: ESP failure event found in recent Shell-Core logs: {failureType} (originalAt={occurredAtUtc:o})");
|
||||
LastEventOccurredAtUtc = occurredAtUtc;
|
||||
try { EspFailureDetected?.Invoke(this, failureType); }
|
||||
catch (Exception ex) { _logger.Error($"Backfill: EspFailureDetected handler failed for '{failureType}'", ex); }
|
||||
finally { LastEventOccurredAtUtc = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
|
||||
+20
-2
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly HashSet<string> _sources;
|
||||
private readonly int _sessionStalledAfterProbeIndex;
|
||||
private readonly HashSet<int> _harmlessModernDeploymentEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
|
||||
private readonly object _stateLock = new object();
|
||||
private readonly HashSet<int> _firedProbeIndices = new HashSet<int>();
|
||||
@@ -151,7 +152,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
int[] traceIndices,
|
||||
string[] sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[] harmlessModernDeploymentEventIds = null)
|
||||
int[] harmlessModernDeploymentEventIds = null,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -161,10 +163,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_traceIndices = new HashSet<int>(traceIndices ?? new int[0]);
|
||||
_sources = new HashSet<string>(sources ?? new string[0], StringComparer.OrdinalIgnoreCase);
|
||||
_sessionStalledAfterProbeIndex = sessionStalledAfterProbeIndex;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
_harmlessModernDeploymentEventIds = new HashSet<int>(
|
||||
harmlessModernDeploymentEventIds != null && harmlessModernDeploymentEventIds.Length > 0
|
||||
? harmlessModernDeploymentEventIds
|
||||
: DefaultHarmlessModernDeploymentEventIds);
|
||||
|
||||
// WDP gate (afee7ae0): the structural WDP noise IDs (417/418 duplicate-workload
|
||||
// chatter, 1005 WIL storm, ...) must never count as stall anomalies on Device
|
||||
// Preparation, independent of what the tenant-configured harmless list contains.
|
||||
// The channels themselves stay scanned — WDP's provisioning stack logs genuine
|
||||
// diagnostics (e.g. 408) into the very same Autopilot channel.
|
||||
if (_isDevicePreparation)
|
||||
{
|
||||
_harmlessModernDeploymentEventIds.UnionWith(ModernDeploymentTracker.DevicePreparationNoiseEventIds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -244,7 +257,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
result.DurationMs = (int)(DateTime.UtcNow - started).TotalMilliseconds;
|
||||
|
||||
// Always log locally for diagnostics — the agent_YYYYMMDD.log is the always-on observability.
|
||||
// Always log locally for diagnostics — agent.log is the always-on observability.
|
||||
var anomaliesStr = result.Anomalies.Count > 0
|
||||
? $"anomalies=[{string.Join(",", result.Anomalies)}]"
|
||||
: "quiet";
|
||||
@@ -368,6 +381,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
private void ScanProvisioningRegistry(ProbeResult result)
|
||||
{
|
||||
// Device Preparation writes no ESP category values — the three Status JSONs
|
||||
// below are ESP-only constructs, so the scan can never find anything there.
|
||||
if (_isDevicePreparation)
|
||||
return;
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Provisioning\AutopilotSettings", writable: false))
|
||||
{
|
||||
if (key == null)
|
||||
|
||||
@@ -972,7 +972,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
/// <summary>
|
||||
/// Appends a log line to the main agent log file (logger isn't initialized yet at update time).
|
||||
/// Uses the same date-based naming and format as AgentLogger: agent_YYYYMMDD.log
|
||||
/// Uses the same naming and format as AgentLogger: agent.log
|
||||
/// </summary>
|
||||
private static void LogToFile(string message)
|
||||
{
|
||||
@@ -980,8 +980,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
var logDir = Environment.ExpandEnvironmentVariables(Constants.LogDirectory);
|
||||
Directory.CreateDirectory(logDir);
|
||||
var logFileName = $"agent_{DateTime.Now:yyyyMMdd}.log";
|
||||
var logPath = Path.Combine(logDir, logFileName);
|
||||
var logPath = Path.Combine(logDir, "agent.log");
|
||||
File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}] [INFO] {message}{Environment.NewLine}");
|
||||
}
|
||||
catch
|
||||
|
||||
+49
-12
@@ -275,14 +275,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Include enrollment type in autopilot_profile event data
|
||||
data["enrollmentType"] = detectedType;
|
||||
|
||||
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
|
||||
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
|
||||
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
|
||||
var diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
|
||||
if (diagnosticsRegistry != null)
|
||||
data["diagnosticsRegistry"] = diagnosticsRegistry;
|
||||
// Device Preparation (WDP) has no Autopilot deployment profile by design:
|
||||
// Windows still runs the ZTD policy download during OOBE and caches the 807
|
||||
// ZtdDeviceIsNotRegistered answer, so without this gate every WDP session
|
||||
// emits an autopilot_profile dump plus a misleading "no deployment profile
|
||||
// was assigned" warning and burns a ZTD event-log query + HTTP probe on
|
||||
// evidence that answers a question nobody asked (afee7ae0). Gated on the
|
||||
// deterministic WDP rule only — the CloudAssigned* fallback rules imply a
|
||||
// real profile (just ESP-less), where the dump stays meaningful. The
|
||||
// enrollment_type_detected event below still carries the rail decision.
|
||||
var isDevicePreparation = detectionRule ==
|
||||
AutopilotMonitor.Agent.V2.Core.Security.EnrollmentRegistryDetector.DevicePreparationExecutionContextRule;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
|
||||
Dictionary<string, object> diagnosticsRegistry = null;
|
||||
if (!isDevicePreparation)
|
||||
{
|
||||
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
|
||||
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
|
||||
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
|
||||
diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
|
||||
if (diagnosticsRegistry != null)
|
||||
data["diagnosticsRegistry"] = diagnosticsRegistry;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: Device Preparation session — skipping autopilot_profile/ZTD evidence (no deployment profile exists on WDP)");
|
||||
}
|
||||
|
||||
// ProfileAvailable=0 means the ZTD service returned no profile for this device
|
||||
// during OOBE. Sessions only exist after the backend's device-registration
|
||||
@@ -293,7 +313,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Autopilot session — surface the edge case explicitly, with a ZTD verdict
|
||||
// backed by the ModernDeployment Autopilot event log plus an endpoint probe
|
||||
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
|
||||
if (IsAutopilotProfileMissing(data))
|
||||
if (!isDevicePreparation && IsAutopilotProfileMissing(data))
|
||||
{
|
||||
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
|
||||
// are provisioned by the Windows 365 service, not by Autopilot — so the event
|
||||
@@ -787,10 +807,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
data["espTrackedModernCount"] = tracking.ModernCount;
|
||||
}
|
||||
|
||||
var summary = BuildSummary(snapshot, tracking);
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
|
||||
PostEspConfigDetectedSignal(snapshot);
|
||||
// No FirstSync value and no ESP tracking list means there is no ESP evidence at
|
||||
// all (Device Preparation, plain Entra join, pre-sync timing) — an event that
|
||||
// says nothing but "unknown/unknown" is noise, so skip it. Mirrors the null-gate
|
||||
// the orchestrator bootstrap already applies to the decision signal
|
||||
// (EnrollmentOrchestrator.PostEspConfigDetectedBootstrap).
|
||||
var hasAnyEspEvidence = snapshot.SkipUser.HasValue
|
||||
|| snapshot.SkipDevice.HasValue
|
||||
|| snapshot.BlockInStatusPage.HasValue
|
||||
|| snapshot.SyncFailureTimeoutMinutes.HasValue
|
||||
|| tracking.HasData;
|
||||
if (hasAnyEspEvidence)
|
||||
{
|
||||
var summary = BuildSummary(snapshot, tracking);
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
|
||||
PostEspConfigDetectedSignal(snapshot);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("EnrollmentTracker: no ESP configuration evidence (FirstSync empty, no tracking lists) — esp_config_detected suppressed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
+8
-3
@@ -300,7 +300,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
{
|
||||
_state = DetectorState.Terminal;
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallCompleted, snap, EventSeverity.Info, "Completed", isTerminal: true, coreBinariesPresent: true);
|
||||
PersistState(OfficeInstallStateData.StateCompleted);
|
||||
PersistState(OfficeInstallStateData.StateCompleted, snap);
|
||||
return CompletionOutcome.Completed;
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
if (_state == DetectorState.Terminal) return;
|
||||
_state = DetectorState.Terminal;
|
||||
EmitLifecycle(Constants.EventTypes.OfficeInstallFailed, snap, EventSeverity.Error, "Failed", isTerminal: true);
|
||||
PersistState(OfficeInstallStateData.StateFailed);
|
||||
PersistState(OfficeInstallStateData.StateFailed, snap);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -444,15 +444,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
/// (the resume is what delivers a completion missed across a mid-install reboot).
|
||||
/// Caller holds the lock.
|
||||
/// </summary>
|
||||
private void PersistState(string state)
|
||||
private void PersistState(string state, OfficeC2RSnapshot? snap = null)
|
||||
{
|
||||
if (_statePersistence == null) return;
|
||||
_lastStatePersistUtc = _clock.UtcNow;
|
||||
var isTerminal = state == OfficeInstallStateData.StateCompleted
|
||||
|| state == OfficeInstallStateData.StateFailed;
|
||||
_statePersistence.Save(new OfficeInstallStateData
|
||||
{
|
||||
State = state,
|
||||
StartedAtUtc = _startedAtUtc,
|
||||
StartedTrigger = _startedTrigger,
|
||||
// Terminal-only metadata for the summary dialog's Office row (duration + version).
|
||||
CompletedAtUtc = isTerminal ? _clock.UtcNow : (DateTime?)null,
|
||||
VersionReached = isTerminal ? snap?.VersionToReport : null,
|
||||
PeakDo = OfficeDoPeakData.FromSample(_peakDo),
|
||||
});
|
||||
}
|
||||
|
||||
+9
@@ -105,6 +105,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office
|
||||
public DateTime? StartedAtUtc { get; set; }
|
||||
public string? StartedTrigger { get; set; }
|
||||
|
||||
/// <summary>Stamped only on the two terminal persists (Completed/Failed) — basis for the
|
||||
/// summary dialog's Office row duration. Null on Active/Preinstalled and on state files
|
||||
/// written by older agents (fail-soft deserialization).</summary>
|
||||
public DateTime? CompletedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Office build reached at the terminal transition (snapshot's VersionToReport).
|
||||
/// Null when the snapshot had none or on older state files.</summary>
|
||||
public string? VersionReached { get; set; }
|
||||
|
||||
/// <summary>Highest-bytes DO sample seen — basis for the doSummary on a resumed completion.</summary>
|
||||
public OfficeDoPeakData? PeakDo { get; set; }
|
||||
|
||||
|
||||
+25
-19
@@ -22,11 +22,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// Pressure thresholds — once either is crossed within a session, a one-shot
|
||||
// `spool_pressure_detected` event is emitted with RequiresImmediateFlush=true
|
||||
// so the backend can surface the condition without waiting for the next snapshot
|
||||
// to be queried out of Storage. Sized so a healthy 6h provisioning session never
|
||||
// trips them; tripping is a strong "upload backlog is growing or downstream stalled"
|
||||
// signal worth investigating in the field.
|
||||
// to be queried out of Storage. Both measure the PENDING backlog (items not yet
|
||||
// marked uploaded), never the append-only spool file size — a healthy long session
|
||||
// grows the file past any fixed size while its backlog stays near zero (field data
|
||||
// 2026-08: 99/100 pressure events were file-size-only false positives). Tripping is
|
||||
// therefore a genuine "upload is stalled or falling behind" signal.
|
||||
internal const int PressurePendingItemThreshold = 2000;
|
||||
internal const long PressureFileSizeBytesThreshold = 5L * 1024 * 1024; // 5 MB
|
||||
internal const long PressurePendingBytesThreshold = 5L * 1024 * 1024; // 5 MB
|
||||
|
||||
private readonly string _agentVersion;
|
||||
private readonly NetworkMetrics _networkMetrics;
|
||||
@@ -77,11 +79,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
|
||||
protected override void Collect()
|
||||
{
|
||||
// Full path writes 19 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (4) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=19 →
|
||||
// HashHelpers.GetPrime(19)=23 buckets → no resize on the 19th key.
|
||||
var data = new Dictionary<string, object>(capacity: 19, StringComparer.Ordinal)
|
||||
// Full path writes 20 keys: agent_version + process metrics (5: cpu, ws, private,
|
||||
// threads, handles) + spool stats (5) + network delta (9: requests, failures,
|
||||
// bytes_up/down, avg_latency, total_up/down/requests/latency). cap=20 →
|
||||
// HashHelpers.GetPrime(20)=23 buckets → no resize on the 20th key.
|
||||
var data = new Dictionary<string, object>(capacity: 20, StringComparer.Ordinal)
|
||||
{
|
||||
{ "agent_version", _agentVersion }
|
||||
};
|
||||
@@ -126,17 +128,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
// EnrollmentOrchestrator.Start into IComponentFactory.CreateCollectorHosts
|
||||
// (ARCH-F4). Null only on test fakes that don't construct a real spool.
|
||||
int pendingItemCount = 0;
|
||||
long pendingBytes = 0L;
|
||||
long spoolFileSizeBytes = 0L;
|
||||
if (_telemetrySpool != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
pendingItemCount = _telemetrySpool.PendingItemCount;
|
||||
pendingBytes = _telemetrySpool.PendingBytes;
|
||||
spoolFileSizeBytes = _telemetrySpool.SpoolFileSizeBytes;
|
||||
var peakPending = _telemetrySpool.PeakPendingItemCount;
|
||||
var totalEnqueued = _telemetrySpool.LastAssignedItemId + 1; // -1 sentinel → 0
|
||||
|
||||
data["spool_pending_item_count"] = pendingItemCount;
|
||||
data["spool_pending_bytes"] = pendingBytes;
|
||||
data["spool_peak_pending_item_count"] = peakPending;
|
||||
data["spool_file_size_bytes"] = spoolFileSizeBytes;
|
||||
data["spool_total_enqueued_count"] = totalEnqueued;
|
||||
@@ -194,20 +199,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
});
|
||||
}
|
||||
|
||||
// One-shot pressure event \u2014 fires once per session when the spool grows past
|
||||
// either threshold. ImmediateUpload=true so it shows up promptly on the
|
||||
// backend without waiting for the next batch drain.
|
||||
// One-shot pressure event \u2014 fires once per session when the pending backlog
|
||||
// grows past either threshold. ImmediateUpload=true so it shows up promptly on
|
||||
// the backend without waiting for the next batch drain.
|
||||
if (_telemetrySpool != null
|
||||
&& (pendingItemCount > PressurePendingItemThreshold
|
||||
|| spoolFileSizeBytes > PressureFileSizeBytesThreshold)
|
||||
|| pendingBytes > PressurePendingBytesThreshold)
|
||||
&& Interlocked.CompareExchange(ref _pressureEmitted, 1, 0) == 0)
|
||||
{
|
||||
var pressureData = new Dictionary<string, object>(capacity: 8, StringComparer.Ordinal)
|
||||
{
|
||||
{ "pendingItemCount", pendingItemCount },
|
||||
{ "fileSizeBytes", spoolFileSizeBytes },
|
||||
{ "pendingBytes", pendingBytes },
|
||||
{ "fileSizeBytes", spoolFileSizeBytes }, // informational only, not a trigger
|
||||
{ "pendingThreshold", PressurePendingItemThreshold },
|
||||
{ "fileSizeThresholdBytes", PressureFileSizeBytesThreshold },
|
||||
{ "pendingBytesThreshold", PressurePendingBytesThreshold },
|
||||
{ "totalEnqueuedCount", _telemetrySpool.LastAssignedItemId + 1 },
|
||||
{ "lastUploadedItemId", _telemetrySpool.LastUploadedItemId },
|
||||
{ "ImmediateUpload", true }
|
||||
@@ -222,15 +228,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "AgentSelfMetricsCollector",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Telemetry spool pressure detected: pending={pendingItemCount}, " +
|
||||
$"fileBytes={spoolFileSizeBytes} \u2014 upload may be stalled or session " +
|
||||
$"is unusually long.",
|
||||
Message = $"Telemetry spool pressure detected: pending={pendingItemCount} items / " +
|
||||
$"{pendingBytes} bytes not yet uploaded \u2014 upload is stalled or " +
|
||||
$"falling behind.",
|
||||
Data = pressureData
|
||||
});
|
||||
|
||||
Logger.Warning(
|
||||
$"AgentSelfMetricsCollector: spool pressure detected " +
|
||||
$"(pending={pendingItemCount}, fileBytes={spoolFileSizeBytes}). " +
|
||||
$"(pending={pendingItemCount}, pendingBytes={pendingBytes}). " +
|
||||
$"Emitted spool_pressure_detected (one-shot per session).");
|
||||
}
|
||||
}
|
||||
|
||||
+28
-3
@@ -21,12 +21,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// <remarks>
|
||||
/// <b>bytes-up</b> is read from <see cref="System.Net.Http.Headers.HttpContentHeaders.ContentLength"/>
|
||||
/// on the request content (set eagerly by <c>ByteArrayContent</c>/<c>StringContent</c>).
|
||||
/// <b>bytes-down</b> is read from the response Content-Length header — when
|
||||
/// <c>AutomaticDecompression</c> is enabled the framework may strip this, so the value
|
||||
/// is best-effort (degrades to 0). The request count and failure count are always exact.
|
||||
/// <b>bytes-down</b> prefers the response Content-Length header; the pipeline's
|
||||
/// <c>AutomaticDecompression</c> strips that header on compressed responses (i.e. on
|
||||
/// virtually every backend JSON response), so when it is absent the content is buffered
|
||||
/// and the DECOMPRESSED payload size is recorded instead — before the fallback the
|
||||
/// counter read 0 on healthy sessions. <c>HttpClient</c> buffers responses anyway
|
||||
/// (default <c>HttpCompletionOption.ResponseContentRead</c>), so this adds no real
|
||||
/// memory cost. The request count and failure count are always exact.
|
||||
/// </remarks>
|
||||
internal sealed class NetworkMetricsRecordingHandler : DelegatingHandler
|
||||
{
|
||||
// Buffering cap for the bytes-down fallback. Backend responses are small JSON
|
||||
// (config, ingest acks); anything larger is counted as 0 rather than buffered.
|
||||
private const long MaxCountedResponseBytes = 16 * 1024 * 1024;
|
||||
|
||||
private readonly NetworkMetrics _metrics;
|
||||
|
||||
public NetworkMetricsRecordingHandler(NetworkMetrics metrics)
|
||||
@@ -54,6 +62,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
{
|
||||
response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
bytesDown = response?.Content?.Headers?.ContentLength ?? 0L;
|
||||
if (bytesDown == 0 && response?.Content != null)
|
||||
{
|
||||
// AutomaticDecompression stripped Content-Length (the norm for the
|
||||
// backend's gzipped JSON). Buffer the content — after buffering the
|
||||
// header reflects the decompressed length. Fail-soft: a buffering
|
||||
// error (too large, connection dropped mid-body) keeps 0 and leaves
|
||||
// the response for the caller to observe the failure itself.
|
||||
try
|
||||
{
|
||||
await response.Content.LoadIntoBufferAsync(MaxCountedResponseBytes).ConfigureAwait(false);
|
||||
bytesDown = response.Content.Headers.ContentLength ?? 0L;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// keep bytesDown = 0
|
||||
}
|
||||
}
|
||||
if (response != null && !response.IsSuccessStatusCode)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
@@ -404,12 +404,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
if (state.Stage.IsTerminal()) return false;
|
||||
if (state.Stage == SessionStage.Unknown || state.Stage == SessionStage.SessionStarted) return false;
|
||||
if (state.AccountSetupEnteredUtc == null) return false;
|
||||
|
||||
// WDP (afee7ae0): Device Preparation has no ESP, so neither the AccountSetup
|
||||
// anchor nor an ESP final exit is guaranteed to exist — its dead-end zone
|
||||
// begins at the real-user desktop instead (from there only Hello or a deadline
|
||||
// can move the session). Normally the DevicePrepCompletion backstop armed at
|
||||
// desktop arrival satisfies the invariant; this branch makes a regression visible.
|
||||
var isDevicePreparation = state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation;
|
||||
if (state.AccountSetupEnteredUtc == null && !isDevicePreparation) return false;
|
||||
|
||||
var deadEndZoneEntered =
|
||||
state.EspAdvisoryFailureRecordedUtc != null
|
||||
|| (state.EspFinalExitUtc != null
|
||||
&& state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value);
|
||||
|| (state.AccountSetupEnteredUtc != null
|
||||
&& state.EspFinalExitUtc != null
|
||||
&& state.EspFinalExitUtc.Value >= state.AccountSetupEnteredUtc.Value)
|
||||
|| (isDevicePreparation && state.DesktopArrivedUtc != null);
|
||||
if (!deadEndZoneEntered) return false;
|
||||
|
||||
foreach (var deadline in state.Deadlines)
|
||||
@@ -431,8 +440,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
["stage"] = state.Stage.ToString(),
|
||||
["stepIndex"] = state.StepIndex.ToString(CultureInfo.InvariantCulture),
|
||||
["signalOrdinal"] = signal.SessionSignalOrdinal.ToString(CultureInfo.InvariantCulture),
|
||||
["accountSetupEnteredUtc"] = state.AccountSetupEnteredUtc!.Value
|
||||
.ToString("o", CultureInfo.InvariantCulture),
|
||||
// Null on the WDP branch of IsParkedWithoutDeadline (no ESP anchor there).
|
||||
["accountSetupEnteredUtc"] = state.AccountSetupEnteredUtc?.Value
|
||||
.ToString("o", CultureInfo.InvariantCulture) ?? "",
|
||||
["signalsSeen"] = string.Join(",", census.SignalsSeen),
|
||||
["armedDeadlines"] = string.Join(",", state.Deadlines.Select(d => d.Name)),
|
||||
["dwellSeconds"] = _parkedTripwireDwell.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture),
|
||||
|
||||
+180
-12
@@ -74,13 +74,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private readonly string _stateDirectory;
|
||||
private readonly Persistence.StartupEventGate? _startupEventGate;
|
||||
|
||||
/// <summary>
|
||||
/// How the previous agent run in this enrollment ended (<c>Program.PreviousExitSummary.ExitType</c>:
|
||||
/// <c>first_run</c> | <c>clean</c> | <c>exception_crash</c> | <c>hard_kill</c> |
|
||||
/// <c>reboot_kill</c>). Decides whether the Shell-Core ESP-exit replay runs at all — see
|
||||
/// <see cref="ResolveEspExitBackfillLookbackMinutes"/>.
|
||||
/// </summary>
|
||||
private readonly string? _previousExitType;
|
||||
|
||||
/// <summary>
|
||||
/// OS boot time when the previous agent run was killed by a reboot (<c>reboot_kill</c>),
|
||||
/// otherwise null. One of the two inputs that size the Shell-Core ESP-exit backfill.
|
||||
/// </summary>
|
||||
private readonly DateTime? _previousBootUtc;
|
||||
|
||||
/// <summary>
|
||||
/// Last moment the previous agent run is known to have been alive — the mtime of its
|
||||
/// reducer snapshot, which is rewritten on every decision step. Independent of HOW that
|
||||
/// run ended, which makes it the more broadly usable of the two window inputs.
|
||||
/// </summary>
|
||||
private readonly DateTime? _previousRunLastAliveUtc;
|
||||
|
||||
public DefaultComponentFactory(
|
||||
AgentConfiguration agentConfig,
|
||||
AgentConfigResponse remoteConfig,
|
||||
NetworkMetrics? networkMetrics,
|
||||
string agentVersion,
|
||||
string stateDirectory,
|
||||
Persistence.StartupEventGate? startupEventGate = null)
|
||||
Persistence.StartupEventGate? startupEventGate = null,
|
||||
DateTime? previousBootUtc = null,
|
||||
string? previousExitType = null,
|
||||
DateTime? previousRunLastAliveUtc = null)
|
||||
{
|
||||
_agentConfig = agentConfig ?? throw new ArgumentNullException(nameof(agentConfig));
|
||||
_remoteConfig = remoteConfig ?? throw new ArgumentNullException(nameof(remoteConfig));
|
||||
@@ -88,6 +112,88 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_agentVersion = string.IsNullOrEmpty(agentVersion) ? "unknown" : agentVersion;
|
||||
_stateDirectory = stateDirectory ?? throw new ArgumentNullException(nameof(stateDirectory));
|
||||
_startupEventGate = startupEventGate;
|
||||
_previousBootUtc = previousBootUtc;
|
||||
_previousExitType = previousExitType;
|
||||
_previousRunLastAliveUtc = previousRunLastAliveUtc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lookback in minutes for the Shell-Core ESP-exit replay; <c>0</c> means "do not replay".
|
||||
/// <para>
|
||||
/// The replay is a RESTART-RECOVERY mechanism: it re-reads observations the agent could not
|
||||
/// make because no agent process was running. That only ever applies when a previous run
|
||||
/// existed. On <c>first_run</c> — the happy path — it stays off, so the agent behaves
|
||||
/// exactly as it did before the recovery was added.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Otherwise the window is measured rather than guessed: it reaches back to the last moment
|
||||
/// the previous run is known to have been alive. Two independent inputs feed it and the
|
||||
/// wider one wins, because either can be missing:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>Snapshot mtime</b> — the reducer snapshot is rewritten on every decision
|
||||
/// step, so its last-write time is "when we last knew what was happening", regardless
|
||||
/// of how that run ended. This is what makes <c>exception_crash</c> safe: the scheduled
|
||||
/// task carries a BootTrigger only, with no restart-on-failure, so a crashed agent does
|
||||
/// not come back until the next boot — possibly hours later, long after Windows wrote
|
||||
/// the final ESP exit. A fixed five minutes would lose it every time.</item>
|
||||
/// <item><b>Last boot</b> — populated for <c>hard_kill</c> / <c>reboot_kill</c>; covers
|
||||
/// the case where the snapshot is missing or was never written.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The default stays the floor (a restart that returns in seconds still gets its five
|
||||
/// minutes) and <c>ShellCoreTracker.ClampLookbackMinutes</c> is the ceiling, so policy and
|
||||
/// backstop together can never build an unbounded event-log query.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static int ResolveEspExitBackfillLookbackMinutes(
|
||||
string? previousExitType,
|
||||
DateTime? previousBootUtc,
|
||||
DateTime? previousRunLastAliveUtc,
|
||||
DateTime utcNow)
|
||||
{
|
||||
if (string.IsNullOrEmpty(previousExitType)
|
||||
|| string.Equals(previousExitType, "first_run", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var lookback = ShellCoreTracker.BackfillLookbackMinutes;
|
||||
|
||||
foreach (var since in new[] { previousRunLastAliveUtc, previousBootUtc })
|
||||
{
|
||||
if (!since.HasValue) continue;
|
||||
|
||||
var elapsed = utcNow - since.Value;
|
||||
if (elapsed <= TimeSpan.Zero) continue; // clock skew — never build a negative window
|
||||
|
||||
var minutes = (int)Math.Ceiling(elapsed.TotalMinutes) + 1;
|
||||
if (minutes > lookback) lookback = minutes;
|
||||
}
|
||||
|
||||
return lookback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Last-write time of the previous run's reducer snapshot, or null when it does not exist
|
||||
/// (first run) or cannot be read. Best-effort by design — a missing timestamp only means
|
||||
/// the replay window falls back to the other inputs.
|
||||
/// </summary>
|
||||
internal static DateTime? ReadPreviousRunLastAliveUtc(string stateDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(stateDirectory)) return null;
|
||||
var snapshotPath = Path.Combine(
|
||||
stateDirectory, Persistence.DeathRattlePrelude.SnapshotFileName);
|
||||
return File.Exists(snapshotPath)
|
||||
? (DateTime?)File.GetLastWriteTimeUtc(snapshotPath)
|
||||
: null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public CollectorSurfaces CreateCollectorHosts(
|
||||
@@ -109,6 +215,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var hosts = new List<ICollectorHost>();
|
||||
var collectors = _remoteConfig.Collectors ?? CollectorConfiguration.CreateDefault();
|
||||
|
||||
// WDP scenario gate (afee7ae0, 2026-08-18): Device Preparation has no ESP, so the
|
||||
// ESP-only machinery below (provisioning-status registry watcher, ESP policy-provider
|
||||
// stall tripwire, Autopilot-channel noise) burns cycles on registry keys that never
|
||||
// exist there — or worse, wakes up on every WDP progress write. Gated on the
|
||||
// DETERMINISTIC WDP marker only (BootstrapperAgent ExecutionContext, present before
|
||||
// the agent starts); the CloudAssigned* fallback rules keep full Classic behavior.
|
||||
var isDevicePreparation = Security.EnrollmentRegistryDetector.IsDeterministicDevicePreparation();
|
||||
if (isDevicePreparation)
|
||||
{
|
||||
logger.Info("DefaultComponentFactory: Device Preparation session (deterministic marker) — ESP-only hosts and Autopilot-channel noise are gated");
|
||||
}
|
||||
|
||||
// ----- Kernel hosts (always-on; they produce decision signals) --------------------
|
||||
|
||||
// Single-rail refactor (plan §5.8) — DeviceInfoCollector existed in V2.Core but had
|
||||
@@ -138,6 +256,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// on esp_exiting events long after Start.
|
||||
ImeLogHost? imeLogHostRef = null;
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19), narrowed after review: the Shell-Core ESP-exit
|
||||
// replay is restart recovery, so it stays OFF on a first run and the happy path keeps
|
||||
// its pre-fix behaviour exactly. See ResolveEspExitBackfillLookbackMinutes.
|
||||
var espExitBackfillLookbackMinutes = ResolveEspExitBackfillLookbackMinutes(
|
||||
_previousExitType, _previousBootUtc, _previousRunLastAliveUtc, DateTime.UtcNow);
|
||||
if (espExitBackfillLookbackMinutes > 0)
|
||||
{
|
||||
logger.Info(
|
||||
$"DefaultComponentFactory: previous run ended as '{_previousExitType}' — Shell-Core " +
|
||||
$"ESP-exit replay enabled with a {espExitBackfillLookbackMinutes} min lookback");
|
||||
}
|
||||
|
||||
var espAndHelloHost = new EspAndHelloHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
@@ -151,6 +281,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes,
|
||||
modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||
stateDirectory: _stateDirectory,
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
userEspAppsSettledProbe: () => imeLogHostRef?.AreUserEspAppsSettled() == true,
|
||||
// Liveness plan PR3: starved-apps probe over the same lazily-assigned IME host.
|
||||
starvedUserEspAppsProbe: () =>
|
||||
@@ -161,7 +292,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// covers Store/WinGet apps invisible to the starved/correlation paths.
|
||||
packageStatesProbe: () =>
|
||||
imeLogHostRef?.AllKnownPackageStates
|
||||
?? Array.Empty<Monitoring.Enrollment.Ime.AppPackageState>());
|
||||
?? Array.Empty<Monitoring.Enrollment.Ime.AppPackageState>(),
|
||||
espExitBackfillLookbackMinutes: espExitBackfillLookbackMinutes);
|
||||
hosts.Add(espAndHelloHost);
|
||||
|
||||
// Hybrid User-Driven completion-gap fix (2026-05-01): the AadJoinHost notifies
|
||||
@@ -292,10 +424,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
stateDirectory: _stateDirectory,
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
|
||||
simulationMode: simulationMode,
|
||||
simulationSpeedFactor: _agentConfig.ReplaySpeedFactor);
|
||||
simulationSpeedFactor: _agentConfig.ReplaySpeedFactor,
|
||||
// Codex review P2 (2026-08-19): the IME tracker restores its persisted app states
|
||||
// inside Start(), silently (LoadState raises no OnAppStateChanged). This host starts
|
||||
// AFTER EspAndHelloHost, so the Shell-Core ESP-exit replay has already run against
|
||||
// an empty app list. Nudge the synthesis once the restored level is in place —
|
||||
// otherwise the exact reboot case (apps terminal BEFORE the reboot, only the final
|
||||
// exit lost to the downtime) still never completes.
|
||||
onStateRestored: () => espAndHelloHost.ReevaluateUserAppsSettledSynthesis());
|
||||
hosts.Add(imeLogHost);
|
||||
imeLogHostRef = imeLogHost;
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19): the user-apps-settled AccountSetup synthesis used
|
||||
// to run ONLY on the EspExited edge. On a tenant with a large required-app set the ESP
|
||||
// page exits while apps are still in flight, the single attempt misses, and nothing
|
||||
// ever re-checks — sessions 8110e262 / a89aac2d reached 138/138 apps with 0 failed and
|
||||
// still never completed. Chain into the tracker's app-state callback (same
|
||||
// preserve-previous pattern DeliveryOptimizationHost uses) so every terminal app
|
||||
// transition gives the synthesis another look. Cheap: the re-check returns immediately
|
||||
// unless the ESP exit was already observed and the synthesis has not fired yet.
|
||||
var previousAppStateChanged = imeLogHost.Tracker.OnAppStateChanged;
|
||||
imeLogHost.Tracker.OnAppStateChanged = (pkg, oldState, newState) =>
|
||||
{
|
||||
try { previousAppStateChanged?.Invoke(pkg, oldState, newState); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"DefaultComponentFactory: previous OnAppStateChanged handler threw: {ex.Message}");
|
||||
}
|
||||
|
||||
try { espAndHelloHost.ReevaluateUserAppsSettledSynthesis(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.Warning($"DefaultComponentFactory: user-apps-settled re-check threw: {ex.Message}");
|
||||
}
|
||||
};
|
||||
|
||||
if (collectors.StallProbeEnabled)
|
||||
{
|
||||
hosts.Add(new StallProbeHost(
|
||||
@@ -308,7 +471,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
traceIndices: collectors.StallProbeTraceIndices,
|
||||
sources: collectors.StallProbeSources,
|
||||
sessionStalledAfterProbeIndex: collectors.SessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds));
|
||||
harmlessModernDeploymentEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||
isDevicePreparation: isDevicePreparation));
|
||||
}
|
||||
|
||||
// ESP policy-provider stall tripwire — always-on kernel host (no config gate, like
|
||||
@@ -317,14 +481,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// TrackingPoliciesCreated=1 the ESP ignores foreign provider names), parks the user
|
||||
// ESP at "Apps (Identifying)" WITHOUT any OS timeout. Wall-clock dwell, deliberately
|
||||
// outside the StallProbe gate: the idle clock there resets on any session activity,
|
||||
// and the stall must accrue regardless of it.
|
||||
hosts.Add(new EspPolicyProviderStallHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
// and the stall must accrue regardless of it. Not created on Device Preparation —
|
||||
// the EnrollmentStatusTracking key it polls every 60s is an ESP-only construct.
|
||||
if (!isDevicePreparation)
|
||||
{
|
||||
hosts.Add(new EspPolicyProviderStallHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
|
||||
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
|
||||
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private readonly EspAndHelloTracker _tracker;
|
||||
private readonly EspAndHelloTrackerAdapter _adapter;
|
||||
private readonly int _espExitBackfillLookbackMinutes;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -70,10 +71,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int modernDeploymentBackfillLookbackMinutes,
|
||||
int[]? modernDeploymentHarmlessEventIds,
|
||||
string stateDirectory,
|
||||
bool isDevicePreparation = false,
|
||||
Func<bool>? userEspAppsSettledProbe = null,
|
||||
Func<System.Collections.Generic.IReadOnlyList<Monitoring.Enrollment.Ime.AppPackageState>>? starvedUserEspAppsProbe = null,
|
||||
Func<System.Collections.Generic.IReadOnlyList<Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null)
|
||||
Func<System.Collections.Generic.IReadOnlyList<Monitoring.Enrollment.Ime.AppPackageState>>? packageStatesProbe = null,
|
||||
int espExitBackfillLookbackMinutes = ShellCoreTracker.BackfillLookbackMinutes)
|
||||
{
|
||||
_espExitBackfillLookbackMinutes = espExitBackfillLookbackMinutes;
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
var post = new InformationalEventPost(ingress, clock);
|
||||
@@ -89,6 +93,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory,
|
||||
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds,
|
||||
isDevicePreparation: isDevicePreparation,
|
||||
userEspAppsSettledProbe: userEspAppsSettledProbe,
|
||||
starvedUserEspAppsProbe: starvedUserEspAppsProbe,
|
||||
packageStatesProbe: packageStatesProbe);
|
||||
@@ -98,9 +103,56 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_adapter = new EspAndHelloTrackerAdapter(_tracker, ingress, clock);
|
||||
}
|
||||
|
||||
public void Start() => _tracker.Start();
|
||||
/// <summary>
|
||||
/// Starts the tracker and replays the recent Shell-Core Hello-wizard start — restart
|
||||
/// recovery for an observation no agent process was around to make.
|
||||
/// <para>
|
||||
/// The backfill has existed since session 772fe502 but was never wired to a caller, so
|
||||
/// the Shell-Core watcher only ever saw events written AFTER <see cref="Start"/>. Any
|
||||
/// agent restart therefore lost an ESP exit (62407) or Hello-wizard start (62404) that
|
||||
/// happened while the agent was down — and after a mid-ESP reboot that is exactly the
|
||||
/// window the completion signal lands in (sits-d Cloud PCs, 2026-08-19: five sessions
|
||||
/// hung in AccountSetup until the server-side timeout while the devices were fine).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A lookback of zero disables the replay entirely. That is the FIRST run of an enrollment:
|
||||
/// there is no gap to recover because no earlier agent process existed, and any 62407
|
||||
/// already in the log belongs to a phase transition that happened before this agent was
|
||||
/// ever meant to observe anything. Replaying it would push an <c>EspExiting</c> into the
|
||||
/// reducer that the pre-fix agent never saw — a happy-path behaviour change for no gain.
|
||||
/// The caller (<c>DefaultComponentFactory</c>) owns that policy.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <para>
|
||||
/// Ordering is deliberate: Start first, then replay. A record written between the two is
|
||||
/// observed twice, which the wizard rail already tolerates (single-shot replay guard, plus
|
||||
/// the HelloTracker once-guard and the adapter's dedup flag downstream). The reverse order
|
||||
/// could drop a record entirely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The replay covers the Hello-wizard start only — a conservative fact that can make the
|
||||
/// agent wait longer but never finish early. ESP exits are deliberately excluded because a
|
||||
/// replayed 62407 cannot be placed in time; see
|
||||
/// <c>ShellCoreTracker.ReplayBackfillRecords</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_tracker.Start();
|
||||
|
||||
if (_espExitBackfillLookbackMinutes > 0)
|
||||
_tracker.BackfillRecentHelloWizardStart(_espExitBackfillLookbackMinutes);
|
||||
}
|
||||
|
||||
public void Stop() => _tracker.Stop();
|
||||
|
||||
/// <summary>
|
||||
/// Re-checks the user-apps-settled AccountSetup synthesis — see
|
||||
/// <see cref="EspAndHelloTracker.ReevaluateUserAppsSettledSynthesis"/>. Driven by the IME
|
||||
/// tracker's app-state-change callback (wired in <c>DefaultComponentFactory</c>).
|
||||
/// </summary>
|
||||
public void ReevaluateUserAppsSettledSynthesis() => _tracker.ReevaluateUserAppsSettledSynthesis();
|
||||
|
||||
private void OnTrackerWhiteGloveCompleted(object sender, EventArgs e)
|
||||
=> WhiteGloveCompleted?.Invoke(this, e);
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private readonly ImeLogTracker _tracker;
|
||||
private readonly ImeProcessWatcher _processWatcher;
|
||||
private readonly Action? _onStateRestored;
|
||||
private readonly ImeLogTrackerAdapter _adapter;
|
||||
private readonly AgentLogger _logger;
|
||||
private int _disposed;
|
||||
@@ -96,9 +97,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
string stateDirectory,
|
||||
IReadOnlyCollection<string>? whiteGloveSealingPatternIds,
|
||||
bool simulationMode = false,
|
||||
double simulationSpeedFactor = 50)
|
||||
double simulationSpeedFactor = 50,
|
||||
Action? onStateRestored = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_onStateRestored = onStateRestored;
|
||||
var logFolder = string.IsNullOrEmpty(imeLogPathOverride) ? DefaultImeLogFolder : imeLogPathOverride!;
|
||||
var expandedMatchLogPath = string.IsNullOrEmpty(imeMatchLogPath)
|
||||
? null
|
||||
@@ -128,10 +131,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_processWatcher = new ImeProcessWatcher(sessionId, tenantId, processWatcherPost, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Codex review P2 (2026-08-19) — <see cref="ImeLogTracker.Start"/> restores the persisted
|
||||
/// package states (LoadState) and does NOT raise <c>OnAppStateChanged</c> for them. This
|
||||
/// host is started AFTER <c>EspAndHelloHost</c>, so a LIVE ESP exit observed before this
|
||||
/// restore completes would evaluate the user-apps-settled synthesis against an empty app
|
||||
/// list — and without a nudge here nothing would ever re-check the restored level. The
|
||||
/// <c>onStateRestored</c> callback fires once, after the restore, so the deferred
|
||||
/// synthesis gets its look. (It deliberately covers ONLY live exits: a 62407 that fired
|
||||
/// while no agent was running is never replayed — see
|
||||
/// <c>ShellCoreTracker.ReplayBackfillRecords</c> for why it cannot be placed in time.)
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_tracker.Start();
|
||||
_processWatcher.Start();
|
||||
|
||||
try { _onStateRestored?.Invoke(); }
|
||||
catch (Exception ex) { _logger.Warning($"ImeLogHost: onStateRestored callback threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
|
||||
@@ -57,7 +57,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
int[]? traceIndices,
|
||||
string[]? sources,
|
||||
int sessionStalledAfterProbeIndex,
|
||||
int[]? harmlessModernDeploymentEventIds)
|
||||
int[]? harmlessModernDeploymentEventIds,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -72,7 +73,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
traceIndices: traceIndices ?? new[] { 2 },
|
||||
sources: sources ?? new[] { "provisioning_registry", "diagnostics_registry", "eventlog", "appworkload_log" },
|
||||
sessionStalledAfterProbeIndex: sessionStalledAfterProbeIndex,
|
||||
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds);
|
||||
harmlessModernDeploymentEventIds: harmlessModernDeploymentEventIds,
|
||||
isDevicePreparation: isDevicePreparation);
|
||||
|
||||
_adapter = new StallProbeCollectorAdapter(_collector, ingress, clock);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
|
||||
@@ -188,6 +188,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
/// </summary>
|
||||
public static string DetectEnrollmentType() => DetectEnrollmentTypeWithRule().enrollmentType;
|
||||
|
||||
/// <summary>
|
||||
/// Rule string returned when the deterministic Device Preparation marker fired
|
||||
/// (the WDP policy orchestration tree — see <see cref="DetectEnrollmentTypeWithRule"/>).
|
||||
/// </summary>
|
||||
public const string DevicePreparationExecutionContextRule =
|
||||
@"DevicePreparation\BootstrapperAgent\ExecutionContext present";
|
||||
|
||||
/// <summary>
|
||||
/// True only when the deterministic Device Preparation marker (BootstrapperAgent
|
||||
/// ExecutionContext) is present. Scenario gates that DISABLE Autopilot/ESP-only
|
||||
/// machinery must use this instead of the "v2" string: the CloudAssigned* fallback
|
||||
/// rules describe a real Autopilot profile (just without ESP) where that machinery
|
||||
/// still has data to observe — gates fail toward Classic behavior by design.
|
||||
/// </summary>
|
||||
public static bool IsDeterministicDevicePreparation()
|
||||
{
|
||||
var (enrollmentType, detectionRule) = DetectEnrollmentTypeWithRule();
|
||||
return enrollmentType == "v2" && detectionRule == DevicePreparationExecutionContextRule;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as <see cref="DetectEnrollmentType"/> but also returns WHICH rule fired, so the
|
||||
/// enrollment_type_detected event can name its evidence instead of re-implementing the
|
||||
@@ -203,7 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
var executionContext = wdpKey?.GetValue("ExecutionContext")?.ToString();
|
||||
if (!string.IsNullOrEmpty(executionContext))
|
||||
return ("v2", @"DevicePreparation\BootstrapperAgent\ExecutionContext present");
|
||||
return ("v2", DevicePreparationExecutionContextRule);
|
||||
}
|
||||
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey))
|
||||
|
||||
+92
-4
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
@@ -56,6 +57,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
public sealed class EnrollmentTerminationHandler
|
||||
{
|
||||
private static readonly TimeSpan DefaultLateEventGrace = TimeSpan.FromMilliseconds(2000);
|
||||
|
||||
/// <summary>
|
||||
/// Session bc803955 — on DevicePreparation the IME user-context app sync starts only
|
||||
/// after desktop arrival and its log lines reach disk with 10-20s flush latency, so the
|
||||
/// dialog snapshot raced it and rendered an empty Applications section 9 seconds before
|
||||
/// the tracker learned about 12 pending apps. Bounded settle wait before the summary
|
||||
/// build; DevicePrep-only and only while the tracker has zero packages, so Classic (data
|
||||
/// long settled) and populated DevicePrep sessions pay nothing. Installs are never
|
||||
/// delayed — this only holds the dialog snapshot.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DefaultDevicePrepAppSettleWait = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan AppSettlePollInterval = TimeSpan.FromMilliseconds(250);
|
||||
// Option 1 (WG Part 1 graceful-exit hardening, 2026-04-30): poll cadence for the
|
||||
// active spool-empty drain. 50ms is short enough that we exit the bounded wait
|
||||
// within ~one cadence after the last upload is acknowledged, but long enough that
|
||||
@@ -90,6 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
private readonly AgentAnalyzerManager _analyzerManager;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly TimeSpan _lateEventGracePeriod;
|
||||
private readonly TimeSpan _devicePrepAppSettleWait;
|
||||
|
||||
// L4 (delta review 2026-07-02): epoch anchor for boot-time derivation. Captured as a
|
||||
// (monotonic tick, wall clock) pair at construction so the boot timestamp used by the
|
||||
@@ -113,7 +127,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
AgentAnalyzerManager analyzerManager = null,
|
||||
InformationalEventPost post = null,
|
||||
Action stopPeripheralCollectors = null,
|
||||
TimeSpan? lateEventGracePeriod = null)
|
||||
TimeSpan? lateEventGracePeriod = null,
|
||||
TimeSpan? devicePrepAppSettleWait = null)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_appTracking = appTracking ?? throw new ArgumentNullException(nameof(appTracking));
|
||||
@@ -126,6 +141,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
_post = post;
|
||||
_stopPeripheralCollectors = stopPeripheralCollectors;
|
||||
_lateEventGracePeriod = lateEventGracePeriod ?? DefaultLateEventGrace;
|
||||
_devicePrepAppSettleWait = devicePrepAppSettleWait ?? DefaultDevicePrepAppSettleWait;
|
||||
|
||||
_configuration = session.Configuration;
|
||||
_stateDirectory = session.StateDirectory;
|
||||
@@ -182,6 +198,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
else
|
||||
{
|
||||
// Session bc803955 — give the IME user-context app wave a bounded chance
|
||||
// to land BEFORE the dialog snapshot, the starved sweep and the
|
||||
// app_tracking_summary below all read package states. DevicePrep-only,
|
||||
// no-op when the tracker already has data.
|
||||
WaitForImeAppSettleIfDevicePrep(state);
|
||||
RunBuildAndLaunchDialog(state, args);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +228,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// Runs on every outcome; the live (esp_exited) path's dedupe set is
|
||||
// consulted so no app is reported twice. Promoted likely-stuck apps are
|
||||
// Error by now and excluded by the probe itself.
|
||||
EmitStarvedUserEspApps(args);
|
||||
EmitStarvedUserEspApps(state, args);
|
||||
|
||||
// Session 6b4993e5 / fc48c71a — on an ESP terminal Apps-subcategory failure,
|
||||
// name the in-flight device app(s) (e.g. stuck Downloading at 0%) as the likely
|
||||
@@ -378,6 +399,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bounded settle wait for the IME app wave on DevicePreparation sessions (see
|
||||
/// <see cref="DefaultDevicePrepAppSettleWait"/>). Returns immediately for every other
|
||||
/// scenario mode and whenever the tracker already reports packages. Poll is cheap
|
||||
/// (in-memory read-model access), so a fine 250ms interval keeps the early exit tight.
|
||||
/// </summary>
|
||||
private void WaitForImeAppSettleIfDevicePrep(DecisionState state)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (state == null || _devicePrepAppSettleWait <= TimeSpan.Zero) return;
|
||||
if (state.ScenarioProfile.Mode != EnrollmentMode.DevicePreparation) return;
|
||||
|
||||
var packages = TryGetPackageStates();
|
||||
if (packages != null && packages.Count > 0) return;
|
||||
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: DevicePreparation session with no IME app data yet — waiting up to " +
|
||||
$"{_devicePrepAppSettleWait.TotalSeconds:0}s for the user-context app wave before building the summary " +
|
||||
"(installs are not delayed; this only holds the dialog snapshot).");
|
||||
|
||||
var deadline = DateTime.UtcNow + _devicePrepAppSettleWait;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
Thread.Sleep(AppSettlePollInterval);
|
||||
packages = TryGetPackageStates();
|
||||
if (packages != null && packages.Count > 0)
|
||||
{
|
||||
_logger.Info($"EnrollmentTerminationHandler: IME app data arrived during settle wait ({packages.Count} package(s)).");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info("EnrollmentTerminationHandler: app settle wait elapsed without IME app data — summary reflects Office/CSP state only.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTerminationHandler: app settle wait failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-soft read of the Office C2R lifecycle state persisted by
|
||||
/// <see cref="OfficeInstallDetector"/> — feeds the summary dialog's synthetic
|
||||
/// "Microsoft 365 Apps" row (Office CSP installs never flow through the IME Win32
|
||||
/// pipeline, so the package-state list cannot represent them).
|
||||
/// </summary>
|
||||
private OfficeInstallStateData TryLoadOfficeInstallState()
|
||||
{
|
||||
try { return new OfficeInstallStatePersistence(_stateDirectory, _logger).Load(); }
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private void RunBuildAndLaunchDialog(DecisionState state, EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
try
|
||||
@@ -385,7 +459,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
var packages = TryGetPackageStates();
|
||||
var timings = TryGetAppTimings();
|
||||
var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc, timings,
|
||||
deviceBootUtc: ObservationCoverage.DeviceBootUtc(_bootAnchorTickMs, _bootAnchorUtc));
|
||||
deviceBootUtc: ObservationCoverage.DeviceBootUtc(_bootAnchorTickMs, _bootAnchorUtc),
|
||||
officeState: TryLoadOfficeInstallState());
|
||||
SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride);
|
||||
|
||||
if (_configuration.ShowEnrollmentSummary)
|
||||
@@ -482,7 +557,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// wording; Failed/TimedOut keep the Warning + "starved" verdict.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void EmitStarvedUserEspApps(EnrollmentTerminatedEventArgs args)
|
||||
private void EmitStarvedUserEspApps(DecisionState state, EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
if (_post == null) return;
|
||||
|
||||
@@ -491,6 +566,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
var starved = _appTracking.GetStarvedUserEspApps();
|
||||
if (starved == null || starved.Count == 0) return;
|
||||
|
||||
// Device Preparation has no ESP apps gate — installing after the desktop is
|
||||
// the DESIGNED order there (only DPP-policy apps run during provisioning, the
|
||||
// rest follow via IME). Reporting every pending app as "starved" turned a
|
||||
// normal WDP session into 10+ noise events (afee7ae0); the same per-app data
|
||||
// still reaches the backend via app_tracking_summary and final-status.json.
|
||||
if (state?.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation)
|
||||
{
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: {starved.Count} app(s) pending at termination on a " +
|
||||
"DevicePreparation session — expected post-desktop install order, app_install_starved sweep skipped.");
|
||||
return;
|
||||
}
|
||||
|
||||
var succeeded = args.Outcome == EnrollmentTerminationOutcome.Succeeded;
|
||||
|
||||
foreach (var app in starved)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
@@ -30,7 +31,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
IReadOnlyList<AppPackageState>? packageStates,
|
||||
DateTime agentStartTimeUtc,
|
||||
IReadOnlyDictionary<string, AppInstallTiming>? appTimings = null,
|
||||
DateTime? deviceBootUtc = null)
|
||||
DateTime? deviceBootUtc = null,
|
||||
OfficeInstallStateData? officeState = null)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
if (terminated == null) throw new ArgumentNullException(nameof(terminated));
|
||||
@@ -78,9 +80,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
PackageStatesByPhase = BuildPackageStatesByPhase(packageStates, timings),
|
||||
};
|
||||
|
||||
AddOfficeRow(status, officeState);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session bc803955 — the Office C2R install (Office CSP app type / M365 "CSP" package)
|
||||
/// never flows through the IME Win32 pipeline, so <see cref="BuildAppSummary"/> cannot see
|
||||
/// it and a DevicePreparation session whose only app activity is Office rendered
|
||||
/// "No application data available". Synthesize one package row from the
|
||||
/// <see cref="OfficeInstallDetector"/>'s persisted lifecycle state instead. Preinstalled
|
||||
/// is deliberately skipped — that state marks resident OEM/consumer Office, not an
|
||||
/// enrollment install.
|
||||
/// </summary>
|
||||
private static void AddOfficeRow(FinalStatus status, OfficeInstallStateData? officeState)
|
||||
{
|
||||
if (officeState == null) return;
|
||||
|
||||
string rowState;
|
||||
bool isCompleted = false, isError = false;
|
||||
switch (officeState.State)
|
||||
{
|
||||
case OfficeInstallStateData.StateActive:
|
||||
rowState = AppInstallationState.Installing.ToString();
|
||||
break;
|
||||
case OfficeInstallStateData.StateCompleted:
|
||||
rowState = AppInstallationState.Installed.ToString();
|
||||
isCompleted = true;
|
||||
break;
|
||||
case OfficeInstallStateData.StateFailed:
|
||||
rowState = AppInstallationState.Error.ToString();
|
||||
isError = true;
|
||||
isCompleted = true; // mirrors IsCompleted(): Error counts as terminal
|
||||
break;
|
||||
default:
|
||||
return; // Preinstalled / unknown — not an enrollment install
|
||||
}
|
||||
|
||||
double? duration = officeState.StartedAtUtc.HasValue && officeState.CompletedAtUtc.HasValue
|
||||
? Math.Max(0, (officeState.CompletedAtUtc.Value - officeState.StartedAtUtc.Value).TotalSeconds)
|
||||
: (double?)null;
|
||||
|
||||
var name = string.IsNullOrEmpty(officeState.VersionReached)
|
||||
? "Microsoft 365 Apps"
|
||||
: $"Microsoft 365 Apps ({officeState.VersionReached})";
|
||||
|
||||
var row = new FinalStatusPackageInfo
|
||||
{
|
||||
AppName = name,
|
||||
State = rowState,
|
||||
IsError = isError,
|
||||
IsCompleted = isCompleted,
|
||||
Targeted = AppTargeted.Device.ToString(),
|
||||
StartedAt = officeState.StartedAtUtc?.ToString("o"),
|
||||
CompletedAt = officeState.CompletedAtUtc?.ToString("o"),
|
||||
DurationSeconds = duration,
|
||||
};
|
||||
|
||||
var phaseKey = AppTargeted.Device.ToString();
|
||||
if (!status.PackageStatesByPhase.TryGetValue(phaseKey, out var bucket))
|
||||
{
|
||||
bucket = new List<FinalStatusPackageInfo>();
|
||||
status.PackageStatesByPhase[phaseKey] = bucket;
|
||||
}
|
||||
// Office installs during device prep / device ESP — before any IME-tracked app.
|
||||
bucket.Insert(0, row);
|
||||
|
||||
var summary = status.AppSummary;
|
||||
summary.TotalApps++;
|
||||
if (isCompleted) summary.CompletedApps++;
|
||||
if (isError)
|
||||
{
|
||||
summary.ErrorCount++;
|
||||
summary.DeviceErrors++;
|
||||
}
|
||||
if (!summary.AppsByPhase.TryGetValue(phaseKey, out var cnt)) cnt = 0;
|
||||
summary.AppsByPhase[phaseKey] = cnt + 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schema 2 — derives a human-readable explanation rendered as the dialog's failure
|
||||
/// banner. Returns <c>null</c> for successful terminal outcomes (succeeded /
|
||||
|
||||
@@ -59,10 +59,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// </summary>
|
||||
int PeakPendingItemCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Serialized on-disk byte size of the pending (not-yet-uploaded) tail — the actual
|
||||
/// upload backlog. Unlike <see cref="SpoolFileSizeBytes"/>, which grows append-only
|
||||
/// over the whole session regardless of upload progress, this shrinks back to 0 as
|
||||
/// <see cref="MarkUploaded"/> advances the cursor, so it is the correct input for
|
||||
/// back-pressure thresholds.
|
||||
/// </summary>
|
||||
long PendingBytes { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Current size of the on-disk <c>spool.jsonl</c> file in bytes. Returns 0 when the
|
||||
/// file does not yet exist. Cheap (single <c>FileInfo.Length</c> call) and safe to
|
||||
/// poll from periodic collectors.
|
||||
/// poll from periodic collectors. Append-only cumulative — informational only, NOT a
|
||||
/// backlog measure (use <see cref="PendingBytes"/> for that).
|
||||
/// </summary>
|
||||
long SpoolFileSizeBytes { get; }
|
||||
|
||||
|
||||
@@ -56,9 +56,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// Populated from disk in the ctor and kept in sync by Enqueue / MarkUploaded. Peek
|
||||
// reads from here so the periodic drain loop is O(batchSize) instead of O(N) per
|
||||
// call. Disk JSONL stays the source of truth — the cache is rebuilt on every
|
||||
// process start.
|
||||
private readonly LinkedList<TelemetryItem> _pending = new LinkedList<TelemetryItem>();
|
||||
// process start. Each entry carries its on-disk line length (incl. trailing \n) so
|
||||
// MarkUploaded can decrement _pendingBytes exactly.
|
||||
private readonly LinkedList<PendingEntry> _pending = new LinkedList<PendingEntry>();
|
||||
private int _peakPendingCount;
|
||||
private long _pendingBytes;
|
||||
|
||||
private readonly struct PendingEntry
|
||||
{
|
||||
public PendingEntry(TelemetryItem item, int byteLength)
|
||||
{
|
||||
Item = item;
|
||||
ByteLength = byteLength;
|
||||
}
|
||||
|
||||
public TelemetryItem Item { get; }
|
||||
public int ByteLength { get; }
|
||||
}
|
||||
|
||||
public TelemetrySpool(string directoryPath, IClock clock, Logging.AgentLogger? logger = null)
|
||||
{
|
||||
@@ -105,6 +119,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
get { lock (_lock) return _peakPendingCount; }
|
||||
}
|
||||
|
||||
public long PendingBytes
|
||||
{
|
||||
get { lock (_lock) return _pendingBytes; }
|
||||
}
|
||||
|
||||
public long SpoolFileSizeBytes
|
||||
{
|
||||
get
|
||||
@@ -171,7 +190,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
|
||||
_lastAssignedItemId = itemId;
|
||||
_pending.AddLast(item);
|
||||
_pending.AddLast(new PendingEntry(item, bytes.Length));
|
||||
_pendingBytes += bytes.Length;
|
||||
if (_pending.Count > _peakPendingCount)
|
||||
_peakPendingCount = _pending.Count;
|
||||
|
||||
@@ -209,7 +229,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
var node = _pending.First;
|
||||
while (node != null && batch.Count < max)
|
||||
{
|
||||
batch.Add(node.Value);
|
||||
batch.Add(node.Value.Item);
|
||||
node = node.Next;
|
||||
}
|
||||
return batch;
|
||||
@@ -233,25 +253,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
// Trim the in-memory tail. The list is in TelemetryItemId-monotonic order
|
||||
// (Enqueue only appends), so we can drop from the front until the first
|
||||
// remaining item is past the cursor.
|
||||
while (_pending.First != null && _pending.First.Value.TelemetryItemId <= upToItemIdInclusive)
|
||||
while (_pending.First != null && _pending.First.Value.Item.TelemetryItemId <= upToItemIdInclusive)
|
||||
{
|
||||
_pendingBytes -= _pending.First.Value.ByteLength;
|
||||
_pending.RemoveFirst();
|
||||
}
|
||||
|
||||
// _pendingBytes drives the spool-pressure trigger: negative drift would
|
||||
// silently disarm it, a stale positive on an empty list would pin it on.
|
||||
// The list is authoritative — on drift, recompute from it and log loudly.
|
||||
if (_pendingBytes < 0 || (_pending.Count == 0 && _pendingBytes != 0))
|
||||
{
|
||||
long recomputed = 0;
|
||||
foreach (var entry in _pending) recomputed += entry.ByteLength;
|
||||
_logger?.Warning(
|
||||
$"TelemetrySpool: pending-bytes accounting drifted to {_pendingBytes} with {_pending.Count} pending items after MarkUploaded({upToItemIdInclusive}) — recomputed to {recomputed}.");
|
||||
_pendingBytes = recomputed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-pass scan of <c>spool.jsonl</c>: returns the highest <c>TelemetryItemId</c>
|
||||
/// found AND populates <see cref="_pending"/> with every item past
|
||||
/// <see cref="_lastUploadedItemId"/>. Called once from the ctor — replaces the
|
||||
/// previous per-Peek <c>File.ReadAllLines</c> hot path.
|
||||
/// Single-pass streaming scan of <c>spool.jsonl</c> (<c>File.ReadLines</c> — never
|
||||
/// materializes the whole file): returns the highest <c>TelemetryItemId</c> found AND
|
||||
/// populates <see cref="_pending"/> / <see cref="_pendingBytes"/> with every item past
|
||||
/// <see cref="_lastUploadedItemId"/>. Called once from the ctor.
|
||||
/// </summary>
|
||||
private long ScanSpoolAndRehydratePending()
|
||||
{
|
||||
if (!File.Exists(_spoolPath)) return -1;
|
||||
|
||||
long highest = -1;
|
||||
foreach (var line in File.ReadAllLines(_spoolPath, Encoding.UTF8))
|
||||
foreach (var line in File.ReadLines(_spoolPath, Encoding.UTF8))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
TelemetryItem item;
|
||||
@@ -270,7 +303,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
if (item.TelemetryItemId > highest) highest = item.TelemetryItemId;
|
||||
if (item.TelemetryItemId > _lastUploadedItemId)
|
||||
{
|
||||
_pending.AddLast(item);
|
||||
// +1 = the trailing \n Enqueue writes after every line, so the rehydrated
|
||||
// byte count matches what Enqueue would have accumulated.
|
||||
var byteLength = Encoding.UTF8.GetByteCount(line) + 1;
|
||||
_pending.AddLast(new PendingEntry(item, byteLength));
|
||||
_pendingBytes += byteLength;
|
||||
}
|
||||
}
|
||||
return highest;
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
/// cleanup and exit.</item>
|
||||
/// <item><b>Session-age emergency break</b> — absolute cap (<c>AbsoluteMaxSessionHours</c>)
|
||||
/// across restarts prevents zombie agents from persisting forever.</item>
|
||||
/// <item><b>Crash log writer</b> — writes a last-resort crash_*.log next to the agent log
|
||||
/// <item><b>Crash log writer</b> — writes a last-resort crash-*.log next to the agent log
|
||||
/// directory when an uncaught exception escapes <c>Main</c>.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
@@ -34,7 +34,10 @@ namespace AutopilotMonitor.Agent.V2
|
||||
{
|
||||
internal const string CleanExitMarkerFileName = "clean-exit.marker";
|
||||
internal const string EnrollmentCompleteMarkerFileName = "enrollment-complete.marker";
|
||||
private const string CrashLogPrefix = "crash_";
|
||||
private const string CrashLogPrefix = "crash-";
|
||||
// Pre-rename agents (< 2026-08-18) wrote crash_*.log; still detected once so an
|
||||
// in-place self-update does not lose a pending crash classification.
|
||||
private const string LegacyCrashLogPrefix = "crash_";
|
||||
|
||||
// ----------------------------------------------------------------- Multi-instance guard
|
||||
|
||||
@@ -102,7 +105,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>clean-exit.marker</c> + <c>crash_*.log</c> + session files + (if needed) the
|
||||
/// Reads <c>clean-exit.marker</c> + <c>crash-*.log</c> + session files + (if needed) the
|
||||
/// Windows event log to classify the previous exit. Deletes the marker and crash logs so
|
||||
/// the next cycle starts fresh. Legacy parity — same rules, same outputs.
|
||||
/// </summary>
|
||||
@@ -114,6 +117,8 @@ namespace AutopilotMonitor.Agent.V2
|
||||
var hadCleanExit = File.Exists(cleanExitMarker);
|
||||
var crashLogs = Directory.Exists(logDirectory)
|
||||
? Directory.GetFiles(logDirectory, CrashLogPrefix + "*.log")
|
||||
.Concat(Directory.GetFiles(logDirectory, LegacyCrashLogPrefix + "*.log"))
|
||||
.ToArray()
|
||||
: Array.Empty<string>();
|
||||
|
||||
if (hadCleanExit)
|
||||
@@ -406,7 +411,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(logDirectory);
|
||||
var crashPath = Path.Combine(logDirectory, $"{CrashLogPrefix}{DateTime.UtcNow:yyyyMMdd_HHmmss}.log");
|
||||
var crashPath = Path.Combine(logDirectory, $"{CrashLogPrefix}{DateTime.UtcNow:yyyyMMdd-HHmmss}.log");
|
||||
File.WriteAllText(crashPath, $"[{DateTime.UtcNow:u}] FATAL: {ex}");
|
||||
}
|
||||
catch { /* nowhere left to log */ }
|
||||
|
||||
@@ -79,13 +79,27 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// write to the same state file; per-enrollment lifecycle like the other state files.
|
||||
var startupEventGate = new AutopilotMonitor.Agent.V2.Core.Persistence.StartupEventGate(stateSubdir, logger);
|
||||
|
||||
// sits-d Cloud-PC fix (2026-08-19): a reboot_kill means the agent was down across the
|
||||
// forced mid-ESP restart. Hand the boot timestamp to the factory so the Shell-Core
|
||||
// ESP-exit backfill can reach back over the whole downtime instead of 5 minutes.
|
||||
var previousBootUtc =
|
||||
string.Equals(previousExit?.ExitType, "reboot_kill", StringComparison.OrdinalIgnoreCase)
|
||||
? previousExit?.LastBootUtc
|
||||
: null;
|
||||
|
||||
var componentFactory = new DefaultComponentFactory(
|
||||
agentConfig: agentConfig,
|
||||
remoteConfig: remoteConfig,
|
||||
networkMetrics: auth.NetworkMetrics,
|
||||
agentVersion: agentVersion,
|
||||
stateDirectory: stateSubdir,
|
||||
startupEventGate: startupEventGate);
|
||||
startupEventGate: startupEventGate,
|
||||
previousBootUtc: previousBootUtc,
|
||||
previousExitType: previousExit?.ExitType,
|
||||
// Measured, not guessed: the reducer snapshot is rewritten on every decision step,
|
||||
// so its mtime is the last moment the previous run is known to have been alive —
|
||||
// and unlike LastBootUtc it exists for every exit type, exception_crash included.
|
||||
previousRunLastAliveUtc: DefaultComponentFactory.ReadPreviousRunLastAliveUtc(stateSubdir));
|
||||
|
||||
var whiteGloveSealingPatternIds = (System.Collections.Generic.IReadOnlyCollection<string>)remoteConfig.WhiteGloveSealingPatternIds
|
||||
?? Array.Empty<string>();
|
||||
|
||||
@@ -306,7 +306,12 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
try
|
||||
{
|
||||
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
|
||||
var (enrollmentType, detectionRule) = EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule();
|
||||
// High-confidence WDP seed only for the deterministic marker — the
|
||||
// CloudAssigned* fallback rules keep the Medium seed (see the payload
|
||||
// key's doc on SignalPayloadKeys.EnrollmentTypeDeterministic).
|
||||
var deterministicWdp = enrollmentType == "v2"
|
||||
&& detectionRule == EnrollmentRegistryDetector.DevicePreparationExecutionContextRule;
|
||||
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
|
||||
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
|
||||
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
|
||||
@@ -314,6 +319,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["enrollmentType"] = enrollmentType,
|
||||
["enrollmentTypeDeterministic"] = deterministicWdp ? "true" : "false",
|
||||
["isHybridJoin"] = isHybridJoin ? "true" : "false",
|
||||
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
|
||||
["isCloudPc"] = isCloudPc ? "true" : "false",
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
# server-updatable without re-shipping the MSI.
|
||||
#
|
||||
# Fail-soft by design: the runner always exits 0 so a transient network failure never
|
||||
# marks the LOB install as failed; diagnostics go to msi-bootstrap.log.
|
||||
# marks the LOB install as failed; diagnostics go to bootstrap-msi.log.
|
||||
# ASCII only - no special characters in this file.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$baseDir = Join-Path $env:ProgramData 'AutopilotMonitor'
|
||||
$logDir = Join-Path $baseDir 'Logs'
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
$log = Join-Path $logDir 'msi-bootstrap.log'
|
||||
$log = Join-Path $logDir 'bootstrap-msi.log'
|
||||
|
||||
function Write-Log([string]$message) {
|
||||
Add-Content -Path $log -Value ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="Autopilot Monitor Bootstrap"
|
||||
Manufacturer="Autopilot Monitor"
|
||||
Version="1.0.0"
|
||||
Version="1.0.2"
|
||||
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
|
||||
Scope="perMachine"
|
||||
Compressed="yes">
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after files land. Return=ignore keeps
|
||||
the LOB install green even if the runner hits a transient error (the runner is
|
||||
additionally fail-soft itself); diagnostics live in msi-bootstrap.log. -->
|
||||
additionally fail-soft itself); diagnostics live in bootstrap-msi.log. -->
|
||||
<CustomAction Id="RunBootstrap"
|
||||
Directory="INSTALLFOLDER"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "[INSTALLFOLDER]Invoke-BootstrapDownload.ps1""
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// The reducer half of the "a replayed 62407 must never reach the decision engine" contract
|
||||
/// (2026-08-19). The agent half — ShellCoreTracker never re-raising a replayed exit — lives in
|
||||
/// <c>ShellCoreTrackerReplayScopeTests</c>; this file proves WHY that upstream restriction is
|
||||
/// the only place the problem can be solved.
|
||||
/// <para>
|
||||
/// The reducer is itself a completion gate, and by design it cannot tell a replayed exit from
|
||||
/// a live one:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>HandleEspExitingV1</c> passes <c>espFinalExitInFlight: true</c> for EVERY
|
||||
/// arriving exit — the signal carries no provenance.</item>
|
||||
/// <item><c>IsPostAccountSetupFinalExit</c> orders exits by INGEST ORDINAL rather than by
|
||||
/// timestamp (deliberately: replayed CMTrace lines carry backdated source times). A
|
||||
/// historic exit replayed today is assigned a fresher ordinal than reality, so it reads as
|
||||
/// post-AccountSetup by construction.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// After a restart the state is restored from the snapshot, so AccountSetupEntered, the IME
|
||||
/// user-session completion and the desktop arrival are all already present. Arm C of
|
||||
/// <c>ShouldTransitionToAwaitingHello</c> then needs exactly one more fact: an exit. These
|
||||
/// tests pin that it opens — which is correct behaviour for a live exit and would be a
|
||||
/// premature completion for a replayed one. Hence the upstream ban.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ClassicEspExitingOnRestoredStateTests
|
||||
{
|
||||
private static readonly DateTime Fixed = new DateTime(2026, 8, 19, 8, 43, 16, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Arm_C_opens_on_any_arriving_exit_once_restored_state_carries_the_other_three_facts()
|
||||
{
|
||||
// This is the danger, stated as a test: with restored facts in place, a single
|
||||
// EspExiting signal is all that separates EspAccountSetup from AwaitingHello. The
|
||||
// reducer has no way to ask "was that exit real, or did we dig it out of the event
|
||||
// log?" — so the answer has to be that a replayed exit is never turned into a signal.
|
||||
var engine = new DecisionEngine();
|
||||
var state = BuildRestoredState(
|
||||
accountSetupEntered: true,
|
||||
imeUserSessionCompleted: true,
|
||||
desktopArrived: true);
|
||||
|
||||
var step = engine.Reduce(state, MakeEspExitingSignal(ordinal: 10));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Each row drops exactly one of arm C's mandatory facts — the transition must not happen.
|
||||
[InlineData(false, true, true)]
|
||||
[InlineData(true, false, true)]
|
||||
[InlineData(true, true, false)]
|
||||
public void Arm_C_stays_shut_when_any_of_its_mandatory_facts_is_missing(
|
||||
bool accountSetupEntered, bool imeUserSessionCompleted, bool desktopArrived)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = BuildRestoredState(accountSetupEntered, imeUserSessionCompleted, desktopArrived);
|
||||
|
||||
var step = engine.Reduce(state, MakeEspExitingSignal(ordinal: 10));
|
||||
|
||||
Assert.NotEqual(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_historic_source_timestamp_does_not_hold_the_gate_shut()
|
||||
{
|
||||
// The exit's OccurredAtUtc is an hour older than the AccountSetup anchor — exactly the
|
||||
// shape a replayed record has. It makes no difference: the ordering that arm C uses is
|
||||
// the ingest ordinal, and the in-flight flag bypasses even that. Timestamps are not a
|
||||
// defence, which is why the fix cannot live in the reducer.
|
||||
var engine = new DecisionEngine();
|
||||
var state = BuildRestoredState(
|
||||
accountSetupEntered: true,
|
||||
imeUserSessionCompleted: true,
|
||||
desktopArrived: true);
|
||||
|
||||
var step = engine.Reduce(
|
||||
state,
|
||||
MakeEspExitingSignal(ordinal: 10, occurredAtUtc: Fixed.AddHours(-1)));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
private static DecisionState BuildRestoredState(
|
||||
bool accountSetupEntered,
|
||||
bool imeUserSessionCompleted,
|
||||
bool desktopArrived)
|
||||
{
|
||||
var anchor = Fixed.AddMinutes(-90);
|
||||
|
||||
var builder = DecisionState.CreateInitial("s", "t", Fixed.AddDays(-1))
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.EspAccountSetup)
|
||||
.WithStepIndex(452) // the restored StepIndex the sits-d sessions showed
|
||||
.WithLastAppliedSignalOrdinal(9);
|
||||
|
||||
if (accountSetupEntered)
|
||||
builder.AccountSetupEnteredUtc = new SignalFact<DateTime>(anchor, 1);
|
||||
|
||||
// IsImeUserSessionGenuine requires the completion to be at-or-after the AccountSetup
|
||||
// anchor (the defaultuser0-ghost guard).
|
||||
if (imeUserSessionCompleted)
|
||||
builder.ImeUserSessionCompletedUtc = new SignalFact<DateTime>(anchor.AddMinutes(5), 2);
|
||||
|
||||
if (desktopArrived)
|
||||
builder.DesktopArrivedUtc = new SignalFact<DateTime>(anchor.AddMinutes(6), 3);
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static DecisionSignal MakeEspExitingSignal(long ordinal, DateTime? occurredAtUtc = null) =>
|
||||
new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: DecisionSignalKind.EspExiting,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc ?? Fixed,
|
||||
sourceOrigin: "EspAndHelloTracker",
|
||||
evidence: new Evidence(
|
||||
kind: EvidenceKind.Derived,
|
||||
identifier: "esp-hello-detector-v1",
|
||||
summary: "ESP exiting (coordinator-forwarded Shell-Core 62407)",
|
||||
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["subSource"] = "ShellCoreTracker",
|
||||
["eventSource"] = "Microsoft-Windows-Shell-Core",
|
||||
["eventId"] = "62407",
|
||||
}));
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// sits-d Cloud-PC fix (2026-08-20, sessions 8110e262 / a89aac2d / e7ba63c9 / cb4a485a /
|
||||
/// 3d6278fb). On a SkipUser=true flow Windows never renders the user ESP page: Shell-Core
|
||||
/// 62407 does not fire and the AccountSetup provisioning categories are never written —
|
||||
/// verified live (Shell-Core watcher armed across three agent runs, zero 62407 in six
|
||||
/// hours; SkipUserStatusPage read 10+ times across all runs, True every time). Arm B of
|
||||
/// <c>ShouldTransitionToAwaitingHello</c> was therefore open from the bootstrap
|
||||
/// EspConfigDetected onward, but every site that evaluates the gate hangs off a signal the
|
||||
/// flow structurally cannot produce, so five sessions sat at "waiting on: hello_resolution"
|
||||
/// until the max-lifetime watchdog.
|
||||
/// <para>
|
||||
/// The fix lets the observed skip stand in for the final-exit evidence inside
|
||||
/// <c>HandleImeUserSessionCompletedV1</c>'s completion attempt. These tests replay the real
|
||||
/// 8110e262 signal ordering end to end (including the Fix-10 AccountSetup bounce-back a
|
||||
/// restart re-emission triggers) and pin the negative space: no skip observed, no desktop,
|
||||
/// ghost IME completion, and Device Preparation flows are all unchanged.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ClassicSkipUserEspUserSessionCompletionTests
|
||||
{
|
||||
// 8110e262 run 1: agent_started 08:30:08Z.
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 19, 8, 30, 8, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Sits_d_signal_ordering_completes_via_hello_safety()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial(
|
||||
"8110e262-da57-4282-9290-bb0c96d05614", "5ca2b350", T0);
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
|
||||
// Bootstrap EspConfigDetected — SkipUserStatusPage=True, read from FirstSync 2 s in.
|
||||
state = engine.Reduce(state, MakeEspConfigSignal(1, skipUser: "true")).NewState;
|
||||
Assert.True(state.ScenarioObservations.SkipUserEsp!.Value);
|
||||
|
||||
// Hello policy enabled (device-scoped CSP) — the reason the desktop fast-path
|
||||
// could never take this flow.
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.HelloPolicyDetected, T0.AddSeconds(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "true" })).NewState;
|
||||
|
||||
// Desktop 5 s after agent start (the user ESP page does not exist — the user is
|
||||
// simply logged on). Pins that desktop arrival alone does NOT promote: the knock
|
||||
// deliberately lives on the IME user-session edge, because a desktop-side promotion
|
||||
// would be undone by Fix 10's AccountSetup bounce-back two signals later (the IME
|
||||
// AccountSetup phase line lands AFTER the desktop on this flow).
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DesktopArrived, T0.AddSeconds(5), null)).NewState;
|
||||
Assert.NotEqual(SessionStage.AwaitingHello, state.Stage);
|
||||
Assert.DoesNotContain(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
|
||||
// IME's AccountSetup phase line (source ts 08:29:56 — before agent start; the IME
|
||||
// logs the phase for its user-session app processing even though no page is shown).
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.EspPhaseChanged, T0.AddSeconds(-12),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
Assert.Equal(SessionStage.EspAccountSetup, state.Stage);
|
||||
|
||||
// 08:36:59 — IME user session completed (138/138 apps, 0 failed). THE knock:
|
||||
// skip-user stands in for the exit that cannot exist; Hello enabled + unresolved
|
||||
// promotes to AwaitingHello with HelloSafety armed.
|
||||
var promoted = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.ImeUserSessionCompleted,
|
||||
T0.AddMinutes(6).AddSeconds(51), null));
|
||||
Assert.Equal(SessionStage.AwaitingHello, promoted.NewState.Stage);
|
||||
var helloSafety = Assert.Single(promoted.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.Contains(promoted.Effects,
|
||||
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.HelloSafety);
|
||||
|
||||
// The Hello wizard never appears over RDP — HelloSafety resolves synthetically.
|
||||
var afterSafety = engine.Reduce(promoted.NewState,
|
||||
MakeSignal(6, DecisionSignalKind.DeadlineFired, helloSafety.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety }));
|
||||
Assert.Equal(SessionStage.Finalizing, afterSafety.NewState.Stage);
|
||||
Assert.Equal("Timeout", afterSafety.NewState.HelloOutcome!.Value);
|
||||
|
||||
var completed = engine.Reduce(afterSafety.NewState,
|
||||
MakeSignal(7, DecisionSignalKind.DeadlineFired, helloSafety.DueAtUtc.AddSeconds(5),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.FinalizingGrace }));
|
||||
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, completed.NewState.Outcome);
|
||||
Assert.Contains(completed.Effects,
|
||||
e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fix10_bounceback_is_healed_by_the_ime_reemission()
|
||||
{
|
||||
// Restart shape (8110e262 run 3): the promoted AwaitingHello gets bounced back by a
|
||||
// re-emitted AccountSetup phase line (Fix 10 cancels HelloSafety, deliberately — it
|
||||
// guards the premature-promotion case). The IME phase line always precedes the
|
||||
// user-session-complete line in log order, so the re-emitted completion re-knocks.
|
||||
var engine = new DecisionEngine();
|
||||
var state = ProgressToPromotedAwaitingHello(engine);
|
||||
|
||||
var bounced = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.EspPhaseChanged,
|
||||
T0.AddMinutes(18).AddSeconds(10),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" }));
|
||||
Assert.Equal(SessionStage.EspAccountSetup, bounced.NewState.Stage);
|
||||
Assert.DoesNotContain(bounced.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
|
||||
var reknocked = engine.Reduce(bounced.NewState,
|
||||
MakeSignal(11, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(6).AddSeconds(51), null));
|
||||
Assert.Equal(SessionStage.AwaitingHello, reknocked.NewState.Stage);
|
||||
Assert.Single(reknocked.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// No EspConfigDetected at all — SkipUserEsp unknown.
|
||||
[InlineData(null)]
|
||||
// Explicit SkipUser=false (the two sits-d machines that succeeded had this shape and
|
||||
// a REAL page: exit + registry evidence. Without the exit the gate must stay shut.)
|
||||
[InlineData("false")]
|
||||
public void No_promotion_without_an_observed_skip(string? skipUser)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
if (skipUser != null)
|
||||
{
|
||||
state = engine.Reduce(state, MakeEspConfigSignal(1, skipUser)).NewState;
|
||||
}
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.HelloPolicyDetected, T0.AddSeconds(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "true" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DesktopArrived, T0.AddSeconds(5), null)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.EspPhaseChanged, T0.AddSeconds(10),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state,
|
||||
MakeSignal(5, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(7), null));
|
||||
|
||||
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_promotion_without_the_desktop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
state = engine.Reduce(state, MakeEspConfigSignal(1, skipUser: "true")).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddSeconds(10),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state,
|
||||
MakeSignal(3, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(7), null));
|
||||
|
||||
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_ghost_pre_anchor_ime_completion_does_not_knock()
|
||||
{
|
||||
// defaultuser0-ghost guard stays load-bearing: an IME completion whose source time
|
||||
// precedes the AccountSetup anchor is the OOBE/technician frame, not the user.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
state = engine.Reduce(state, MakeEspConfigSignal(1, skipUser: "true")).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.DesktopArrived, T0.AddSeconds(5), null)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state,
|
||||
MakeSignal(4, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(1), null));
|
||||
|
||||
Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage);
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_preparation_flows_keep_their_own_rails()
|
||||
{
|
||||
// WDP has no ESP at all — completion rides the Hello+Desktop conjunction and the
|
||||
// DevicePrepCompletion backstop (afee7ae0). Even a (spurious) SkipUser=true read
|
||||
// must not route WDP through the classic skip-user knock.
|
||||
var engine = new DecisionEngine();
|
||||
var seed = DecisionState.CreateInitial("s", "t", T0)
|
||||
.ToBuilder()
|
||||
.WithStage(SessionStage.SessionStarted)
|
||||
.WithStepIndex(5)
|
||||
.WithLastAppliedSignalOrdinal(4)
|
||||
.WithScenarioProfile(EnrollmentScenarioProfile.Empty.With(
|
||||
mode: EnrollmentMode.DevicePreparation,
|
||||
confidence: ProfileConfidence.High,
|
||||
evidenceOrdinal: 1,
|
||||
reason: "test"));
|
||||
seed.ScenarioObservations = EnrollmentScenarioObservations.Empty
|
||||
.WithSkipUserEsp(true, 1);
|
||||
seed.AccountSetupEnteredUtc = new SignalFact<DateTime>(T0.AddMinutes(1), 2);
|
||||
seed.DesktopArrivedUtc = new SignalFact<DateTime>(T0.AddMinutes(2), 3);
|
||||
var state = seed.Build();
|
||||
|
||||
var step = engine.Reduce(state,
|
||||
MakeSignal(5, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(3), null));
|
||||
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
Assert.NotEqual(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hello_disabled_skip_user_completes_directly_from_the_knock()
|
||||
{
|
||||
// HelloSatisfiedForCompletion holds (policy disabled, no wizard observed) — the
|
||||
// knock completes through Finalizing with the synthetic Skipped outcome instead of
|
||||
// parking 5 minutes in AwaitingHello.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("s", "t", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
state = engine.Reduce(state, MakeEspConfigSignal(1, skipUser: "true")).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.HelloPolicyDetected, T0.AddSeconds(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddSeconds(10),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
// Desktop arrives; the hello-disabled fast-path already promotes/completes via the
|
||||
// gate's arm B on this ordering, so the knock is not even needed here — assert the
|
||||
// flow terminates either way (belt for orderings where desktop precedes the config).
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DesktopArrived, T0.AddMinutes(5), null)).NewState;
|
||||
|
||||
if (state.Stage != SessionStage.Finalizing && !state.Stage.IsTerminal())
|
||||
{
|
||||
var step = engine.Reduce(state,
|
||||
MakeSignal(5, DecisionSignalKind.ImeUserSessionCompleted, T0.AddMinutes(7), null));
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(SessionStage.Finalizing, state.Stage);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
private static DecisionState ProgressToPromotedAwaitingHello(DecisionEngine engine)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("s", "t", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||
state = engine.Reduce(state, MakeEspConfigSignal(1, skipUser: "true")).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.HelloPolicyDetected, T0.AddSeconds(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "true" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.DesktopArrived, T0.AddSeconds(5), null)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.EspPhaseChanged, T0.AddSeconds(-12),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.ImeUserSessionCompleted,
|
||||
T0.AddMinutes(6).AddSeconds(51), null)).NewState;
|
||||
Assert.Equal(SessionStage.AwaitingHello, state.Stage);
|
||||
return state;
|
||||
}
|
||||
|
||||
private static DecisionSignal MakeEspConfigSignal(long ordinal, string skipUser) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.EspConfigDetected, T0.AddSeconds(ordinal),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.SkipUserEsp] = skipUser,
|
||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||
});
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"test-{kind}-{ordinal}", $"synthetic {kind}"),
|
||||
payload: payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Device Preparation (WDP) engine semantics — afee7ae0 audit (2026-08-18).
|
||||
/// WDP has no ESP, so the ESP-derived arms/gates/deadlines can never fire there.
|
||||
/// Coverage:
|
||||
/// <list type="bullet">
|
||||
/// <item>Profile seeding: the deterministic WDP marker seeds High confidence; the
|
||||
/// CloudAssigned* fallback rules stay Medium.</item>
|
||||
/// <item><c>completion_waiting</c> never lists <c>account_setup_provisioning_complete</c>
|
||||
/// on WDP (unsatisfiable by construction — no ESP categories).</item>
|
||||
/// <item>Desktop-first arming of the <see cref="DeadlineNames.DevicePrepCompletion"/>
|
||||
/// backstop, its fire semantics (synthetic Hello timeout + Finalizing), and the
|
||||
/// no-rearm/no-classic-leak guards.</item>
|
||||
/// <item>Arm D: the Hello-disabled fast-path completes at desktop arrival on WDP.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class DevicePreparationEngineTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 18, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null)
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
|
||||
|
||||
private static List<DecisionEffect> CompletionWaitingEffects(DecisionStep step) =>
|
||||
step.Effects.Where(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == "completion_waiting").ToList();
|
||||
|
||||
/// <summary>Session seeded as WDP via the EnrollmentFactsObserved signal rail.</summary>
|
||||
private static DecisionState SeedWdpSession(DecisionEngine engine, bool deterministic = true)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-wdp", "tenant-wdp", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
1, DecisionSignalKind.EnrollmentFactsObserved, T0,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v2",
|
||||
[SignalPayloadKeys.EnrollmentTypeDeterministic] = deterministic ? "true" : "false",
|
||||
})).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
// ===================================================== profile seeding
|
||||
|
||||
[Fact]
|
||||
public void DeterministicV2Seed_SetsDevicePreparationAtHighConfidence()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine, deterministic: true);
|
||||
|
||||
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
|
||||
Assert.Equal(ProfileConfidence.High, state.ScenarioProfile.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallbackV2Seed_StaysAtMediumConfidence()
|
||||
{
|
||||
// CloudAssigned* fallback rules (no deterministic marker) — Medium, as before
|
||||
// d0ab3eee: an ESP-less classic profile could in principle trip them.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine, deterministic: false);
|
||||
|
||||
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
|
||||
Assert.Equal(ProfileConfidence.Medium, state.ScenarioProfile.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LegacyV2Payload_WithoutDeterministicKey_StaysAtMediumConfidence()
|
||||
{
|
||||
// Older agents post enrollmentType=v2 without the new key — must behave like
|
||||
// the fallback (Medium), not throw or over-promote.
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("sess-legacy", "tenant-wdp", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
1, DecisionSignalKind.EnrollmentFactsObserved, T0,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EnrollmentType] = "v2" })).NewState;
|
||||
|
||||
Assert.Equal(EnrollmentMode.DevicePreparation, state.ScenarioProfile.Mode);
|
||||
Assert.Equal(ProfileConfidence.Medium, state.ScenarioProfile.Confidence);
|
||||
}
|
||||
|
||||
// ===================================================== desktop-first backstop
|
||||
|
||||
[Fact]
|
||||
public void DesktopFirst_OnWdp_ArmsDevicePrepCompletionBackstop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
// Hello policy unknown → no fast-path; stage parked, backstop armed.
|
||||
var backstop = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
Assert.Equal(T0.AddMinutes(9).AddMinutes(30), backstop.DueAtUtc);
|
||||
Assert.Contains(step.Effects,
|
||||
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.DevicePrepCompletion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DesktopFirst_OnWdp_WaitingListsOnlyHello_NeverTheEspProvisioningGate()
|
||||
{
|
||||
// account_setup_provisioning_complete is unsatisfiable on WDP (no ESP categories)
|
||||
// and must not appear as a missing prerequisite (afee7ae0 listed it).
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
var waiting = Assert.Single(CompletionWaitingEffects(step));
|
||||
Assert.Equal("hello_resolution", waiting.Parameters!["missingPrerequisites"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateDesktopSignal_DoesNotRearmTheBackstop()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
|
||||
|
||||
var second = engine.Reduce(state, MakeSignal(11, DecisionSignalKind.DesktopArrived, T0.AddMinutes(10)));
|
||||
|
||||
Assert.Single(second.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
Assert.DoesNotContain(second.Effects,
|
||||
e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.DevicePrepCompletion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicDesktopFirst_DoesNotArmTheBackstop()
|
||||
{
|
||||
// Classic regression guard: a non-WDP session parked desktop-first keeps the
|
||||
// pre-change deadline picture (nothing armed by this handler).
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial("sess-classic", "tenant-classic", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
}
|
||||
|
||||
// ===================================================== backstop fire semantics
|
||||
|
||||
[Fact]
|
||||
public void BackstopFired_WithoutHello_SynthesizesTimeoutAndCompletesThroughFinalizing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
|
||||
|
||||
var fired = engine.Reduce(state, DeadlineFired(20, T0.AddMinutes(39), DeadlineNames.DevicePrepCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, fired.NewState.Stage);
|
||||
Assert.NotNull(fired.NewState.HelloResolvedUtc);
|
||||
Assert.Equal("Timeout", fired.NewState.HelloOutcome!.Value);
|
||||
Assert.DoesNotContain(fired.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
Assert.Contains(fired.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
|
||||
|
||||
// FinalizingGrace completes the session — the full WDP no-Hello resolution chain.
|
||||
var completed = engine.Reduce(fired.NewState,
|
||||
DeadlineFired(21, T0.AddMinutes(39).AddSeconds(5), DeadlineNames.FinalizingGrace));
|
||||
Assert.Equal(SessionStage.Completed, completed.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackstopFired_WithoutDesktop_ParksInAwaitingDesktopUntilDesktopCompletes()
|
||||
{
|
||||
// The arming site (HandleDesktopArrivedV1) guarantees desktop-first, so this
|
||||
// fires only on a stale/replayed timer — Dispatch routes DeadlineFired purely on
|
||||
// the payload name, no armed-state check. The fallback must keep the handler
|
||||
// total: synthetic Hello timeout recorded, session parks in AwaitingDesktop, and
|
||||
// a later real desktop arrival still completes it.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
|
||||
var fired = engine.Reduce(state, DeadlineFired(20, T0.AddMinutes(39), DeadlineNames.DevicePrepCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingDesktop, fired.NewState.Stage);
|
||||
Assert.NotNull(fired.NewState.HelloResolvedUtc);
|
||||
Assert.Equal("Timeout", fired.NewState.HelloOutcome!.Value);
|
||||
Assert.DoesNotContain(fired.NewState.Deadlines, d => d.Name == DeadlineNames.DevicePrepCompletion);
|
||||
|
||||
// Hello is satisfied (synthetically) — only the desktop is still missing.
|
||||
var waiting = Assert.Single(CompletionWaitingEffects(fired));
|
||||
Assert.Equal("desktop_arrival", waiting.Parameters!["missingPrerequisites"]);
|
||||
|
||||
var completed = engine.Reduce(fired.NewState,
|
||||
MakeSignal(21, DecisionSignalKind.DesktopArrived, T0.AddMinutes(45)));
|
||||
Assert.Equal(SessionStage.Finalizing, completed.NewState.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloResolved_BeforeBackstopFires_CompletesNormally()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9))).NewState;
|
||||
|
||||
var resolved = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.HelloResolved, T0.AddMinutes(10),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "completed" }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, resolved.NewState.Stage);
|
||||
Assert.Equal("completed", resolved.NewState.HelloOutcome!.Value);
|
||||
}
|
||||
|
||||
// ===================================================== arm D fast-path
|
||||
|
||||
[Fact]
|
||||
public void HelloDisabledFastPath_OnWdp_CompletesAtDesktopArrival()
|
||||
{
|
||||
// Arm D: no ESP facts exist on WDP, yet a disabled Hello policy + real-user
|
||||
// desktop is a legitimate completion — previously arms A–C all failed and the
|
||||
// session parked despite Hello being disabled.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SeedWdpSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(9)));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// AgentEfficiencyMetricsService: per-agent-version bucketing, nearest-rank percentiles,
|
||||
/// crash tally per bucket, top-offender selection and the scanned/with-snapshots split.
|
||||
/// Distinct `days` values per test keep the static per-(days,limit,tenant) cache from
|
||||
/// cross-talking between tests.
|
||||
/// </summary>
|
||||
public class AgentEfficiencyMetricsServiceTests
|
||||
{
|
||||
private const string Tenant = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
private static (AgentEfficiencyMetricsService Service, Mock<ISessionRepository> Repo) CreateService()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(new List<SessionSummary>(), null));
|
||||
repo
|
||||
.Setup(r => r.GetSessionEventsByTypesAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(new List<EnrollmentEvent>());
|
||||
|
||||
var service = new AgentEfficiencyMetricsService(repo.Object, NullLogger<AgentEfficiencyMetricsService>.Instance);
|
||||
return (service, repo);
|
||||
}
|
||||
|
||||
private static SessionSummary MakeSession(int i, string agentVersion, double? avgLatency = null, int? requestCount = null) => new SessionSummary
|
||||
{
|
||||
SessionId = $"00000000-0000-0000-0000-{i:D12}",
|
||||
TenantId = Tenant,
|
||||
AgentVersion = agentVersion,
|
||||
DeviceName = $"PC-{i}",
|
||||
StartedAt = DateTime.UtcNow.AddHours(-i),
|
||||
Status = SessionStatus.Succeeded,
|
||||
AvgApiLatencyMs = avgLatency,
|
||||
ApiRequestCount = requestCount
|
||||
};
|
||||
|
||||
private static EnrollmentEvent Snapshot(double cpu, double ws, double handles = 0, double threads = 0, double spool = 0, long sequence = 1) => new EnrollmentEvent
|
||||
{
|
||||
EventType = "agent_metrics_snapshot",
|
||||
Sequence = sequence,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["agent_cpu_percent"] = cpu,
|
||||
["agent_working_set_mb"] = ws,
|
||||
["agent_handle_count"] = handles,
|
||||
["agent_thread_count"] = threads,
|
||||
["spool_pending_item_count"] = spool
|
||||
}
|
||||
};
|
||||
|
||||
private static EnrollmentEvent AgentStarted(string exitType, string? exception = null)
|
||||
{
|
||||
var data = new Dictionary<string, object> { ["previousExitType"] = exitType };
|
||||
if (exception != null) data["previousCrashException"] = exception;
|
||||
return new EnrollmentEvent { EventType = "agent_started", Data = data };
|
||||
}
|
||||
|
||||
private static void SetupSessions(Mock<ISessionRepository> repo, List<SessionSummary> sessions)
|
||||
{
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(sessions, null));
|
||||
}
|
||||
|
||||
private static void SetupEvents(Mock<ISessionRepository> repo, string sessionId, params EnrollmentEvent[] events)
|
||||
{
|
||||
repo
|
||||
.Setup(r => r.GetSessionEventsByTypesAsync(It.IsAny<string>(), sessionId, It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(events.ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compute_buckets_by_agent_version_with_nearest_rank_percentiles()
|
||||
{
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
MakeSession(1, "2.0.1400"), MakeSession(2, "2.0.1400"),
|
||||
MakeSession(3, "2.0.1400"), MakeSession(4, "2.0.1400"),
|
||||
MakeSession(5, "2.0.1402")
|
||||
};
|
||||
SetupSessions(repo, sessions);
|
||||
// Per-session max working set for the 1400 bucket: 100, 200, 300, 400.
|
||||
SetupEvents(repo, sessions[0].SessionId, Snapshot(cpu: 1, ws: 50), Snapshot(cpu: 3, ws: 100, sequence: 2));
|
||||
SetupEvents(repo, sessions[1].SessionId, Snapshot(cpu: 2, ws: 200));
|
||||
SetupEvents(repo, sessions[2].SessionId, Snapshot(cpu: 4, ws: 300));
|
||||
SetupEvents(repo, sessions[3].SessionId, Snapshot(cpu: 8, ws: 400));
|
||||
SetupEvents(repo, sessions[4].SessionId, Snapshot(cpu: 5, ws: 500));
|
||||
|
||||
var result = await service.ComputeAsync(days: 41, limit: 100);
|
||||
|
||||
Assert.Equal(5, result.SessionsScanned);
|
||||
Assert.Equal(5, result.SessionsWithSnapshots);
|
||||
Assert.Equal(2, result.ByVersion.Count);
|
||||
|
||||
var v1400 = result.ByVersion.Single(b => b.AgentVersion == "2.0.1400");
|
||||
Assert.Equal(4, v1400.SessionsScanned);
|
||||
Assert.Equal(4, v1400.SessionsWithSnapshots);
|
||||
// Nearest-rank over sorted [100, 200, 300, 400]: p50 → 2nd (200), p95 → 4th (400).
|
||||
Assert.Equal(200, v1400.MaxWorkingSetMb!.P50);
|
||||
Assert.Equal(400, v1400.MaxWorkingSetMb.P95);
|
||||
Assert.Equal(400, v1400.MaxWorkingSetMb.Max);
|
||||
Assert.Equal(4, v1400.MaxWorkingSetMb.SampleCount);
|
||||
// Session 1's max CPU is 3 (max over its two snapshots), not the last value.
|
||||
Assert.Equal(8, v1400.MaxCpuPercent!.Max);
|
||||
|
||||
var v1402 = result.ByVersion.Single(b => b.AgentVersion == "2.0.1402");
|
||||
Assert.Equal(1, v1402.SessionsScanned);
|
||||
Assert.Equal(500, v1402.MaxWorkingSetMb!.Max);
|
||||
|
||||
Assert.NotNull(result.Overall);
|
||||
Assert.Null(result.Overall!.AgentVersion);
|
||||
Assert.Equal(5, result.Overall.SessionsScanned);
|
||||
Assert.Equal(500, result.Overall.MaxWorkingSetMb!.Max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compute_tallies_crash_rate_per_version_bucket()
|
||||
{
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = new List<SessionSummary> { MakeSession(1, "2.0.1400"), MakeSession(2, "2.0.1402") };
|
||||
SetupSessions(repo, sessions);
|
||||
SetupEvents(repo, sessions[0].SessionId,
|
||||
AgentStarted("first_run"),
|
||||
AgentStarted("exception_crash", "System.IO.IOException"),
|
||||
AgentStarted("clean"));
|
||||
SetupEvents(repo, sessions[1].SessionId, AgentStarted("first_run"), AgentStarted("clean"));
|
||||
|
||||
var result = await service.ComputeAsync(days: 42, limit: 100);
|
||||
|
||||
var v1400 = result.ByVersion.Single(b => b.AgentVersion == "2.0.1400");
|
||||
Assert.Equal(3, v1400.CrashRate!.TotalStarts);
|
||||
Assert.Equal(1, v1400.CrashRate.ExceptionCrashes);
|
||||
Assert.Equal("System.IO.IOException", Assert.Single(v1400.CrashRate.TopExceptions).ExceptionType);
|
||||
// 2 non-first-run starts, 1 crash → 50%.
|
||||
Assert.Equal(50, v1400.CrashRate.CrashRatePercent);
|
||||
|
||||
var v1402 = result.ByVersion.Single(b => b.AgentVersion == "2.0.1402");
|
||||
Assert.Equal(0, v1402.CrashRate!.ExceptionCrashes);
|
||||
Assert.Equal(0, v1402.CrashRate.CrashRatePercent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compute_selects_top_offenders_per_dimension_descending()
|
||||
{
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = Enumerable.Range(1, 5).Select(i => MakeSession(i, "2.0.1400")).ToList();
|
||||
SetupSessions(repo, sessions);
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
// Working sets 100..500 — top 3 must be sessions 5, 4, 3 in that order.
|
||||
SetupEvents(repo, sessions[i].SessionId, Snapshot(cpu: 1, ws: (i + 1) * 100));
|
||||
}
|
||||
|
||||
var result = await service.ComputeAsync(days: 43, limit: 100);
|
||||
|
||||
var bucket = result.ByVersion.Single();
|
||||
var wsOffenders = bucket.TopOffenders!.Where(o => o.Dimension == "maxWorkingSetMb").ToList();
|
||||
Assert.Equal(3, wsOffenders.Count);
|
||||
Assert.Equal(new[] { 500d, 400d, 300d }, wsOffenders.Select(o => o.Value).ToArray());
|
||||
Assert.Equal(sessions[4].SessionId, wsOffenders[0].SessionId);
|
||||
Assert.Equal("PC-5", wsOffenders[0].DeviceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compute_snapshotless_sessions_still_contribute_index_mirrored_latency()
|
||||
{
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = new List<SessionSummary>
|
||||
{
|
||||
MakeSession(1, "2.0.1400", avgLatency: 80, requestCount: 200),
|
||||
MakeSession(2, "2.0.1400", avgLatency: 120, requestCount: 300) // no snapshots
|
||||
};
|
||||
SetupSessions(repo, sessions);
|
||||
SetupEvents(repo, sessions[0].SessionId, Snapshot(cpu: 1, ws: 100));
|
||||
|
||||
var result = await service.ComputeAsync(days: 44, limit: 100);
|
||||
|
||||
Assert.Equal(2, result.SessionsScanned);
|
||||
Assert.Equal(1, result.SessionsWithSnapshots);
|
||||
var bucket = result.ByVersion.Single();
|
||||
// Latency stats cover BOTH sessions (mirror-sourced), resource stats only the one with snapshots.
|
||||
Assert.Equal(2, bucket.ApiLatencyMs!.SampleCount);
|
||||
Assert.Equal(120, bucket.ApiLatencyMs.Max);
|
||||
Assert.Equal(1, bucket.MaxWorkingSetMb!.SampleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compute_passes_projection_and_tenant_filter_to_repo_and_echoes_tenant()
|
||||
{
|
||||
var (service, repo) = CreateService();
|
||||
string? capturedTenant = null;
|
||||
IEnumerable<string>? capturedSelect = null;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?>((t, _, _, _, _, sel) => { capturedTenant = t; capturedSelect = sel; })
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(new List<SessionSummary>(), null));
|
||||
|
||||
var result = await service.ComputeAsync(days: 45, limit: 100, tenantId: Tenant);
|
||||
|
||||
Assert.Equal(Tenant, capturedTenant);
|
||||
Assert.Same(AgentEfficiencyMetricsService.SessionIndexProjection, capturedSelect);
|
||||
Assert.Equal(Tenant, result.TenantId);
|
||||
Assert.Equal(45, result.WindowDays);
|
||||
Assert.Equal(100, result.SessionLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compute_caches_per_days_limit_tenant_key()
|
||||
{
|
||||
var (service, repo) = CreateService();
|
||||
int callCount = 0;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback(() => callCount++)
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(new List<SessionSummary>(), null));
|
||||
|
||||
var first = await service.ComputeAsync(days: 46, limit: 100);
|
||||
var second = await service.ComputeAsync(days: 46, limit: 100); // cache hit
|
||||
await service.ComputeAsync(days: 46, limit: 100, tenantId: Tenant); // miss — tenant-scoped
|
||||
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.False(first.FromCache);
|
||||
Assert.True(second.FromCache);
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins that the column-projected SessionsIndex scan driving
|
||||
/// <c>AgentEfficiencyMetricsService</c> maps every field the aggregation reads
|
||||
/// (SessionId, TenantId, DeviceName, AgentVersion, AvgApiLatencyMs, ApiRequestCount,
|
||||
/// StartedAt) identically to the full-row drain. Same fixture discipline as
|
||||
/// <see cref="SessionStatsProjectionEquivalenceTests"/>: the projected row carries the
|
||||
/// projection's keys ONLY, so a getter for an omitted column returns null exactly as it
|
||||
/// would against live storage — and dropping a load-bearing column from the production
|
||||
/// array fails here immediately.
|
||||
/// </summary>
|
||||
public class AgentEfficiencyProjectionEquivalenceTests
|
||||
{
|
||||
private const string TenantId = "00000000-0000-0000-0000-000000000abc";
|
||||
|
||||
private static readonly TableStorageService Sut =
|
||||
new(new Mock<TableServiceClient>().Object, NullLogger<TableStorageService>.Instance);
|
||||
|
||||
private static string IndexRowKey(DateTime startedAt, string sessionId)
|
||||
=> $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}";
|
||||
|
||||
private static TableEntity FullRow(string sessionId, DateTime startedAt)
|
||||
=> new TableEntity(TenantId, IndexRowKey(startedAt, sessionId))
|
||||
{
|
||||
["SessionId"] = sessionId,
|
||||
["Status"] = "Succeeded",
|
||||
["StartedAt"] = new DateTimeOffset(startedAt),
|
||||
["AgentVersion"] = "2.0.1400",
|
||||
["DeviceName"] = "PC-EFF",
|
||||
["AvgApiLatencyMs"] = 84.5,
|
||||
["ApiRequestCount"] = 321,
|
||||
// Representative noise the efficiency scan never reads.
|
||||
["SerialNumber"] = "SN-FULL",
|
||||
["Manufacturer"] = "Contoso",
|
||||
["Model"] = "Model-X",
|
||||
["OsName"] = "Windows 11",
|
||||
["GeoCountry"] = "DE",
|
||||
["EventCount"] = 123,
|
||||
["FailureSnapshotJson"] = "{\"big\":\"" + new string('x', 2000) + "\"}",
|
||||
};
|
||||
|
||||
private static TableEntity Project(TableEntity full)
|
||||
{
|
||||
var keep = new HashSet<string>(AgentEfficiencyMetricsService.SessionIndexProjection, StringComparer.Ordinal);
|
||||
var projected = new TableEntity(full.PartitionKey, full.RowKey);
|
||||
foreach (var kv in full)
|
||||
{
|
||||
if (kv.Key is "PartitionKey" or "RowKey" or "Timestamp" or "odata.etag") continue;
|
||||
if (keep.Contains(kv.Key)) projected[kv.Key] = kv.Value;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projected_row_maps_every_efficiency_relevant_field_identically()
|
||||
{
|
||||
var started = DateTime.UtcNow.AddHours(-2);
|
||||
var sid = Guid.NewGuid().ToString();
|
||||
var fullRow = FullRow(sid, started);
|
||||
|
||||
var full = Sut.MapIndexEntityToSessionSummary(fullRow);
|
||||
var projected = Sut.MapIndexEntityToSessionSummary(Project(fullRow));
|
||||
|
||||
Assert.Equal(sid, projected.SessionId);
|
||||
Assert.Equal(full.TenantId, projected.TenantId);
|
||||
Assert.Equal(full.DeviceName, projected.DeviceName);
|
||||
Assert.Equal(full.AgentVersion, projected.AgentVersion);
|
||||
Assert.Equal(full.AvgApiLatencyMs, projected.AvgApiLatencyMs);
|
||||
Assert.Equal(full.ApiRequestCount, projected.ApiRequestCount);
|
||||
// StartedAt feeds the repo's cross-tenant merge-sort and next-page cursor.
|
||||
Assert.Equal(full.StartedAt, projected.StartedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_contains_the_repo_cursor_columns()
|
||||
{
|
||||
// FetchAllSessionsPageInternalAsync merge-sorts on StartedAt and rebuilds the cursor
|
||||
// from StartedAt + SessionId; a projection without them silently breaks pagination.
|
||||
Assert.Contains("StartedAt", AgentEfficiencyMetricsService.SessionIndexProjection);
|
||||
Assert.Contains("SessionId", AgentEfficiencyMetricsService.SessionIndexProjection);
|
||||
Assert.Contains("PartitionKey", AgentEfficiencyMetricsService.SessionIndexProjection);
|
||||
Assert.Contains("RowKey", AgentEfficiencyMetricsService.SessionIndexProjection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// DO ingest fallback (IME ≥ 1.104 removed the [DO TEL] log line; the agent's DO collector
|
||||
/// only emits <c>do_telemetry</c> for downloads whose completion a poll itself observed).
|
||||
/// <c>download_progress</c> events carry the full <c>do*</c> set on every poll and must fold
|
||||
/// into the summary's Do* columns so interrupted / between-poll-completed downloads still
|
||||
/// reach the Delivery Optimization aggregates:
|
||||
/// - progress-only sessions populate Do* incl. a valid DoDownloadMode (DoAggregator gate),
|
||||
/// - a later do_telemetry stays authoritative,
|
||||
/// - replayed progress events are idempotent (Math.Max),
|
||||
/// - DownloadBytes prefers transferred bytes over file size.
|
||||
/// </summary>
|
||||
public class AppInstallDoFieldsFallbackTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 8, 19, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static Dictionary<string, AppInstallAggregationState> Aggregate(params EnrollmentEvent[] events)
|
||||
{
|
||||
var summaries = new Dictionary<string, AppInstallAggregationState>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var evt in events)
|
||||
EventIngestProcessor.AggregateAppInstallEvent(evt, "tenant", "session", summaries);
|
||||
return summaries;
|
||||
}
|
||||
|
||||
private static EnrollmentEvent DownloadProgress(
|
||||
long bytesDownloaded, long doTotal, long doPeers, long doHttp, int doMode,
|
||||
long doFileSize = 0, int doPct = 0, long doCacheServer = 0, DateTime? ts = null)
|
||||
=> new EnrollmentEvent
|
||||
{
|
||||
EventType = "download_progress",
|
||||
Timestamp = ts ?? T0,
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["appName"] = "App",
|
||||
["bytesDownloaded"] = bytesDownloaded,
|
||||
["doFileSize"] = doFileSize,
|
||||
["doTotalBytesDownloaded"] = doTotal,
|
||||
["doBytesFromPeers"] = doPeers,
|
||||
["doBytesFromHttp"] = doHttp,
|
||||
["doPercentPeerCaching"] = doPct,
|
||||
["doDownloadMode"] = doMode,
|
||||
["doBytesFromCacheServer"] = doCacheServer,
|
||||
},
|
||||
};
|
||||
|
||||
private static EnrollmentEvent DoTelemetry(
|
||||
long doFileSize, long doTotal, long doPeers, long doHttp, int doMode,
|
||||
string? duration = null)
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["appName"] = "App",
|
||||
["doFileSize"] = doFileSize,
|
||||
["doTotalBytesDownloaded"] = doTotal,
|
||||
["doBytesFromPeers"] = doPeers,
|
||||
["doBytesFromHttp"] = doHttp,
|
||||
["doPercentPeerCaching"] = 50,
|
||||
["doDownloadMode"] = doMode,
|
||||
};
|
||||
if (duration != null) data["doDownloadDuration"] = duration;
|
||||
return new EnrollmentEvent { EventType = "do_telemetry", Timestamp = T0, Data = data };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressOnly_PopulatesDoColumns_AndSurvivesDoAggregator()
|
||||
{
|
||||
// An interrupted download: progress events observed, do_telemetry never fired.
|
||||
var s = Aggregate(
|
||||
DownloadProgress(bytesDownloaded: 1000, doTotal: 1000, doPeers: 600, doHttp: 400, doMode: 1, doFileSize: 5000, doPct: 60),
|
||||
DownloadProgress(bytesDownloaded: 3000, doTotal: 3000, doPeers: 2000, doHttp: 1000, doMode: 1, doFileSize: 5000, doPct: 66)
|
||||
)["App"].Summary;
|
||||
|
||||
Assert.Equal(3000, s.DoTotalBytesDownloaded);
|
||||
Assert.Equal(2000, s.DoBytesFromPeers);
|
||||
Assert.Equal(1000, s.DoBytesFromHttp);
|
||||
Assert.Equal(5000, s.DoFileSize);
|
||||
Assert.Equal(66, s.DoPercentPeerCaching);
|
||||
Assert.Equal(1, s.DoDownloadMode);
|
||||
Assert.Equal(3000, s.DownloadBytes); // from bytesDownloaded, not the file size
|
||||
|
||||
// The whole point: DoDownloadMode >= 0 keeps the row inside the DO aggregate.
|
||||
var aggregate = DoAggregator.Compute(new[] { s });
|
||||
Assert.Equal(1, aggregate.DoAppCount);
|
||||
Assert.Equal(2000, aggregate.BytesFromPeers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgressReplay_IsIdempotent_MaxFoldNeverRegresses()
|
||||
{
|
||||
// Out-of-order / replayed batch: the older (smaller) progress arrives again last.
|
||||
var s = Aggregate(
|
||||
DownloadProgress(bytesDownloaded: 3000, doTotal: 3000, doPeers: 2000, doHttp: 1000, doMode: 1),
|
||||
DownloadProgress(bytesDownloaded: 1000, doTotal: 1000, doPeers: 600, doHttp: 400, doMode: 1)
|
||||
)["App"].Summary;
|
||||
|
||||
Assert.Equal(3000, s.DoTotalBytesDownloaded);
|
||||
Assert.Equal(2000, s.DoBytesFromPeers);
|
||||
Assert.Equal(1000, s.DoBytesFromHttp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoTelemetry_StaysAuthoritative_OverEarlierProgress()
|
||||
{
|
||||
// Progress saw a partial state; the final telemetry read has the complete numbers
|
||||
// (which can be LOWER per bucket than a transient progress max — telemetry wins).
|
||||
var s = Aggregate(
|
||||
DownloadProgress(bytesDownloaded: 4000, doTotal: 4000, doPeers: 3500, doHttp: 500, doMode: 1, doPct: 87),
|
||||
DoTelemetry(doFileSize: 5000, doTotal: 5000, doPeers: 3000, doHttp: 2000, doMode: 1, duration: "00:01:40")
|
||||
)["App"].Summary;
|
||||
|
||||
Assert.Equal(5000, s.DoTotalBytesDownloaded);
|
||||
Assert.Equal(3000, s.DoBytesFromPeers); // telemetry overwrote the higher progress value
|
||||
Assert.Equal(2000, s.DoBytesFromHttp);
|
||||
Assert.Equal(50, s.DoPercentPeerCaching);
|
||||
Assert.Equal("00:01:40", s.DoDownloadDuration);
|
||||
Assert.Equal(100, s.DownloadDurationSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoTelemetry_WithUnsetMode_DoesNotRegressAKnownMode()
|
||||
{
|
||||
// A telemetry event whose DownloadMode property was absent parses as -1; it must not
|
||||
// re-hide the row from DoAggregator after progress already established the mode.
|
||||
var s = Aggregate(
|
||||
DownloadProgress(bytesDownloaded: 1000, doTotal: 1000, doPeers: 600, doHttp: 400, doMode: 3),
|
||||
DoTelemetry(doFileSize: 1000, doTotal: 1000, doPeers: 600, doHttp: 400, doMode: -1)
|
||||
)["App"].Summary;
|
||||
|
||||
Assert.Equal(3, s.DoDownloadMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadBytes_PrefersTransferredBytes_OverFileSize()
|
||||
{
|
||||
// Historic bug: DownloadBytes was max-ed with doFileSize, inflating the transfer
|
||||
// measure. With a real transfer total present the file size must not win.
|
||||
var s = Aggregate(
|
||||
DoTelemetry(doFileSize: 9000, doTotal: 5000, doPeers: 3000, doHttp: 2000, doMode: 1)
|
||||
)["App"].Summary;
|
||||
|
||||
Assert.Equal(5000, s.DownloadBytes);
|
||||
Assert.Equal(9000, s.DoFileSize); // the file size column itself keeps the real value
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DownloadBytes_FallsBackToFileSize_WhenNoTransferTotal()
|
||||
{
|
||||
var evt = new EnrollmentEvent
|
||||
{
|
||||
EventType = "do_telemetry",
|
||||
Timestamp = T0,
|
||||
Data = new Dictionary<string, object> { ["appName"] = "App", ["doFileSize"] = 7000L, ["doDownloadMode"] = 1 },
|
||||
};
|
||||
var s = Aggregate(evt)["App"].Summary;
|
||||
Assert.Equal(7000, s.DownloadBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Queues;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using AutopilotMonitor.Functions.Services.Ime;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Producer tests for the ime-msi-archive queue (daily review 2026-08-18: previously
|
||||
/// untested). Contract: the caller is the ingest path's fire-and-forget continuation, so
|
||||
/// NOTHING may throw — version-less envelopes are skipped, send failures are swallowed,
|
||||
/// and a failed CreateIfNotExists is retried on the next enqueue instead of latching.
|
||||
/// </summary>
|
||||
public class AzureQueueImeMsiArchiveProducerTests
|
||||
{
|
||||
private const string Version = "1.104.102.0";
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_sends_envelope_json_and_ensures_queue_once()
|
||||
{
|
||||
var (producer, queue, sentBodies) = BuildSut();
|
||||
|
||||
await producer.EnqueueAsync(Envelope());
|
||||
await producer.EnqueueAsync(Envelope());
|
||||
|
||||
Assert.Equal(2, sentBodies.Count);
|
||||
var roundTripped = JsonConvert.DeserializeObject<ImeMsiArchiveEnvelope>(sentBodies[0])!;
|
||||
Assert.Equal(Version, roundTripped.Version);
|
||||
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, roundTripped.MsiDownloadUrl);
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", roundTripped.TenantId);
|
||||
// Queue existence is ensured once, then latched — not re-checked per message.
|
||||
queue.Verify(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_skips_envelope_without_version()
|
||||
{
|
||||
var (producer, queue, sentBodies) = BuildSut();
|
||||
|
||||
await producer.EnqueueAsync(new ImeMsiArchiveEnvelope { Version = string.Empty });
|
||||
await producer.EnqueueAsync(null!);
|
||||
|
||||
Assert.Empty(sentBodies);
|
||||
queue.Verify(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_send_failure_is_swallowed()
|
||||
{
|
||||
var (producer, queue, _) = BuildSut();
|
||||
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "queue busy"));
|
||||
|
||||
// Fire-and-forget continuation on the ingest path — must never throw.
|
||||
await producer.EnqueueAsync(Envelope());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_retries_queue_creation_after_failure()
|
||||
{
|
||||
var (producer, queue, sentBodies) = BuildSut();
|
||||
queue.SetupSequence(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(500, "storage down"))
|
||||
.ReturnsAsync((Response?)null);
|
||||
|
||||
await producer.EnqueueAsync(Envelope()); // ensure fails — not latched
|
||||
await producer.EnqueueAsync(Envelope()); // ensure retried and succeeds
|
||||
|
||||
queue.Verify(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2));
|
||||
// Both messages were still attempted (fail-soft send after a failed ensure).
|
||||
Assert.Equal(2, sentBodies.Count);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static ImeMsiArchiveEnvelope Envelope() => new()
|
||||
{
|
||||
Version = Version,
|
||||
MsiDownloadUrl = ImeMsiArchiver.CanonicalMsiUrl,
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static (AzureQueueImeMsiArchiveProducer Producer, Mock<QueueClient> Queue, List<string> SentBodies) BuildSut()
|
||||
{
|
||||
var queue = new Mock<QueueClient>();
|
||||
var sentBodies = new List<string>();
|
||||
|
||||
queue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, CancellationToken>((body, _) =>
|
||||
{
|
||||
sentBodies.Add(body);
|
||||
var receipt = QueuesModelFactory.SendReceipt(
|
||||
"msg", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(7),
|
||||
"pop", DateTimeOffset.UtcNow);
|
||||
return Task.FromResult(Response.FromValue(receipt, new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
var producer = new AzureQueueImeMsiArchiveProducer(
|
||||
queue.Object, NullLogger<AzureQueueImeMsiArchiveProducer>.Instance);
|
||||
return (producer, queue, sentBodies);
|
||||
}
|
||||
}
|
||||
@@ -247,4 +247,64 @@ public class CorporateIdentifierValidatorTests
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.IsTransient);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "Virtual Machine", Serial)]
|
||||
[InlineData("Microsoft Corporation", null, Serial)]
|
||||
[InlineData("Microsoft Corporation", "Virtual Machine", null)]
|
||||
[InlineData("", "Virtual Machine", Serial)]
|
||||
[InlineData("Microsoft Corporation", "", Serial)]
|
||||
[InlineData("Microsoft Corporation", "Virtual Machine", "")]
|
||||
[InlineData(" ", "Virtual Machine", Serial)]
|
||||
[InlineData("Microsoft Corporation", "Virtual Machine", " ")]
|
||||
public async Task ValidateAsync_missingHeaderComponent_isDefinitive_withoutGraphCall(
|
||||
string? manufacturer, string? model, string? serial)
|
||||
{
|
||||
// Agents that fail to read a hardware property send the header empty — that is a
|
||||
// definitive rejection (retries cannot conjure the value), and Graph must not be hit.
|
||||
var (sut, handler) = BuildSut(Json("""{"value":[]}"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, manufacturer, model, serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.Contains("not provided", result.ErrorMessage);
|
||||
Assert.Empty(handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_specialCharsInSerial_neverReachOdataFilter()
|
||||
{
|
||||
// The contains() narrowing uses the NORMALIZED (alphanumeric-only) serial, so an
|
||||
// OData-breaking quote in a raw serial can neither corrupt nor inject into the filter.
|
||||
const string hostileSerial = "O'Brien-123'&$top=9999";
|
||||
var (sut, handler) = BuildSut(Json("""{"value":[]}"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", hostileSerial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
var url = Uri.UnescapeDataString(Assert.Single(handler.Requests).RequestUri!.ToString());
|
||||
Assert.Contains("contains(importedDeviceIdentifier,'OBRIEN123TOP9999')", url);
|
||||
Assert.DoesNotContain("O'Brien", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_pageCapExceeded_isTransient_andNotCached()
|
||||
{
|
||||
// Stopping mid-list must NOT be classified "not found" — a cached false negative would
|
||||
// block a legitimately registered device for the negative-cache TTL.
|
||||
var (sut, handler) = BuildSut(Json("""
|
||||
{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"DELL,XPS,OTHER1"}],
|
||||
"@odata.nextLink":"https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities?$skiptoken=more"}
|
||||
"""));
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.IsTransient);
|
||||
Assert.Contains("page budget", result.ErrorMessage);
|
||||
// Filtered scan pages up to its cap of 5, and the in-call retry runs a second sweep —
|
||||
// proof the transient path went through the retry loop instead of caching.
|
||||
Assert.Equal(10, handler.Requests.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the OData filter composition behind <c>GetSessionEventsByTypesAsync</c>:
|
||||
/// partition scope AND-ed with a parenthesized or-chain, values escaped. The grouping
|
||||
/// parentheses are load-bearing — without them the or-branches escape the partition
|
||||
/// scope and the query degrades to a cross-partition table scan.
|
||||
/// </summary>
|
||||
public class EventTypesFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Single_type_builds_scoped_equality()
|
||||
{
|
||||
var filter = TableStorageService.BuildEventTypesFilter("tenant_session", new[] { "agent_metrics_snapshot" });
|
||||
Assert.Equal("PartitionKey eq 'tenant_session' and (EventType eq 'agent_metrics_snapshot')", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_types_or_chain_stays_inside_the_partition_scope()
|
||||
{
|
||||
var filter = TableStorageService.BuildEventTypesFilter(
|
||||
"tenant_session", new[] { "agent_metrics_snapshot", "agent_started", "spool_pressure_detected" });
|
||||
Assert.Equal(
|
||||
"PartitionKey eq 'tenant_session' and (EventType eq 'agent_metrics_snapshot' or EventType eq 'agent_started' or EventType eq 'spool_pressure_detected')",
|
||||
filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Values_with_single_quotes_are_escaped()
|
||||
{
|
||||
var filter = TableStorageService.BuildEventTypesFilter("t'x", new[] { "a'b" });
|
||||
Assert.Equal("PartitionKey eq 't''x' and (EventType eq 'a''b')", filter);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Queues;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Ime;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Worker-level tests for the ime-msi-archive queue consumer (daily review 2026-08-18:
|
||||
/// previously untested). The worker's contract: merge the archiver's outcome onto the
|
||||
/// ImeVersionHistory row on EVERY attempt (also failures, so operators see a stuck
|
||||
/// download), delete the message for terminal outcomes, rethrow for retryable ones
|
||||
/// (visibility-timeout ladder), pause — not drop — while ImeMsiArchivingEnabled is off,
|
||||
/// and drop version-less envelopes without invoking the archiver.
|
||||
/// </summary>
|
||||
public class ImeMsiArchiveQueueWorkerTests
|
||||
{
|
||||
private const string Version = "1.104.102.0";
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_success_merges_row_and_deletes_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
true, ImeMsiArchiver.Statuses.Archived, Retryable: false,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.CanonicalMsiUrl);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.Archived,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.CanonicalMsiUrl),
|
||||
Times.Once);
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_retryable_failure_merges_row_but_leaves_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedDownload, Retryable: true,
|
||||
null, null, null, ImeMsiArchiver.CanonicalMsiUrl);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.Archiver.Calls > 0);
|
||||
|
||||
// Row always tells the truth — the failure status IS merged...
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedDownload,
|
||||
null, null, null, ImeMsiArchiver.CanonicalMsiUrl),
|
||||
Times.AtLeastOnce);
|
||||
// ...but the message stays for the visibility-timeout retry → poison ladder.
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_permanent_failure_merges_row_and_completes_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedTooLarge, Retryable: false,
|
||||
null, null, 999L, ImeMsiArchiver.CanonicalMsiUrl);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedTooLarge,
|
||||
null, null, 999L, ImeMsiArchiver.CanonicalMsiUrl),
|
||||
Times.Once);
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_pauses_while_archiving_disabled()
|
||||
{
|
||||
var harness = new Harness(archivingEnabled: false);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Pause, not drop: no receive, no archive attempt — the message parks in the queue.
|
||||
harness.MainQueue.Verify(q => q.ReceiveMessagesAsync(
|
||||
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
Assert.Equal(0, harness.Archiver.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_drops_envelope_without_version_before_archiver()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(new ImeMsiArchiveEnvelope
|
||||
{
|
||||
Version = string.Empty,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
}));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
Assert.Equal(0, harness.Archiver.Calls);
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
|
||||
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static ImeMsiArchiveEnvelope Envelope() => new()
|
||||
{
|
||||
Version = Version,
|
||||
MsiDownloadUrl = ImeMsiArchiver.CanonicalMsiUrl,
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>Scripted archiver — the worker only consumes the classified result.</summary>
|
||||
private sealed class StubArchiver : ImeMsiArchiver
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public ImeMsiArchiveResult Result { get; set; } = new(
|
||||
true, Statuses.Archived, Retryable: false, null, null, null, null);
|
||||
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public override Task<ImeMsiArchiveResult> ArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mock-queue poll-loop harness, same shape as SessionDeletionWorkerTests.</summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<QueueClient> MainQueue { get; }
|
||||
public Mock<QueueClient> PoisonQueue { get; }
|
||||
public StubArchiver Archiver { get; } = new();
|
||||
public Mock<ISessionRepository> Repo { get; } = new();
|
||||
public ImeMsiArchiveQueueWorker Sut { get; }
|
||||
|
||||
private readonly Queue<QueueMessage> _pendingMessages = new();
|
||||
|
||||
public Harness(bool archivingEnabled = true)
|
||||
{
|
||||
MainQueue = new Mock<QueueClient>();
|
||||
PoisonQueue = new Mock<QueueClient>();
|
||||
|
||||
MainQueue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
PoisonQueue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
|
||||
MainQueue.Setup(q => q.ReceiveMessagesAsync(
|
||||
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<int, TimeSpan?, CancellationToken>((maxMessages, _, _) =>
|
||||
{
|
||||
var batch = new List<QueueMessage>();
|
||||
lock (_pendingMessages)
|
||||
{
|
||||
while (batch.Count < maxMessages && _pendingMessages.Count > 0)
|
||||
batch.Add(_pendingMessages.Dequeue());
|
||||
}
|
||||
return Task.FromResult(Response.FromValue(batch.ToArray(), new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
MainQueue.Setup(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Mock<Response>().Object);
|
||||
|
||||
MainQueue.Setup(q => q.UpdateMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, string, TimeSpan, CancellationToken>((_, _, _, vis, _) =>
|
||||
{
|
||||
var updated = QueuesModelFactory.UpdateReceipt(
|
||||
"pop-extended-" + Guid.NewGuid().ToString("N"),
|
||||
DateTimeOffset.UtcNow.Add(vis));
|
||||
return Task.FromResult(Response.FromValue(updated, new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { ImeMsiArchivingEnabled = archivingEnabled });
|
||||
|
||||
Sut = new ImeMsiArchiveQueueWorker(
|
||||
MainQueue.Object, PoisonQueue.Object,
|
||||
Archiver, Repo.Object, adminConfig.Object,
|
||||
NullLogger<ImeMsiArchiveQueueWorker>.Instance,
|
||||
pollInterval: TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
public void EnqueueMessage(string body)
|
||||
{
|
||||
var msg = QueuesModelFactory.QueueMessage(
|
||||
messageId: "msg-" + Guid.NewGuid().ToString("N"),
|
||||
popReceipt: "pop-" + Guid.NewGuid().ToString("N"),
|
||||
body: new BinaryData(body),
|
||||
dequeueCount: 1);
|
||||
lock (_pendingMessages) _pendingMessages.Enqueue(msg);
|
||||
}
|
||||
|
||||
public async Task RunForAsync(TimeSpan duration)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(duration);
|
||||
try { await Sut.StartAsync(cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
try { await Task.Delay(duration, cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
try { await Sut.StopAsync(CancellationToken.None); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public async Task RunUntilAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
|
||||
using var cts = new CancellationTokenSource();
|
||||
try { await Sut.StartAsync(cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(25);
|
||||
cts.Cancel();
|
||||
try { await Sut.StopAsync(CancellationToken.None); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public bool MainQueueDeleted() =>
|
||||
MainQueue.Invocations.Any(i => i.Method.Name == nameof(QueueClient.DeleteMessageAsync));
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,7 @@ public class ImeMsiArchiverTests
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
UploadException = new RequestFailedException(409, "BlobAlreadyExists"),
|
||||
ProvenanceExists = true,
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
@@ -202,6 +203,101 @@ public class ImeMsiArchiverTests
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", result.BlobPath);
|
||||
// Sidecar already there — the re-delivery must not rewrite it.
|
||||
Assert.Equal(0, archiver.ProvenanceCalls);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Provenance-write gap (daily review 2026-08-18): MSI upload is write-once,
|
||||
// so a first attempt that dies between the MSI and the provenance upload
|
||||
// funnels every retry into the 409 path — which must heal the sidecar.
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_ProvenanceUploadFails_RetryableError()
|
||||
{
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
ProvenanceException = new RequestFailedException(500, "storage hiccup"),
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedError, result.Status);
|
||||
Assert.True(result.Retryable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_RedeliveryWithMissingProvenance_HealsSidecarFromArchivedBytes()
|
||||
{
|
||||
// The archived blob's content deliberately differs from this attempt's re-download —
|
||||
// the healed provenance must describe the ARCHIVED bytes, not the fresh download.
|
||||
var archivedPayload = new byte[1024];
|
||||
new Random(7).NextBytes(archivedPayload);
|
||||
var expectedSha = Convert.ToHexString(SHA256.HashData(archivedPayload)).ToLowerInvariant();
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
UploadException = new RequestFailedException(409, "BlobAlreadyExists"),
|
||||
ProvenanceExists = false,
|
||||
ArchivedPayload = archivedPayload,
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
|
||||
Assert.False(result.Retryable);
|
||||
Assert.Equal(expectedSha, result.Sha256);
|
||||
Assert.Equal(archivedPayload.Length, result.SizeBytes);
|
||||
Assert.Equal(1, archiver.ProvenanceCalls);
|
||||
Assert.Equal($"{Version}/provenance.json", archiver.ProvenanceLastPath);
|
||||
Assert.Contains(expectedSha, archiver.ProvenanceLastJson);
|
||||
Assert.Contains(Version, archiver.ProvenanceLastJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_ProvenanceFailure_ThenRedelivery_EndsHealed()
|
||||
{
|
||||
// THE regression scenario end to end on one instance: attempt 1 uploads the MSI but
|
||||
// the provenance write dies; attempt 2 (queue re-delivery) gets 409 on the write-once
|
||||
// MSI and must still produce the sidecar.
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
ProvenanceException = new RequestFailedException(500, "storage hiccup"),
|
||||
};
|
||||
var first = await archiver.ArchiveAsync(Envelope());
|
||||
Assert.False(first.Success);
|
||||
Assert.True(first.Retryable);
|
||||
|
||||
archiver.ProvenanceException = null;
|
||||
archiver.UploadException = new RequestFailedException(409, "BlobAlreadyExists");
|
||||
archiver.ProvenanceExists = false; // attempt 1 never landed the sidecar
|
||||
var second = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.True(second.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.Archived, second.Status);
|
||||
Assert.Equal($"{Version}/provenance.json", archiver.ProvenanceLastPath);
|
||||
Assert.NotNull(second.Sha256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ArchiveAsync_HealFails_RetryableError()
|
||||
{
|
||||
// Heal errors ride the normal retry ladder — the next re-delivery tries again.
|
||||
var archiver = new RecordingArchiver
|
||||
{
|
||||
UploadException = new RequestFailedException(409, "BlobAlreadyExists"),
|
||||
ProvenanceExists = false,
|
||||
ArchivedOpenException = new RequestFailedException(500, "read failed"),
|
||||
};
|
||||
|
||||
var result = await archiver.ArchiveAsync(Envelope());
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(ImeMsiArchiver.Statuses.FailedError, result.Status);
|
||||
Assert.True(result.Retryable);
|
||||
Assert.Equal(0, archiver.ProvenanceCalls);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -288,6 +384,10 @@ public class ImeMsiArchiverTests
|
||||
public int MaxDownloadSizeMB { get; set; } = 250;
|
||||
public Exception? DownloadException { get; set; }
|
||||
public Exception? UploadException { get; set; }
|
||||
public Exception? ProvenanceException { get; set; }
|
||||
public bool ProvenanceExists { get; set; } = true;
|
||||
public byte[]? ArchivedPayload { get; set; }
|
||||
public Exception? ArchivedOpenException { get; set; }
|
||||
|
||||
public int DownloadCalls { get; private set; }
|
||||
public string? DownloadLastUrl { get; private set; }
|
||||
@@ -326,10 +426,20 @@ public class ImeMsiArchiverTests
|
||||
|
||||
protected override Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ProvenanceException is not null) throw ProvenanceException;
|
||||
ProvenanceCalls++;
|
||||
ProvenanceLastPath = blobPath;
|
||||
ProvenanceLastJson = json;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected override Task<bool> ProvenanceExistsAsync(string blobPath, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(ProvenanceExists);
|
||||
|
||||
protected override Task<Stream> OpenArchivedMsiAsync(string blobPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ArchivedOpenException is not null) throw ArchivedOpenException;
|
||||
return Task.FromResult<Stream>(new MemoryStream(ArchivedPayload ?? Payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies PlatformMetricsService passes the requested window + session limit
|
||||
/// through to GetAllSessionsPageAsync and surfaces both on the response.
|
||||
/// through to GetAllSessionsPageAsync and surfaces both on the response, that
|
||||
/// SessionsScanned reports the honest scan count (not the has-snapshots subset),
|
||||
/// and that the split fetch (full events for the 20 latency-sample sessions,
|
||||
/// filtered three-type fetch for the rest) preserves the aggregation semantics.
|
||||
/// </summary>
|
||||
public class PlatformMetricsServiceWindowTests
|
||||
{
|
||||
@@ -20,16 +23,36 @@ public class PlatformMetricsServiceWindowTests
|
||||
{
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.ReturnsAsync(EmptyPage());
|
||||
sessionRepo
|
||||
.Setup(r => r.GetSessionEventsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(new List<EnrollmentEvent>());
|
||||
sessionRepo
|
||||
.Setup(r => r.GetSessionEventsByTypesAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(new List<EnrollmentEvent>());
|
||||
|
||||
var service = new PlatformMetricsService(sessionRepo.Object, NullLogger<PlatformMetricsService>.Instance);
|
||||
return (service, sessionRepo);
|
||||
}
|
||||
|
||||
private static SessionSummary MakeSession(int i) => new SessionSummary
|
||||
{
|
||||
SessionId = $"00000000-0000-0000-0000-{i:D12}",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
AgentVersion = "2.0.1400",
|
||||
StartedAt = DateTime.UtcNow.AddHours(-i),
|
||||
Status = SessionStatus.Succeeded
|
||||
};
|
||||
|
||||
private static EnrollmentEvent MakeEvent(string eventType, Dictionary<string, object>? data = null, long sequence = 1) => new EnrollmentEvent
|
||||
{
|
||||
EventType = eventType,
|
||||
Data = data ?? new Dictionary<string, object>(),
|
||||
Sequence = sequence,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(11)]
|
||||
[InlineData(13)]
|
||||
@@ -41,8 +64,8 @@ public class PlatformMetricsServiceWindowTests
|
||||
|
||||
int? capturedDays = null;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?>((_, d, _, _, _) => capturedDays = d)
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?>((_, d, _, _, _, _) => capturedDays = d)
|
||||
.ReturnsAsync(EmptyPage());
|
||||
|
||||
var result = await service.ComputePlatformMetricsAsync(days, limit: 50);
|
||||
@@ -66,8 +89,8 @@ public class PlatformMetricsServiceWindowTests
|
||||
|
||||
int? capturedPageSize = null;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?>((_, _, ps, _, _) => capturedPageSize = ps)
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?>((_, _, ps, _, _, _) => capturedPageSize = ps)
|
||||
.ReturnsAsync(EmptyPage());
|
||||
|
||||
var result = await service.ComputePlatformMetricsAsync(days: days, limit: limit);
|
||||
@@ -83,8 +106,8 @@ public class PlatformMetricsServiceWindowTests
|
||||
|
||||
int? capturedDays = null;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?>((_, d, _, _, _) => capturedDays = d)
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback<string?, int?, int, string?, IReadOnlyCollection<string>?, IEnumerable<string>?>((_, d, _, _, _, _) => capturedDays = d)
|
||||
.ReturnsAsync(EmptyPage());
|
||||
|
||||
var result = await service.ComputePlatformMetricsAsync(0);
|
||||
@@ -117,7 +140,7 @@ public class PlatformMetricsServiceWindowTests
|
||||
var (service, repo) = CreateService();
|
||||
int callCount = 0;
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>()))
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.Callback(() => callCount++)
|
||||
.ReturnsAsync(EmptyPage());
|
||||
|
||||
@@ -128,4 +151,96 @@ public class PlatformMetricsServiceWindowTests
|
||||
|
||||
Assert.Equal(3, callCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputePlatformMetrics_reports_sessionsScanned_even_when_no_session_has_snapshots()
|
||||
{
|
||||
// The truncated check must be based on scanned sessions, not the has-snapshots
|
||||
// subset — a window full of snapshot-less sessions previously read as "not truncated".
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = Enumerable.Range(1, 7).Select(MakeSession).ToList();
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(sessions, null));
|
||||
|
||||
var result = await service.ComputePlatformMetricsAsync(days: 77, limit: 50);
|
||||
|
||||
Assert.Equal(7, result.SessionsScanned);
|
||||
Assert.Empty(result.Sessions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputePlatformMetrics_uses_full_fetch_for_latency_sample_and_filtered_fetch_for_rest()
|
||||
{
|
||||
// First 20 sessions form the delivery-latency sample (deltas over ALL events);
|
||||
// every further session only needs the three metrics event types.
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = Enumerable.Range(1, 25).Select(MakeSession).ToList();
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(sessions, null));
|
||||
|
||||
var fullFetchSessions = new List<string>();
|
||||
var filteredFetchSessions = new List<string>();
|
||||
IReadOnlyCollection<string>? capturedEventTypes = null;
|
||||
repo
|
||||
.Setup(r => r.GetSessionEventsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
|
||||
.Callback<string, string, int>((_, sid, _) => { lock (fullFetchSessions) fullFetchSessions.Add(sid); })
|
||||
.ReturnsAsync(new List<EnrollmentEvent>());
|
||||
repo
|
||||
.Setup(r => r.GetSessionEventsByTypesAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<int>()))
|
||||
.Callback<string, string, IReadOnlyCollection<string>, IEnumerable<string>?, int>((_, sid, types, _, _) =>
|
||||
{
|
||||
lock (filteredFetchSessions) { filteredFetchSessions.Add(sid); capturedEventTypes = types; }
|
||||
})
|
||||
.ReturnsAsync(new List<EnrollmentEvent>());
|
||||
|
||||
await service.ComputePlatformMetricsAsync(days: 78, limit: 50);
|
||||
|
||||
Assert.Equal(20, fullFetchSessions.Count);
|
||||
Assert.Equal(5, filteredFetchSessions.Count);
|
||||
Assert.Equal(sessions.Take(20).Select(s => s.SessionId).ToHashSet(), fullFetchSessions.ToHashSet());
|
||||
Assert.NotNull(capturedEventTypes);
|
||||
Assert.Contains("agent_metrics_snapshot", capturedEventTypes!);
|
||||
Assert.Contains("agent_started", capturedEventTypes!);
|
||||
// Without spool_pressure_detected in the filter the SpoolPressureDetected flag
|
||||
// silently dies on the filtered path.
|
||||
Assert.Contains("spool_pressure_detected", capturedEventTypes!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputePlatformMetrics_spool_pressure_and_crash_survive_the_filtered_path()
|
||||
{
|
||||
// A session outside the latency sample gets its events via the filtered fetch;
|
||||
// the spool-pressure flag and crash tally must still work on that stream.
|
||||
var (service, repo) = CreateService();
|
||||
var sessions = Enumerable.Range(1, 21).Select(MakeSession).ToList();
|
||||
repo
|
||||
.Setup(r => r.GetAllSessionsPageAsync(It.IsAny<string?>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<IEnumerable<string>?>()))
|
||||
.ReturnsAsync(new RawPage<SessionSummary>(sessions, null));
|
||||
|
||||
var session21 = sessions[20].SessionId;
|
||||
repo
|
||||
.Setup(r => r.GetSessionEventsByTypesAsync(It.IsAny<string>(), session21, It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<IEnumerable<string>?>(), It.IsAny<int>()))
|
||||
.ReturnsAsync(new List<EnrollmentEvent>
|
||||
{
|
||||
MakeEvent("agent_started", new Dictionary<string, object> { ["previousExitType"] = "exception_crash", ["previousCrashException"] = "System.NullReferenceException" }, sequence: 1),
|
||||
MakeEvent("agent_metrics_snapshot", new Dictionary<string, object>
|
||||
{
|
||||
["agent_cpu_percent"] = 3.5,
|
||||
["agent_working_set_mb"] = 90.0,
|
||||
["spool_pending_item_count"] = 12.0
|
||||
}, sequence: 2),
|
||||
MakeEvent("spool_pressure_detected", sequence: 3)
|
||||
});
|
||||
|
||||
var result = await service.ComputePlatformMetricsAsync(days: 79, limit: 50);
|
||||
|
||||
var metric = Assert.Single(result.Sessions);
|
||||
Assert.Equal(session21, metric.SessionId);
|
||||
Assert.True(metric.SpoolPressureDetected);
|
||||
Assert.Equal(1, result.CrashRate!.ExceptionCrashes);
|
||||
Assert.Equal("System.NullReferenceException", Assert.Single(result.CrashRate.TopExceptions).ExceptionType);
|
||||
Assert.Equal(21, result.SessionsScanned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1023,6 +1023,7 @@ public class PolicyEnforcementMiddlewareTests
|
||||
// the GlobalReadOrDelegatedSubset tier — see GlobalSessions_IsBoundedSubsetTier below.)
|
||||
[InlineData("GET", "/api/global/audit/logs", TenantScoping.QueryParam)]
|
||||
[InlineData("GET", "/api/global/metrics/app", TenantScoping.QueryParam)]
|
||||
[InlineData("GET", "/api/global/metrics/agent-efficiency", TenantScoping.QueryParam)]
|
||||
[InlineData("GET", "/api/global/metrics/fleet-health", TenantScoping.QueryParam)]
|
||||
[InlineData("GET", "/api/global/metrics/summary", TenantScoping.QueryParam)]
|
||||
[InlineData("GET", "/api/global/search/sessions", TenantScoping.QueryParam)]
|
||||
|
||||
@@ -197,6 +197,53 @@ public class TimeAttributionCalculatorTests
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.PartialObservation));
|
||||
}
|
||||
|
||||
// ── prior-enrollment residue ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void HistoricImeReplay_SetsPriorEnrollmentResidue_AndExcludesFromFleetStats()
|
||||
{
|
||||
// Re-enrollment without a wipe (session f475e697): the surviving IME log pulls the
|
||||
// phase anchors ahead of the real ESP page — the segments must be flagged as unfit.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddSeconds(2), "historic_ime_replay_detected"),
|
||||
Evt(T0.AddMinutes(1), "esp_phase_changed", EnrollmentPhase.AppsDevice),
|
||||
Evt(T0.AddMinutes(10), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.True(b.QualityFlags.HasFlag(TimeAttributionFlags.PriorEnrollmentResidue));
|
||||
Assert.True(TimeAttributionFlagQuality.ExcludesFromFleetStats(b.QualityFlags));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegistryAppBaseline_Alone_DoesNotSetResidueFlag()
|
||||
{
|
||||
// A registry_app_baseline with successes is by-design normal for WDP (DPP Batch-1
|
||||
// apps install before the agent exists) — treating it as residue would starve the
|
||||
// device_preparation class's fleet aggregates. Only the IME-log replay signal counts.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
Evt(T0, "agent_started", EnrollmentPhase.DeviceSetup),
|
||||
Evt(T0.AddSeconds(2), "registry_app_baseline", data: new Dictionary<string, object>
|
||||
{
|
||||
["totalApps"] = "3", ["successCount"] = "3",
|
||||
["errorCount"] = "0", ["inProgressCount"] = "0",
|
||||
}),
|
||||
Evt(T0.AddMinutes(10), "enrollment_complete"),
|
||||
};
|
||||
|
||||
var b = TimeAttributionCalculator.Compute(
|
||||
Input(events, completedAt: T0.AddMinutes(10), durationSeconds: 600))!;
|
||||
|
||||
AssertExactPartition(b);
|
||||
Assert.False(b.QualityFlags.HasFlag(TimeAttributionFlags.PriorEnrollmentResidue));
|
||||
}
|
||||
|
||||
// ── WhiteGlove: two windows in ONE session, pause never attributed ──────
|
||||
|
||||
[Fact]
|
||||
|
||||
+5
-2
@@ -48,8 +48,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<RawPage<SessionSummary>> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation)
|
||||
=> _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation);
|
||||
|
||||
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation, IReadOnlyCollection<string>? allowedTenantIds = null)
|
||||
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation, allowedTenantIds);
|
||||
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation, IReadOnlyCollection<string>? allowedTenantIds = null, IEnumerable<string>? select = null)
|
||||
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation, allowedTenantIds, select);
|
||||
|
||||
public Task<SessionStats> GetSessionStatsAsync(string tenantId, int days)
|
||||
=> _storage.GetSessionStatsAsync(tenantId, days);
|
||||
@@ -201,6 +201,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<EnrollmentEvent>> GetSessionEventsByTypeAsync(string tenantId, string sessionId, string eventType, int maxResults = 200)
|
||||
=> _storage.GetSessionEventsByTypeAsync(tenantId, sessionId, eventType, maxResults);
|
||||
|
||||
public Task<List<EnrollmentEvent>> GetSessionEventsByTypesAsync(string tenantId, string sessionId, IReadOnlyCollection<string> eventTypes, IEnumerable<string>? select = null, int maxResults = 2000)
|
||||
=> _storage.GetSessionEventsByTypesAsync(tenantId, sessionId, eventTypes, select, maxResults);
|
||||
|
||||
public Task<RawPage<EnrollmentEvent>> GetSessionEventsPageAsync(string tenantId, string sessionId, int pageSize, string? continuation)
|
||||
=> _storage.GetSessionEventsPageAsync(tenantId, sessionId, pageSize, continuation);
|
||||
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent-efficiency aggregate (Global Admin / Global Reader): per-agent-version percentiles
|
||||
/// for CPU, memory, handles, threads, spool and request metrics plus crash rates — computed
|
||||
/// server-side so review tooling never has to pull raw snapshot rows. Honors <c>?tenantId=</c>
|
||||
/// to scope the aggregate to one tenant (TenantScoping.QueryParam in the policy catalog).
|
||||
/// </summary>
|
||||
public class GetGlobalAgentEfficiencyFunction
|
||||
{
|
||||
private readonly ILogger<GetGlobalAgentEfficiencyFunction> _logger;
|
||||
private readonly AgentEfficiencyMetricsService _efficiencyService;
|
||||
|
||||
public GetGlobalAgentEfficiencyFunction(
|
||||
ILogger<GetGlobalAgentEfficiencyFunction> logger,
|
||||
AgentEfficiencyMetricsService efficiencyService)
|
||||
{
|
||||
_logger = logger;
|
||||
_efficiencyService = efficiencyService;
|
||||
}
|
||||
|
||||
[Function("GetGlobalAgentEfficiency")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/agent-efficiency")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var days = ParseIntParam(query["days"], defaultValue: 30, min: 1, max: 365);
|
||||
var limit = ParseIntParam(query["limit"], defaultValue: 500, min: 1, max: 2000);
|
||||
|
||||
var tenantIdRaw = query["tenantId"];
|
||||
string? tenantId = null;
|
||||
if (!string.IsNullOrWhiteSpace(tenantIdRaw))
|
||||
{
|
||||
// The repo's per-tenant route throws on malformed GUIDs — turn that
|
||||
// caller bug into a 400 instead of a 500.
|
||||
if (!Guid.TryParse(tenantIdRaw, out var parsedTenant))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid tenantId" });
|
||||
return badRequest;
|
||||
}
|
||||
tenantId = parsedTenant.ToString();
|
||||
}
|
||||
|
||||
var metrics = await _efficiencyService.ComputeAsync(days, limit, tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(metrics);
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error computing agent efficiency metrics");
|
||||
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = "Failed to compute agent efficiency metrics"
|
||||
});
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseIntParam(string? raw, int defaultValue, int min, int max)
|
||||
{
|
||||
var value = defaultValue;
|
||||
if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0)
|
||||
value = parsed;
|
||||
if (value < min) value = min;
|
||||
if (value > max) value = max;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,9 @@ public sealed class TimeAttributionInput
|
||||
public static class TimeAttributionCalculator
|
||||
{
|
||||
/// <summary>Bump on any semantic change so aggregates never mix definitions (truthfulness rule 8).</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
// v2: PriorEnrollmentResidue flag — sessions whose phase anchors are distorted by
|
||||
// pre-enrollment state on disk are now flagged and excluded from fleet segment stats.
|
||||
public const int CurrentVersion = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Enrollment class for fleet aggregation — classes are NEVER mixed in one aggregate (a
|
||||
@@ -164,6 +166,15 @@ public static class TimeAttributionCalculator
|
||||
if (events.Any(e => e.EventType == Constants.EventTypes.AgentLateStart))
|
||||
flags |= TimeAttributionFlags.PartialObservation;
|
||||
|
||||
// Prior-enrollment residue (session f475e697): an IME log predating this enrollment
|
||||
// means the disk was not wiped — pre-installed apps complete as instant detections and
|
||||
// pull the phase anchors ahead of the real ESP page, so the segment durations lie.
|
||||
// historic_ime_replay_detected is the ONLY trigger on purpose: registry_app_baseline
|
||||
// with successes is by-design normal for WDP (DPP Batch-1 apps install before the
|
||||
// agent exists) and would starve that class's aggregates (see DurationCriticalFlags).
|
||||
if (events.Any(e => e.EventType == Constants.EventTypes.HistoricImeReplayDetected))
|
||||
flags |= TimeAttributionFlags.PriorEnrollmentResidue;
|
||||
|
||||
return new SessionTimeBreakdown
|
||||
{
|
||||
TenantId = input.TenantId,
|
||||
|
||||
@@ -242,6 +242,7 @@ builder.Services.AddSingleton<RateLimitService>();
|
||||
builder.Services.AddSingleton<DistressRateLimitService>();
|
||||
builder.Services.AddSingleton<UsageMetricsService>();
|
||||
builder.Services.AddSingleton<PlatformMetricsService>();
|
||||
builder.Services.AddSingleton<AgentEfficiencyMetricsService>();
|
||||
builder.Services.AddSingleton<SlaMetricsService>();
|
||||
builder.Services.AddSingleton<SlaBreachEvaluationService>();
|
||||
builder.Services.AddSingleton<GlobalAdminService>();
|
||||
|
||||
@@ -456,6 +456,7 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "global/presence", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/tenants-with-deletion-manifests", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/metrics/platform", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("GET", "global/metrics/agent-efficiency", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/app", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/fleet-health", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/metrics/time-attribution", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-side agent-efficiency aggregation, bucketed by agent version: CPU, working set,
|
||||
/// private bytes, thread/handle counts, spool depth/file size, API latency/request counts
|
||||
/// and crash rates — as percentiles per version, so review tooling (MCP) gets the answer
|
||||
/// directly instead of pulling thousands of raw snapshot rows. Raw per-session data only
|
||||
/// surfaces as the top offenders per dimension.
|
||||
/// Scan shape: one projected SessionsIndex page + one filtered, projected per-session
|
||||
/// event query (agent_metrics_snapshot / agent_started / spool_pressure_detected only),
|
||||
/// bounded to 32 concurrent storage calls. 5-minute per-(days, limit, tenant) cache.
|
||||
/// </summary>
|
||||
public class AgentEfficiencyMetricsService
|
||||
{
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
private readonly ILogger<AgentEfficiencyMetricsService> _logger;
|
||||
|
||||
private static readonly Dictionary<(int days, int limit, string tenantKey), (AgentEfficiencyMetricsResponse metrics, DateTime expiry)> _cachedByKey = new();
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
private static readonly object _cacheLock = new object();
|
||||
|
||||
private const int DefaultWindowDays = 30;
|
||||
private const int DefaultSessionLimit = 500;
|
||||
private const int MaxSessionLimit = 2000;
|
||||
private const int PerSessionFetchConcurrency = 32;
|
||||
private const int TopOffendersPerDimension = 3;
|
||||
|
||||
// SessionsIndex columns this scan consumes. PartitionKey (TenantId), RowKey, StartedAt and
|
||||
// SessionId also feed the repo's merge/cursor mechanics; AgentVersion buckets, DeviceName
|
||||
// labels the offender rows; AvgApiLatencyMs/ApiRequestCount are the mirrored per-session
|
||||
// network aggregates (SessionIndexFieldManifest) so latency percentiles need no event
|
||||
// fetch at all. Everything else on the ~40-column mirror maps to defaults via the
|
||||
// null-safe getters and is never read here. Pinned by
|
||||
// AgentEfficiencyProjectionEquivalenceTests.
|
||||
internal static readonly string[] SessionIndexProjection =
|
||||
{
|
||||
"PartitionKey", "RowKey", "SessionId", "StartedAt",
|
||||
"AgentVersion", "DeviceName", "AvgApiLatencyMs", "ApiRequestCount"
|
||||
};
|
||||
|
||||
public AgentEfficiencyMetricsService(
|
||||
ISessionRepository sessionRepo,
|
||||
ILogger<AgentEfficiencyMetricsService> logger)
|
||||
{
|
||||
_sessionRepo = sessionRepo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AgentEfficiencyMetricsResponse> ComputeAsync(
|
||||
int days = DefaultWindowDays,
|
||||
int limit = DefaultSessionLimit,
|
||||
string? tenantId = null)
|
||||
{
|
||||
days = Math.Clamp(days, 1, 365);
|
||||
limit = Math.Clamp(limit, 1, MaxSessionLimit);
|
||||
var key = (days, limit, tenantId ?? string.Empty);
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_cachedByKey.TryGetValue(key, out var entry) && DateTime.UtcNow < entry.expiry)
|
||||
{
|
||||
// Shallow copy so flipping FromCache never mutates the instance an earlier
|
||||
// (fresh) caller may still be holding/serializing. The buckets are shared
|
||||
// read-only — nothing downstream writes into a response.
|
||||
return entry.metrics.CloneAsCacheHit();
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Computing agent efficiency metrics for days={Days} limit={Limit} tenant={Tenant}...",
|
||||
days, limit, tenantId ?? "(all)");
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var metrics = await ComputeInternalAsync(days, limit, tenantId);
|
||||
|
||||
stopwatch.Stop();
|
||||
metrics.ComputeDurationMs = (int)stopwatch.ElapsedMilliseconds;
|
||||
metrics.ComputedAt = DateTime.UtcNow;
|
||||
metrics.FromCache = false;
|
||||
metrics.WindowDays = days;
|
||||
metrics.SessionLimit = limit;
|
||||
metrics.TenantId = tenantId;
|
||||
|
||||
_logger.LogInformation("Agent efficiency metrics computed in {Ms}ms (days={Days} limit={Limit} sessionsScanned={Scanned})",
|
||||
metrics.ComputeDurationMs, days, limit, metrics.SessionsScanned);
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cachedByKey[key] = (metrics, DateTime.UtcNow.Add(CacheDuration));
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// Per-session intermediate: everything the bucket aggregation needs from one
|
||||
// storage round-trip. Snapshot-derived values are meaningless when HasSnapshots
|
||||
// is false — the aggregation only reads them behind that flag.
|
||||
private sealed record SessionEfficiencyData(
|
||||
string SessionId,
|
||||
string TenantId,
|
||||
string? DeviceName,
|
||||
string ResolvedVersion,
|
||||
bool HasSnapshots,
|
||||
double AvgCpu,
|
||||
double MaxCpu,
|
||||
double MaxWorkingSetMb,
|
||||
double MaxPrivateBytesMb,
|
||||
double MaxThreads,
|
||||
double MaxHandles,
|
||||
double MaxSpoolDepth,
|
||||
double MaxSpoolFileBytes,
|
||||
double ApiLatencyMs,
|
||||
double ApiRequestCount,
|
||||
bool SpoolPressure,
|
||||
List<Dictionary<string, object>> AgentStartedEvents);
|
||||
|
||||
private async Task<AgentEfficiencyMetricsResponse> ComputeInternalAsync(int days, int limit, string? tenantId)
|
||||
{
|
||||
var sessionPage = await _sessionRepo.GetAllSessionsPageAsync(
|
||||
tenantIdFilter: tenantId, days: days, pageSize: limit, continuation: null,
|
||||
allowedTenantIds: null, select: SessionIndexProjection);
|
||||
var allSessions = sessionPage.Items;
|
||||
|
||||
if (allSessions.Count == 0)
|
||||
{
|
||||
return new AgentEfficiencyMetricsResponse();
|
||||
}
|
||||
|
||||
var perSession = await AgentMetricsAggregation.RunWithBoundedConcurrencyAsync(
|
||||
allSessions, PerSessionFetchConcurrency, ProcessSessionAsync);
|
||||
|
||||
var byVersion = perSession
|
||||
.GroupBy(s => s.ResolvedVersion, StringComparer.Ordinal)
|
||||
.Select(g => BuildBucket(g.Key, g.ToList()))
|
||||
.OrderByDescending(b => b.SessionsScanned)
|
||||
.ThenBy(b => b.AgentVersion, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new AgentEfficiencyMetricsResponse
|
||||
{
|
||||
SessionsScanned = allSessions.Count,
|
||||
SessionsWithSnapshots = perSession.Count(s => s.HasSnapshots),
|
||||
ByVersion = byVersion,
|
||||
Overall = BuildBucket(null, perSession)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<SessionEfficiencyData> ProcessSessionAsync(SessionSummary session)
|
||||
{
|
||||
List<Dictionary<string, object>> snapshots = new();
|
||||
List<Dictionary<string, object>> agentStarts = new();
|
||||
var spoolPressure = false;
|
||||
|
||||
try
|
||||
{
|
||||
var events = await _sessionRepo.GetSessionEventsByTypesAsync(
|
||||
session.TenantId, session.SessionId, AgentMetricsAggregation.MetricsEventTypes,
|
||||
TableStorageService.AgentMetricsEventProjection);
|
||||
|
||||
foreach (var e in events)
|
||||
{
|
||||
if (e.EventType == Shared.Constants.EventTypes.AgentMetricsSnapshot && e.Data != null)
|
||||
snapshots.Add(e.Data);
|
||||
else if (e.EventType == Shared.Constants.EventTypes.AgentStarted && e.Data != null)
|
||||
agentStarts.Add(e.Data);
|
||||
else if (e.EventType == Shared.Constants.EventTypes.SpoolPressureDetected)
|
||||
spoolPressure = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch efficiency events for session {SessionId}", session.SessionId);
|
||||
}
|
||||
|
||||
// Resolve the bucket version: snapshot payload first (authoritative for the run),
|
||||
// SessionsIndex mirror second, "unknown" last. All of a session's agent_started
|
||||
// events tally into this one bucket — a mid-session self-update attributes the
|
||||
// pre-update exits to the final version, an accepted simplification.
|
||||
var resolvedVersion = snapshots
|
||||
.Select(s => AgentMetricsAggregation.GetString(s, "agent_version"))
|
||||
.FirstOrDefault(v => !string.IsNullOrEmpty(v));
|
||||
if (string.IsNullOrEmpty(resolvedVersion))
|
||||
resolvedVersion = string.IsNullOrEmpty(session.AgentVersion) ? "unknown" : session.AgentVersion;
|
||||
|
||||
double avgCpu = 0, maxCpu = 0, maxWs = 0, maxPb = 0, maxThreads = 0, maxHandles = 0, maxSpool = 0, maxSpoolFile = 0;
|
||||
double apiLatency = session.AvgApiLatencyMs ?? 0;
|
||||
double apiRequests = session.ApiRequestCount ?? 0;
|
||||
|
||||
if (snapshots.Count > 0)
|
||||
{
|
||||
var cpuValues = snapshots.Select(s => AgentMetricsAggregation.GetDouble(s, "agent_cpu_percent")).ToList();
|
||||
avgCpu = cpuValues.Average();
|
||||
maxCpu = cpuValues.Max();
|
||||
maxWs = snapshots.Max(s => AgentMetricsAggregation.GetDouble(s, "agent_working_set_mb"));
|
||||
maxPb = snapshots.Max(s => AgentMetricsAggregation.GetDouble(s, "agent_private_bytes_mb"));
|
||||
maxThreads = snapshots.Max(s => AgentMetricsAggregation.GetDouble(s, "agent_thread_count"));
|
||||
maxHandles = snapshots.Max(s => AgentMetricsAggregation.GetDouble(s, "agent_handle_count"));
|
||||
// V2 emits `spool_pending_item_count`; V1 emitted `spool_queue_depth`.
|
||||
maxSpool = snapshots.Max(s => AgentMetricsAggregation.GetDoubleFirst(s, "spool_pending_item_count", "spool_queue_depth"));
|
||||
maxSpoolFile = snapshots.Max(s => AgentMetricsAggregation.GetDouble(s, "spool_file_size_bytes"));
|
||||
|
||||
// The SessionsIndex mirror carries the exact session average for agents that
|
||||
// emit the cumulative counters; fall back to computing it from the last
|
||||
// snapshot for sessions whose mirror write never happened.
|
||||
if (apiLatency <= 0)
|
||||
{
|
||||
var last = snapshots[snapshots.Count - 1];
|
||||
var totalLatencyMs = AgentMetricsAggregation.GetDouble(last, "net_total_latency_ms");
|
||||
var totalRequests = AgentMetricsAggregation.GetDouble(last, "net_total_requests");
|
||||
if (totalLatencyMs > 0 && totalRequests > 0)
|
||||
apiLatency = totalLatencyMs / totalRequests;
|
||||
if (apiRequests <= 0)
|
||||
apiRequests = totalRequests;
|
||||
}
|
||||
}
|
||||
|
||||
return new SessionEfficiencyData(
|
||||
session.SessionId, session.TenantId, session.DeviceName, resolvedVersion!,
|
||||
HasSnapshots: snapshots.Count > 0,
|
||||
avgCpu, maxCpu, maxWs, maxPb, maxThreads, maxHandles, maxSpool, maxSpoolFile,
|
||||
apiLatency, apiRequests, spoolPressure, agentStarts);
|
||||
}
|
||||
|
||||
// agentVersion == null builds the cross-version "overall" bucket (version omitted on the wire).
|
||||
private static AgentVersionEfficiency BuildBucket(string? agentVersion, List<SessionEfficiencyData> sessions)
|
||||
{
|
||||
var withSnapshots = sessions.Where(s => s.HasSnapshots).ToList();
|
||||
|
||||
var bucket = new AgentVersionEfficiency
|
||||
{
|
||||
AgentVersion = agentVersion,
|
||||
SessionsScanned = sessions.Count,
|
||||
SessionsWithSnapshots = withSnapshots.Count,
|
||||
SpoolPressureSessions = sessions.Count(s => s.SpoolPressure),
|
||||
AvgCpuPercent = BuildStats(withSnapshots.Select(s => s.AvgCpu)),
|
||||
MaxCpuPercent = BuildStats(withSnapshots.Select(s => s.MaxCpu)),
|
||||
MaxWorkingSetMb = BuildStats(withSnapshots.Select(s => s.MaxWorkingSetMb)),
|
||||
MaxPrivateBytesMb = BuildStats(withSnapshots.Select(s => s.MaxPrivateBytesMb)),
|
||||
// Thread/handle counts only exist on agents that emit them (V2); zero-filtered so
|
||||
// legacy sessions don't drag the percentiles to 0.
|
||||
MaxThreadCount = BuildStats(withSnapshots.Select(s => s.MaxThreads).Where(v => v > 0)),
|
||||
MaxHandleCount = BuildStats(withSnapshots.Select(s => s.MaxHandles).Where(v => v > 0)),
|
||||
MaxSpoolDepth = BuildStats(withSnapshots.Select(s => s.MaxSpoolDepth)),
|
||||
MaxSpoolFileBytes = BuildStats(withSnapshots.Select(s => s.MaxSpoolFileBytes)),
|
||||
// Latency/requests come from the SessionsIndex mirror, so sessions without
|
||||
// snapshots still contribute; zero means "no data", not "0 ms".
|
||||
ApiLatencyMs = BuildStats(sessions.Select(s => s.ApiLatencyMs).Where(v => v > 0)),
|
||||
ApiRequestCount = BuildStats(sessions.Select(s => s.ApiRequestCount).Where(v => v > 0)),
|
||||
CrashRate = AgentMetricsAggregation.AggregateCrashRate(sessions.SelectMany(s => s.AgentStartedEvents))
|
||||
};
|
||||
|
||||
var offenders = new List<EfficiencyOffender>();
|
||||
AddTopOffenders(offenders, withSnapshots, "maxCpuPercent", s => s.MaxCpu);
|
||||
AddTopOffenders(offenders, withSnapshots, "maxWorkingSetMb", s => s.MaxWorkingSetMb);
|
||||
AddTopOffenders(offenders, withSnapshots, "maxHandleCount", s => s.MaxHandles);
|
||||
bucket.TopOffenders = offenders.Count > 0 ? offenders : null;
|
||||
|
||||
return bucket;
|
||||
}
|
||||
|
||||
private static void AddTopOffenders(
|
||||
List<EfficiencyOffender> target,
|
||||
List<SessionEfficiencyData> sessions,
|
||||
string dimension,
|
||||
Func<SessionEfficiencyData, double> value)
|
||||
{
|
||||
target.AddRange(sessions
|
||||
.Where(s => value(s) > 0)
|
||||
.OrderByDescending(value)
|
||||
.Take(TopOffendersPerDimension)
|
||||
.Select(s => new EfficiencyOffender
|
||||
{
|
||||
SessionId = s.SessionId,
|
||||
TenantId = s.TenantId,
|
||||
DeviceName = string.IsNullOrEmpty(s.DeviceName) ? null : s.DeviceName,
|
||||
Dimension = dimension,
|
||||
Value = Math.Round(value(s), 1)
|
||||
}));
|
||||
}
|
||||
|
||||
private static PercentileStats? BuildStats(IEnumerable<double> values)
|
||||
{
|
||||
var sorted = values.OrderBy(v => v).ToList();
|
||||
if (sorted.Count == 0) return null;
|
||||
|
||||
return new PercentileStats
|
||||
{
|
||||
P50 = MetricsMath.Percentile(sorted, 50),
|
||||
P95 = MetricsMath.Percentile(sorted, 95),
|
||||
Max = Math.Round(sorted[sorted.Count - 1], 1),
|
||||
Avg = Math.Round(sorted.Average(), 1),
|
||||
SampleCount = sorted.Count
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Response DTOs ────────────────────────────────────────────────────────────
|
||||
|
||||
public class AgentEfficiencyMetricsResponse
|
||||
{
|
||||
public int WindowDays { get; set; }
|
||||
public int SessionLimit { get; set; }
|
||||
/// <summary>Echo of the requested tenant filter; null = cross-tenant aggregate.</summary>
|
||||
public string? TenantId { get; set; }
|
||||
/// <summary>Sessions the scan covered — compare against <see cref="SessionLimit"/> for truncation.</summary>
|
||||
public int SessionsScanned { get; set; }
|
||||
public int SessionsWithSnapshots { get; set; }
|
||||
public List<AgentVersionEfficiency> ByVersion { get; set; } = new();
|
||||
public AgentVersionEfficiency? Overall { get; set; }
|
||||
public DateTime ComputedAt { get; set; }
|
||||
public int ComputeDurationMs { get; set; }
|
||||
public bool FromCache { get; set; }
|
||||
|
||||
/// <summary>Shallow copy flagged as a cache hit — see the cache-read path.</summary>
|
||||
internal AgentEfficiencyMetricsResponse CloneAsCacheHit() => new()
|
||||
{
|
||||
WindowDays = WindowDays,
|
||||
SessionLimit = SessionLimit,
|
||||
TenantId = TenantId,
|
||||
SessionsScanned = SessionsScanned,
|
||||
SessionsWithSnapshots = SessionsWithSnapshots,
|
||||
ByVersion = ByVersion,
|
||||
Overall = Overall,
|
||||
ComputedAt = ComputedAt,
|
||||
ComputeDurationMs = ComputeDurationMs,
|
||||
FromCache = true
|
||||
};
|
||||
}
|
||||
|
||||
public class AgentVersionEfficiency
|
||||
{
|
||||
/// <summary>Null on the cross-version "overall" bucket (omitted on the wire).</summary>
|
||||
public string? AgentVersion { get; set; }
|
||||
public int SessionsScanned { get; set; }
|
||||
public int SessionsWithSnapshots { get; set; }
|
||||
public int SpoolPressureSessions { get; set; }
|
||||
public PercentileStats? AvgCpuPercent { get; set; }
|
||||
public PercentileStats? MaxCpuPercent { get; set; }
|
||||
public PercentileStats? MaxWorkingSetMb { get; set; }
|
||||
public PercentileStats? MaxPrivateBytesMb { get; set; }
|
||||
public PercentileStats? MaxThreadCount { get; set; }
|
||||
public PercentileStats? MaxHandleCount { get; set; }
|
||||
public PercentileStats? MaxSpoolDepth { get; set; }
|
||||
public PercentileStats? MaxSpoolFileBytes { get; set; }
|
||||
public PercentileStats? ApiLatencyMs { get; set; }
|
||||
public PercentileStats? ApiRequestCount { get; set; }
|
||||
public CrashRateMetrics? CrashRate { get; set; }
|
||||
public List<EfficiencyOffender>? TopOffenders { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Distribution of a per-session statistic across a version bucket.</summary>
|
||||
public class PercentileStats
|
||||
{
|
||||
public double P50 { get; set; }
|
||||
public double P95 { get; set; }
|
||||
public double Max { get; set; }
|
||||
public double Avg { get; set; }
|
||||
public int SampleCount { get; set; }
|
||||
}
|
||||
|
||||
public class EfficiencyOffender
|
||||
{
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string? DeviceName { get; set; }
|
||||
public string Dimension { get; set; } = string.Empty;
|
||||
public double Value { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared primitives for the agent-metrics aggregation services
|
||||
/// (<see cref="PlatformMetricsService"/>, <see cref="AgentEfficiencyMetricsService"/>):
|
||||
/// crash-rate tally over <c>agent_started</c> payloads, bounded fan-out, and the
|
||||
/// loosely-typed payload getters. One home so the two services cannot drift on the
|
||||
/// exit-type classification or the numeric coercion rules.
|
||||
/// </summary>
|
||||
internal static class AgentMetricsAggregation
|
||||
{
|
||||
/// <summary>
|
||||
/// Event types the metrics aggregations consume. Everything else in a session's
|
||||
/// Events partition is dead weight for these endpoints — pairs with
|
||||
/// <see cref="TableStorageService.AgentMetricsEventProjection"/> for a filtered,
|
||||
/// projected per-session fetch instead of a full-partition drain.
|
||||
/// </summary>
|
||||
internal static readonly string[] MetricsEventTypes =
|
||||
{
|
||||
Shared.Constants.EventTypes.AgentMetricsSnapshot,
|
||||
Shared.Constants.EventTypes.AgentStarted,
|
||||
Shared.Constants.EventTypes.SpoolPressureDetected
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Tallies crash classifications over <c>agent_started</c> event payloads.
|
||||
/// Unknown/absent <c>previousExitType</c> counts as a first run (the agent only
|
||||
/// omits it on its very first boot of a session).
|
||||
/// </summary>
|
||||
internal static CrashRateMetrics AggregateCrashRate(IEnumerable<Dictionary<string, object>> agentStartedEvents)
|
||||
{
|
||||
var metrics = new CrashRateMetrics();
|
||||
var exceptionCounts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var data in agentStartedEvents)
|
||||
{
|
||||
metrics.TotalStarts++;
|
||||
var exitType = GetString(data, "previousExitType");
|
||||
switch (exitType)
|
||||
{
|
||||
case "clean":
|
||||
metrics.CleanExits++;
|
||||
break;
|
||||
case "exception_crash":
|
||||
metrics.ExceptionCrashes++;
|
||||
var exType = GetString(data, "previousCrashException");
|
||||
if (!string.IsNullOrEmpty(exType))
|
||||
{
|
||||
exceptionCounts.TryGetValue(exType, out var count);
|
||||
exceptionCounts[exType] = count + 1;
|
||||
}
|
||||
break;
|
||||
case "hard_kill":
|
||||
metrics.HardKills++;
|
||||
break;
|
||||
case "reboot_kill":
|
||||
metrics.RebootKills++;
|
||||
break;
|
||||
default:
|
||||
metrics.FirstRuns++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var nonFirstRuns = metrics.TotalStarts - metrics.FirstRuns;
|
||||
metrics.CrashRatePercent = nonFirstRuns > 0
|
||||
? Math.Round((double)metrics.ExceptionCrashes / nonFirstRuns * 100, 1)
|
||||
: 0;
|
||||
|
||||
metrics.TopExceptions = exceptionCounts
|
||||
.OrderByDescending(kv => kv.Value)
|
||||
.Take(5)
|
||||
.Select(kv => new CrashExceptionSummary { ExceptionType = kv.Key, Count = kv.Value })
|
||||
.ToList();
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// Bounded fan-out: equivalent to Task.WhenAll over `body(item)` but with
|
||||
// at most `maxConcurrency` tasks in flight. Without this guard a 1000-
|
||||
// session metric query would fire 1000 simultaneous storage requests
|
||||
// and either throttle Azure Tables or run the worker out of file
|
||||
// handles before responding.
|
||||
internal static async Task<List<TResult>> RunWithBoundedConcurrencyAsync<TInput, TResult>(
|
||||
IReadOnlyList<TInput> items,
|
||||
int maxConcurrency,
|
||||
Func<TInput, Task<TResult>> body)
|
||||
{
|
||||
using var sem = new SemaphoreSlim(maxConcurrency);
|
||||
var tasks = items.Select(async item =>
|
||||
{
|
||||
await sem.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await body(item).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sem.Release();
|
||||
}
|
||||
});
|
||||
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
return results.ToList();
|
||||
}
|
||||
|
||||
internal static double GetDouble(Dictionary<string, object> data, string key)
|
||||
{
|
||||
if (data.TryGetValue(key, out var value))
|
||||
{
|
||||
if (value is double d) return d;
|
||||
if (value is int i) return i;
|
||||
if (value is long l) return l;
|
||||
if (value is float f) return f;
|
||||
if (double.TryParse(value?.ToString(), out var parsed)) return parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Returns the value of the first key that is present in `data`. Lets us read a V2 field
|
||||
// name with a V1 fallback without conflating "key missing" with "key present but zero".
|
||||
internal static double GetDoubleFirst(Dictionary<string, object> data, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (data.ContainsKey(key)) return GetDouble(data, key);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
internal static string GetString(Dictionary<string, object> data, string key)
|
||||
{
|
||||
if (data.TryGetValue(key, out var value))
|
||||
{
|
||||
return value?.ToString() ?? string.Empty;
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
-36
@@ -184,52 +184,109 @@ namespace AutopilotMonitor.Functions.Services
|
||||
: evt.Data?.ContainsKey("bytes_downloaded") == true ? "bytes_downloaded" : null;
|
||||
if (bytesKey != null && long.TryParse(evt.Data![bytesKey]?.ToString(), out var bytes))
|
||||
summary.DownloadBytes = Math.Max(summary.DownloadBytes, bytes);
|
||||
// DO fallback: IME >= 1.104 removed the [DO TEL] log line, and the agent's
|
||||
// DO collector only emits do_telemetry for downloads whose completion a poll
|
||||
// itself observed — fast, interrupted or between-poll-completed downloads
|
||||
// never get one. The progress events carry the full do* set on every poll,
|
||||
// so fold them in monotonically; a do_telemetry (below) stays authoritative.
|
||||
if (evt.Data != null)
|
||||
ApplyDoFields(summary, evt.Data, isAuthoritative: false);
|
||||
break;
|
||||
|
||||
case "do_telemetry":
|
||||
if (evt.Data != null)
|
||||
{
|
||||
if (evt.Data.ContainsKey("doFileSize") && long.TryParse(evt.Data["doFileSize"]?.ToString(), out var doFs))
|
||||
{
|
||||
summary.DownloadBytes = Math.Max(summary.DownloadBytes, doFs);
|
||||
summary.DoFileSize = doFs;
|
||||
}
|
||||
if (evt.Data.ContainsKey("doTotalBytesDownloaded") && long.TryParse(evt.Data["doTotalBytesDownloaded"]?.ToString(), out var doTotalDl))
|
||||
summary.DoTotalBytesDownloaded = doTotalDl;
|
||||
if (evt.Data.ContainsKey("doBytesFromPeers") && long.TryParse(evt.Data["doBytesFromPeers"]?.ToString(), out var doPeers))
|
||||
summary.DoBytesFromPeers = doPeers;
|
||||
if (evt.Data.ContainsKey("doBytesFromHttp") && long.TryParse(evt.Data["doBytesFromHttp"]?.ToString(), out var doHttp))
|
||||
summary.DoBytesFromHttp = doHttp;
|
||||
if (evt.Data.ContainsKey("doPercentPeerCaching") && int.TryParse(evt.Data["doPercentPeerCaching"]?.ToString(), out var doPct))
|
||||
summary.DoPercentPeerCaching = doPct;
|
||||
if (evt.Data.ContainsKey("doDownloadMode") && int.TryParse(evt.Data["doDownloadMode"]?.ToString(), out var doMode))
|
||||
summary.DoDownloadMode = doMode;
|
||||
if (evt.Data.ContainsKey("doDownloadDuration"))
|
||||
{
|
||||
var doDurStr = evt.Data["doDownloadDuration"]?.ToString() ?? string.Empty;
|
||||
summary.DoDownloadDuration = doDurStr;
|
||||
if (TimeSpan.TryParse(doDurStr, out var doDurTs) && doDurTs.TotalSeconds >= 1)
|
||||
summary.DownloadDurationSeconds = Math.Max(summary.DownloadDurationSeconds, (int)doDurTs.TotalSeconds);
|
||||
}
|
||||
if (evt.Data.ContainsKey("doBytesFromLanPeers") && long.TryParse(evt.Data["doBytesFromLanPeers"]?.ToString(), out var doLan))
|
||||
summary.DoBytesFromLanPeers = doLan;
|
||||
if (evt.Data.ContainsKey("doBytesFromGroupPeers") && long.TryParse(evt.Data["doBytesFromGroupPeers"]?.ToString(), out var doGroup))
|
||||
summary.DoBytesFromGroupPeers = doGroup;
|
||||
if (evt.Data.ContainsKey("doBytesFromInternetPeers") && long.TryParse(evt.Data["doBytesFromInternetPeers"]?.ToString(), out var doInet))
|
||||
summary.DoBytesFromInternetPeers = doInet;
|
||||
if (evt.Data.ContainsKey("doBytesFromLinkLocalPeers") && long.TryParse(evt.Data["doBytesFromLinkLocalPeers"]?.ToString(), out var doLinkLocal))
|
||||
summary.DoBytesFromLinkLocalPeers = doLinkLocal;
|
||||
if (evt.Data.ContainsKey("doBytesFromCacheServer") && long.TryParse(evt.Data["doBytesFromCacheServer"]?.ToString(), out var doCache))
|
||||
summary.DoBytesFromCacheServer = doCache;
|
||||
if (evt.Data.ContainsKey("doCacheHost"))
|
||||
summary.DoCacheHost = evt.Data["doCacheHost"]?.ToString() ?? string.Empty;
|
||||
}
|
||||
ApplyDoFields(summary, evt.Data, isAuthoritative: true);
|
||||
break;
|
||||
}
|
||||
|
||||
RecalculateAppDurations(state);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds the <c>do*</c> Delivery Optimization fields of one event into the summary.
|
||||
/// Two writers share this so they cannot drift:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>do_telemetry</c> (<paramref name="isAuthoritative"/> = true) — the collector's
|
||||
/// final per-app read: last-write-wins per present key (pre-existing semantics), except
|
||||
/// <c>DoDownloadMode</c> never regresses to the -1 "unset" sentinel — that would re-hide
|
||||
/// the row from <c>DoAggregator</c> after a progress event already established a mode.</item>
|
||||
/// <item><c>download_progress</c> (false) — per-poll observations: byte counters and file
|
||||
/// size fold via <c>Math.Max</c> (monotonic per download, so replays and out-of-order
|
||||
/// batches stay idempotent); mode/percent/cacheHost are latest-observation writes.</item>
|
||||
/// </list>
|
||||
/// <c>DownloadBytes</c> prefers actually-transferred bytes (<c>doTotalBytesDownloaded</c>)
|
||||
/// over <c>doFileSize</c> — the file size is only the fallback when no transfer total exists
|
||||
/// (it used to unconditionally inflate the transfer measure).
|
||||
/// </summary>
|
||||
internal static void ApplyDoFields(AppInstallSummary summary, Dictionary<string, object> data, bool isAuthoritative)
|
||||
{
|
||||
static bool TryGetLong(Dictionary<string, object> d, string key, out long value)
|
||||
{
|
||||
value = 0;
|
||||
return d.TryGetValue(key, out var raw) && long.TryParse(raw?.ToString(), out value);
|
||||
}
|
||||
static bool TryGetInt(Dictionary<string, object> d, string key, out int value)
|
||||
{
|
||||
value = 0;
|
||||
return d.TryGetValue(key, out var raw) && int.TryParse(raw?.ToString(), out value);
|
||||
}
|
||||
static long Fold(bool authoritative, long current, long incoming)
|
||||
=> authoritative ? incoming : Math.Max(current, incoming);
|
||||
|
||||
var hasFileSize = TryGetLong(data, "doFileSize", out var doFs);
|
||||
var hasTotalDl = TryGetLong(data, "doTotalBytesDownloaded", out var doTotalDl);
|
||||
|
||||
if (hasFileSize)
|
||||
summary.DoFileSize = Fold(isAuthoritative, summary.DoFileSize, doFs);
|
||||
if (hasTotalDl)
|
||||
summary.DoTotalBytesDownloaded = Fold(isAuthoritative, summary.DoTotalBytesDownloaded, doTotalDl);
|
||||
|
||||
// Transfer measure: real transferred bytes when known, file size only as fallback.
|
||||
// Progress events already fed DownloadBytes via their bytesDownloaded field, so only
|
||||
// the authoritative telemetry contributes here.
|
||||
if (isAuthoritative && (hasFileSize || hasTotalDl))
|
||||
summary.DownloadBytes = Math.Max(summary.DownloadBytes, hasTotalDl && doTotalDl > 0 ? doTotalDl : doFs);
|
||||
|
||||
if (TryGetLong(data, "doBytesFromPeers", out var doPeers))
|
||||
summary.DoBytesFromPeers = Fold(isAuthoritative, summary.DoBytesFromPeers, doPeers);
|
||||
if (TryGetLong(data, "doBytesFromHttp", out var doHttp))
|
||||
summary.DoBytesFromHttp = Fold(isAuthoritative, summary.DoBytesFromHttp, doHttp);
|
||||
if (TryGetLong(data, "doBytesFromLanPeers", out var doLan))
|
||||
summary.DoBytesFromLanPeers = Fold(isAuthoritative, summary.DoBytesFromLanPeers, doLan);
|
||||
if (TryGetLong(data, "doBytesFromGroupPeers", out var doGroup))
|
||||
summary.DoBytesFromGroupPeers = Fold(isAuthoritative, summary.DoBytesFromGroupPeers, doGroup);
|
||||
if (TryGetLong(data, "doBytesFromInternetPeers", out var doInet))
|
||||
summary.DoBytesFromInternetPeers = Fold(isAuthoritative, summary.DoBytesFromInternetPeers, doInet);
|
||||
if (TryGetLong(data, "doBytesFromLinkLocalPeers", out var doLinkLocal))
|
||||
summary.DoBytesFromLinkLocalPeers = Fold(isAuthoritative, summary.DoBytesFromLinkLocalPeers, doLinkLocal);
|
||||
if (TryGetLong(data, "doBytesFromCacheServer", out var doCache))
|
||||
summary.DoBytesFromCacheServer = Fold(isAuthoritative, summary.DoBytesFromCacheServer, doCache);
|
||||
|
||||
if (TryGetInt(data, "doPercentPeerCaching", out var doPct))
|
||||
summary.DoPercentPeerCaching = doPct;
|
||||
|
||||
// -1 is the "unset" sentinel DoAggregator filters on — never let it clobber a
|
||||
// known mode (a telemetry event without the DownloadMode property parses as -1).
|
||||
if (TryGetInt(data, "doDownloadMode", out var doMode) && doMode >= 0)
|
||||
summary.DoDownloadMode = doMode;
|
||||
|
||||
if (data.TryGetValue("doCacheHost", out var cacheHostRaw))
|
||||
{
|
||||
var cacheHost = cacheHostRaw?.ToString();
|
||||
if (isAuthoritative || !string.IsNullOrEmpty(cacheHost))
|
||||
summary.DoCacheHost = cacheHost ?? string.Empty;
|
||||
}
|
||||
|
||||
// Only do_telemetry carries a duration; progress events never have the key.
|
||||
if (data.ContainsKey("doDownloadDuration"))
|
||||
{
|
||||
var doDurStr = data["doDownloadDuration"]?.ToString() ?? string.Empty;
|
||||
summary.DoDownloadDuration = doDurStr;
|
||||
if (TimeSpan.TryParse(doDurStr, out var doDurTs) && doDurTs.TotalSeconds >= 1)
|
||||
summary.DownloadDurationSeconds = Math.Max(summary.DownloadDurationSeconds, (int)doDurTs.TotalSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void RecalculateAppDurations(AppInstallAggregationState state)
|
||||
{
|
||||
var summary = state.Summary;
|
||||
|
||||
+12
@@ -37,6 +37,18 @@ namespace AutopilotMonitor.Functions.Services.Ime
|
||||
_queueClient = queueFactory.Create(Constants.QueueNames.ImeMsiArchive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: construct directly with a mock <see cref="QueueClient"/>. Mirrors
|
||||
/// <see cref="Deletion.SessionDeletionProducer"/>'s internal test ctor.
|
||||
/// </summary>
|
||||
internal AzureQueueImeMsiArchiveProducer(
|
||||
QueueClient queueClient,
|
||||
ILogger<AzureQueueImeMsiArchiveProducer> logger)
|
||||
{
|
||||
_queueClient = queueClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task EnqueueAsync(ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (envelope is null) return;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user