Compare commits

...

8 Commits

Author SHA1 Message Date
Oliver Kieselbach b1e7077d0a fix(agent): spool pending-bytes drift guard + WDP backstop fallback test
Codex findings on d8c84f41/a369ff99: (1) MarkUploaded now recomputes
_pendingBytes from the pending list when accounting drifts negative or
stays non-zero on an empty list — a silent negative would disarm the
spool-pressure trigger. (2) New test covers the DevicePrepCompletion
handler''s AwaitingDesktop fallback (stale/replayed timer without a
desktop fact): synthetic Hello timeout, park, and completion on the
later real desktop arrival. Third finding (HashingCappedReadStream
maxBytes:0) refuted — the 0=uncapped contract is implemented in the
same class and exercised by the heal-path tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 12:59:56 +02:00
Oliver Kieselbach d8c84f41c1 fix(agent): spool pressure trigger on pending backlog, not file size
99/100 spool_pressure_detected events in the field were file-size-only
triggers on a near-empty backlog (append-only spool.jsonl grows past 5 MB
in healthy long sessions). TelemetrySpool now tracks PendingBytes (exact
serialized size of the not-yet-uploaded tail, maintained on enqueue /
mark-uploaded / restart rehydrate) and the collector thresholds on that
instead of SpoolFileSizeBytes, which stays as an informational metric.
Snapshot gains spool_pending_bytes; pressure event data carries
pendingBytes + pendingBytesThreshold.

Also: startup recovery scan streams via File.ReadLines instead of
materializing the whole spool with File.ReadAllLines (corrupt-tail
break semantics unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 17:43:15 +02:00
Oliver Kieselbach 12b51bbc24 feat(landing): scenario coverage chips in hero + MSI channel in about claims
Hero gets a quiet checkmark chip row (user-driven, pre-provisioning,
self-deploying, Device Preparation, Windows 365, Entra ID & Hybrid join);
about page deployment claim now includes the MSI LOB channel for WDP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 15:47:15 +02:00
Oliver Kieselbach a369ff99cd feat(wdp): scenario gates for Device Preparation + completion backstop
afee7ae0 audit: WDP sessions ran the full Classic/ESP apparatus. Gates key on
the deterministic BootstrapperAgent ExecutionContext marker only (CloudAssigned*
fallbacks keep Classic behavior).

Paket A (event hygiene): no autopilot_profile/-missing + ZTD query/probe on WDP;
esp_config_detected null-gate (no more unknown/unknown); starved sweep skipped
on WDP (data stays in app_tracking_summary/final-status).

Paket B (resources): EspPolicyProviderStallHost + ProvisioningStatusTracker not
started on WDP; ModernDeployment noise IDs 100/417/418/1005 fully suppressed
(Critical always passes) + Autopilot error backfill skipped; StallProbe skips
ESP category scan and treats noise IDs as harmless.

Paket C (DecisionCore): enrollmentTypeDeterministic payload key seeds
DevicePreparation at High confidence (fallbacks stay Medium); completion_waiting
drops the unsatisfiable account_setup_provisioning_complete gate on WDP; arm D
promotes on real-user desktop; new device_prep_completion 30-min backstop closes
the park-until-watchdog hole; parked tripwire gets a WDP branch.

17 new tests; suites green (532/2307/4287). OKF doc agent/wdp-scenario-gates.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 13:12:09 +02:00
Oliver Kieselbach 7a131af74d agent(summary): DevicePrep app-wave settle wait + Office/CSP row in summary dialog
Session bc803955 (DevicePrep, Succeeded) rendered "No application data
available" although the M365 CSP package installed during prep and IME
delivered 12 required user apps 9 seconds after final-status was written.

- EnrollmentTerminationHandler: bounded settle wait (20s, 250ms poll) before
  the dialog snapshot - DevicePreparation-only and only while the tracker has
  zero packages, so Classic pays nothing. Root cause of the lost race is IME
  AppWorkload.log flush latency (~10-20s), not the 100ms tracker poll.
  Dialog, starved sweep and app_tracking_summary all see settled data.
- FinalStatusBuilder: synthetic "Microsoft 365 Apps" row from the Office C2R
  detector's persisted lifecycle state (Office CSP installs never flow through
  the IME Win32 pipeline). Prepended to the Device bucket, counted in
  appSummary; Preinstalled state deliberately excluded. No SummaryDialog.exe
  change needed - it renders packageStatesByPhase generically.
- OfficeInstallStateData: CompletedAtUtc + VersionReached stamped on terminal
  persists (duration/version for the row); older state files load fail-soft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 10:57:46 +02:00
Oliver Kieselbach e750cfe9dd feat(web): gap badge on timeline rows after multi-hour/day time gaps
Timeline rows render time-of-day only, so a large gap between adjacent
events (and the date change it implies) was invisible without expanding
event details. Rows following a gap >= 1h now show an amber chip
(e.g. "+3h" / "+19d"); hovering reveals the full date. Applies to both
the phase timeline and raw mode, using the same display timestamp as
the row (backfilled events keep their event-log time).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 10:39:36 +02:00
Oliver Kieselbach 3629728d93 feat(time-attribution): PriorEnrollmentResidue quality flag (v2) + WG-signal comment fix
Session f475e697: a device re-enrolled without a wipe (IME log residue from a
previous enrollment) completes its pre-installed apps as instant detections,
pulling the phase anchors ~6.5 min ahead of the real ESP page — the breakdown
was mechanically exact but semantically wrong, and entered fleet stats as clean.

- New duration-critical flag PriorEnrollmentResidue, set on
  historic_ime_replay_detected; excluded from fleet segment stats via the
  existing DurationCriticalFlags mask. registry_app_baseline successes are
  deliberately NOT a trigger (by-design normal for WDP Batch-1) — pinned by test.
- AttributionVersion 1 -> 2 so the maintenance sweep recomputes in-retention
  breakdowns and flags old residue sessions retroactively (rule 8).
- Web: flag label in TimeAttributionLane; docs: quality-flags section.
- Stale comment fix: SaveWhiteGloveSuccessResult is NOT a WhiteGlove indicator
  on Win11 25H2+ (validated 2026-08-18 against user-driven sessions) —
  observability only, consumed nowhere for classification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 10:39:11 +02:00
Oliver Kieselbach e521fd5cf4 fix(ime-archive): heal provenance.json lost behind the write-once 409 path
Daily-review finding: the MSI upload is write-once (If-None-Match:*), so a
first attempt that died between the MSI and the provenance upload funneled
every retry into the 409 short-circuit — which returned "Archived" without
ever writing provenance.json (permanently lost). The 409 path now checks for
the sidecar and, when missing, rehashes the ARCHIVED bytes and writes it;
heal failures ride the normal retryable ladder.

Also closes the review's test gaps: ImeMsiArchiveQueueWorker + producer
worker-level tests (internal QueueClient test ctors, SessionDeletionWorker
pattern), CorporateIdentifierValidator null/whitespace + OData special-char
+ page-cap-transient tests, registry observer helper tests
(TerminalOutcome/ChangedFields/ClassifyStatusServiceStatus).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 10:21:26 +02:00
50 changed files with 2465 additions and 134 deletions
+108
View File
@@ -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`
+16 -1
View File
@@ -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.
+1
View File
@@ -26,6 +26,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [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
+3
View File
@@ -2,6 +2,9 @@
## 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.
@@ -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()
{
@@ -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()
{
@@ -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()
{
@@ -264,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();
@@ -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);
}
}
}
@@ -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()
{
@@ -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>
@@ -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()
{
@@ -37,6 +37,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;
@@ -194,7 +195,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 +212,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 +230,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));
@@ -347,16 +351,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 +385,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_modernDeploymentBackfillEnabled,
_modernDeploymentBackfillLookbackMinutes,
_stateDirectory,
_modernDeploymentHarmlessEventIds);
_modernDeploymentHarmlessEventIds,
_isDevicePreparation);
_modernDeploymentTracker.Start();
}
}
@@ -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:
@@ -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;
@@ -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>
@@ -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)
@@ -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)
{
@@ -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),
});
}
@@ -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; }
@@ -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).");
}
}
@@ -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),
@@ -109,6 +109,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
@@ -151,6 +163,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: () =>
@@ -308,7 +321,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 +331,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,
@@ -70,6 +70,7 @@ 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)
@@ -89,6 +90,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
stateDirectory: stateDirectory,
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds,
isDevicePreparation: isDevicePreparation,
userEspAppsSettledProbe: userEspAppsSettledProbe,
starvedUserEspAppsProbe: starvedUserEspAppsProbe,
packageStatesProbe: packageStatesProbe);
@@ -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))
@@ -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;
@@ -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",
@@ -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,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,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));
}
}
}
@@ -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]
@@ -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,
@@ -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;
@@ -39,6 +39,26 @@ namespace AutopilotMonitor.Functions.Services.Ime
_adminConfigService = adminConfigService ?? throw new ArgumentNullException(nameof(adminConfigService));
}
/// <summary>
/// Test seam: construct directly with mock <see cref="Azure.Storage.Queues.QueueClient"/>
/// instances so the poll loop can be exercised without Azurite. Mirrors
/// <see cref="Deletion.SessionDeletionWorker"/>'s internal test ctor.
/// </summary>
internal ImeMsiArchiveQueueWorker(
Azure.Storage.Queues.QueueClient mainQueue,
Azure.Storage.Queues.QueueClient poisonQueue,
ImeMsiArchiver archiver,
ISessionRepository sessionRepo,
AdminConfigurationService adminConfigService,
ILogger<ImeMsiArchiveQueueWorker> logger,
TimeSpan? pollInterval = null)
: base(mainQueue, poisonQueue, logger, pollInterval)
{
_archiver = archiver ?? throw new ArgumentNullException(nameof(archiver));
_sessionRepo = sessionRepo ?? throw new ArgumentNullException(nameof(sessionRepo));
_adminConfigService = adminConfigService ?? throw new ArgumentNullException(nameof(adminConfigService));
}
/// <summary>Download can legitimately take minutes — extend visibility while working.</summary>
protected override bool UseHeartbeat => true;
@@ -34,7 +34,9 @@ namespace AutopilotMonitor.Functions.Services.Ime
/// <c>IntuneWindowsAgent.msi</c> are accepted; anything else falls back to the canonical
/// versionless CDN URL — safe for a NEW version, because "new" is by definition what
/// that URL currently serves. The blob write uses If-None-Match:* so a queue re-delivery
/// can never overwrite an existing archive entry.
/// can never overwrite an existing archive entry; the 409 path additionally heals a
/// provenance sidecar the first attempt failed to write (see
/// <see cref="CompleteExistingArchiveAsync"/>).
/// </para>
/// </summary>
public class ImeMsiArchiver
@@ -179,34 +181,18 @@ namespace AutopilotMonitor.Functions.Services.Ime
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Queue re-delivery after a successful upload: the archive already has
// this version — that IS the desired end state.
_logger.LogInformation(
"ImeMsiArchiver: blob {BlobPath} already exists — treating re-delivery as archived",
blobPath);
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, null, null, sourceUrl);
// this version — that IS the desired end state. But if the first
// attempt died between the MSI and the provenance upload, the sidecar
// is missing and this 409 pre-empts the happy path's provenance write
// on every future retry — so it must be healed here.
return await CompleteExistingArchiveAsync(
envelope, version, blobPath, sourceUrl, linked.Token);
}
sha256 = guarded.Sha256Hex;
totalBytes = guarded.TotalBytes;
}
var provenance = new
{
version,
url = sourceUrl,
urlFromEvent = sourceUrl != CanonicalMsiUrl,
msiMatchedBy = envelope.MsiMatchedBy,
sha256,
msiBytes = totalBytes,
firstSeenTenantId = envelope.TenantId,
firstSeenSessionId = envelope.SessionId,
archivedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
};
await UploadProvenanceAsync(
$"{version}/provenance.json",
System.Text.Json.JsonSerializer.Serialize(provenance,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
linked.Token);
await WriteProvenanceAsync(envelope, version, sourceUrl, sha256, totalBytes, linked.Token);
_logger.LogInformation(
"ImeMsiArchiver: archived {Version} ({SizeBytes} bytes, sha256 {Sha256}) from {SourceUrl}",
@@ -247,6 +233,72 @@ namespace AutopilotMonitor.Functions.Services.Ime
}
}
/// <summary>
/// Re-delivery landed on an already-archived version (409 on the write-once MSI
/// upload). Normally the provenance sidecar exists too and there is nothing to do —
/// but when the first attempt died between the MSI and the provenance upload
/// (transient storage failure, host shutdown), the sidecar would otherwise be lost
/// FOREVER: the 409 short-circuit pre-empts the happy path's provenance write on
/// every retry. Hash and size are recomputed from the ARCHIVED bytes, not this
/// attempt's re-download, so the provenance describes the blob it sits next to.
/// Failures propagate into the caller's catch ladder (retryable), so the queue
/// retries the heal until it lands.
/// </summary>
private async Task<ImeMsiArchiveResult> CompleteExistingArchiveAsync(
ImeMsiArchiveEnvelope envelope, string version, string blobPath, string sourceUrl,
CancellationToken cancellationToken)
{
if (await ProvenanceExistsAsync($"{version}/provenance.json", cancellationToken))
{
_logger.LogInformation(
"ImeMsiArchiver: blob {BlobPath} already exists — treating re-delivery as archived",
blobPath);
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, null, null, sourceUrl);
}
string sha256;
long totalBytes;
using (var archived = await OpenArchivedMsiAsync(blobPath, cancellationToken))
using (var hashing = new HashingCappedReadStream(archived, maxBytes: 0))
{
await hashing.CopyToAsync(Stream.Null, cancellationToken);
sha256 = hashing.Sha256Hex;
totalBytes = hashing.TotalBytes;
}
await WriteProvenanceAsync(envelope, version, sourceUrl, sha256, totalBytes, cancellationToken);
_logger.LogInformation(
"ImeMsiArchiver: healed missing provenance for already-archived {Version} ({SizeBytes} bytes, sha256 {Sha256})",
version, totalBytes, sha256);
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, sha256, totalBytes, sourceUrl);
}
private async Task WriteProvenanceAsync(
ImeMsiArchiveEnvelope envelope, string version, string sourceUrl,
string sha256, long totalBytes, CancellationToken cancellationToken)
{
var provenance = new
{
version,
url = sourceUrl,
urlFromEvent = sourceUrl != CanonicalMsiUrl,
msiMatchedBy = envelope.MsiMatchedBy,
sha256,
msiBytes = totalBytes,
firstSeenTenantId = envelope.TenantId,
firstSeenSessionId = envelope.SessionId,
archivedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
};
await UploadProvenanceAsync(
$"{version}/provenance.json",
System.Text.Json.JsonSerializer.Serialize(provenance,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
cancellationToken);
}
// -------- Test seams (production defaults hit Azure / the network) --------
/// <summary>Fetches the platform admin configuration (size cap).</summary>
@@ -280,6 +332,20 @@ namespace AutopilotMonitor.Functions.Services.Ime
}, cancellationToken);
}
/// <summary>True when the provenance sidecar already exists in the archive container.</summary>
protected virtual async Task<bool> ProvenanceExistsAsync(string blobPath, CancellationToken cancellationToken)
{
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
return (await containerClient.GetBlobClient(blobPath).ExistsAsync(cancellationToken)).Value;
}
/// <summary>Opens the already-archived installer blob for the provenance-heal rehash.</summary>
protected virtual async Task<Stream> OpenArchivedMsiAsync(string blobPath, CancellationToken cancellationToken)
{
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
return await containerClient.GetBlobClient(blobPath).OpenReadAsync(cancellationToken: cancellationToken);
}
/// <summary>Uploads the provenance sidecar (overwrite allowed — it is derived data).</summary>
protected virtual async Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
{
@@ -64,6 +64,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// of failing.
/// </summary>
public const string AdvisoryCompletion = "advisory_completion";
/// <summary>
/// Device Preparation completion backstop (afee7ae0, 2026-08-18). WDP has no ESP,
/// so none of the ESP-derived resolution deadlines (HelloSafety, AdvisoryCompletion)
/// can ever arm there — a WDP session whose Hello never resolves would park until
/// the agent max-lifetime watchdog with no verdict. Armed by
/// <c>HandleDesktopArrivedV1</c> when the desktop arrives on a DevicePreparation
/// session without completing in the same step; on fire the session resolves like
/// the HelloSafety timeout (synthetic <c>HelloOutcome=Timeout</c> + completion —
/// the desktop is present by construction of the arming site).
/// </summary>
public const string DevicePrepCompletion = "device_prep_completion";
}
/// <summary>
@@ -130,6 +142,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>On <c>SessionStarted</c>: literal "v1" / "v2" from <c>EnrollmentRegistryDetector.DetectEnrollmentType()</c>.</summary>
public const string EnrollmentType = "enrollmentType";
/// <summary>
/// On <c>EnrollmentFactsObserved</c>: "true" when the v2 verdict came from the
/// deterministic Device Preparation marker (BootstrapperAgent ExecutionContext)
/// rather than the CloudAssigned* fallback rules. Lifts the DevicePreparation
/// profile seed to High confidence — the fallback rules stay Medium because they
/// can in principle fire on ESP-less classic profiles.
/// </summary>
public const string EnrollmentTypeDeterministic = "enrollmentTypeDeterministic";
/// <summary>On <c>SessionStarted</c>: "true" / "false" from <c>EnrollmentRegistryDetector.DetectHybridJoin()</c>.</summary>
public const string IsHybridJoin = "isHybridJoin";
@@ -657,12 +657,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
// HelloResolved arrives.
builder.WithStage(state.Stage);
// WDP completion backstop (afee7ae0): no ESP → HelloSafety / AdvisoryCompletion can
// never arm on Device Preparation, so a Hello that never resolves would park the
// session until the max-lifetime watchdog. Desktop is here, Hello is not — bound
// the wait. Armed once (name-keyed dedupe is belt; the explicit check keeps the
// audit trail free of re-arm noise on duplicate desktop signals).
var effects = new List<DecisionEffect>(2);
if (state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation
&& !HasDevicePrepCompletionDeadline(state))
{
var backstop = BuildDevicePrepCompletionDeadline(state, signal);
builder.AddDeadline(backstop);
effects.Add(new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: backstop));
}
// Liveness plan PR2: Desktop is in but completion is still blocked (Hello pending,
// or the Hello-disabled fast-path's strong gate is not satisfied) — surface the
// missing prerequisites. Covers both the gate-false fast-path and the plain
// desktop-first ordering; the fingerprint dedupes against earlier emissions.
var waitingEffect = BuildCompletionWaitingEffect(
state, builder, signal, trigger: nameof(DecisionSignalKind.DesktopArrived));
if (waitingEffect != null) effects.Add(waitingEffect);
var newState = builder.Build();
@@ -676,7 +691,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(
newState,
transition,
waitingEffect != null ? new[] { waitingEffect } : Array.Empty<DecisionEffect>());
effects.Count > 0 ? effects.ToArray() : Array.Empty<DecisionEffect>());
}
/// <summary>
@@ -226,7 +226,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
state.AccountSetupEnteredUtc,
state.EspFinalExitUtc,
state.ImeUserSessionCompletedUtc,
state.DesktopArrivedUtc));
state.DesktopArrivedUtc),
devicePreparation: state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation);
/// <summary>
/// Arm C of <c>ShouldTransitionToAwaitingHello</c> (session a4537c36) restated over raw
@@ -257,10 +258,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
bool helloPolicyDisabled,
bool desktopArrived,
bool realmJoinGateOpen,
bool postEspUserSessionEvidence = false)
bool postEspUserSessionEvidence = false,
bool devicePreparation = false)
{
var missing = new List<string>(4);
if (!accountSetupProvisioned && !skipUserEsp && !postEspUserSessionEvidence)
// Device Preparation has no ESP: AccountSetupCategory.Status is never written
// there, so the provisioning gate is unsatisfiable by construction and must not
// be reported as a missing prerequisite (afee7ae0 — mirrors the skipUserEsp
// exemption; completion on WDP rides on Hello + Desktop, see arm D of
// ShouldTransitionToAwaitingHello and the DevicePrepCompletion backstop).
if (!accountSetupProvisioned && !skipUserEsp && !postEspUserSessionEvidence && !devicePreparation)
missing.Add(CompletionPrerequisites.AccountSetupProvisioningComplete);
if (!helloResolved && !helloPolicyDisabled)
missing.Add(CompletionPrerequisites.HelloResolution);
@@ -302,7 +309,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
builder.AccountSetupEnteredUtc,
builder.EspFinalExitUtc,
builder.ImeUserSessionCompletedUtc,
builder.DesktopArrivedUtc));
builder.DesktopArrivedUtc),
devicePreparation: builder.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation);
if (missing.Count == 0) return null;
var fingerprint = string.Join(",", missing);
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
namespace AutopilotMonitor.DecisionCore.Engine
{
// Device Preparation (WDP) — completion backstop (afee7ae0, 2026-08-18).
//
// WDP has no ESP, so every ESP-derived resolution deadline is structurally unarmable
// there: HelloSafety arms only on EspExiting / AccountSetupProvisioningComplete / arm C,
// AdvisoryCompletion only on an EspTerminalFailure or a guard-blocked post-AccountSetup
// esp_exiting — none of which exist on Device Preparation. A WDP session whose Hello
// never resolves therefore used to park until the agent max-lifetime watchdog with no
// verdict. The engine otherwise deliberately rides the Classic rails for WDP (the
// Hello + Desktop conjunction and arm D of ShouldTransitionToAwaitingHello); this
// partial adds only the WDP-specific bounded wait.
public sealed partial class DecisionEngine
{
/// <summary>
/// Resolution window from real-user desktop arrival on a Device Preparation session.
/// Deliberately the same order of magnitude as <c>AdvisoryCompletion</c> (30 min),
/// not <c>HelloSafety</c> (300 s): the user may legitimately still be walking
/// through a configured Hello wizard, and the tracker's own Hello completion
/// timers normally resolve long before this fires — it is the last net, not the
/// expected path.
/// </summary>
private static readonly TimeSpan s_devicePrepCompletionWindow = TimeSpan.FromMinutes(30);
/// <summary>True when <see cref="DeadlineNames.DevicePrepCompletion"/> is armed in <paramref name="state"/>.</summary>
private static bool HasDevicePrepCompletionDeadline(DecisionState state)
{
foreach (var d in state.Deadlines)
{
if (d.Name == DeadlineNames.DevicePrepCompletion) return true;
}
return false;
}
/// <summary>
/// Build the <see cref="DeadlineNames.DevicePrepCompletion"/> backstop deadline.
/// Replay-safe via <see cref="EffectiveDeadlineBase"/> (floored at AgentBootUtc).
/// </summary>
internal static ActiveDeadline BuildDevicePrepCompletionDeadline(DecisionState state, DecisionSignal signal) =>
new ActiveDeadline(
name: DeadlineNames.DevicePrepCompletion,
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(s_devicePrepCompletionWindow),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.DevicePrepCompletion,
});
/// <summary>
/// <see cref="DeadlineNames.DevicePrepCompletion"/> fired: the WDP session reached
/// the real-user desktop but Hello never resolved within the window. Mirror of
/// <see cref="HandleHelloSafetyDeadlineFired"/>: record the synthetic
/// <see cref="SyntheticHelloOutcomeTimeout"/> (unless a real resolution raced the
/// timer) and complete through Finalizing. Desktop is present by construction of
/// the arming site (<c>HandleDesktopArrivedV1</c>); the AwaitingDesktop fallback
/// only keeps the handler total. A timer surviving into a terminal stage is
/// swallowed by the post-terminal guard in <see cref="Dispatch"/> — no explicit
/// cancel wiring at the completion sites is needed for correctness.
/// </summary>
private DecisionStep HandleDevicePrepCompletionDeadlineFired(DecisionState state, DecisionSignal signal)
{
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.CancelDeadline(DeadlineNames.DevicePrepCompletion);
if (state.HelloResolvedUtc == null)
{
builder.HelloResolvedUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
builder.HelloOutcome = new SignalFact<string>(SyntheticHelloOutcomeTimeout, signal.SessionSignalOrdinal);
}
if (state.DesktopArrivedUtc != null)
{
return CompleteThroughFinalizingOrDefer(
state: state,
signal: signal,
preparedBuilder: builder,
nextStepIndex: nextStep,
trigger: $"DeadlineFired:{DeadlineNames.DevicePrepCompletion}");
}
builder.WithStage(SessionStage.AwaitingDesktop);
var waitingEffect = BuildCompletionWaitingEffect(
state, builder, signal, trigger: $"DeadlineFired:{DeadlineNames.DevicePrepCompletion}");
var newState = builder.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: SessionStage.AwaitingDesktop,
nextStepIndex: nextStep,
trigger: $"DeadlineFired:{DeadlineNames.DevicePrepCompletion}");
return new DecisionStep(
newState,
transition,
waitingEffect != null ? new[] { waitingEffect } : Array.Empty<DecisionEffect>());
}
}
}
@@ -362,6 +362,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
return HandleRealmJoinTimeoutDeadlineFired(state, signal);
case DeadlineNames.AdvisoryCompletion:
return HandleAdvisoryCompletionDeadlineFired(state, signal);
case DeadlineNames.DevicePrepCompletion:
return HandleDevicePrepCompletionDeadlineFired(state, signal);
default:
// Deadline name not recognized in this sub-milestone. Cancel it from state
// and record a neutral taken transition — M3.3+ adds handlers for
@@ -784,6 +786,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
{
return true;
}
// Arm D (afee7ae0, 2026-08-18) — Device Preparation: there is no ESP, so arms A–C
// are unsatisfiable by construction (no provisioning categories, no Shell-Core
// final exit, IME anchors are incidental pattern hits). The DAD-validated
// real-user desktop IS the end of WDP provisioning — the DPP page has closed and
// the user is signed in; only the Hello gate remains after this promotion.
if (state.ScenarioProfile.Mode == EnrollmentMode.DevicePreparation
&& (desktopArrivedInFlight || state.DesktopArrivedUtc != null))
{
return true;
}
return false;
}
@@ -99,8 +99,19 @@ namespace AutopilotMonitor.DecisionCore.State
if (string.Equals(rawType, "v2", StringComparison.OrdinalIgnoreCase))
{
// Deterministic WDP marker (BootstrapperAgent ExecutionContext) seeds
// High — the marker is as registry-deterministic as the self-deploying
// OobeConfig bits below, and a High seed lets the generic monotonic
// guard protect the mode instead of relying solely on the explicit
// DevicePreparation checks in the downstream promotion handlers.
// The CloudAssigned* fallback rules stay Medium (ESP-less classic
// profiles could in principle trip them).
var deterministic = signal.Payload.TryGetValue(SignalPayloadKeys.EnrollmentTypeDeterministic, out var rawDeterministic)
&& bool.TryParse(rawDeterministic, out var isDeterministic)
&& isDeterministic;
mode = EnrollmentMode.DevicePreparation;
confidence = Max(confidence, ProfileConfidence.Medium);
confidence = Max(confidence, deterministic ? ProfileConfidence.High : ProfileConfidence.Medium);
reason = newReason;
changed = true;
}
@@ -61,7 +61,17 @@ namespace AutopilotMonitor.Shared.Models
/// event, missing ResumedAt, or a derived part-1 length that contradicts the stored
/// combined duration) — the two-window split is best-effort rather than event-anchored.
/// </summary>
WhiteGloveAnchorsIncomplete = 16
WhiteGloveAnchorsIncomplete = 16,
/// <summary>
/// The device carries state from a previous enrollment (<c>historic_ime_replay_detected</c>:
/// the IME log on disk predates this enrollment by more than the staleness threshold — the
/// machine was re-enrolled without a wipe). Pre-installed apps then complete as instant
/// detections, which pulls the phase anchors far ahead of the real ESP page (session
/// f475e697: the AccountSetup anchor fired 6.5 min before device setup actually finished),
/// so the segment durations misrepresent the enrollment.
/// </summary>
PriorEnrollmentResidue = 32
}
/// <summary>
@@ -74,7 +84,8 @@ namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Flags that make the SEGMENT DURATIONS themselves untrustworthy — dropped anchors,
/// underobserved early phases, best-effort WhiteGlove window boundaries. Only these
/// underobserved early phases, best-effort WhiteGlove window boundaries, phase anchors
/// distorted by prior-enrollment residue. Only these
/// exclude a breakdown from fleet segment statistics. The blocking-set flags
/// (<see cref="TimeAttributionFlags.BlockingSetUnknown"/> / BlockingSetTruncated) say
/// nothing about the measured spans — they only limit per-app blocking evidence, which
@@ -85,7 +96,8 @@ namespace AutopilotMonitor.Shared.Models
public const TimeAttributionFlags DurationCriticalFlags =
TimeAttributionFlags.ClockSkewDropped |
TimeAttributionFlags.PartialObservation |
TimeAttributionFlags.WhiteGloveAnchorsIncomplete;
TimeAttributionFlags.WhiteGloveAnchorsIncomplete |
TimeAttributionFlags.PriorEnrollmentResidue;
/// <summary>True when the breakdown's segment durations are unfit for fleet statistics.</summary>
public static bool ExcludesFromFleetStats(TimeAttributionFlags flags)
@@ -226,7 +226,7 @@ export default function AboutPage() {
items: [
".NET binary — lightweight, low-overhead monitoring",
"Runs via scheduled task (no Windows service — easy, residue-free removal)",
"Deployed via an Intune platform script (PowerShell bootstrapper)",
"Deployed via an Intune platform script (PowerShell bootstrapper); for Autopilot Device Preparation additionally as a thin MSI line-of-business app",
"Mutual TLS using the existing Intune MDM device certificate",
"Self-destruct on enrollment completion (on by default — removes task and files)",
],
@@ -180,8 +180,12 @@ export default function EventTimeline({
<div className="bg-white shadow rounded-lg p-4">
<h2 className="text-sm font-semibold text-gray-700 mb-3">Raw Events ({sortedBySequence.length})</h2>
<div className="divide-y divide-gray-100">
{sortedBySequence.map((ev) => (
<RawEventRow key={ev.eventId || `${ev.sessionId}-${ev.sequence}`} event={ev} />
{sortedBySequence.map((ev, i) => (
<RawEventRow
key={ev.eventId || `${ev.sessionId}-${ev.sequence}`}
event={ev}
prevDisplayTime={i > 0 ? getDisplayTime(sortedBySequence[i - 1]) : null}
/>
))}
</div>
</div>
@@ -341,11 +345,12 @@ function PhaseSection({
{isExpanded && (
<div className="space-y-3">
{events.map((event) => (
{events.map((event, i) => (
<EventRow
key={event.eventId || `${event.sessionId}-${event.sequence}`}
event={event}
showScriptOutput={showScriptOutput}
prevDisplayTime={i > 0 ? getDisplayTime(events[i - 1]) : null}
/>
))}
</div>
@@ -372,7 +377,32 @@ const BACKFILL_TOOLTIP =
"If enrollment progressed afterwards, this error was already resolved by a retry. " +
"The timestamp shown is the original event-log time.";
function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScriptOutput?: boolean }) {
// Display timestamp of a row: backfilled events show the original event-log time.
function getDisplayTime(event: EnrollmentEvent): Date {
return getBackfillInfo(event).recordedAt ?? new Date(event.timestamp);
}
// Rows render time-of-day only, so a multi-hour/multi-day gap to the previous row
// (and the date change it implies) is invisible without expanding event details.
// Shown on the row AFTER the gap; hover reveals the full date.
function GapBadge({ prevTime, eventTime }: { prevTime: Date | null; eventTime: Date }) {
if (!prevTime) return null;
const gapMs = eventTime.getTime() - prevTime.getTime();
if (gapMs < 60 * 60 * 1000) return null;
const label = gapMs >= 48 * 60 * 60 * 1000
? `+${Math.round(gapMs / (24 * 60 * 60 * 1000))}d`
: `+${Math.round(gapMs / (60 * 60 * 1000))}h`;
return (
<span
className="px-1.5 py-0.5 rounded text-xs font-medium bg-amber-50 text-amber-700 whitespace-nowrap flex-shrink-0"
title={`${label} after the previous event — ${eventTime.toLocaleString()}`}
>
⏱ {label}
</span>
);
}
function EventRow({ event, showScriptOutput, prevDisplayTime }: { event: EnrollmentEvent; showScriptOutput?: boolean; prevDisplayTime?: Date | null }) {
const [showDetails, setShowDetails] = useState(false);
const [showRaw, setShowRaw] = useState(false);
const [copied, setCopied] = useState(false);
@@ -458,6 +488,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
<span className="text-gray-400 font-sans"> (reported {new Date(event.timestamp).toLocaleTimeString()})</span>
)}
</span>
<GapBadge prevTime={prevDisplayTime ?? null} eventTime={recordedAt ?? new Date(event.timestamp)} />
<SeverityBadge severity={event.severity} />
{isBackfilled && (
<span
@@ -786,7 +817,7 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
);
}
function RawEventRow({ event }: { event: EnrollmentEvent }) {
function RawEventRow({ event, prevDisplayTime }: { event: EnrollmentEvent; prevDisplayTime?: Date | null }) {
const [expanded, setExpanded] = useState(false);
const detailData = useMemo(() => normalizeEventDataForDisplay(event.data), [event.data]);
const hasDetails = detailData && Object.keys(detailData).length > 0;
@@ -806,6 +837,7 @@ function RawEventRow({ event }: { event: EnrollmentEvent }) {
<div className="flex items-start gap-2">
<span className="text-gray-400 w-8 text-right flex-shrink-0">{event.sequence}</span>
<span className="text-gray-500 flex-shrink-0">{(recordedAt ?? new Date(event.timestamp)).toLocaleTimeString()}</span>
<GapBadge prevTime={prevDisplayTime ?? null} eventTime={recordedAt ?? new Date(event.timestamp)} />
<span className={`flex-shrink-0 w-14 ${sevColor[event.severity] || "text-gray-500"}`}>{event.severity}</span>
{isBackfilled && (
<span className="text-slate-500 flex-shrink-0" title={BACKFILL_TOOLTIP}>⟲</span>
@@ -64,6 +64,7 @@ const FLAG_LABELS: Record<string, string> = {
BlockingSetUnknown: "ESP blocking set unknown",
BlockingSetTruncated: "ESP blocking list truncated",
WhiteGloveAnchorsIncomplete: "Pre-provisioning window estimated",
PriorEnrollmentResidue: "Device re-enrolled without wipe — phase timing unreliable",
};
function formatDuration(totalSeconds: number): string {
@@ -1,6 +1,15 @@
import { HeroShots } from "./HeroShots";
import { DOCS_URL } from "@/utils/config";
const SCENARIOS = [
"User-driven",
"Pre-provisioning",
"Self-deploying",
"Device Preparation",
"Windows 365",
"Entra ID & Hybrid join",
];
export function Hero() {
return (
<header className="pt-16 sm:pt-20 pb-0 px-6">
@@ -42,6 +51,27 @@ export function Hero() {
</div>
</div>
{/* Scenario coverage — quiet chips, no competition with the CTAs */}
<div className="mt-7 flex flex-wrap items-center gap-x-4 gap-y-2">
{SCENARIOS.map((scenario) => (
<span
key={scenario}
className="inline-flex items-center gap-1.5 text-[13px] text-[var(--lp-ink-soft)]"
>
<svg
className="w-3.5 h-3.5 text-[var(--lp-accent-ink)] shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{scenario}
</span>
))}
</div>
{/* The portal, front and center */}
<div className="mt-12 sm:mt-14 pb-16 sm:pb-20">
<HeroShots />