mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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>
This commit is contained in:
@@ -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`
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## 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.
|
||||
|
||||
+35
@@ -61,6 +61,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
|
||||
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoEspEvidenceAtAll_SuppressesTheEvent()
|
||||
{
|
||||
// afee7ae0 (WDP / plain Entra join): FirstSync has no values and the ESP tracking
|
||||
// key does not exist — an event saying only "SkipUser=unknown, SkipDevice=unknown"
|
||||
// is noise and must not be emitted (mirrors the orchestrator bootstrap's null-gate).
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var collector = new DeviceInfoCollector(
|
||||
"session-nullgate", "tenant-1",
|
||||
new InformationalEventPost(ingress, clock), logger,
|
||||
signalIngress: null, clock: clock,
|
||||
startupGate: new StartupEventGate(tmp.Path, logger));
|
||||
|
||||
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
|
||||
_ => new EspFirstSyncSnapshot(
|
||||
skipUser: null, skipDevice: null,
|
||||
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => default))
|
||||
{
|
||||
collector.CollectEspConfiguration();
|
||||
}
|
||||
|
||||
Assert.Empty(EspConfigEvents(ingress));
|
||||
|
||||
// A later refresh where real evidence appeared must still emit normally.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsDevice);
|
||||
}
|
||||
Assert.Single(EspConfigEvents(ingress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
|
||||
{
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Session afee7ae0 (2026-08-18) — Device Preparation structural-noise suppression.
|
||||
/// A nine-minute WDP session shipped 900+ occurrences of WIL error 1005 plus 400+
|
||||
/// duplicate-workload 417/418 records and "Autopilot policy not found" 100s — all
|
||||
/// structural on WDP (no deployment profile exists; the BootstrapperAgent's SLDM
|
||||
/// progress poll produces the 417/418 chatter). On WDP those IDs are suppressed
|
||||
/// entirely (<see cref="ModernDeploymentTracker.DevicePreparationNoiseEventIds"/>);
|
||||
/// genuine diagnostics (e.g. 408 provisioning warnings) and Critical records keep
|
||||
/// flowing, and Classic sessions keep the harmless-downgrade + rollup path.
|
||||
/// </summary>
|
||||
public sealed class ModernDeploymentTrackerDevicePrepTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 8, 18, 9, 10, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
public ModernDeploymentTrackerDevicePrepTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
_logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private ModernDeploymentTracker BuildTracker(bool isDevicePreparation) =>
|
||||
new ModernDeploymentTracker(
|
||||
sessionId: "sess-wdp-noise",
|
||||
tenantId: "tenant-wdp-noise",
|
||||
post: new InformationalEventPost(_sink, new VirtualClock(At)),
|
||||
logger: _logger,
|
||||
backfillEnabled: false,
|
||||
isDevicePreparation: isDevicePreparation);
|
||||
|
||||
private void Process(ModernDeploymentTracker tracker, int eventId, int level, string shortName, string channel)
|
||||
{
|
||||
tracker.ProcessEvent(
|
||||
eventId: eventId,
|
||||
level: level,
|
||||
levelDisplayName: level == 1 ? "Critical" : level == 2 ? "Error" : "Warning",
|
||||
providerName: "ModernDeployment-Diagnostics-Provider",
|
||||
timeCreatedUtc: At,
|
||||
formattedDescription: $"EventID {eventId} record.",
|
||||
shortName: shortName,
|
||||
channelName: channel,
|
||||
isBackfill: false);
|
||||
}
|
||||
|
||||
private List<FakeSignalIngressSink.PostedSignal> PostedModernDeployment() =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et.StartsWith("modern_deployment", StringComparison.Ordinal)).ToList();
|
||||
|
||||
[Theory]
|
||||
[InlineData(100, 3, "Autopilot")] // policy not found — no profile exists on WDP
|
||||
[InlineData(417, 3, "Autopilot")] // duplicate workload — SLDM poll chatter
|
||||
[InlineData(418, 3, "Autopilot")] // duplicate batch — SLDM poll chatter
|
||||
[InlineData(1005, 2, "ManagementService")] // WIL error storm
|
||||
public void WdpNoiseIds_AreFullySuppressed_OnDevicePreparation(int eventId, int level, string shortName)
|
||||
{
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
var channel = shortName == "Autopilot"
|
||||
? ModernDeploymentTracker.AutopilotChannel
|
||||
: ModernDeploymentTracker.ManagementChannel;
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
Process(tracker, eventId, level, shortName, channel);
|
||||
}
|
||||
|
||||
Assert.Empty(PostedModernDeployment());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenuineWdpWarning_408_KeepsFlowing_OnDevicePreparation()
|
||||
{
|
||||
// 408 "Autopilot Provisioning reported a warning" carries the real WDP session
|
||||
// context — it is NOT in the noise set and must keep flowing.
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
|
||||
Process(tracker, 408, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
|
||||
|
||||
var posted = Assert.Single(PostedModernDeployment());
|
||||
Assert.Equal("modern_deployment_warning", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CriticalRecords_AreNeverSuppressed_EvenForNoiseIds()
|
||||
{
|
||||
var tracker = BuildTracker(isDevicePreparation: true);
|
||||
|
||||
Process(tracker, 1005, level: 1, shortName: "ManagementService", channel: ModernDeploymentTracker.ManagementChannel);
|
||||
|
||||
var posted = Assert.Single(PostedModernDeployment());
|
||||
Assert.Equal("modern_deployment_error", posted.Payload![SignalPayloadKeys.EventType]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicSessions_KeepTheHarmlessDowngradePath_ForTheSameIds()
|
||||
{
|
||||
// Classic regression guard: without the WDP flag EventID 100 still flows through
|
||||
// the harmless-downgrade path (individual Debug emissions up to the rollup limit).
|
||||
var tracker = BuildTracker(isDevicePreparation: false);
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
Process(tracker, 100, level: 3, shortName: "Autopilot", channel: ModernDeploymentTracker.AutopilotChannel);
|
||||
}
|
||||
|
||||
Assert.Equal(2, PostedModernDeployment().Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -1497,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()
|
||||
{
|
||||
|
||||
+27
-11
@@ -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;
|
||||
@@ -211,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,
|
||||
@@ -228,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));
|
||||
@@ -348,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)
|
||||
{
|
||||
@@ -370,7 +385,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_modernDeploymentBackfillEnabled,
|
||||
_modernDeploymentBackfillLookbackMinutes,
|
||||
_stateDirectory,
|
||||
_modernDeploymentHarmlessEventIds);
|
||||
_modernDeploymentHarmlessEventIds,
|
||||
_isDevicePreparation);
|
||||
_modernDeploymentTracker.Start();
|
||||
}
|
||||
}
|
||||
|
||||
+38
-2
@@ -66,6 +66,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
internal const string AutopilotErrorBackfillStateFileName = "autopilot-error-backfill.json";
|
||||
|
||||
/// <summary>
|
||||
/// EventIDs that are STRUCTURAL noise on a Device Preparation session and are
|
||||
/// suppressed there entirely (afee7ae0: 900+ occurrences of 1005 plus 400+ of
|
||||
/// 417/418 in a nine-minute session, all expected): 100 "Autopilot policy not
|
||||
/// found" (no deployment profile exists on WDP by design), 417/418 "duplicate
|
||||
/// deployment workload/batch" (BootstrapperAgent SLDM progress-poll chatter),
|
||||
/// 1005 ManagementService WIL error storm. Genuine WDP diagnostics (e.g. 408
|
||||
/// provisioning warnings) keep flowing; Critical (level 1) records are never
|
||||
/// suppressed. Classic sessions are unaffected — they keep the configurable
|
||||
/// harmless-downgrade + rollup path instead.
|
||||
/// </summary>
|
||||
internal static readonly HashSet<int> DevicePreparationNoiseEventIds = new HashSet<int> { 100, 417, 418, 1005 };
|
||||
|
||||
/// <summary>
|
||||
/// Per-EventId emission caps for harmless-downgraded events (session 8bc1180f,
|
||||
/// 2026-06-12). Windows can dump hundreds of identical EventID-100 "Autopilot policy
|
||||
@@ -93,6 +106,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly HashSet<int> _harmlessEventIds;
|
||||
private readonly bool _isDevicePreparation;
|
||||
|
||||
private EventLogWatcher _autopilotWatcher;
|
||||
private EventLogWatcher _managementWatcher;
|
||||
@@ -131,7 +145,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 30,
|
||||
string stateDirectory = null,
|
||||
int[] harmlessEventIds = null)
|
||||
int[] harmlessEventIds = null,
|
||||
bool isDevicePreparation = false)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -140,6 +155,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_logLevelMax = logLevelMax;
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_isDevicePreparation = isDevicePreparation;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_harmlessEventIds = harmlessEventIds != null && harmlessEventIds.Length > 0
|
||||
? new HashSet<int>(harmlessEventIds)
|
||||
@@ -166,7 +182,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (_backfillEnabled)
|
||||
{
|
||||
BackfillTargetedEvents();
|
||||
BackfillAutopilotErrors();
|
||||
// WDP gate (afee7ae0): the error backfill replays ZTD registration /
|
||||
// attestation errors (807/809/815/908) — on Device Preparation the 807
|
||||
// ZtdDeviceIsNotRegistered answer is the EXPECTED state, not a failure.
|
||||
if (!_isDevicePreparation)
|
||||
{
|
||||
BackfillAutopilotErrors();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("ModernDeployment: Device Preparation session — Autopilot error backfill skipped (ZTD errors are structural there)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -393,6 +419,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
var effectiveLevel = level ?? 4;
|
||||
|
||||
// WDP structural-noise gate — see DevicePreparationNoiseEventIds. Sits before
|
||||
// the severity mapping so suppressed records also skip FormatDescription().
|
||||
// Critical (1) and LogAlways (0) records always pass.
|
||||
if (_isDevicePreparation
|
||||
&& effectiveLevel >= 2
|
||||
&& DevicePreparationNoiseEventIds.Contains(eventId))
|
||||
{
|
||||
return EventVerdict.Suppress;
|
||||
}
|
||||
switch (effectiveLevel)
|
||||
{
|
||||
case 1:
|
||||
|
||||
+19
-1
@@ -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)
|
||||
|
||||
+49
-12
@@ -275,14 +275,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Include enrollment type in autopilot_profile event data
|
||||
data["enrollmentType"] = detectedType;
|
||||
|
||||
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
|
||||
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
|
||||
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
|
||||
var diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
|
||||
if (diagnosticsRegistry != null)
|
||||
data["diagnosticsRegistry"] = diagnosticsRegistry;
|
||||
// Device Preparation (WDP) has no Autopilot deployment profile by design:
|
||||
// Windows still runs the ZTD policy download during OOBE and caches the 807
|
||||
// ZtdDeviceIsNotRegistered answer, so without this gate every WDP session
|
||||
// emits an autopilot_profile dump plus a misleading "no deployment profile
|
||||
// was assigned" warning and burns a ZTD event-log query + HTTP probe on
|
||||
// evidence that answers a question nobody asked (afee7ae0). Gated on the
|
||||
// deterministic WDP rule only — the CloudAssigned* fallback rules imply a
|
||||
// real profile (just ESP-less), where the dump stays meaningful. The
|
||||
// enrollment_type_detected event below still carries the rail decision.
|
||||
var isDevicePreparation = detectionRule ==
|
||||
AutopilotMonitor.Agent.V2.Core.Security.EnrollmentRegistryDetector.DevicePreparationExecutionContextRule;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
|
||||
Dictionary<string, object> diagnosticsRegistry = null;
|
||||
if (!isDevicePreparation)
|
||||
{
|
||||
// Second diagnostic surface (independent of AutopilotPolicyCache): Microsoft's
|
||||
// documented Diagnostics\Autopilot key (IsAutopilotDisabled, TenantMatched,
|
||||
// CloudAssignedTenantDomain/Id, ...). See docs/agent/autopilot-ztd-diagnostics.md.
|
||||
diagnosticsRegistry = ZtdEvidence.ReadDiagnosticsRegistry(_logger);
|
||||
if (diagnosticsRegistry != null)
|
||||
data["diagnosticsRegistry"] = diagnosticsRegistry;
|
||||
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfile, "Autopilot profile configuration", data);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("EnrollmentTracker: Device Preparation session — skipping autopilot_profile/ZTD evidence (no deployment profile exists on WDP)");
|
||||
}
|
||||
|
||||
// ProfileAvailable=0 means the ZTD service returned no profile for this device
|
||||
// during OOBE. Sessions only exist after the backend's device-registration
|
||||
@@ -293,7 +313,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// Autopilot session — surface the edge case explicitly, with a ZTD verdict
|
||||
// backed by the ModernDeployment Autopilot event log plus an endpoint probe
|
||||
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
|
||||
if (IsAutopilotProfileMissing(data))
|
||||
if (!isDevicePreparation && IsAutopilotProfileMissing(data))
|
||||
{
|
||||
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
|
||||
// are provisioned by the Windows 365 service, not by Autopilot — so the event
|
||||
@@ -787,10 +807,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
data["espTrackedModernCount"] = tracking.ModernCount;
|
||||
}
|
||||
|
||||
var summary = BuildSummary(snapshot, tracking);
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
|
||||
PostEspConfigDetectedSignal(snapshot);
|
||||
// No FirstSync value and no ESP tracking list means there is no ESP evidence at
|
||||
// all (Device Preparation, plain Entra join, pre-sync timing) — an event that
|
||||
// says nothing but "unknown/unknown" is noise, so skip it. Mirrors the null-gate
|
||||
// the orchestrator bootstrap already applies to the decision signal
|
||||
// (EnrollmentOrchestrator.PostEspConfigDetectedBootstrap).
|
||||
var hasAnyEspEvidence = snapshot.SkipUser.HasValue
|
||||
|| snapshot.SkipDevice.HasValue
|
||||
|| snapshot.BlockInStatusPage.HasValue
|
||||
|| snapshot.SyncFailureTimeoutMinutes.HasValue
|
||||
|| tracking.HasData;
|
||||
if (hasAnyEspEvidence)
|
||||
{
|
||||
var summary = BuildSummary(snapshot, tracking);
|
||||
_logger.Info($"EnrollmentTracker: ESP configuration detected — {summary}");
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.EspConfigDetected, $"ESP configuration: {summary}", data);
|
||||
PostEspConfigDetectedSignal(snapshot);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Debug("EnrollmentTracker: no ESP configuration evidence (FirstSync empty, no tracking lists) — esp_config_detected suppressed");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
+15
-2
@@ -228,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
|
||||
@@ -557,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;
|
||||
|
||||
@@ -566,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)
|
||||
|
||||
@@ -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,230 @@
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user