mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
agent feat: added CompletionStateMachine enhance reliability and testability
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class CompletionGuards
|
||||
{
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
|
||||
/// line: helloResolved = _espAndHelloTracker == null || ...IsHelloCompleted || !...IsPolicyConfigured || isDeviceOnly
|
||||
/// </remarks>
|
||||
public static bool IsHelloResolved(bool hasTracker, bool isHelloCompleted,
|
||||
bool isPolicyConfigured, bool isDeviceOnly)
|
||||
{
|
||||
return !hasTracker
|
||||
|| isHelloCompleted
|
||||
|| !isPolicyConfigured
|
||||
|| isDeviceOnly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
|
||||
/// line: espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") && enrollmentType != "v2" && espEverSeen && !espFinalExitSeen
|
||||
/// </remarks>
|
||||
public static bool IsEspGateBlocking(string source, string enrollmentType,
|
||||
bool espEverSeen, bool espFinalExitSeen)
|
||||
{
|
||||
return (source == "desktop_arrival" || source == "desktop_hello")
|
||||
&& enrollmentType != "v2"
|
||||
&& espEverSeen
|
||||
&& !espFinalExitSeen;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Source: EnrollmentTracker.CompletionLogic.cs, TryEmitEnrollmentComplete(),
|
||||
/// lines: if (_isHybridJoin && source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion && !agentRestartedAfterEspExit; }
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Source: EnrollmentTracker.cs, property IsDeviceOnlyDeployment
|
||||
/// line: IsSelfDeploying || (_skipUserStatusPage == true && !_aadJoinedWithUser)
|
||||
/// </remarks>
|
||||
public static bool IsDeviceOnlyDeployment(int? autopilotMode, bool? skipUserStatusPage,
|
||||
bool aadJoinedWithUser)
|
||||
{
|
||||
bool isSelfDeploying = autopilotMode == 1;
|
||||
return isSelfDeploying || (skipUserStatusPage == true && !aadJoinedWithUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,752 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> SignalsToRecord { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; } } }
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs state from persisted EnrollmentStateData (crash recovery).
|
||||
/// Must be called before Start() if recovering from a crash.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> { "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<string>() };
|
||||
|
||||
// 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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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) =====
|
||||
|
||||
/// <summary>
|
||||
/// Checks all three completion guards for a given source.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completion check with Hello always resolved (caller has just confirmed Hello completed).
|
||||
/// Only checks ESP gate and hybrid reboot gate.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal enum EnrollmentCompletionState
|
||||
{
|
||||
/// <summary>
|
||||
/// Initial state. No completion-relevant signals received.
|
||||
/// Waiting for: ESP phase event, desktop arrival, or device setup provisioning.
|
||||
/// </summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
EspActive,
|
||||
|
||||
/// <summary>
|
||||
/// ESP exited from AccountSetup (or desktop-arrival backup). espFinalExitSeen=true.
|
||||
/// Waiting for: Hello resolution, IME user session completion, or safety timeout.
|
||||
/// </summary>
|
||||
EspExitedAwaitingCompletion,
|
||||
|
||||
/// <summary>
|
||||
/// IME user session completed but Hello is configured and not yet resolved.
|
||||
/// isWaitingForHello=true. Safety timer running (420s / 7 min).
|
||||
/// </summary>
|
||||
WaitingForHello,
|
||||
|
||||
/// <summary>
|
||||
/// IME user session completed but ESP provisioning categories not yet resolved in registry.
|
||||
/// isWaitingForEspSettle=true. Settle timer running (30s).
|
||||
/// </summary>
|
||||
WaitingForEspSettle,
|
||||
|
||||
/// <summary>
|
||||
/// Desktop arrived but ESP is still active (ESP gate blocking completion).
|
||||
/// Waiting for: ESP final exit.
|
||||
/// </summary>
|
||||
DesktopArrivedEspBlocking,
|
||||
|
||||
/// <summary>
|
||||
/// Desktop arrived, no ESP blocking (ESP exited, never seen, or v2). Hello may be pending.
|
||||
/// Waiting for: Hello resolution or direct completion.
|
||||
/// </summary>
|
||||
DesktopArrivedAwaitingHello,
|
||||
|
||||
/// <summary>
|
||||
/// Device-only deployment detected (Self-Deploying or SkipUserStatusPage=true without AAD user).
|
||||
/// Waiting for provisioning complete or IME completion. Hello guard bypassed.
|
||||
/// </summary>
|
||||
DeviceOnlyAwaitingCompletion,
|
||||
|
||||
/// <summary>
|
||||
/// Device-only ESP exit detected but completion didn't fire immediately (Hello pending).
|
||||
/// Safety timer running (420s / 7 min).
|
||||
/// </summary>
|
||||
DeviceOnlySafetyWait,
|
||||
|
||||
/// <summary>
|
||||
/// Hybrid join: esp_hello_composite blocked by reboot gate.
|
||||
/// Waiting for: IME user session completion or agent restart confirmation after ESP exit.
|
||||
/// </summary>
|
||||
HybridRebootGateBlocked,
|
||||
|
||||
/// <summary>
|
||||
/// Device info not yet collected when a completion signal arrived.
|
||||
/// Completion source deferred until CollectDeviceInfo finishes (pendingCompletionSource set).
|
||||
/// </summary>
|
||||
DeferredForDeviceInfo,
|
||||
|
||||
/// <summary>
|
||||
/// Terminal: enrollment_complete event emitted. No further transitions possible.
|
||||
/// </summary>
|
||||
Completed,
|
||||
|
||||
/// <summary>
|
||||
/// Terminal: enrollment_failed event emitted. No further transitions possible.
|
||||
/// </summary>
|
||||
Failed,
|
||||
|
||||
/// <summary>
|
||||
/// Terminal: whiteglove_complete event emitted. No further transitions possible.
|
||||
/// </summary>
|
||||
WhiteGloveCompleted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for EnrollmentCompletionState.
|
||||
/// </summary>
|
||||
internal static class EnrollmentCompletionStateExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if the state is terminal (Completed, Failed, or WhiteGloveCompleted).
|
||||
/// No further transitions are allowed from terminal states.
|
||||
/// </summary>
|
||||
public static bool IsTerminal(this EnrollmentCompletionState state)
|
||||
{
|
||||
return state == EnrollmentCompletionState.Completed
|
||||
|| state == EnrollmentCompletionState.Failed
|
||||
|| state == EnrollmentCompletionState.WhiteGloveCompleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the state is a waiting state (actively waiting for a signal or timeout).
|
||||
/// </summary>
|
||||
public static bool IsWaiting(this EnrollmentCompletionState state)
|
||||
{
|
||||
return state == EnrollmentCompletionState.WaitingForHello
|
||||
|| state == EnrollmentCompletionState.WaitingForEspSettle
|
||||
|| state == EnrollmentCompletionState.DeviceOnlySafetyWait
|
||||
|| state == EnrollmentCompletionState.HybridRebootGateBlocked
|
||||
|| state == EnrollmentCompletionState.DeferredForDeviceInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string CompletionState { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a read-only snapshot of current state for the shadow state machine.
|
||||
/// Must be called OUTSIDE _stateLock (takes its own lock internally).
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, DateTime> _lastCompletionCheckBySource = new Dictionary<string, DateTime>();
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1 +1 @@
|
||||
927
|
||||
933
|
||||
|
||||
Reference in New Issue
Block a user