agent feat: added CompletionStateMachine enhance reliability and testability

This commit is contained in:
Oliver Kieselbach
2026-04-13 22:14:44 +02:00
parent 559167863c
commit f2bbee6aa7
10 changed files with 2419 additions and 1 deletions
@@ -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));
}
}
}
@@ -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") &amp;&amp; enrollmentType != "v2" &amp;&amp; espEverSeen &amp;&amp; !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 &amp;&amp; source == "esp_hello_composite") { ... hybridRebootGateBlocked = !hasImeCompletion &amp;&amp; !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 &amp;&amp; !_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;
}
}
}
@@ -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;
}
}
}
@@ -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; }
}
}
@@ -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");
}
}
}
@@ -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