From f2bbee6aa75706ae326f52b1356ff3d29ec5ae77 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Mon, 13 Apr 2026 22:14:44 +0200 Subject: [PATCH] agent feat: added CompletionStateMachine enhance reliability and testability --- .../Tracking/CompletionGuardsTests.cs | 206 ++++ .../Tracking/CompletionStateMachineTests.cs | 1076 +++++++++++++++++ .../Monitoring/Tracking/CompletionGuards.cs | 96 ++ .../Tracking/CompletionStateMachine.cs | 752 ++++++++++++ .../Tracking/EnrollmentCompletionState.cs | 120 ++ .../Tracking/EnrollmentStatePersistence.cs | 8 + .../EnrollmentTracker.CompletionLogic.cs | 68 ++ .../EnrollmentTracker.EventHandlers.cs | 12 + .../Monitoring/Tracking/EnrollmentTracker.cs | 80 ++ .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- 10 files changed, 2419 insertions(+), 1 deletion(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionGuardsTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionGuards.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionStateMachine.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentCompletionState.cs diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionGuardsTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionGuardsTests.cs new file mode 100644 index 00000000..5888e1a8 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionGuardsTests.cs @@ -0,0 +1,206 @@ +using System; +using AutopilotMonitor.Agent.Core.Monitoring.Tracking; +using Xunit; + +namespace AutopilotMonitor.Agent.Core.Tests.Tracking +{ + public class CompletionGuardsTests + { + // ===== Hello Resolution Guard ===== + + [Fact] + public void HelloGuard_NoTracker_ReturnsResolved() + { + Assert.True(CompletionGuards.IsHelloResolved( + hasTracker: false, isHelloCompleted: false, + isPolicyConfigured: false, isDeviceOnly: false)); + } + + [Fact] + public void HelloGuard_HelloCompleted_ReturnsResolved() + { + Assert.True(CompletionGuards.IsHelloResolved( + hasTracker: true, isHelloCompleted: true, + isPolicyConfigured: true, isDeviceOnly: false)); + } + + [Fact] + public void HelloGuard_PolicyNotConfigured_ReturnsResolved() + { + Assert.True(CompletionGuards.IsHelloResolved( + hasTracker: true, isHelloCompleted: false, + isPolicyConfigured: false, isDeviceOnly: false)); + } + + [Fact] + public void HelloGuard_DeviceOnly_ReturnsResolved() + { + Assert.True(CompletionGuards.IsHelloResolved( + hasTracker: true, isHelloCompleted: false, + isPolicyConfigured: true, isDeviceOnly: true)); + } + + [Fact] + public void HelloGuard_PolicyConfigured_HelloPending_ReturnsPending() + { + Assert.False(CompletionGuards.IsHelloResolved( + hasTracker: true, isHelloCompleted: false, + isPolicyConfigured: true, isDeviceOnly: false)); + } + + // ===== ESP Gate Guard ===== + + [Fact] + public void EspGate_DesktopArrival_EspActive_V1_Blocks() + { + Assert.True(CompletionGuards.IsEspGateBlocking( + source: "desktop_arrival", enrollmentType: "v1", + espEverSeen: true, espFinalExitSeen: false)); + } + + [Fact] + public void EspGate_DesktopHello_EspActive_V1_Blocks() + { + Assert.True(CompletionGuards.IsEspGateBlocking( + source: "desktop_hello", enrollmentType: "v1", + espEverSeen: true, espFinalExitSeen: false)); + } + + [Fact] + public void EspGate_DesktopArrival_EspExited_Passes() + { + Assert.False(CompletionGuards.IsEspGateBlocking( + source: "desktop_arrival", enrollmentType: "v1", + espEverSeen: true, espFinalExitSeen: true)); + } + + [Fact] + public void EspGate_DesktopArrival_V2_Passes() + { + Assert.False(CompletionGuards.IsEspGateBlocking( + source: "desktop_arrival", enrollmentType: "v2", + espEverSeen: true, espFinalExitSeen: false)); + } + + [Fact] + public void EspGate_NonDesktopSource_Passes() + { + Assert.False(CompletionGuards.IsEspGateBlocking( + source: "esp_hello_composite", enrollmentType: "v1", + espEverSeen: true, espFinalExitSeen: false)); + } + + [Fact] + public void EspGate_EspNeverSeen_Passes() + { + Assert.False(CompletionGuards.IsEspGateBlocking( + source: "desktop_arrival", enrollmentType: "v1", + espEverSeen: false, espFinalExitSeen: false)); + } + + // ===== Hybrid Reboot Gate Guard ===== + + [Fact] + public void HybridRebootGate_NonHybrid_Passes() + { + Assert.False(CompletionGuards.IsHybridRebootGateBlocking( + isHybridJoin: false, source: "esp_hello_composite", + imePatternSeenUtc: null, espFinalExitUtc: DateTime.UtcNow.AddMinutes(-5), + agentStartTimeUtc: DateTime.UtcNow.AddMinutes(-10))); + } + + [Fact] + public void HybridRebootGate_NonCompositeSource_Passes() + { + Assert.False(CompletionGuards.IsHybridRebootGateBlocking( + isHybridJoin: true, source: "ime_pattern", + imePatternSeenUtc: null, espFinalExitUtc: DateTime.UtcNow.AddMinutes(-5), + agentStartTimeUtc: DateTime.UtcNow.AddMinutes(-10))); + } + + [Fact] + public void HybridRebootGate_Hybrid_NoIme_NoRestart_Blocks() + { + var espExitTime = DateTime.UtcNow.AddMinutes(-5); + var agentStartBeforeEspExit = espExitTime.AddMinutes(-10); + + Assert.True(CompletionGuards.IsHybridRebootGateBlocking( + isHybridJoin: true, source: "esp_hello_composite", + imePatternSeenUtc: null, espFinalExitUtc: espExitTime, + agentStartTimeUtc: agentStartBeforeEspExit)); + } + + [Fact] + public void HybridRebootGate_Hybrid_ImeCompleted_Passes() + { + var espExitTime = DateTime.UtcNow.AddMinutes(-5); + var agentStartBeforeEspExit = espExitTime.AddMinutes(-10); + + Assert.False(CompletionGuards.IsHybridRebootGateBlocking( + isHybridJoin: true, source: "esp_hello_composite", + imePatternSeenUtc: DateTime.UtcNow.AddMinutes(-1), + espFinalExitUtc: espExitTime, + agentStartTimeUtc: agentStartBeforeEspExit)); + } + + [Fact] + public void HybridRebootGate_Hybrid_AgentRestartedAfterEspExit_Passes() + { + var espExitTime = DateTime.UtcNow.AddMinutes(-10); + var agentStartAfterEspExit = espExitTime.AddMinutes(5); + + Assert.False(CompletionGuards.IsHybridRebootGateBlocking( + isHybridJoin: true, source: "esp_hello_composite", + imePatternSeenUtc: null, espFinalExitUtc: espExitTime, + agentStartTimeUtc: agentStartAfterEspExit)); + } + + [Fact] + public void HybridRebootGate_Hybrid_EspExitUtcNull_Blocks() + { + // Edge case: hybrid join, composite source, no ESP exit recorded + // agentRestartedAfterEspExit = false (espFinalExitUtc is null) + Assert.True(CompletionGuards.IsHybridRebootGateBlocking( + isHybridJoin: true, source: "esp_hello_composite", + imePatternSeenUtc: null, espFinalExitUtc: null, + agentStartTimeUtc: DateTime.UtcNow)); + } + + // ===== Device-Only Deployment Classification ===== + + [Fact] + public void IsDeviceOnly_SelfDeploying_True() + { + Assert.True(CompletionGuards.IsDeviceOnlyDeployment( + autopilotMode: 1, skipUserStatusPage: null, aadJoinedWithUser: false)); + } + + [Fact] + public void IsDeviceOnly_SkipUserNoAadUser_True() + { + Assert.True(CompletionGuards.IsDeviceOnlyDeployment( + autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false)); + } + + [Fact] + public void IsDeviceOnly_SkipUserWithAadUser_False() + { + Assert.False(CompletionGuards.IsDeviceOnlyDeployment( + autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: true)); + } + + [Fact] + public void IsDeviceOnly_UserDriven_NoSkip_False() + { + Assert.False(CompletionGuards.IsDeviceOnlyDeployment( + autopilotMode: 0, skipUserStatusPage: false, aadJoinedWithUser: false)); + } + + [Fact] + public void IsDeviceOnly_UnknownMode_SkipUserNull_False() + { + Assert.False(CompletionGuards.IsDeviceOnlyDeployment( + autopilotMode: null, skipUserStatusPage: null, aadJoinedWithUser: false)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs new file mode 100644 index 00000000..acd71c8d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs @@ -0,0 +1,1076 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.Core.Monitoring.Tracking; +using Xunit; + +namespace AutopilotMonitor.Agent.Core.Tests.Tracking +{ + public class CompletionStateMachineTests + { + // ===== Helper Methods ===== + + private static CompletionContext DefaultContext( + string enrollmentType = "v1", + bool isHybridJoin = false, + int? autopilotMode = 0, + bool? skipUserStatusPage = false, + bool aadJoinedWithUser = false, + bool hasHelloTracker = true, + bool isHelloCompleted = false, + bool isHelloPolicyConfigured = true, + bool hasUnresolvedEspCategories = false, + bool deviceInfoCollected = true, + string lastEspPhase = null, + string source = null) + { + return new CompletionContext + { + EnrollmentType = enrollmentType, + IsHybridJoin = isHybridJoin, + AutopilotMode = autopilotMode, + SkipUserStatusPage = skipUserStatusPage, + AadJoinedWithUser = aadJoinedWithUser, + HasHelloTracker = hasHelloTracker, + IsHelloCompleted = isHelloCompleted, + IsHelloPolicyConfigured = isHelloPolicyConfigured, + HasUnresolvedEspCategories = hasUnresolvedEspCategories, + DeviceInfoCollected = deviceInfoCollected, + LastEspPhase = lastEspPhase, + Source = source, + AgentStartTimeUtc = DateTime.UtcNow.AddMinutes(-30), + EspFinalExitUtc = null, + EspEverSeen = false, + EspFinalExitSeen = false, + DesktopArrived = false, + ImePatternSeenUtc = null + }; + } + + private static CompletionContext DeviceOnlyContext(int? autopilotMode = 1) + { + return DefaultContext( + autopilotMode: autopilotMode, + skipUserStatusPage: autopilotMode == 1 ? (bool?)null : true, + aadJoinedWithUser: false, + isHelloPolicyConfigured: false); + } + + private static CompletionContext HybridJoinContext() + { + return DefaultContext( + isHybridJoin: true, + lastEspPhase: "AccountSetup"); + } + + // ===== SECTION 1: State Transition Unit Tests ===== + + [Fact] + public void Idle_EspPhaseChanged_TransitionsToEspActive() + { + var sm = new CompletionStateMachine(); + var result = sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + Assert.True(result.Transitioned); + Assert.Equal(EnrollmentCompletionState.EspActive, sm.CurrentState); + } + + [Fact] + public void Idle_DesktopArrived_NoEsp_TransitionsToDesktopArrivedAwaitingHello() + { + var sm = new CompletionStateMachine(); + var ctx = DefaultContext(isHelloPolicyConfigured: true, isHelloCompleted: false); + var result = sm.ProcessTrigger("desktop_arrived", ctx); + + Assert.True(result.Transitioned); + Assert.Equal(EnrollmentCompletionState.DesktopArrivedAwaitingHello, sm.CurrentState); + Assert.True(result.ShouldStartHelloWaitTimer); + } + + [Fact] + public void Idle_DesktopArrived_NoHelloTracker_CompletesImmediately() + { + var sm = new CompletionStateMachine(); + var ctx = DefaultContext(hasHelloTracker: false); + var result = sm.ProcessTrigger("desktop_arrived", ctx); + + Assert.True(result.Transitioned); + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("desktop_arrival", result.CompletionSource); + } + + [Fact] + public void EspActive_EspExitingFromAccountSetup_TransitionsToEspExited() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + var ctx = DefaultContext(lastEspPhase: "AccountSetup"); + var result = sm.ProcessTrigger("esp_exiting", ctx); + + Assert.True(result.Transitioned); + Assert.True(result.ShouldStartHelloWaitTimer); + Assert.True(result.ShouldPersistState); + Assert.Contains("esp_final_exit", result.SignalsToRecord); + } + + [Fact] + public void EspActive_EspExitingSkipUser_TransitionsToDeviceOnly() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + var ctx = DefaultContext( + lastEspPhase: "DeviceSetup", + skipUserStatusPage: true, + aadJoinedWithUser: false, + isHelloPolicyConfigured: false); + var result = sm.ProcessTrigger("esp_exiting", ctx); + + Assert.True(result.Transitioned); + Assert.Contains("device_only_esp_registry", result.SignalsToRecord); + } + + [Fact] + public void EspActive_EspExitingFallback_StartsDeviceOnlyTimer() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + var ctx = DefaultContext(lastEspPhase: "DeviceSetup", skipUserStatusPage: null); + var result = sm.ProcessTrigger("esp_exiting", ctx); + + Assert.True(result.ShouldStartDeviceOnlyEspTimer); + Assert.Equal(EnrollmentCompletionState.EspActive, sm.CurrentState); + } + + [Fact] + public void EspActive_DesktopArrived_TransitionsToDesktopArrivedEspBlocking() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + var ctx = DefaultContext(skipUserStatusPage: false); + var result = sm.ProcessTrigger("desktop_arrived", ctx); + + Assert.True(result.Transitioned); + Assert.Equal(EnrollmentCompletionState.DesktopArrivedEspBlocking, sm.CurrentState); + } + + [Fact] + public void EspExited_HelloCompleted_TransitionsToCompleted() + { + var sm = new CompletionStateMachine(); + // Set up: ESP seen -> ESP exited + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); + + var ctx = DefaultContext(isHelloCompleted: true); + var result = sm.ProcessTrigger("hello_completed", ctx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("esp_hello_composite", result.CompletionSource); + } + + [Fact] + public void EspExited_HelloCompleted_HybridGate_TransitionsToBlocked() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + // ESP exits — but hybrid join with agent started before ESP exit + var espExitTime = DateTime.UtcNow; + var ctx = HybridJoinContext(); + ctx.EspFinalExitUtc = espExitTime; + ctx.AgentStartTimeUtc = espExitTime.AddMinutes(-10); // Started BEFORE ESP exit + sm.ProcessTrigger("esp_exiting", ctx); + + // Hello completes — but reboot gate should block + var helloCtx = HybridJoinContext(); + helloCtx.IsHelloCompleted = true; + helloCtx.EspFinalExitUtc = espExitTime; + helloCtx.AgentStartTimeUtc = espExitTime.AddMinutes(-10); + helloCtx.ImePatternSeenUtc = null; // No IME completion + var result = sm.ProcessTrigger("hello_completed", helloCtx); + + Assert.Equal(EnrollmentCompletionState.HybridRebootGateBlocked, sm.CurrentState); + Assert.False(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void HybridGateBlocked_ImePattern_TransitionsToCompleted() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + var espExitTime = DateTime.UtcNow; + var ctx = HybridJoinContext(); + ctx.EspFinalExitUtc = espExitTime; + ctx.AgentStartTimeUtc = espExitTime.AddMinutes(-10); + sm.ProcessTrigger("esp_exiting", ctx); + + var helloCtx = HybridJoinContext(); + helloCtx.IsHelloCompleted = true; + helloCtx.EspFinalExitUtc = espExitTime; + helloCtx.AgentStartTimeUtc = espExitTime.AddMinutes(-10); + sm.ProcessTrigger("hello_completed", helloCtx); + Assert.Equal(EnrollmentCompletionState.HybridRebootGateBlocked, sm.CurrentState); + + // IME pattern arrives — should unlock + var imeCtx = HybridJoinContext(); + imeCtx.IsHelloCompleted = true; + imeCtx.ImePatternSeenUtc = DateTime.UtcNow; + imeCtx.IsHelloPolicyConfigured = false; // Hello already resolved + var result = sm.ProcessTrigger("ime_user_session_completed", imeCtx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void WaitingForHello_HelloCompleted_TransitionsToCompleted() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.WaitingForHello, + espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, + isWaitingForHello: true, isWaitingForEspSettle: false, deferredSource: null); + + var ctx = DefaultContext(isHelloCompleted: true); + var result = sm.ProcessTrigger("hello_completed", ctx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("ime_hello", result.CompletionSource); + } + + [Fact] + public void WaitingForHello_SafetyTimeout_TransitionsToCompleted() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.WaitingForHello, + espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, + isWaitingForHello: true, isWaitingForEspSettle: false, deferredSource: null); + + var result = sm.ProcessTrigger("hello_safety_timeout", DefaultContext()); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.True(result.ShouldForceMarkHelloCompleted); + Assert.Equal("ime_hello_safety_timeout", result.CompletionSource); + } + + [Fact] + public void WaitingForEspSettle_SettleTimeout_TransitionsToCompleted() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.WaitingForEspSettle, + espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, + isWaitingForHello: false, isWaitingForEspSettle: true, deferredSource: null); + + var result = sm.ProcessTrigger("esp_settle_timeout", DefaultContext()); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("ime_pattern", result.CompletionSource); + } + + [Fact] + public void DesktopEspBlocking_EspFinalExit_HelloResolved_TransitionsToCompleted() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + sm.ProcessTrigger("desktop_arrived", DefaultContext(skipUserStatusPage: false)); + Assert.Equal(EnrollmentCompletionState.DesktopArrivedEspBlocking, sm.CurrentState); + + // ESP exits — Hello already resolved + var ctx = DefaultContext(lastEspPhase: "AccountSetup", isHelloCompleted: true); + var result = sm.ProcessTrigger("esp_exiting", ctx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void DesktopEspBlocking_EspFinalExit_HelloPending_TransitionsToAwaitingCompletion() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + sm.ProcessTrigger("desktop_arrived", DefaultContext(skipUserStatusPage: false)); + + // ESP exits — Hello still pending + var ctx = DefaultContext(lastEspPhase: "AccountSetup", isHelloCompleted: false); + var result = sm.ProcessTrigger("esp_exiting", ctx); + + // Should be in EspExitedAwaitingCompletion (not Completed, Hello pending) + Assert.NotEqual(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.False(result.ShouldEmitEnrollmentComplete); + Assert.True(result.ShouldStartHelloWaitTimer); + } + + [Fact] + public void DesktopAwaitingHello_HelloCompleted_TransitionsToCompleted() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.DesktopArrivedAwaitingHello, + espEverSeen: false, espFinalExitSeen: false, desktopArrived: true, + isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); + + var ctx = DefaultContext(isHelloCompleted: true); + var result = sm.ProcessTrigger("hello_completed", ctx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("desktop_hello", result.CompletionSource); + } + + [Fact] + public void DeviceOnly_ProvisioningComplete_TransitionsToCompleted() + { + var sm = new CompletionStateMachine(); + var ctx = DeviceOnlyContext(); + ctx.DeviceInfoCollected = true; + var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("self_deploying_provisioning_complete", result.CompletionSource); + } + + [Fact] + public void DeviceOnlySafety_Timeout_TransitionsToCompleted() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.DeviceOnlySafetyWait, + espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, + isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); + + var result = sm.ProcessTrigger("device_only_safety_timeout", DefaultContext()); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.True(result.ShouldForceMarkHelloCompleted); + } + + [Fact] + public void AnyNonTerminal_EspFailure_TransitionsToFailed() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + var ctx = DefaultContext(source: "ESPProgress_Failure"); + var result = sm.ProcessTrigger("esp_failure_terminal", ctx); + + Assert.Equal(EnrollmentCompletionState.Failed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentFailed); + } + + [Fact] + public void AnyNonTerminal_WhiteGlove_TransitionsToWhiteGloveCompleted() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + var result = sm.ProcessTrigger("whiteglove_complete", DefaultContext()); + + Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); + Assert.True(result.ShouldEmitWhiteGloveComplete); + } + + [Fact] + public void Completed_AnyTrigger_RemainsCompleted() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.Completed, + espEverSeen: true, espFinalExitSeen: true, desktopArrived: true, + isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); + + var result = sm.ProcessTrigger("hello_completed", DefaultContext()); + Assert.False(result.Transitioned); + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + + result = sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + Assert.False(result.Transitioned); + + result = sm.ProcessTrigger("desktop_arrived", DefaultContext()); + Assert.False(result.Transitioned); + } + + [Fact] + public void Failed_AnyTrigger_RemainsFailed() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.Failed, + espEverSeen: true, espFinalExitSeen: false, desktopArrived: false, + isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); + + var result = sm.ProcessTrigger("hello_completed", DefaultContext()); + Assert.False(result.Transitioned); + Assert.Equal(EnrollmentCompletionState.Failed, sm.CurrentState); + } + + [Fact] + public void EspExited_EspResumed_Hybrid_TransitionsToEspActive() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup", isHybridJoin: true)); + + var ctx = DefaultContext(isHybridJoin: true); + var result = sm.ProcessTrigger("esp_resumed", ctx); + + Assert.Equal(EnrollmentCompletionState.EspActive, sm.CurrentState); + Assert.True(result.ShouldResetEspForResumption); + Assert.True(result.ShouldPersistState); + Assert.False(sm.EspFinalExitSeen); + } + + [Fact] + public void EspResumed_NonHybrid_NoTransition() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); + + var result = sm.ProcessTrigger("esp_resumed", DefaultContext(isHybridJoin: false)); + Assert.False(result.Transitioned); + } + + [Fact] + public void DeviceOnlyEspTimer_AccountSetupArrives_NoTransition() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + var ctx = DefaultContext(lastEspPhase: "AccountSetup"); + var result = sm.ProcessTrigger("device_only_esp_timer_expired", ctx); + + Assert.False(result.Transitioned); + } + + [Fact] + public void DeviceOnlyEspTimer_NoDesktop_NoTransition() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + var ctx = DefaultContext(lastEspPhase: "DeviceSetup"); + var result = sm.ProcessTrigger("device_only_esp_timer_expired", ctx); + + Assert.False(result.Transitioned); + } + + [Fact] + public void DeviceOnlyEspTimer_DesktopAvailable_TransitionsToAwaitingHello() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + sm.ProcessTrigger("desktop_arrived", DefaultContext(skipUserStatusPage: false)); + + var ctx = DefaultContext(lastEspPhase: "DeviceSetup"); + var result = sm.ProcessTrigger("device_only_esp_timer_expired", ctx); + + Assert.True(result.Transitioned); + Assert.True(result.ShouldStartHelloWaitTimer); + Assert.Contains("device_only_esp_final_exit", result.SignalsToRecord); + } + + // ===== SECTION 2: Full Path Integration Tests ===== + + [Fact] + public void Path_DeviceOnly_SelfDeploying_FullSequence() + { + var sm = new CompletionStateMachine(); + + // 1. ESP phase detected + sm.ProcessTrigger("esp_phase_changed", DeviceOnlyContext()); + Assert.Equal(EnrollmentCompletionState.EspActive, sm.CurrentState); + + // 2. Device setup provisioning completes + var ctx = DeviceOnlyContext(); + ctx.DeviceInfoCollected = true; + var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("self_deploying_provisioning_complete", result.CompletionSource); + } + + [Fact] + public void Path_DeviceOnly_SkipUser_FullSequence() + { + var sm = new CompletionStateMachine(); + + // 1. ESP phase detected + var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false, + isHelloPolicyConfigured: false); + sm.ProcessTrigger("esp_phase_changed", ctx); + + // 2. ESP exits — SkipUserStatusPage=true + ctx.LastEspPhase = "DeviceSetup"; + var result = sm.ProcessTrigger("esp_exiting", ctx); + + Assert.True(result.ShouldEmitEnrollmentComplete || sm.CurrentState == EnrollmentCompletionState.DeviceOnlySafetyWait); + } + + [Fact] + public void Path_EspAccountSetup_HelloResolves_FullSequence() + { + var sm = new CompletionStateMachine(); + + // 1. ESP DeviceSetup + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + // 2. ESP exits from AccountSetup + sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); + + // 3. Hello resolves + var ctx = DefaultContext(isHelloCompleted: true); + var result = sm.ProcessTrigger("hello_completed", ctx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("esp_hello_composite", result.CompletionSource); + } + + [Fact] + public void Path_EspAccountSetup_HelloAlreadyResolved_FullSequence() + { + var sm = new CompletionStateMachine(); + + // 1. ESP DeviceSetup + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + // 2. ESP exits from AccountSetup — Hello already resolved (backfill) + var ctx = DefaultContext(lastEspPhase: "AccountSetup", isHelloCompleted: true); + var result = sm.ProcessTrigger("esp_exiting", ctx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("esp_hello_composite", result.CompletionSource); + } + + [Fact] + public void Path_HybridJoin_RebootGate_ImeCompletes_FullSequence() + { + var sm = new CompletionStateMachine(); + var espExitTime = DateTime.UtcNow; + var agentStartBeforeEsp = espExitTime.AddMinutes(-30); + + // 1. ESP DeviceSetup + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + // 2. ESP exits (hybrid join, agent started before ESP exit) + var ctx = HybridJoinContext(); + ctx.EspFinalExitUtc = espExitTime; + ctx.AgentStartTimeUtc = agentStartBeforeEsp; + sm.ProcessTrigger("esp_exiting", ctx); + + // 3. Hello resolves — blocked by reboot gate + var helloCtx = HybridJoinContext(); + helloCtx.IsHelloCompleted = true; + helloCtx.EspFinalExitUtc = espExitTime; + helloCtx.AgentStartTimeUtc = agentStartBeforeEsp; + sm.ProcessTrigger("hello_completed", helloCtx); + Assert.Equal(EnrollmentCompletionState.HybridRebootGateBlocked, sm.CurrentState); + + // 4. IME user session completes — gate unlocks + var imeCtx = HybridJoinContext(); + imeCtx.IsHelloCompleted = true; + imeCtx.ImePatternSeenUtc = DateTime.UtcNow; + imeCtx.IsHelloPolicyConfigured = false; + var result = sm.ProcessTrigger("ime_user_session_completed", imeCtx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void Path_Desktop_NoEsp_HelloResolves_FullSequence() + { + var sm = new CompletionStateMachine(); + + // 1. Desktop arrives (no ESP ever seen — WDP v2 or no ESP config) + var ctx = DefaultContext(enrollmentType: "v2"); + sm.ProcessTrigger("desktop_arrived", ctx); + Assert.Equal(EnrollmentCompletionState.DesktopArrivedAwaitingHello, sm.CurrentState); + + // 2. Hello resolves + var helloCtx = DefaultContext(enrollmentType: "v2", isHelloCompleted: true); + var result = sm.ProcessTrigger("hello_completed", helloCtx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("desktop_hello", result.CompletionSource); + } + + [Fact] + public void Path_Desktop_EspActive_WaitsForExit_ThenCompletes() + { + var sm = new CompletionStateMachine(); + + // 1. ESP starts + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + // 2. Desktop arrives while ESP active — blocked + sm.ProcessTrigger("desktop_arrived", DefaultContext(skipUserStatusPage: false)); + Assert.Equal(EnrollmentCompletionState.DesktopArrivedEspBlocking, sm.CurrentState); + + // 3. ESP exits from AccountSetup + sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); + + // 4. Hello resolves + var result = sm.ProcessTrigger("hello_completed", DefaultContext(isHelloCompleted: true)); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void Path_ImePattern_WaitingForHello_SafetyTimeout() + { + var sm = new CompletionStateMachine(); + + // 1. ESP starts + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + // 2. ESP exits + sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); + + // 3. IME user session completes — Hello pending + var ctx = DefaultContext(isHelloPolicyConfigured: true, isHelloCompleted: false); + sm.ProcessTrigger("ime_user_session_completed", ctx); + Assert.Equal(EnrollmentCompletionState.WaitingForHello, sm.CurrentState); + + // 4. Safety timeout fires + var result = sm.ProcessTrigger("hello_safety_timeout", DefaultContext()); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldForceMarkHelloCompleted); + Assert.True(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void Path_ImePattern_EspSettle_Then_Completes() + { + var sm = new CompletionStateMachine(); + + // 1. ESP starts + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + // 2. ESP exits + sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup")); + + // 3. IME user session completes — ESP categories unresolved + var ctx = DefaultContext( + isHelloPolicyConfigured: false, // Hello not configured + hasUnresolvedEspCategories: true); + sm.ProcessTrigger("ime_user_session_completed", ctx); + Assert.Equal(EnrollmentCompletionState.WaitingForEspSettle, sm.CurrentState); + + // 4. Settle timeout + var result = sm.ProcessTrigger("esp_settle_timeout", DefaultContext()); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("ime_pattern", result.CompletionSource); + } + + [Fact] + public void Path_WhiteGlove_Completes() + { + var sm = new CompletionStateMachine(); + + // 1. ESP starts + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + // 2. WhiteGlove detected + var result = sm.ProcessTrigger("whiteglove_complete", DefaultContext()); + + Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); + Assert.True(result.ShouldEmitWhiteGloveComplete); + Assert.False(result.ShouldEmitEnrollmentComplete); + } + + // ===== SECTION 3: Edge Case / Idempotency Tests ===== + + [Fact] + public void IdempotentCompletion_SecondCallNoOp() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.Completed, + espEverSeen: true, espFinalExitSeen: true, desktopArrived: true, + isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); + + // Try all triggers — none should transition + Assert.False(sm.ProcessTrigger("hello_completed", DefaultContext()).Transitioned); + Assert.False(sm.ProcessTrigger("desktop_arrived", DefaultContext()).Transitioned); + Assert.False(sm.ProcessTrigger("esp_phase_changed", DefaultContext()).Transitioned); + Assert.False(sm.ProcessTrigger("esp_exiting", DefaultContext()).Transitioned); + Assert.False(sm.ProcessTrigger("esp_failure_terminal", DefaultContext()).Transitioned); + Assert.False(sm.ProcessTrigger("whiteglove_complete", DefaultContext()).Transitioned); + } + + [Fact] + public void DesktopArrival_DuplicateIgnored() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("desktop_arrived", DefaultContext(hasHelloTracker: false)); + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + + // Second desktop arrival — no-op (terminal) + var result = sm.ProcessTrigger("desktop_arrived", DefaultContext()); + Assert.False(result.Transitioned); + } + + [Fact] + public void WhiteGloveIdempotent_SecondNoOp() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("whiteglove_complete", DefaultContext()); + Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); + + var result = sm.ProcessTrigger("whiteglove_complete", DefaultContext()); + Assert.False(result.Transitioned); + } + + [Fact] + public void EspResumed_ResetsCompletionState_HybridJoin() + { + var sm = new CompletionStateMachine(); + sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + sm.ProcessTrigger("esp_exiting", DefaultContext(lastEspPhase: "AccountSetup", isHybridJoin: true)); + Assert.True(sm.EspFinalExitSeen); + + // ESP resumes — resets final exit + sm.ProcessTrigger("esp_resumed", DefaultContext(isHybridJoin: true)); + Assert.False(sm.EspFinalExitSeen); + Assert.Equal(EnrollmentCompletionState.EspActive, sm.CurrentState); + } + + [Fact] + public void DeviceInfoNotReady_Deferred_ReEvaluated() + { + var sm = new CompletionStateMachine(); + + // Device setup provisioning completes but device info not collected + var ctx = DefaultContext( + autopilotMode: null, + skipUserStatusPage: null, + deviceInfoCollected: false); + var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); + + Assert.Equal(EnrollmentCompletionState.DeferredForDeviceInfo, sm.CurrentState); + Assert.Equal("device_setup_provisioning_complete", result.DeferredSource); + + // Device info collected — now detected as device-only + var resolvedCtx = DeviceOnlyContext(); + resolvedCtx.DeviceInfoCollected = true; + result = sm.ProcessTrigger("device_info_collected", resolvedCtx); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void DeviceInfoNotReady_Deferred_ReEvaluated_NotDeviceOnly() + { + var sm = new CompletionStateMachine(); + + var ctx = DefaultContext( + autopilotMode: null, + skipUserStatusPage: null, + deviceInfoCollected: false); + sm.ProcessTrigger("device_setup_provisioning_complete", ctx); + Assert.Equal(EnrollmentCompletionState.DeferredForDeviceInfo, sm.CurrentState); + + // Device info collected — NOT device-only, return to Idle + var resolvedCtx = DefaultContext( + autopilotMode: 0, + skipUserStatusPage: false, + aadJoinedWithUser: true, + deviceInfoCollected: true); + var result = sm.ProcessTrigger("device_info_collected", resolvedCtx); + + Assert.Equal(EnrollmentCompletionState.Idle, sm.CurrentState); + Assert.False(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void NullTrigger_NoTransition() + { + var sm = new CompletionStateMachine(); + var result = sm.ProcessTrigger(null, DefaultContext()); + Assert.False(result.Transitioned); + } + + [Fact] + public void EmptyTrigger_NoTransition() + { + var sm = new CompletionStateMachine(); + var result = sm.ProcessTrigger("", DefaultContext()); + Assert.False(result.Transitioned); + } + + [Fact] + public void UnknownTrigger_NoTransition() + { + var sm = new CompletionStateMachine(); + var result = sm.ProcessTrigger("unknown_trigger_xyz", DefaultContext()); + Assert.False(result.Transitioned); + } + + [Fact] + public void SignalsRecorded_OnTransition() + { + var sm = new CompletionStateMachine(); + var result = sm.ProcessTrigger("esp_phase_changed", DefaultContext()); + + Assert.NotNull(result.SignalsToRecord); + Assert.Contains("esp_phase_changed", result.SignalsToRecord); + } + + // ===== SECTION 4: Crash Recovery / State Reconstruction Tests ===== + + [Fact] + public void ReconstructState_Completed_ReturnsCompleted() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: true, isWaitingForHello: false, + isWaitingForEspSettle: false, espFinalExitSeen: true, + desktopArrived: true, espEverSeen: true, isDeviceOnly: false); + + Assert.Equal(EnrollmentCompletionState.Completed, state); + } + + [Fact] + public void ReconstructState_WaitingForHello_ReturnsWaitingForHello() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: false, isWaitingForHello: true, + isWaitingForEspSettle: false, espFinalExitSeen: true, + desktopArrived: false, espEverSeen: true, isDeviceOnly: false); + + Assert.Equal(EnrollmentCompletionState.WaitingForHello, state); + } + + [Fact] + public void ReconstructState_WaitingForEspSettle_ReturnsWaitingForEspSettle() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: false, isWaitingForHello: false, + isWaitingForEspSettle: true, espFinalExitSeen: true, + desktopArrived: false, espEverSeen: true, isDeviceOnly: false); + + Assert.Equal(EnrollmentCompletionState.WaitingForEspSettle, state); + } + + [Fact] + public void ReconstructState_DeviceOnly_EspExited_ReturnsDeviceOnly() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: false, isWaitingForHello: false, + isWaitingForEspSettle: false, espFinalExitSeen: true, + desktopArrived: false, espEverSeen: true, isDeviceOnly: true); + + Assert.Equal(EnrollmentCompletionState.DeviceOnlyAwaitingCompletion, state); + } + + [Fact] + public void ReconstructState_EspExited_Desktop_ReturnsDesktopAwaitingHello() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: false, isWaitingForHello: false, + isWaitingForEspSettle: false, espFinalExitSeen: true, + desktopArrived: true, espEverSeen: true, isDeviceOnly: false); + + Assert.Equal(EnrollmentCompletionState.DesktopArrivedAwaitingHello, state); + } + + [Fact] + public void ReconstructState_EspExited_NoDesktop_ReturnsEspExitedAwaiting() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: false, isWaitingForHello: false, + isWaitingForEspSettle: false, espFinalExitSeen: true, + desktopArrived: false, espEverSeen: true, isDeviceOnly: false); + + Assert.Equal(EnrollmentCompletionState.EspExitedAwaitingCompletion, state); + } + + [Fact] + public void ReconstructState_EspActive_Desktop_ReturnsDesktopEspBlocking() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: false, isWaitingForHello: false, + isWaitingForEspSettle: false, espFinalExitSeen: false, + desktopArrived: true, espEverSeen: true, isDeviceOnly: false); + + Assert.Equal(EnrollmentCompletionState.DesktopArrivedEspBlocking, state); + } + + [Fact] + public void ReconstructState_EspActive_NoDesktop_ReturnsEspActive() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: false, isWaitingForHello: false, + isWaitingForEspSettle: false, espFinalExitSeen: false, + desktopArrived: false, espEverSeen: true, isDeviceOnly: false); + + Assert.Equal(EnrollmentCompletionState.EspActive, state); + } + + [Fact] + public void ReconstructState_DesktopOnly_ReturnsDesktopAwaitingHello() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: false, isWaitingForHello: false, + isWaitingForEspSettle: false, espFinalExitSeen: false, + desktopArrived: true, espEverSeen: false, isDeviceOnly: false); + + Assert.Equal(EnrollmentCompletionState.DesktopArrivedAwaitingHello, state); + } + + [Fact] + public void ReconstructState_Fresh_ReturnsIdle() + { + var state = CompletionStateMachine.ReconstructStateFromFlags( + enrollmentCompleteEmitted: false, isWaitingForHello: false, + isWaitingForEspSettle: false, espFinalExitSeen: false, + desktopArrived: false, espEverSeen: false, isDeviceOnly: false); + + Assert.Equal(EnrollmentCompletionState.Idle, state); + } + + [Fact] + public void RestoreState_PreservesAllFields() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.EspExitedAwaitingCompletion, + espEverSeen: true, espFinalExitSeen: true, desktopArrived: true, + isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); + + Assert.Equal(EnrollmentCompletionState.EspExitedAwaitingCompletion, sm.CurrentState); + Assert.True(sm.EspEverSeen); + Assert.True(sm.EspFinalExitSeen); + Assert.True(sm.DesktopArrived); + } + + [Fact] + public void RestoreState_WaitingForHello_CanResumeCompletion() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.WaitingForHello, + espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, + isWaitingForHello: true, isWaitingForEspSettle: false, deferredSource: null); + + // Hello completes after crash recovery + var result = sm.ProcessTrigger("hello_completed", DefaultContext(isHelloCompleted: true)); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + Assert.Equal("ime_hello", result.CompletionSource); + } + + [Fact] + public void RestoreState_WaitingForEspSettle_CanResumeCompletion() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.WaitingForEspSettle, + espEverSeen: true, espFinalExitSeen: true, desktopArrived: false, + isWaitingForHello: false, isWaitingForEspSettle: true, deferredSource: null); + + // Settle timeout fires after crash recovery + var result = sm.ProcessTrigger("esp_settle_timeout", DefaultContext()); + + Assert.Equal(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.True(result.ShouldEmitEnrollmentComplete); + } + + // ===== SECTION 5: Extension Method Tests ===== + + [Fact] + public void IsTerminal_Completed_ReturnsTrue() + { + Assert.True(EnrollmentCompletionState.Completed.IsTerminal()); + } + + [Fact] + public void IsTerminal_Failed_ReturnsTrue() + { + Assert.True(EnrollmentCompletionState.Failed.IsTerminal()); + } + + [Fact] + public void IsTerminal_WhiteGloveCompleted_ReturnsTrue() + { + Assert.True(EnrollmentCompletionState.WhiteGloveCompleted.IsTerminal()); + } + + [Fact] + public void IsTerminal_Idle_ReturnsFalse() + { + Assert.False(EnrollmentCompletionState.Idle.IsTerminal()); + } + + [Fact] + public void IsTerminal_EspActive_ReturnsFalse() + { + Assert.False(EnrollmentCompletionState.EspActive.IsTerminal()); + } + + [Fact] + public void IsTerminal_WaitingForHello_ReturnsFalse() + { + Assert.False(EnrollmentCompletionState.WaitingForHello.IsTerminal()); + } + + [Fact] + public void IsWaiting_WaitingForHello_ReturnsTrue() + { + Assert.True(EnrollmentCompletionState.WaitingForHello.IsWaiting()); + } + + [Fact] + public void IsWaiting_WaitingForEspSettle_ReturnsTrue() + { + Assert.True(EnrollmentCompletionState.WaitingForEspSettle.IsWaiting()); + } + + [Fact] + public void IsWaiting_DeviceOnlySafetyWait_ReturnsTrue() + { + Assert.True(EnrollmentCompletionState.DeviceOnlySafetyWait.IsWaiting()); + } + + [Fact] + public void IsWaiting_HybridRebootGateBlocked_ReturnsTrue() + { + Assert.True(EnrollmentCompletionState.HybridRebootGateBlocked.IsWaiting()); + } + + [Fact] + public void IsWaiting_DeferredForDeviceInfo_ReturnsTrue() + { + Assert.True(EnrollmentCompletionState.DeferredForDeviceInfo.IsWaiting()); + } + + [Fact] + public void IsWaiting_Idle_ReturnsFalse() + { + Assert.False(EnrollmentCompletionState.Idle.IsWaiting()); + } + + [Fact] + public void IsWaiting_Completed_ReturnsFalse() + { + Assert.False(EnrollmentCompletionState.Completed.IsWaiting()); + } + + [Fact] + public void IsWaiting_EspActive_ReturnsFalse() + { + Assert.False(EnrollmentCompletionState.EspActive.IsWaiting()); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionGuards.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionGuards.cs new file mode 100644 index 00000000..36c9172e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionGuards.cs @@ -0,0 +1,96 @@ +using System; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking +{ + /// + /// Pure-function guards for enrollment completion decisions. + /// Extracted 1:1 from the lock block in TryEmitEnrollmentComplete() for testability. + /// Each method mirrors the exact condition evaluated in CompletionLogic.cs. + /// + internal static class CompletionGuards + { + /// + /// Guard 1: Hello resolution check. + /// Hello is considered resolved when: + /// - No EspAndHelloTracker exists (no Hello tracking) + /// - Hello has already completed (success, skip, or timeout) + /// - Hello policy is not configured (Hello disabled) + /// - Deployment is device-only (no interactive user session) + /// + /// + /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(), + /// line: helloResolved = _espAndHelloTracker == null || ...IsHelloCompleted || !...IsPolicyConfigured || isDeviceOnly + /// + public static bool IsHelloResolved(bool hasTracker, bool isHelloCompleted, + bool isPolicyConfigured, bool isDeviceOnly) + { + return !hasTracker + || isHelloCompleted + || !isPolicyConfigured + || isDeviceOnly; + } + + /// + /// Guard 2: ESP gate for desktop-based completion sources. + /// Blocks completion when: + /// - Source is desktop_arrival or desktop_hello + /// - Enrollment type is v1 (not WDP) + /// - ESP has been seen but has NOT exited yet + /// Rationale: In v1 enrollments with active ESP, desktop presence alone is + /// NOT sufficient to complete (AccountSetup may still be running). + /// + /// + /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(), + /// line: espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") && enrollmentType != "v2" && espEverSeen && !espFinalExitSeen + /// + public static bool IsEspGateBlocking(string source, string enrollmentType, + bool espEverSeen, bool espFinalExitSeen) + { + return (source == "desktop_arrival" || source == "desktop_hello") + && enrollmentType != "v2" + && espEverSeen + && !espFinalExitSeen; + } + + /// + /// Guard 3: Hybrid join reboot gate. + /// In hybrid join, ESP may exit for a mid-enrollment reboot (domain user login required). + /// Blocks esp_hello_composite unless we have stronger confirmation: + /// - IME user session completed (imePatternSeenUtc has value) + /// - Agent restarted after ESP exit (agentStartTimeUtc > espFinalExitUtc) + /// + /// + /// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(), + /// lines: if (_isHybridJoin && source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit; } + /// + public static bool IsHybridRebootGateBlocking(bool isHybridJoin, string source, + DateTime? imePatternSeenUtc, DateTime? espFinalExitUtc, DateTime agentStartTimeUtc) + { + if (!isHybridJoin || source != "esp_hello_composite") + return false; + + bool hasImeCompletion = imePatternSeenUtc.HasValue; + bool agentRestartedAfterEspExit = espFinalExitUtc.HasValue + && agentStartTimeUtc > espFinalExitUtc.Value; + + return !hasImeCompletion && !agentRestartedAfterEspExit; + } + + /// + /// Deployment classification: device-only check. + /// A deployment is device-only when: + /// - Self-Deploying mode (autopilotMode == 1), OR + /// - SkipUserStatusPage == true AND no user has AAD-joined + /// + /// + /// Source: EnrollmentTracker.cs, property IsDeviceOnlyDeployment + /// line: IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser) + /// + public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage, + bool aadJoinedWithUser) + { + bool isSelfDeploying = autopilotMode == 1; + return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionStateMachine.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionStateMachine.cs new file mode 100644 index 00000000..865de651 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionStateMachine.cs @@ -0,0 +1,752 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking +{ + /// + /// Read-only snapshot of all state needed for guard evaluation and transition decisions. + /// Populated under lock in the caller, consumed outside lock by the state machine. + /// Preserves the existing thread-safety pattern from EnrollmentTracker. + /// + internal class CompletionContext + { + // Deployment classification + public string EnrollmentType { get; set; } + public int? AutopilotMode { get; set; } + public bool? SkipUserStatusPage { get; set; } + public bool AadJoinedWithUser { get; set; } + public bool IsHybridJoin { get; set; } + + // Computed deployment classification (mirrors IsDeviceOnlyDeployment property) + public bool IsDeviceOnly => CompletionGuards.IsDeviceOnlyDeployment( + AutopilotMode, SkipUserStatusPage, AadJoinedWithUser); + + // Signal state + public bool EspEverSeen { get; set; } + public bool EspFinalExitSeen { get; set; } + public bool DesktopArrived { get; set; } + public string LastEspPhase { get; set; } + public bool DeviceInfoCollected { get; set; } + + // Hello state (from EspAndHelloTracker) + public bool HasHelloTracker { get; set; } + public bool IsHelloCompleted { get; set; } + public bool IsHelloPolicyConfigured { get; set; } + public bool HasUnresolvedEspCategories { get; set; } + + // Timing + public DateTime AgentStartTimeUtc { get; set; } + public DateTime? EspFinalExitUtc { get; set; } + public DateTime? ImePatternSeenUtc { get; set; } + + // Trigger metadata + public string Source { get; set; } + } + + /// + /// Result of a state machine transition. Contains the new state and metadata + /// about what actions the caller should take (emit events, start timers, etc.). + /// The state machine itself is side-effect-free — all actions are communicated + /// via this result object to preserve the lock-free emission pattern. + /// + internal class TransitionResult + { + public static readonly TransitionResult NoTransition = new TransitionResult + { + Transitioned = false, + NewState = EnrollmentCompletionState.Idle + }; + + public bool Transitioned { get; set; } + public EnrollmentCompletionState NewState { get; set; } + public EnrollmentCompletionState PreviousState { get; set; } + public string CompletionSource { get; set; } + + // Action flags — caller executes these OUTSIDE lock + public bool ShouldEmitEnrollmentComplete { get; set; } + public bool ShouldEmitEnrollmentFailed { get; set; } + public bool ShouldEmitWhiteGloveComplete { get; set; } + public bool ShouldStartHelloWaitTimer { get; set; } + public bool ShouldStartHelloSafetyTimer { get; set; } + public bool ShouldStartEspSettleTimer { get; set; } + public bool ShouldStartDeviceOnlyEspTimer { get; set; } + public bool ShouldStartDeviceOnlySafetyTimer { get; set; } + public bool ShouldForceMarkHelloCompleted { get; set; } + public bool ShouldResetEspForResumption { get; set; } + public bool ShouldPersistState { get; set; } + public string FailureType { get; set; } + public string DeferredSource { get; set; } + + // Signals to record + public List SignalsToRecord { get; set; } + } + + /// + /// Explicit state machine for enrollment completion logic. + /// Replaces the implicit boolean-flag-based state tracking in CompletionLogic.cs. + /// + /// Thread safety: The ProcessTrigger method is thread-safe. State reads and transitions + /// happen under a single lock (microsecond-level critical sections). All side effects + /// (event emission, timer management) are communicated via TransitionResult and executed + /// by the caller OUTSIDE the lock — identical pattern to the original code. + /// + /// This class is intentionally side-effect-free: it computes state transitions and + /// returns action flags. The caller (EnrollmentTracker) owns the actual side effects. + /// + internal class CompletionStateMachine + { + private readonly object _lock = new object(); + private EnrollmentCompletionState _currentState = EnrollmentCompletionState.Idle; + private string _deferredSource; + + // Signal state (mirrored from EnrollmentTracker fields) + private bool _espEverSeen; + private bool _espFinalExitSeen; + private bool _desktopArrived; + private bool _isWaitingForHello; + private bool _isWaitingForEspSettle; + + public EnrollmentCompletionState CurrentState + { + get { lock (_lock) { return _currentState; } } + } + + // Expose internal state for shadow-mode comparison and persistence + internal bool EspEverSeen { get { lock (_lock) { return _espEverSeen; } } } + internal bool EspFinalExitSeen { get { lock (_lock) { return _espFinalExitSeen; } } } + internal bool DesktopArrived { get { lock (_lock) { return _desktopArrived; } } } + + /// + /// Reconstructs state from persisted EnrollmentStateData (crash recovery). + /// Must be called before Start() if recovering from a crash. + /// + public void RestoreState(EnrollmentCompletionState state, bool espEverSeen, + bool espFinalExitSeen, bool desktopArrived, bool isWaitingForHello, + bool isWaitingForEspSettle, string deferredSource) + { + lock (_lock) + { + _currentState = state; + _espEverSeen = espEverSeen; + _espFinalExitSeen = espFinalExitSeen; + _desktopArrived = desktopArrived; + _isWaitingForHello = isWaitingForHello; + _isWaitingForEspSettle = isWaitingForEspSettle; + _deferredSource = deferredSource; + } + } + + /// + /// Reconstructs the explicit state from boolean flags (backwards compatibility). + /// Used when loading state from an old-format persistence file that doesn't + /// contain the CompletionState field. + /// + public static EnrollmentCompletionState ReconstructStateFromFlags( + bool enrollmentCompleteEmitted, bool isWaitingForHello, bool isWaitingForEspSettle, + bool espFinalExitSeen, bool desktopArrived, bool espEverSeen, + bool isDeviceOnly) + { + if (enrollmentCompleteEmitted) + return EnrollmentCompletionState.Completed; + if (isWaitingForHello) + return EnrollmentCompletionState.WaitingForHello; + if (isWaitingForEspSettle) + return EnrollmentCompletionState.WaitingForEspSettle; + if (isDeviceOnly && espFinalExitSeen) + return EnrollmentCompletionState.DeviceOnlyAwaitingCompletion; + if (espFinalExitSeen && desktopArrived) + return EnrollmentCompletionState.DesktopArrivedAwaitingHello; + if (espFinalExitSeen) + return EnrollmentCompletionState.EspExitedAwaitingCompletion; + if (espEverSeen && desktopArrived) + return EnrollmentCompletionState.DesktopArrivedEspBlocking; + if (espEverSeen) + return EnrollmentCompletionState.EspActive; + if (desktopArrived) + return EnrollmentCompletionState.DesktopArrivedAwaitingHello; + return EnrollmentCompletionState.Idle; + } + + /// + /// Core state machine: processes a trigger against the current state and context. + /// Returns a TransitionResult describing what happened and what actions to take. + /// + /// The state transition happens atomically under lock. + /// All side-effect instructions are in the returned result — caller executes them outside lock. + /// + public TransitionResult ProcessTrigger(string trigger, CompletionContext ctx) + { + if (string.IsNullOrEmpty(trigger)) + return TransitionResult.NoTransition; + + lock (_lock) + { + // Terminal states: no transitions allowed + if (_currentState.IsTerminal()) + return TransitionResult.NoTransition; + + switch (trigger) + { + case "esp_phase_changed": + return HandleEspPhaseChanged(ctx); + + case "esp_exiting": + return HandleEspExiting(ctx); + + case "desktop_arrived": + return HandleDesktopArrived(ctx); + + case "hello_completed": + return HandleHelloCompleted(ctx); + + case "ime_user_session_completed": + return HandleImeUserSessionCompleted(ctx); + + case "device_setup_provisioning_complete": + return HandleDeviceSetupProvisioningComplete(ctx); + + case "hello_safety_timeout": + return HandleHelloSafetyTimeout(ctx); + + case "esp_settle_timeout": + return HandleEspSettleTimeout(ctx); + + case "device_only_esp_timer_expired": + return HandleDeviceOnlyEspTimerExpired(ctx); + + case "device_only_safety_timeout": + return HandleDeviceOnlySafetyTimeout(ctx); + + case "esp_failure_terminal": + return HandleEspFailureTerminal(ctx); + + case "esp_failure_grace_expired": + return HandleEspFailureTerminal(ctx); + + case "whiteglove_complete": + return HandleWhiteGloveComplete(ctx); + + case "esp_resumed": + return HandleEspResumed(ctx); + + case "device_info_collected": + return HandleDeviceInfoCollected(ctx); + + default: + return TransitionResult.NoTransition; + } + } + } + + // ===== Trigger Handlers (all called under _lock) ===== + + private TransitionResult HandleEspPhaseChanged(CompletionContext ctx) + { + if (_currentState != EnrollmentCompletionState.Idle + && _currentState != EnrollmentCompletionState.EspActive) + return TransitionResult.NoTransition; + + var prev = _currentState; + _espEverSeen = true; + _currentState = EnrollmentCompletionState.EspActive; + + return new TransitionResult + { + Transitioned = prev != _currentState, + PreviousState = prev, + NewState = _currentState, + SignalsToRecord = new List { "esp_phase_changed" } + }; + } + + private TransitionResult HandleEspExiting(CompletionContext ctx) + { + // ESP exiting is only relevant when ESP has been seen + if (!_espEverSeen) + return TransitionResult.NoTransition; + + var prev = _currentState; + var result = new TransitionResult { PreviousState = prev, SignalsToRecord = new List() }; + + // Path A: AccountSetup completed or desktop already arrived + if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase) + || _desktopArrived) + { + _espFinalExitSeen = true; + _currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion; + result.SignalsToRecord.Add("esp_final_exit"); + result.ShouldStartHelloWaitTimer = true; + result.ShouldPersistState = true; + + // Check if we can complete immediately + if (CanComplete(ctx, "esp_hello_composite")) + { + _currentState = EnrollmentCompletionState.Completed; + result.ShouldEmitEnrollmentComplete = true; + result.CompletionSource = "esp_hello_composite"; + } + else if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking( + ctx.IsHybridJoin, "esp_hello_composite", + ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc)) + { + _currentState = EnrollmentCompletionState.HybridRebootGateBlocked; + } + } + // Path B: Registry definitively says device-only + else if (ctx.SkipUserStatusPage == true) + { + _espFinalExitSeen = true; + _currentState = EnrollmentCompletionState.DeviceOnlyAwaitingCompletion; + result.SignalsToRecord.Add("device_only_esp_registry"); + result.ShouldStartHelloWaitTimer = true; + result.ShouldPersistState = true; + + if (CanComplete(ctx, "device_only_esp_registry")) + { + _currentState = EnrollmentCompletionState.Completed; + result.ShouldEmitEnrollmentComplete = true; + result.CompletionSource = "device_only_esp_registry"; + } + else + { + _currentState = EnrollmentCompletionState.DeviceOnlySafetyWait; + result.ShouldStartDeviceOnlySafetyTimer = true; + } + } + // Path C: Unknown — start device-only detection timer + else + { + result.ShouldStartDeviceOnlyEspTimer = true; + // State stays as-is (EspActive or DesktopArrivedEspBlocking) + } + + result.Transitioned = prev != _currentState; + result.NewState = _currentState; + return result; + } + + private TransitionResult HandleDesktopArrived(CompletionContext ctx) + { + if (_desktopArrived) + return TransitionResult.NoTransition; + + var prev = _currentState; + _desktopArrived = true; + + var result = new TransitionResult + { + PreviousState = prev, + ShouldPersistState = true, + SignalsToRecord = new List { "desktop_arrived" } + }; + + // SkipUserStatusPage handling: if ESP seen and SkipUser=true but no final exit yet + if (_espEverSeen && ctx.SkipUserStatusPage == true && !_espFinalExitSeen) + { + _espFinalExitSeen = true; + result.SignalsToRecord.Add("desktop_arrived_skip_user"); + } + + // Determine target state based on ESP status + if (_espEverSeen && !_espFinalExitSeen) + { + // ESP still active — gate blocks + _currentState = EnrollmentCompletionState.DesktopArrivedEspBlocking; + } + else + { + // No ESP blocking — can proceed toward completion + _currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello; + + // Start Hello wait timer if needed (no active ESP, not device-only) + if (!ctx.IsDeviceOnly && ctx.HasHelloTracker && !ctx.IsHelloCompleted) + { + result.ShouldStartHelloWaitTimer = true; + } + + // Try immediate completion + if (CanComplete(ctx, "desktop_arrival")) + { + _currentState = EnrollmentCompletionState.Completed; + result.ShouldEmitEnrollmentComplete = true; + result.CompletionSource = "desktop_arrival"; + } + } + + result.Transitioned = prev != _currentState; + result.NewState = _currentState; + return result; + } + + private TransitionResult HandleHelloCompleted(CompletionContext ctx) + { + var prev = _currentState; + var result = new TransitionResult + { + PreviousState = prev, + SignalsToRecord = new List { "hello_resolved" } + }; + + if (_isWaitingForHello) + { + // IME path: was waiting for Hello + _isWaitingForHello = false; + if (CanCompleteWithHelloResolved(ctx, "ime_hello")) + { + _currentState = EnrollmentCompletionState.Completed; + result.ShouldEmitEnrollmentComplete = true; + result.CompletionSource = "ime_hello"; + } + } + else if (_espFinalExitSeen) + { + // ESP composite path + string source = "esp_hello_composite"; + if (ctx.IsHybridJoin && CompletionGuards.IsHybridRebootGateBlocking( + ctx.IsHybridJoin, source, + ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc)) + { + _currentState = EnrollmentCompletionState.HybridRebootGateBlocked; + } + else + { + _currentState = EnrollmentCompletionState.Completed; + result.ShouldEmitEnrollmentComplete = true; + result.CompletionSource = source; + } + } + else if (_desktopArrived) + { + // Desktop + Hello path + string source = "desktop_hello"; + if (!CompletionGuards.IsEspGateBlocking(source, ctx.EnrollmentType, + _espEverSeen, _espFinalExitSeen)) + { + _currentState = EnrollmentCompletionState.Completed; + result.ShouldEmitEnrollmentComplete = true; + result.CompletionSource = source; + } + } + + result.Transitioned = prev != _currentState; + result.NewState = _currentState; + return result; + } + + private TransitionResult HandleImeUserSessionCompleted(CompletionContext ctx) + { + var prev = _currentState; + var result = new TransitionResult + { + PreviousState = prev, + SignalsToRecord = new List { "ime_pattern" } + }; + + // Device-only: skip Hello entirely + if (ctx.IsDeviceOnly) + { + _currentState = EnrollmentCompletionState.Completed; + result.ShouldEmitEnrollmentComplete = true; + result.CompletionSource = "ime_pattern"; + result.Transitioned = true; + result.NewState = _currentState; + return result; + } + + // Hello configured but not yet completed — wait + if (ctx.HasHelloTracker && ctx.IsHelloPolicyConfigured && !ctx.IsHelloCompleted) + { + _isWaitingForHello = true; + _currentState = EnrollmentCompletionState.WaitingForHello; + result.ShouldStartHelloWaitTimer = true; + result.ShouldStartHelloSafetyTimer = true; + result.ShouldPersistState = true; + result.Transitioned = true; + result.NewState = _currentState; + return result; + } + + // ESP categories unresolved — wait for settle + if (ctx.HasHelloTracker && ctx.HasUnresolvedEspCategories) + { + _isWaitingForEspSettle = true; + _currentState = EnrollmentCompletionState.WaitingForEspSettle; + result.ShouldStartEspSettleTimer = true; + result.ShouldPersistState = true; + result.Transitioned = true; + result.NewState = _currentState; + return result; + } + + // All clear — complete + _currentState = EnrollmentCompletionState.Completed; + result.ShouldEmitEnrollmentComplete = true; + result.CompletionSource = "ime_pattern"; + result.Transitioned = true; + result.NewState = _currentState; + return result; + } + + private TransitionResult HandleDeviceSetupProvisioningComplete(CompletionContext ctx) + { + var prev = _currentState; + var result = new TransitionResult + { + PreviousState = prev, + SignalsToRecord = new List { "device_setup_provisioning_complete" } + }; + + if (!ctx.IsDeviceOnly) + { + // Not device-only: check if device info collected yet + if (!ctx.DeviceInfoCollected) + { + _deferredSource = "device_setup_provisioning_complete"; + _currentState = EnrollmentCompletionState.DeferredForDeviceInfo; + result.DeferredSource = _deferredSource; + result.Transitioned = true; + result.NewState = _currentState; + return result; + } + // Not device-only and device info collected: normal paths handle it + return TransitionResult.NoTransition; + } + + // Device-only: mark ESP as seen and exited + if (!_espEverSeen) _espEverSeen = true; + if (!_espFinalExitSeen) + { + _espFinalExitSeen = true; + result.SignalsToRecord.Add("self_deploying_esp_final_exit"); + } + result.ShouldPersistState = true; + + _currentState = EnrollmentCompletionState.Completed; + result.ShouldEmitEnrollmentComplete = true; + result.CompletionSource = "self_deploying_provisioning_complete"; + result.Transitioned = true; + result.NewState = _currentState; + return result; + } + + private TransitionResult HandleHelloSafetyTimeout(CompletionContext ctx) + { + if (!_isWaitingForHello) + return TransitionResult.NoTransition; + + var prev = _currentState; + _isWaitingForHello = false; + _currentState = EnrollmentCompletionState.Completed; + + return new TransitionResult + { + Transitioned = true, + PreviousState = prev, + NewState = _currentState, + ShouldForceMarkHelloCompleted = true, + ShouldEmitEnrollmentComplete = true, + CompletionSource = "ime_hello_safety_timeout", + SignalsToRecord = new List { "ime_hello_safety_timeout" } + }; + } + + private TransitionResult HandleEspSettleTimeout(CompletionContext ctx) + { + if (!_isWaitingForEspSettle) + return TransitionResult.NoTransition; + + var prev = _currentState; + _isWaitingForEspSettle = false; + _currentState = EnrollmentCompletionState.Completed; + + return new TransitionResult + { + Transitioned = true, + PreviousState = prev, + NewState = _currentState, + ShouldEmitEnrollmentComplete = true, + CompletionSource = "ime_pattern", + SignalsToRecord = new List { "esp_provisioning_settled" } + }; + } + + private TransitionResult HandleDeviceOnlyEspTimerExpired(CompletionContext ctx) + { + // AccountSetup started meanwhile? Timer is obsolete + if (string.Equals(ctx.LastEspPhase, "AccountSetup", StringComparison.OrdinalIgnoreCase)) + return TransitionResult.NoTransition; + + if (!_desktopArrived) + return TransitionResult.NoTransition; // Wait for desktop + + var prev = _currentState; + _espFinalExitSeen = true; + _currentState = EnrollmentCompletionState.DesktopArrivedAwaitingHello; + + return new TransitionResult + { + Transitioned = true, + PreviousState = prev, + NewState = _currentState, + ShouldStartHelloWaitTimer = true, + ShouldPersistState = true, + SignalsToRecord = new List { "device_only_esp_final_exit" } + }; + } + + private TransitionResult HandleDeviceOnlySafetyTimeout(CompletionContext ctx) + { + var prev = _currentState; + _currentState = EnrollmentCompletionState.Completed; + + return new TransitionResult + { + Transitioned = true, + PreviousState = prev, + NewState = _currentState, + ShouldForceMarkHelloCompleted = true, + ShouldEmitEnrollmentComplete = true, + CompletionSource = "device_only_esp_safety_timeout", + SignalsToRecord = new List { "device_only_esp_safety_timeout" } + }; + } + + private TransitionResult HandleEspFailureTerminal(CompletionContext ctx) + { + var prev = _currentState; + _currentState = EnrollmentCompletionState.Failed; + + return new TransitionResult + { + Transitioned = true, + PreviousState = prev, + NewState = _currentState, + ShouldEmitEnrollmentFailed = true, + FailureType = ctx.Source + }; + } + + private TransitionResult HandleWhiteGloveComplete(CompletionContext ctx) + { + var prev = _currentState; + _currentState = EnrollmentCompletionState.WhiteGloveCompleted; + + return new TransitionResult + { + Transitioned = true, + PreviousState = prev, + NewState = _currentState, + ShouldEmitWhiteGloveComplete = true + }; + } + + private TransitionResult HandleEspResumed(CompletionContext ctx) + { + if (!ctx.IsHybridJoin || !_espFinalExitSeen) + return TransitionResult.NoTransition; + + var prev = _currentState; + _espFinalExitSeen = false; + _currentState = EnrollmentCompletionState.EspActive; + + return new TransitionResult + { + Transitioned = true, + PreviousState = prev, + NewState = _currentState, + ShouldResetEspForResumption = true, + ShouldPersistState = true, + SignalsToRecord = new List { "esp_resumed" } + }; + } + + private TransitionResult HandleDeviceInfoCollected(CompletionContext ctx) + { + if (_currentState != EnrollmentCompletionState.DeferredForDeviceInfo) + return TransitionResult.NoTransition; + + var prev = _currentState; + var deferredSource = _deferredSource; + _deferredSource = null; + + // Re-evaluate: now that device info is available, check if device-only + if (ctx.IsDeviceOnly) + { + if (!_espEverSeen) _espEverSeen = true; + if (!_espFinalExitSeen) + { + _espFinalExitSeen = true; + } + + _currentState = EnrollmentCompletionState.Completed; + return new TransitionResult + { + Transitioned = true, + PreviousState = prev, + NewState = _currentState, + ShouldEmitEnrollmentComplete = true, + CompletionSource = deferredSource ?? "device_setup_provisioning_complete", + ShouldPersistState = true, + SignalsToRecord = new List { "self_deploying_esp_final_exit" } + }; + } + + // Not device-only: revert to appropriate non-terminal state + if (_espFinalExitSeen) + _currentState = EnrollmentCompletionState.EspExitedAwaitingCompletion; + else if (_espEverSeen) + _currentState = _desktopArrived + ? EnrollmentCompletionState.DesktopArrivedEspBlocking + : EnrollmentCompletionState.EspActive; + else + _currentState = _desktopArrived + ? EnrollmentCompletionState.DesktopArrivedAwaitingHello + : EnrollmentCompletionState.Idle; + + return new TransitionResult + { + Transitioned = true, + PreviousState = prev, + NewState = _currentState, + DeferredSource = deferredSource + }; + } + + // ===== Guard Helpers (called under lock) ===== + + /// + /// Checks all three completion guards for a given source. + /// + private bool CanComplete(CompletionContext ctx, string source) + { + bool helloResolved = CompletionGuards.IsHelloResolved( + ctx.HasHelloTracker, ctx.IsHelloCompleted, + ctx.IsHelloPolicyConfigured, ctx.IsDeviceOnly); + + bool espGateBlocking = CompletionGuards.IsEspGateBlocking( + source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen); + + bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking( + ctx.IsHybridJoin, source, + ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc); + + return helloResolved && !espGateBlocking && !hybridRebootGateBlocking; + } + + /// + /// Completion check with Hello always resolved (caller has just confirmed Hello completed). + /// Only checks ESP gate and hybrid reboot gate. + /// + private bool CanCompleteWithHelloResolved(CompletionContext ctx, string source) + { + bool espGateBlocking = CompletionGuards.IsEspGateBlocking( + source, ctx.EnrollmentType, _espEverSeen, _espFinalExitSeen); + + bool hybridRebootGateBlocking = CompletionGuards.IsHybridRebootGateBlocking( + ctx.IsHybridJoin, source, + ctx.ImePatternSeenUtc, ctx.EspFinalExitUtc, ctx.AgentStartTimeUtc); + + return !espGateBlocking && !hybridRebootGateBlocking; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentCompletionState.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentCompletionState.cs new file mode 100644 index 00000000..2a682cfc --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentCompletionState.cs @@ -0,0 +1,120 @@ +namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking +{ + /// + /// Explicit states for the enrollment completion state machine. + /// Each state represents a distinguishable configuration of the implicit boolean flags + /// that previously existed only as field combinations in EnrollmentTracker.CompletionLogic.cs. + /// + internal enum EnrollmentCompletionState + { + /// + /// Initial state. No completion-relevant signals received. + /// Waiting for: ESP phase event, desktop arrival, or device setup provisioning. + /// + Idle, + + /// + /// ESP has been seen (at least one phase detected) but no final exit yet. + /// Waiting for: ESP exit (FinalizingSetup trigger), desktop arrival, or device-only timer. + /// + EspActive, + + /// + /// ESP exited from AccountSetup (or desktop-arrival backup). espFinalExitSeen=true. + /// Waiting for: Hello resolution, IME user session completion, or safety timeout. + /// + EspExitedAwaitingCompletion, + + /// + /// IME user session completed but Hello is configured and not yet resolved. + /// isWaitingForHello=true. Safety timer running (420s / 7 min). + /// + WaitingForHello, + + /// + /// IME user session completed but ESP provisioning categories not yet resolved in registry. + /// isWaitingForEspSettle=true. Settle timer running (30s). + /// + WaitingForEspSettle, + + /// + /// Desktop arrived but ESP is still active (ESP gate blocking completion). + /// Waiting for: ESP final exit. + /// + DesktopArrivedEspBlocking, + + /// + /// Desktop arrived, no ESP blocking (ESP exited, never seen, or v2). Hello may be pending. + /// Waiting for: Hello resolution or direct completion. + /// + DesktopArrivedAwaitingHello, + + /// + /// Device-only deployment detected (Self-Deploying or SkipUserStatusPage=true without AAD user). + /// Waiting for provisioning complete or IME completion. Hello guard bypassed. + /// + DeviceOnlyAwaitingCompletion, + + /// + /// Device-only ESP exit detected but completion didn't fire immediately (Hello pending). + /// Safety timer running (420s / 7 min). + /// + DeviceOnlySafetyWait, + + /// + /// Hybrid join: esp_hello_composite blocked by reboot gate. + /// Waiting for: IME user session completion or agent restart confirmation after ESP exit. + /// + HybridRebootGateBlocked, + + /// + /// Device info not yet collected when a completion signal arrived. + /// Completion source deferred until CollectDeviceInfo finishes (pendingCompletionSource set). + /// + DeferredForDeviceInfo, + + /// + /// Terminal: enrollment_complete event emitted. No further transitions possible. + /// + Completed, + + /// + /// Terminal: enrollment_failed event emitted. No further transitions possible. + /// + Failed, + + /// + /// Terminal: whiteglove_complete event emitted. No further transitions possible. + /// + WhiteGloveCompleted + } + + /// + /// Extension methods for EnrollmentCompletionState. + /// + internal static class EnrollmentCompletionStateExtensions + { + /// + /// Returns true if the state is terminal (Completed, Failed, or WhiteGloveCompleted). + /// No further transitions are allowed from terminal states. + /// + public static bool IsTerminal(this EnrollmentCompletionState state) + { + return state == EnrollmentCompletionState.Completed + || state == EnrollmentCompletionState.Failed + || state == EnrollmentCompletionState.WhiteGloveCompleted; + } + + /// + /// Returns true if the state is a waiting state (actively waiting for a signal or timeout). + /// + public static bool IsWaiting(this EnrollmentCompletionState state) + { + return state == EnrollmentCompletionState.WaitingForHello + || state == EnrollmentCompletionState.WaitingForEspSettle + || state == EnrollmentCompletionState.DeviceOnlySafetyWait + || state == EnrollmentCompletionState.HybridRebootGateBlocked + || state == EnrollmentCompletionState.DeferredForDeviceInfo; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentStatePersistence.cs index cead11ff..b68f8008 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentStatePersistence.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentStatePersistence.cs @@ -133,5 +133,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // ESP provisioning settle wait state (crash recovery) public bool IsWaitingForEspSettle { get; set; } public DateTime? WaitingForEspSettleStartedUtc { get; set; } + + /// + /// Explicit completion state machine state (dual-write). + /// When present, used to restore the state machine directly. + /// When absent (old format), the state is reconstructed from boolean flags. + /// Serialized as string (enum name) for forward compatibility. + /// + public string CompletionState { get; set; } } } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs index 1efa9f65..e5ec9f22 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs @@ -59,6 +59,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking { _logger.Debug("EnrollmentTracker: HelloCompleted event received but no completion trigger active yet"); } + + // Shadow state machine: track hello_completed trigger + ShadowProcessTrigger("hello_completed"); } /// @@ -162,6 +165,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // Attempt completion — Hello guard is bypassed for device-only deployments (IsDeviceOnlyDeployment) TryEmitEnrollmentComplete("self_deploying_provisioning_complete"); + + // Shadow state machine: track device_setup_provisioning_complete trigger + ShadowProcessTrigger("device_setup_provisioning_complete"); } /// @@ -205,6 +211,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // Das Marker-File wuerde verhindern, dass der Agent beim naechsten Start wieder laeuft. // WICHTIG: Kein _imeLogTracker?.DeleteState()! // Der State wird fuer Part 2 benoetigt falls der Tracker weiterlaufen muss. + + // Shadow state machine: track whiteglove_complete trigger + ShadowProcessTrigger("whiteglove_complete"); } /// @@ -348,6 +357,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // Terminal failure — emit enrollment_failed immediately _logger.Info($"EnrollmentTracker: '{failureType}' is terminal — emitting enrollment_failed immediately"); EmitEnrollmentFailed(failureType, "esp_failure"); + + // Shadow state machine: track terminal ESP failure + var ctx = SnapshotCompletionContext(); + ctx.Source = failureType; + ShadowProcessTrigger("esp_failure_terminal", ctx); } } @@ -366,6 +380,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking _logger.Warning($"EnrollmentTracker: ESP failure grace period ({EspFailureGracePeriodSeconds}s) expired for '{failureType}' — emitting enrollment_failed"); EmitEnrollmentFailed(failureType, "esp_failure_grace_expired"); + + // Shadow state machine: track grace period expiry + var ctx = SnapshotCompletionContext(); + ctx.Source = failureType; + ShadowProcessTrigger("esp_failure_grace_expired", ctx); } /// @@ -415,6 +434,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking } TryEmitEnrollmentComplete("ime_hello_safety_timeout"); + + // Shadow state machine: track hello safety timeout + ShadowProcessTrigger("hello_safety_timeout"); } /// @@ -444,6 +466,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking "— proceeding to completion with current ESP provisioning state"); RecordSignal("esp_provisioning_settled"); TryEmitEnrollmentComplete("ime_pattern"); + + // Shadow state machine: track ESP settle timeout + ShadowProcessTrigger("esp_settle_timeout"); } /// @@ -479,6 +504,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking { _logger.Error("EnrollmentTracker: device-only ESP safety timeout — TryEmitEnrollmentComplete still did not fire (unexpected guard block)"); } + + // Shadow state machine: track device-only safety timeout + ShadowProcessTrigger("device_only_safety_timeout"); } /// @@ -686,6 +714,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking try { CollectDeviceInfoAtFinalizingSetup(reason); } catch (Exception ex) { _logger.Warning($"EnrollmentTracker: final device info collection failed (hello_wizard_started): {ex.Message}"); } } + + // Shadow state machine: track esp_exiting trigger (covers all branches above) + if (reason == "esp_exiting") + ShadowProcessTrigger("esp_exiting"); } // ===== State Persistence ===== @@ -718,6 +750,36 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking _stateData = loaded; } + // Shadow state machine: restore state from persistence + try + { + EnrollmentCompletionState smState; + if (!string.IsNullOrEmpty(loaded.CompletionState) + && Enum.TryParse(loaded.CompletionState, out smState)) + { + // New format: explicit state stored + _logger.Verbose($"EnrollmentTracker [SHADOW]: restoring explicit state '{loaded.CompletionState}' from persistence"); + } + else + { + // Old format: reconstruct from boolean flags + bool isDeviceOnly = CompletionGuards.IsDeviceOnlyDeployment( + loaded.AutopilotMode, loaded.SkipUserStatusPage, loaded.AadJoinedWithUser); + smState = CompletionStateMachine.ReconstructStateFromFlags( + loaded.EnrollmentCompleteEmitted, loaded.IsWaitingForHello, + loaded.IsWaitingForEspSettle, loaded.EspFinalExitSeen, + loaded.DesktopArrived, loaded.EspEverSeen, isDeviceOnly); + _logger.Verbose($"EnrollmentTracker [SHADOW]: reconstructed state '{smState}' from boolean flags"); + } + _completionSm.RestoreState(smState, + loaded.EspEverSeen, loaded.EspFinalExitSeen, loaded.DesktopArrived, + loaded.IsWaitingForHello, loaded.IsWaitingForEspSettle, deferredSource: null); + } + catch (Exception ex) + { + _logger.Debug($"EnrollmentTracker [SHADOW ERROR]: state restoration failed: {ex.Message}"); + } + _logger.Info($"EnrollmentTracker: state restored — espEverSeen={_espEverSeen}, espFinalExitSeen={_espFinalExitSeen}, desktopArrived={_desktopArrived}, lastEspPhase={_lastEspPhase}, enrollmentCompleteEmitted={_enrollmentCompleteEmitted}"); // Restart Hello wait timer if needed after crash recovery @@ -1222,6 +1284,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking } TryEmitEnrollmentComplete("desktop_arrival"); + + // Shadow state machine: track desktop_arrived trigger + ShadowProcessTrigger("desktop_arrived"); } /// @@ -1262,6 +1327,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking { _logger.Info($"EnrollmentTracker: No AccountSetup phase after {DeviceOnlyEspTimerMinutes}min and no desktop yet — waiting for Desktop Arrival or Lifetime Timer"); } + + // Shadow state machine: track device-only ESP timer expiry + ShadowProcessTrigger("device_only_esp_timer_expired"); } } } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs index f61b8e4e..00cb0e99 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs @@ -90,6 +90,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking }, ImmediateUpload = true }); + + // Shadow state machine: track ESP resumed + ShadowProcessTrigger("esp_resumed"); return; } @@ -183,6 +186,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking _debugStateTimerActive = true; _debugStateTimer?.Change(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)); } + + // Shadow state machine: track ESP phase change + ShadowProcessTrigger("esp_phase_changed"); } private void HandleImeAgentVersion(string version) @@ -595,6 +601,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking { _logger.Info($"EnrollmentTracker: Device-only deployment (autopilotMode={autopilotModeSnap}, skipUserStatusPage={skipUserSnap}) — skipping Hello wait, proceeding to completion"); TryEmitEnrollmentComplete("ime_pattern"); + ShadowProcessTrigger("ime_user_session_completed"); return; } @@ -646,6 +653,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking TimeSpan.FromSeconds(WaitingForHelloSafetyTimeoutSeconds), TimeSpan.FromMilliseconds(-1)); + ShadowProcessTrigger("ime_user_session_completed"); return; } } @@ -701,11 +709,15 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking TimeSpan.FromSeconds(EspSettleTimeoutSeconds), TimeSpan.FromMilliseconds(-1)); + ShadowProcessTrigger("ime_user_session_completed"); return; } } TryEmitEnrollmentComplete("ime_pattern"); + + // Shadow state machine: track IME user session completed + ShadowProcessTrigger("ime_user_session_completed"); } /// diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs index 09203668..fb0f0fad 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs @@ -126,6 +126,79 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // SkipUserStatusPage=true AND no user has AAD-joined (no aad_join_status with userEmail). private bool IsDeviceOnlyDeployment => IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser); + /// + /// Creates a read-only snapshot of current state for the shadow state machine. + /// Must be called OUTSIDE _stateLock (takes its own lock internally). + /// + private CompletionContext SnapshotCompletionContext() + { + lock (_stateLock) + { + return new CompletionContext + { + EnrollmentType = _enrollmentType, + IsHybridJoin = _isHybridJoin, + AutopilotMode = _autopilotMode, + SkipUserStatusPage = _skipUserStatusPage, + AadJoinedWithUser = _aadJoinedWithUser, + EspEverSeen = _espEverSeen, + EspFinalExitSeen = _espFinalExitSeen, + DesktopArrived = _desktopArrived, + LastEspPhase = _lastEspPhase, + DeviceInfoCollected = _deviceInfoCollected, + HasHelloTracker = _espAndHelloTracker != null, + IsHelloCompleted = _espAndHelloTracker?.IsHelloCompleted ?? false, + IsHelloPolicyConfigured = _espAndHelloTracker?.IsPolicyConfigured ?? false, + HasUnresolvedEspCategories = false, // Will be set by caller when available + AgentStartTimeUtc = _agentStartTimeUtc, + EspFinalExitUtc = _stateData.EspFinalExitUtc, + ImePatternSeenUtc = _stateData.ImePatternSeenUtc + }; + } + } + + /// + /// Fires the shadow state machine trigger and compares the result with the actual state. + /// Logs discrepancies as warnings. Does NOT change actual behavior. + /// Also dual-writes the CompletionState to _stateData for persistence. + /// + private void ShadowProcessTrigger(string trigger, CompletionContext ctx = null) + { + try + { + ctx = ctx ?? SnapshotCompletionContext(); + var result = _completionSm.ProcessTrigger(trigger, ctx); + + // Dual-write: persist the explicit state alongside the boolean flags + lock (_stateLock) + { + _stateData.CompletionState = _completionSm.CurrentState.ToString(); + } + + // Compare terminal state: shadow SM reached terminal vs actual _enrollmentCompleteEmitted + bool actualTerminal; + lock (_stateLock) { actualTerminal = _enrollmentCompleteEmitted; } + bool shadowTerminal = _completionSm.CurrentState == EnrollmentCompletionState.Completed; + + if (actualTerminal != shadowTerminal) + { + _logger.Warning($"EnrollmentTracker [SHADOW DISCREPANCY]: trigger='{trigger}', " + + $"shadowState={_completionSm.CurrentState}, actualEmitted={actualTerminal}, " + + $"shadowCompleted={shadowTerminal}"); + } + else if (result.Transitioned) + { + _logger.Verbose($"EnrollmentTracker [SHADOW]: trigger='{trigger}', " + + $"{result.PreviousState} -> {result.NewState}"); + } + } + catch (Exception ex) + { + // Shadow mode must never crash the agent + _logger.Debug($"EnrollmentTracker [SHADOW ERROR]: trigger='{trigger}', error={ex.Message}"); + } + } + // Safety-net timer for waiting_for_hello state private Timer _waitingForHelloSafetyTimer; private const int WaitingForHelloSafetyTimeoutSeconds = 420; // 7 min — longer than Hello chain (330s) @@ -148,6 +221,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking // Completion check throttling (max 1x/min per source) private readonly Dictionary _lastCompletionCheckBySource = new Dictionary(); + // Shadow state machine: runs in parallel with existing logic for behavioral verification. + // Logs discrepancies but does NOT affect actual enrollment completion. + private readonly CompletionStateMachine _completionSm = new CompletionStateMachine(); + // Default IME log folder private const string DefaultImeLogFolder = @"%ProgramData%\Microsoft\IntuneManagementExtension\Logs"; @@ -472,6 +549,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking else TryEmitEnrollmentComplete(pendingSource); } + + // Shadow state machine: track device info collected + ShadowProcessTrigger("device_info_collected"); } /// diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 866faa8c..2abc897a 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -927 +933