diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs index acd71c8d..57d497ce 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs @@ -1072,5 +1072,64 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking { Assert.False(EnrollmentCompletionState.EspActive.IsWaiting()); } + + // ===== SECTION 5: Drift regression tests ===== + // Repro for session 1fda3b0a-e586-4980-bc90-491e4e8ca870: + // ESP phase events were not persisted before reboot, so SM was restored with + // espEverSeen=false. Subsequent esp_exiting + desktop_arrived must not complete + // the SM prematurely — the ProcessTrigger snapshot sync lifts the missing flags + // from the context (the authoritative tracker state). + + [Fact] + public void Drift_RestoredWithoutEspEverSeen_EspExitingSyncsFromContext() + { + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.Idle, + espEverSeen: false, espFinalExitSeen: false, desktopArrived: false, + isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); + + // ctx says ESP was seen (real tracker is the source of truth). + // Hello policy IS configured but not yet completed — must wait, not complete. + var ctx = DefaultContext( + lastEspPhase: "AccountSetup", + isHelloPolicyConfigured: true, + isHelloCompleted: false); + ctx.EspEverSeen = true; + ctx.EspFinalExitSeen = false; + + var result = sm.ProcessTrigger("esp_exiting", ctx); + + Assert.True(result.Transitioned); + Assert.NotEqual(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.False(result.ShouldEmitEnrollmentComplete); + Assert.True(sm.EspEverSeen, "SM must lift _espEverSeen from ctx via the sync block"); + Assert.True(sm.EspFinalExitSeen, "esp_exiting Path A must mark final exit"); + } + + [Fact] + public void Drift_RestoredWithoutEspEverSeen_DesktopArrivedRoutesToEspBlocking() + { + // The exact scenario from session 1fda3b0a-e586-4980-bc90-491e4e8ca870 at 06:05:41: + // SM restored with espEverSeen=false, but ctx says ESP IS active. + // Pre-fix: SM took the "no ESP" else branch and reached Completed. + // Post-fix: sync block lifts _espEverSeen from ctx, SM correctly routes to + // DesktopArrivedEspBlocking. + var sm = new CompletionStateMachine(); + sm.RestoreState(EnrollmentCompletionState.Idle, + espEverSeen: false, espFinalExitSeen: false, desktopArrived: false, + isWaitingForHello: false, isWaitingForEspSettle: false, deferredSource: null); + + var ctx = DefaultContext( + isHelloPolicyConfigured: true, + isHelloCompleted: false); + ctx.EspEverSeen = true; + ctx.EspFinalExitSeen = false; + + var result = sm.ProcessTrigger("desktop_arrived", ctx); + + Assert.NotEqual(EnrollmentCompletionState.Completed, sm.CurrentState); + Assert.Equal(EnrollmentCompletionState.DesktopArrivedEspBlocking, sm.CurrentState); + Assert.False(result.ShouldEmitEnrollmentComplete); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionStateMachine.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionStateMachine.cs index 865de651..ad1157b1 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionStateMachine.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/CompletionStateMachine.cs @@ -181,6 +181,20 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking lock (_lock) { + // Sync internal signal flags from the authoritative tracker snapshot. + // Monoton false→true: these signals are stable-once-seen. The single + // intentional reset (HandleEspResumed clearing _espFinalExitSeen) runs + // AFTER this sync inside its own switch case, so it remains the last writer. + // Without this, the SM can drift from the tracker (e.g. when + // _stateData.EspEverSeen=true was set in memory but Save() hadn't run + // before a reboot — restore loads false and downstream guards misfire). + if (ctx != null) + { + if (ctx.EspEverSeen) _espEverSeen = true; + if (ctx.EspFinalExitSeen) _espFinalExitSeen = true; + if (ctx.DesktopArrived) _desktopArrived = true; + } + // Terminal states: no transitions allowed if (_currentState.IsTerminal()) return TransitionResult.NoTransition; diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 4524e19e..45b0f829 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -984 +987