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