diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/DesktopArrivalDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/DesktopArrivalDetectorTests.cs index 02d4f1e7..ddc62407 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/DesktopArrivalDetectorTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/DesktopArrivalDetectorTests.cs @@ -405,5 +405,144 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring var started = trace.Events.Find(e => e.EventType == SharedEventTypes.DesktopDetectorStarted); Assert.Equal(0, started.Data["noCandidateThresholdMinutes"]); } + + // ============================================================================ + // OOBE-state flip (oobe_state_completed) — observational one-shot on the poll + // ============================================================================ + // The WinRT OutOfBoxExperienceState flip in_progress->completed is an owner- + // independent desktop corroboration for DAD-starved sessions (4d5a0b78 class). + // Tests drive polls synchronously via PollOnceForTest after Stop() so the real + // timer cannot race, and pin the owner seams to an excluded user so a real + // explorer.exe on the test machine can never set _desktopArrived (which would + // short-circuit PollForDesktop before the flip check). + + private static DesktopArrivalDetector BuildFlipDetector(TempDirectory tmp, TraceCapture trace, System.Func oobeProvider) + { + var detector = new DesktopArrivalDetector(new AgentLogger(tmp.Path, AgentLogLevel.Info), noCandidateTimeoutMinutes: 0) + { + OnTraceEvent = trace.Sink, + OobeStateProvider = oobeProvider, + SessionOwnerResolver = _ => "SYSTEM", + ProcessOwnerResolver = _ => "SYSTEM", + }; + return detector; + } + + /// Returns the queued states in order, then repeats the last one forever. + private static System.Func StateQueue(params string[] states) + { + var queue = new Queue(states); + var last = states[states.Length - 1]; + return () => queue.Count > 0 ? queue.Dequeue() : last; + } + + [Fact] + public void OobeFlip_emits_exactly_once_with_payload() + { + using var tmp = new TempDirectory(); + var trace = new TraceCapture(); + // Baseline sample happens synchronously inside Start()/ArmTimer. + using var detector = BuildFlipDetector(tmp, trace, StateQueue("in_progress", "completed")); + + detector.Start(); + detector.Stop(); + detector.PollOnceForTest(); // reads "completed" -> flip + detector.PollOnceForTest(); // still "completed" -> no second emit + + Assert.Equal(1, trace.CountOf(SharedEventTypes.OobeStateCompleted)); + var flip = trace.Events.Find(e => e.EventType == SharedEventTypes.OobeStateCompleted); + Assert.Equal("in_progress", flip.Data["previousState"]); + Assert.True(flip.Data.ContainsKey("pollCount")); + Assert.True(flip.Data.ContainsKey("minutesSinceStart")); + Assert.False((bool)flip.Data["desktopArrived"]); + Assert.True(flip.Data.ContainsKey("wmiErrorCount")); + Assert.True(flip.Data.ContainsKey("wtsErrorCount")); + } + + [Fact] + public void OobeFlip_does_not_emit_when_state_is_already_completed() + { + using var tmp = new TempDirectory(); + var trace = new TraceCapture(); + using var detector = BuildFlipDetector(tmp, trace, StateQueue("completed")); + + detector.Start(); + detector.Stop(); + detector.PollOnceForTest(); + detector.PollOnceForTest(); + + // Productive machines read "completed" from the very first sample — the flip + // event must never fire there (the agent_started field already records the state). + Assert.Equal(0, trace.CountOf(SharedEventTypes.OobeStateCompleted)); + } + + [Fact] + public void OobeFlip_does_not_emit_when_in_progress_was_never_seen() + { + using var tmp = new TempDirectory(); + var trace = new TraceCapture(); + using var detector = BuildFlipDetector(tmp, trace, StateQueue("unavailable", "completed")); + + detector.Start(); + detector.Stop(); + detector.PollOnceForTest(); + + // Old builds without the WinRT contract report "unavailable"; a later + // "completed" without a positively observed "in_progress" is not a flip. + Assert.Equal(0, trace.CountOf(SharedEventTypes.OobeStateCompleted)); + } + + [Fact] + public void OobeFlip_survives_transient_unavailable_between_polls() + { + using var tmp = new TempDirectory(); + var trace = new TraceCapture(); + using var detector = BuildFlipDetector(tmp, trace, StateQueue("in_progress", "unavailable", "completed")); + + detector.Start(); + detector.Stop(); + detector.PollOnceForTest(); // transient reflection failure — must NOT clear the baseline + detector.PollOnceForTest(); // "completed" -> flip still detected + + Assert.Equal(1, trace.CountOf(SharedEventTypes.OobeStateCompleted)); + } + + [Fact] + public void OobeFlip_provider_throwing_never_emits_and_never_pollutes_wmi_error_count() + { + using var tmp = new TempDirectory(); + var trace = new TraceCapture(); + using var detector = BuildFlipDetector(tmp, trace, () => throw new System.InvalidOperationException("boom")); + + detector.Start(); // baseline sample throws -> swallowed + detector.Stop(); + detector.PollOnceForTest(); + detector.PollOnceForTest(); + + Assert.Equal(0, trace.CountOf(SharedEventTypes.OobeStateCompleted)); + // The flip check has its own try/catch — a throw must not fall into + // PollForDesktop's outer catch, which increments the WMI error counter the + // session-4d5a0b78 liveness diagnostics rely on. first_poll carries that counter. + var firstPoll = trace.Events.Find(e => e.EventType == SharedEventTypes.DesktopDetectorFirstPoll); + Assert.Equal(0, firstPoll.Data["wmiErrorCount"]); + } + + [Fact] + public void OobeFlip_one_shot_flag_survives_ResetForRealUserSwitch() + { + using var tmp = new TempDirectory(); + var trace = new TraceCapture(); + using var detector = BuildFlipDetector(tmp, trace, + StateQueue("in_progress", "completed", "in_progress", "completed")); + + detector.Start(); + detector.Stop(); + detector.PollOnceForTest(); // flip #1 + detector.ResetForRealUserSwitch(); // re-arms + re-samples baseline ("in_progress") + detector.Stop(); + detector.PollOnceForTest(); // "completed" again — must NOT re-emit + + Assert.Equal(1, trace.CountOf(SharedEventTypes.OobeStateCompleted)); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/OobeStateReaderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/OobeStateReaderTests.cs new file mode 100644 index 00000000..9d0ebfe5 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/OobeStateReaderTests.cs @@ -0,0 +1,40 @@ +#nullable enable +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring +{ + /// + /// Smoke tests for the WinRT SystemSetupInfo.OutOfBoxExperienceState reader. + /// The actual state on the test machine is unknown (CI runners are post-OOBE; Server + /// SKUs may lack the UniversalApiContract entirely), so only the fail-soft contract + /// is pinned: never throw, always return a member of the known value set. + /// + public sealed class OobeStateReaderTests + { + [Fact] + public void Read_never_throws_and_returns_known_value() + { + var value = OobeStateReader.Read(); + + Assert.False(string.IsNullOrEmpty(value)); + Assert.True( + value == "not_started" + || value == "in_progress" + || value == "completed" + || value == OobeStateReader.Unavailable + || value.StartsWith("unknown_"), + $"unexpected reader value '{value}'"); + } + + [Fact] + public void Read_is_stable_across_repeated_calls() + { + // Exercises the cached-PropertyInfo path (second call skips resolution). + var first = OobeStateReader.Read(); + var second = OobeStateReader.Read(); + + Assert.Equal(first, second); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs index ddfffcb9..4224014d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/DesktopArrivalDetector.cs @@ -61,6 +61,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals internal Func SessionOwnerResolver { get; set; } internal Func ProcessOwnerResolver { get; set; } + // Test seam for the WinRT OOBE-state sample (null = OobeStateReader.Read). + internal Func OobeStateProvider { get; set; } + + // OOBE-state flip tracking (observational only — see OobeStateReader contract). + // _oobeCompletedEmitted is one-shot per agent lifetime and deliberately NOT reset + // by ResetForRealUserSwitch: the OOBE flip is a global OS transition, not per-user. + private string _lastOobeState; + private bool _oobeCompletedEmitted; + /// /// System/service account names that should NOT be considered real users. /// @@ -161,6 +170,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals { _startedAtUtc = DateTime.UtcNow; + // Baseline OOBE-state sample so the first poll can already detect a flip + // (self-contained try/catch inside, like the Emit* helpers). + CheckOobeStateFlip(); + _pollingTimer?.Dispose(); _pollingTimer = new Timer( PollForDesktop, @@ -186,6 +199,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals _pollCount++; + CheckOobeStateFlip(); + try { var explorerProcesses = Process.GetProcessesByName("explorer"); @@ -381,6 +396,66 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals catch (Exception ex) { _logger.Verbose($"DesktopArrivalDetector: detector-started emit failed: {ex.Message}"); } } + /// + /// Samples the WinRT OOBE state () and emits the one-shot + /// oobe_state_completed trace event on the in_progress->completed flip. + /// Observational only — an owner-independent desktop corroboration for sessions where + /// WTS+WMI owner resolution starves and desktop_arrived never fires. Often absent + /// in healthy sessions: the polling timer is disposed at desktop arrival, which is when + /// the flip lands (empirical probe, session 9c404ae9). + /// + /// Self-contained try/catch: a throw here must NOT reach 's + /// outer catch, which would inflate and skew the + /// session-4d5a0b78 liveness diagnostics. "unavailable" readings never overwrite the last + /// known state, so a transient reflection failure between two polls cannot mask the flip. + /// One-shot flag survives — the flip is a global OS + /// transition, not per-user. + /// + /// + private void CheckOobeStateFlip() + { + try + { + var current = (OobeStateProvider ?? OobeStateReader.Read)(); + if (string.IsNullOrEmpty(current) || current == OobeStateReader.Unavailable) + return; + + if (!_oobeCompletedEmitted + && _lastOobeState == "in_progress" + && current == "completed") + { + _oobeCompletedEmitted = true; + var minutesSinceStart = Math.Round((DateTime.UtcNow - _startedAtUtc).TotalMinutes, 1); + _logger.Info("DesktopArrivalDetector: OOBE state flipped in_progress->completed (WinRT SystemSetupInfo)"); + OnTraceEvent?.Invoke( + SharedEventTypes.OobeStateCompleted, + $"OOBE state flipped in_progress->completed (WinRT SystemSetupInfo, poll {_pollCount}) — owner-independent desktop corroboration.", + new Dictionary + { + { "previousState", "in_progress" }, + { "pollCount", _pollCount }, + { "minutesSinceStart", minutesSinceStart }, + { "desktopArrived", _desktopArrived }, + { "wmiErrorCount", _wmiErrorCountSinceStart }, + { "wtsErrorCount", _wtsErrorCountSinceStart }, + }); + } + + _lastOobeState = current; + } + catch (Exception ex) + { + _logger.Verbose($"DesktopArrivalDetector: OOBE state check failed: {ex.Message}"); + } + } + + /// + /// Drives a single synchronous poll for unit tests — the production poll is private and + /// timer-driven with a fixed 30 s interval (precedent: is + /// likewise internal for direct testing). + /// + internal void PollOnceForTest() => PollForDesktop(null); + /// /// Resolves the owning user of an explorer.exe candidate. Session 4d5a0b78 fix /// (2026-06-11): the WMI Win32_Process.GetOwner call failed on every poll on diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/OobeStateReader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/OobeStateReader.cs new file mode 100644 index 00000000..f4839d53 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/OobeStateReader.cs @@ -0,0 +1,72 @@ +using System; +using System.Reflection; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop +{ + /// + /// Reads WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState + /// (NotStarted=0 / InProgress=1 / Completed=2) via the CLR's built-in WinRT projection — + /// no NuGet package, no winmd reference; the type is resolved at runtime with + /// ContentType=WindowsRuntime, the same mechanism the bootstrap script uses from + /// PowerShell 5.1. + /// + /// Contract: observational only — never feed this into decision-engine logic. + /// Requires Windows 10 1809+ (build 17763, UniversalApiContract 7.0) and is callable + /// from the SYSTEM service context (empirically validated, probe session 9c404ae9). + /// On older builds or SKUs without the contract the type resolves to null and + /// returns "unavailable". never throws. + /// + /// + /// Empirical timing (Win11 24H2 probe): the state flips InProgress→Completed at desktop + /// arrival — it is a corroboration signal, not an early one. WDP reseal and placeholder + /// (fooUser) flip semantics are unverified; another reason this must stay observational. + /// + /// + internal static class OobeStateReader + { + public const string Unavailable = "unavailable"; + + // Resolved once per process. Benign race: concurrent first calls resolve the same + // PropertyInfo and the assignment is idempotent — no lock needed. + private static PropertyInfo _stateProperty; + private static bool _initTried; + + /// + /// Returns "not_started" / "in_progress" / "completed", + /// "unknown_<n>" for unexpected enum values, or "unavailable" + /// when the WinRT contract is absent or any part of the read fails. Never throws. + /// + public static string Read() + { + try + { + if (!_initTried) + { + var type = Type.GetType( + "Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType=WindowsRuntime", + throwOnError: false); + _stateProperty = type?.GetProperty( + "OutOfBoxExperienceState", + BindingFlags.Public | BindingFlags.Static); + _initTried = true; + } + + if (_stateProperty == null) + return Unavailable; + + var value = Convert.ToInt32(_stateProperty.GetValue(null)); + switch (value) + { + case 0: return "not_started"; + case 1: return "in_progress"; + case 2: return "completed"; + default: return "unknown_" + value; + } + } + catch + { + return Unavailable; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs index 17d993cb..cb504e02 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using AutopilotMonitor.Agent.V2.Core.Configuration; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Runtime; @@ -93,6 +94,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination // correction between agent start and termination no longer skews bootToStartSeconds. private readonly ulong _bootAnchorTickMs; private readonly DateTime _bootAnchorUtc; + // WinRT OOBE state at construction (handler is built at agent start) — corroborates + // the late-start heuristic: "completed" here means OOBE was already over on arrival. + private readonly string _oobeStateAtAgentStart; private int _handled; public EnrollmentTerminationHandler( @@ -129,6 +133,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination _bootAnchorTickMs = ObservationCoverage.CurrentTickMs(); _bootAnchorUtc = DateTime.UtcNow; + _oobeStateAtAgentStart = OobeStateReader.Read(); } /// @@ -658,6 +663,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination { "agentUptimeSeconds", Math.Round(uptimeSeconds, 1) }, { "deviceBootUtc", deviceBootUtc.ToString("o") }, { "agentStartUtc", _agentStartTimeUtc.ToString("o") }, + { "oobeStateAtAgentStart", _oobeStateAtAgentStart }, { "outcome", args.Outcome.ToString() }, { "note", "The agent's bootstrap (an Intune platform script) ran only near the end of the enrollment, so the agent observed the already-decided end-state rather than the failure window. Treat the diagnosis as a post-mortem; check for a platform/remediation script that hung ahead of the bootstrap (see script_timeout_suspected)." }, }, diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs index 60ff2914..ae5857c9 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using AutopilotMonitor.Agent.V2.Core.Configuration; using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Security; @@ -68,6 +69,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime { "configVersion", configVersion }, { "remoteConfigFetched", outcome == RemoteConfigFetchOutcome.Succeeded }, { "remoteConfigOutcome", outcome.ToString() }, + // WinRT OOBE state sampled at this start. Together with previousExitType + // this makes late starts provable: "completed" on a first_run means OOBE + // was already over when the agent arrived (e.g. IME queue blocked). + { "oobeStateAtAgentStart", OobeStateReader.Read() }, }; if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType)) diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index e9efac94..b5f09a67 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -34,6 +34,7 @@ export const EVENT_TYPES_CATALOG = { 'desktop_detector_started', 'desktop_detector_first_poll', 'desktop_detector_no_candidate', + 'oobe_state_completed', ], whiteglove_events: [ 'whiteglove_complete', diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 84474b66..b96d37bd 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -317,6 +317,12 @@ namespace AutopilotMonitor.Shared public const string DesktopDetectorStarted = "desktop_detector_started"; // 1x on Start() / ResetForRealUserSwitch() public const string DesktopDetectorFirstPoll = "desktop_detector_first_poll"; // 1x after first PollForDesktop() completes public const string DesktopDetectorNoCandidate = "desktop_detector_no_candidate"; // 1x after threshold polls without resolution (configurable) + // 1x when WinRT SystemSetupInfo.OutOfBoxExperienceState flips InProgress->Completed, + // sampled on the DAD 30s poll. Observational only (no decision-engine involvement) — + // an owner-independent desktop corroboration for sessions where WTS+WMI owner + // resolution starves and desktop_arrived never fires. Often absent in healthy + // sessions: the DAD timer is disposed at desktop arrival, which is when the flip lands. + public const string OobeStateCompleted = "oobe_state_completed"; public const string CompletionCheck = "completion_check"; // Liveness plan PR2 — emitted (state-change-only, deduped via the // CompletionWaitingFingerprint state fact) whenever the DecisionEngine blocks or diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index 05a0f6c3..f698db6d 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -32,6 +32,8 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ description: "Pre-Provisioning Part 2 resumed after the user powered on the device." }, { value: "desktop_arrived", label: "desktop_arrived", category: "enrollment", description: "User desktop detected (explorer.exe under real user)." }, + { value: "oobe_state_completed", label: "oobe_state_completed", category: "enrollment", + description: "Windows OOBE state flipped InProgress→Completed (WinRT SystemSetupInfo, sampled on the desktop-arrival poll). One-shot and observational only; valuable as an owner-independent desktop corroboration in sessions where desktop_arrived never fires." }, { value: "completion_check", label: "completion_check", category: "enrollment", description: "Throttled state-machine snapshot during completion evaluation." }, { value: "completion_waiting", label: "completion_waiting", category: "enrollment",