diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ShellCoreToEngineEspExitingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ShellCoreToEngineEspExitingTests.cs index 45515832..9177b624 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ShellCoreToEngineEspExitingTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ShellCoreToEngineEspExitingTests.cs @@ -87,7 +87,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration var espExiting = ingress.Posted.FirstOrDefault(p => p.Kind == DecisionSignalKind.EspExiting); Assert.NotNull(espExiting); - // Build a Classic state already at AccountSetup so the EspExiting guard unlocks. + // Build a Classic state already at AccountSetup AND with the strong post-AccountSetup + // gate satisfied (session 330f73f3 fix) so the EspExiting guard unlocks. Pre-fix this + // worked with only AccountSetupEnteredUtc; the new gate requires AccountSetupProvisioningSucceededUtc. var engine = new DecisionEngine(); var seed = DecisionState.CreateInitial("S1", "T1", agentBootUtc: sourceTime.AddMinutes(-5)) .ToBuilder() @@ -95,6 +97,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration .WithStepIndex(2) .WithLastAppliedSignalOrdinal(1); seed.AccountSetupEnteredUtc = new SignalFact(sourceTime.AddMinutes(-2), 1); + seed.AccountSetupProvisioningSucceededUtc = new SignalFact(sourceTime.AddMinutes(-1).AddSeconds(-30), 1); var state = seed.Build(); // Re-wrap the adapter-posted signal with the ordinal the engine expects. @@ -220,6 +223,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration .WithStepIndex(2) .WithLastAppliedSignalOrdinal(1); seed.AccountSetupEnteredUtc = new SignalFact(sourceTime.AddMinutes(-2), 1); + seed.AccountSetupProvisioningSucceededUtc = new SignalFact(sourceTime.AddMinutes(-1).AddSeconds(-30), 1); var state = seed.Build(); var enriched = new DecisionSignal( diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs index 76437db8..13ef7b11 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs @@ -80,6 +80,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// public event EventHandler DeviceSetupProvisioningComplete; + /// + /// Session 330f73f3 fix: fired when AccountSetup provisioning resolves with success + /// (or the fallback confirmed). Forwarded from ; + /// the V2 adapter posts it as DecisionSignalKind.AccountSetupProvisioningComplete + /// so the reducer's ShouldTransitionToAwaitingHello gate has the strong + /// post-AccountSetup fact and stops promoting on intermediate Shell-Core 62407 events. + /// + public event EventHandler AccountSetupProvisioningComplete; + // PR4 (882fef64 debrief) — coordinator-forwarded copy of . // Args: (helloEnabled, source). Subscribed by EspAndHelloTrackerAdapter to post a // DecisionSignalKind.HelloPolicyDetected signal. @@ -250,6 +259,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals _logger); _provisioningTracker.EspFailureDetected += OnEspFailureDetected; _provisioningTracker.DeviceSetupProvisioningComplete += OnDeviceSetupProvisioningComplete; + _provisioningTracker.AccountSetupProvisioningComplete += OnAccountSetupProvisioningComplete; _provisioningTracker.Start(); if (_modernDeploymentWatcherEnabled) @@ -315,6 +325,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals { _provisioningTracker.EspFailureDetected -= OnEspFailureDetected; _provisioningTracker.DeviceSetupProvisioningComplete -= OnDeviceSetupProvisioningComplete; + _provisioningTracker.AccountSetupProvisioningComplete -= OnAccountSetupProvisioningComplete; _provisioningTracker.Stop("tracker_stopped"); } catch (Exception ex) { _logger.Error("Error stopping provisioning status tracker", ex); } @@ -471,6 +482,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals catch (Exception ex) { _logger.Error("Error forwarding DeviceSetupProvisioningComplete", ex); } } + private void OnAccountSetupProvisioningComplete(object sender, EventArgs e) + { + // Mirror of DeviceSetup path. Adapter falls back to clock for the signal timestamp. + LastEventOccurredAtUtc = null; + try { AccountSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("Error forwarding AccountSetupProvisioningComplete", ex); } + } + private void OnEspExited(object sender, EspExitedEventArgs args) { // EspExited carries its source-event timestamp on the args (set by ShellCoreTracker diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs index a20bde0a..e437f87e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ProvisioningStatusTracker.cs @@ -40,6 +40,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals { internal const string ProvisioningStatusRegistryPath = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings"; internal const int DeviceSetupFallbackDelaySeconds = 30; + // Same fallback window as DeviceSetup, applied to AccountSetup. Session 330f73f3 fix: + // some Windows builds never set AccountSetupCategory.Status.categorySucceeded even when + // all subcategories resolve to succeeded/notRequired; without a fallback the strong + // post-AccountSetup gate (AccountSetupProvisioningSucceededUtc) would never be set and + // the reducer would never promote to AwaitingHello. + internal const int AccountSetupFallbackDelaySeconds = 30; internal const int ProvisioningDebounceMilliseconds = 1000; private static readonly string[] ProvisioningCategoryNames = @@ -58,6 +64,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals private Timer _provisioningWatcherRetryTimer; private Timer _provisioningDebounceTimer; private Timer _deviceSetupFallbackTimer; + private Timer _accountSetupFallbackTimer; // Track the raw JSON per category — used to detect any changes at all private Dictionary _lastProvisioningJson; @@ -73,6 +80,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals private Dictionary> _lastSubcategoryStates; // Fire-once guard for DeviceSetupProvisioningComplete event private bool _deviceSetupProvisioningCompleteFired; + // Fire-once guard for AccountSetupProvisioningComplete event (session 330f73f3 fix) + private bool _accountSetupProvisioningCompleteFired; // WhiteGlove confirmation: DeviceSetup registry contains SaveWhiteGloveSuccessResult=succeeded private bool _saveWhiteGloveSuccessResultSeen; // Track SaveWhiteGloveSuccessResult state transitions for observability (null → notStarted → succeeded) @@ -81,6 +90,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals public event EventHandler EspFailureDetected; public event EventHandler DeviceSetupProvisioningComplete; + /// + /// Fires once when AccountSetupCategory.Status resolves to + /// categorySucceeded=true, or when the fallback fires (all subcategories + /// succeeded/notRequired but Windows never set the boolean — same fallback shape as + /// DeviceSetup). Session 330f73f3 fix: this is the strong "User-ESP truly finished" + /// signal that gates the reducer's promotion to AwaitingHello; without it the + /// reducer ignores intermediate Shell-Core 62407 events. + /// + public event EventHandler AccountSetupProvisioningComplete; public ProvisioningStatusTracker( string sessionId, @@ -219,6 +237,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals _lastSubcategoryStates = new Dictionary>(StringComparer.OrdinalIgnoreCase); _deviceSetupProvisioningCompleteFired = false; _deviceSetupFallbackTimer = null; + _accountSetupProvisioningCompleteFired = false; + _accountSetupFallbackTimer = null; // Try to start immediately; if key doesn't exist yet, retry every 2s if (!TryStartWatcher()) @@ -252,6 +272,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals _deviceSetupFallbackTimer?.Dispose(); _deviceSetupFallbackTimer = null; + _accountSetupFallbackTimer?.Dispose(); + _accountSetupFallbackTimer = null; + if (_provisioningWatcher != null) { _provisioningWatcher.Dispose(); @@ -418,6 +441,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals TimeSpan.FromSeconds(DeviceSetupFallbackDelaySeconds), Timeout.InfiniteTimeSpan); } + + // Session 330f73f3 fix: same flow for AccountSetup. Fires + // AccountSetupProvisioningComplete when the category resolves with + // success, or when the fallback confirms all subcategories + // succeeded/notRequired but categorySucceeded was never set. + if (!_accountSetupProvisioningCompleteFired + && _lastCategorySucceeded.TryGetValue("AccountSetupCategory.Status", out var asSucceeded) + && asSucceeded == true) + { + _accountSetupProvisioningCompleteFired = true; + _logger.Info("Provisioning status: AccountSetup succeeded — firing AccountSetupProvisioningComplete"); + try { AccountSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("AccountSetupProvisioningComplete handler failed", ex); } + } + + if (!_accountSetupProvisioningCompleteFired + && _accountSetupFallbackTimer == null + && _lastCategorySucceeded.TryGetValue("AccountSetupCategory.Status", out var asFallbackState) + && asFallbackState == null + && _lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var asFallbackSubStates) + && asFallbackSubStates.Count > 0 + && asFallbackSubStates.Values.All(s => + string.Equals(s, "succeeded", StringComparison.OrdinalIgnoreCase) || + string.Equals(s, "notRequired", StringComparison.OrdinalIgnoreCase))) + { + _logger.Info($"Provisioning status: AccountSetup — all {asFallbackSubStates.Count} subcategories succeeded " + + $"but categorySucceeded not set by Windows — starting {AccountSetupFallbackDelaySeconds}s fallback timer"); + _accountSetupFallbackTimer = new Timer( + OnAccountSetupFallbackTimerExpired, + null, + TimeSpan.FromSeconds(AccountSetupFallbackDelaySeconds), + Timeout.InfiniteTimeSpan); + } } } } @@ -427,6 +483,189 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } } + /// + /// Session 330f73f3 fix: AccountSetup-side mirror of . + /// Confirms via a fresh registry read that all subcategories are still + /// succeeded/notRequired and categorySucceeded is still unset, then fires + /// . If Windows wrote a real + /// categorySucceeded value in the meantime, the normal path re-runs and handles it. + /// + /// On every abort path the timer field is reset to null so a later + /// CheckProvisioningStatus can re-arm the fallback when the "all subcategories + /// succeeded" condition is re-satisfied (transient flicker race: a subcategory briefly + /// drops to in_progress between fallback arm and timer fire, the callback aborts — + /// without resetting the field, a re-stabilised state could never re-arm and the + /// strong-gate fact never gets posted). + /// + /// + private void OnAccountSetupFallbackTimerExpired(object state) + { + try + { + lock (_stateLock) + { + if (_accountSetupProvisioningCompleteFired) + { + _logger.Debug("AccountSetup fallback timer expired but AccountSetupProvisioningComplete already fired — ignoring"); + // No timer-field reset needed: re-arm is gated on _accountSetupProvisioningCompleteFired too. + return; + } + + string registryJson = null; + using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusRegistryPath, writable: false)) + { + registryJson = key?.GetValue("AccountSetupCategory.Status")?.ToString(); + } + + if (string.IsNullOrEmpty(registryJson)) + { + _logger.Warning("AccountSetup fallback timer: registry value not found — aborting fallback"); + ResetAccountSetupFallbackTimer(); + return; + } + + bool? categorySucceeded = null; + List subcategories = null; + try + { + using (var doc = JsonDocument.Parse(registryJson)) + { + categorySucceeded = SafeGetBool(doc.RootElement, "categorySucceeded"); + subcategories = ParseSubcategories(doc.RootElement); + } + } + catch (JsonException ex) + { + _logger.Warning($"AccountSetup fallback timer: failed to parse registry JSON — aborting fallback: {ex.Message}"); + ResetAccountSetupFallbackTimer(); + return; + } + + if (categorySucceeded.HasValue) + { + _logger.Info($"AccountSetup fallback timer: categorySucceeded is now {categorySucceeded.Value} — normal path will handle this"); + ResetAccountSetupFallbackTimer(); + CheckProvisioningStatus(); + return; + } + + if (subcategories == null || subcategories.Count == 0) + { + _logger.Warning("AccountSetup fallback timer: no subcategories found — aborting fallback"); + ResetAccountSetupFallbackTimer(); + return; + } + + var nonSucceeded = subcategories.Where(s => + !string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase) && + !string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)).ToList(); + + if (nonSucceeded.Count > 0) + { + _logger.Warning($"AccountSetup fallback timer: {nonSucceeded.Count} subcategory/ies not succeeded " + + $"({string.Join(", ", nonSucceeded.Select(s => $"{s.Name}={s.State}"))}) — aborting fallback"); + ResetAccountSetupFallbackTimer(); + return; + } + + EmitRawRegistryDump("AccountSetup", registryJson, "fallback_confirmed"); + + _logger.Warning($"AccountSetup fallback: all {subcategories.Count} subcategories succeeded but " + + $"categorySucceeded was not set by Windows after {AccountSetupFallbackDelaySeconds}s — treating as complete"); + + var subcatData = new Dictionary(); + foreach (var sub in subcategories) + { + subcatData[sub.Name] = new Dictionary + { + { "state", sub.State }, + { "statusText", sub.StatusText } + }; + } + + _post.Emit(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = Constants.EventTypes.EspProvisioningStatus, + Severity = EventSeverity.Warning, + Source = "EspAndHelloTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"ESP provisioning status: AccountSetup — all {subcategories.Count} subcategories succeeded " + + $"but categorySucceeded was not confirmed by Windows — treating as complete (fallback after {AccountSetupFallbackDelaySeconds}s)", + Data = new Dictionary + { + { "category", "AccountSetup" }, + { "categorySucceeded", "in_progress" }, + { "fallbackApplied", true }, + { "fallbackReason", "all_subcategories_succeeded_category_unresolved" }, + { "fallbackDelaySeconds", AccountSetupFallbackDelaySeconds }, + { "subcategoryCount", subcategories.Count }, + { "subcategories", subcatData } + } + }); + + // Fired success path: dispose + null the timer so Stop() doesn't redundantly + // dispose it. Re-arm is also gated on _accountSetupProvisioningCompleteFired so + // null-ing here is correctness-neutral on the start-condition side. + ResetAccountSetupFallbackTimer(); + _accountSetupProvisioningCompleteFired = true; + try { AccountSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } + catch (Exception ex) { _logger.Error("AccountSetupProvisioningComplete handler failed (fallback path)", ex); } + } + } + catch (Exception ex) + { + _logger.Error($"AccountSetup fallback timer callback failed: {ex.Message}", ex); + // Defensive re-arm-blocker cleanup. If the inner block threw before any of the + // explicit Reset paths ran (e.g. registry open / Emit / Invoke failure), the + // timer field would otherwise stay non-null and CheckProvisioningStatus' start + // condition could never re-arm a re-stabilised "all subcategories succeeded" + // state. The lock is re-entrant — safe to re-acquire here. + try + { + lock (_stateLock) + { + ResetAccountSetupFallbackTimer(); + } + } + catch (Exception cleanupEx) + { + _logger.Error("AccountSetup fallback timer reset on exception path failed", cleanupEx); + } + } + } + + /// + /// Reset the AccountSetup fallback timer field after the callback consumes it. Required + /// because the start condition in gates re-arming + /// on _accountSetupFallbackTimer == null; without this reset a transient flicker + /// (subcategory briefly drops to in_progress between arm and timer fire → callback + /// aborts) would permanently prevent re-arming even when the state re-stabilises. + /// Caller already holds . + /// + private void ResetAccountSetupFallbackTimer() + { + _accountSetupFallbackTimer?.Dispose(); + _accountSetupFallbackTimer = null; + } + + /// + /// DeviceSetup mirror of . Same re-arm-blocker + /// concern, same fix. Caller already holds . + /// + private void ResetDeviceSetupFallbackTimer() + { + _deviceSetupFallbackTimer?.Dispose(); + _deviceSetupFallbackTimer = null; + } + + /// + /// Parity with — every abort path + /// resets to null so a transient + /// subcategory flicker that aborts the callback cannot permanently block a re-arm + /// from . + /// private void OnDeviceSetupFallbackTimerExpired(object state) { try @@ -436,6 +675,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals if (_deviceSetupProvisioningCompleteFired) { _logger.Debug("DeviceSetup fallback timer expired but DeviceSetupProvisioningComplete already fired — ignoring"); + // No timer-field reset needed: re-arm is gated on _deviceSetupProvisioningCompleteFired too. return; } @@ -448,6 +688,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals if (string.IsNullOrEmpty(registryJson)) { _logger.Warning("DeviceSetup fallback timer: registry value not found — aborting fallback"); + ResetDeviceSetupFallbackTimer(); return; } @@ -464,12 +705,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals catch (JsonException ex) { _logger.Warning($"DeviceSetup fallback timer: failed to parse registry JSON — aborting fallback: {ex.Message}"); + ResetDeviceSetupFallbackTimer(); return; } if (categorySucceeded.HasValue) { _logger.Info($"DeviceSetup fallback timer: categorySucceeded is now {categorySucceeded.Value} — normal path will handle this"); + ResetDeviceSetupFallbackTimer(); CheckProvisioningStatus(); return; } @@ -477,6 +720,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals if (subcategories == null || subcategories.Count == 0) { _logger.Warning("DeviceSetup fallback timer: no subcategories found — aborting fallback"); + ResetDeviceSetupFallbackTimer(); return; } @@ -488,6 +732,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals { _logger.Warning($"DeviceSetup fallback timer: {nonSucceeded.Count} subcategory/ies not succeeded " + $"({string.Join(", ", nonSucceeded.Select(s => $"{s.Name}={s.State}"))}) — aborting fallback"); + ResetDeviceSetupFallbackTimer(); return; } @@ -528,6 +773,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } }); + // Fired success path: dispose + null the timer so Stop() doesn't redundantly + // dispose it. Re-arm is also gated on _deviceSetupProvisioningCompleteFired so + // null-ing here is correctness-neutral on the start-condition side. + ResetDeviceSetupFallbackTimer(); _deviceSetupProvisioningCompleteFired = true; try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _logger.Error("DeviceSetupProvisioningComplete handler failed (fallback path)", ex); } @@ -536,6 +785,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals catch (Exception ex) { _logger.Error($"DeviceSetup fallback timer callback failed: {ex.Message}", ex); + // Mirror of AccountSetup defensive cleanup: if the inner block threw before any + // of the explicit Reset paths ran, the timer field would otherwise stay non-null + // and CheckProvisioningStatus' start condition could never re-arm. Lock is + // re-entrant — safe to re-acquire. + try + { + lock (_stateLock) + { + ResetDeviceSetupFallbackTimer(); + } + } + catch (Exception cleanupEx) + { + _logger.Error("DeviceSetup fallback timer reset on exception path failed", cleanupEx); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs index c044b285..bca9bea5 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs @@ -26,6 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters /// WhiteGloveCompleted → /// EspFailureDetected → (merged aus ShellCore + Provisioning) /// DeviceSetupProvisioningComplete → + /// AccountSetupProvisioningComplete → (session 330f73f3 fix — strong AccountSetup-done gate) /// EspExited → (no dedup — every 62407 forwarded; reducer guard decides) /// /// @@ -50,6 +51,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters private bool _whiteGloveSuccessPosted; private bool _espFailurePosted; private bool _deviceSetupCompletePosted; + private bool _accountSetupCompletePosted; private bool _helloPolicyPosted; /// Tracked HelloOutcome (read via event; coordinator exposes property). @@ -67,6 +69,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _coordinator.WhiteGloveCompleted += OnWhiteGloveCompleted; _coordinator.EspFailureDetected += OnEspFailure; _coordinator.DeviceSetupProvisioningComplete += OnDeviceSetupComplete; + _coordinator.AccountSetupProvisioningComplete += OnAccountSetupComplete; _coordinator.HelloPolicyDetected += OnHelloPolicyDetected; _coordinator.EspExited += OnEspExited; } @@ -78,6 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _coordinator.WhiteGloveCompleted -= OnWhiteGloveCompleted; _coordinator.EspFailureDetected -= OnEspFailure; _coordinator.DeviceSetupProvisioningComplete -= OnDeviceSetupComplete; + _coordinator.AccountSetupProvisioningComplete -= OnAccountSetupComplete; _coordinator.HelloPolicyDetected -= OnHelloPolicyDetected; _coordinator.EspExited -= OnEspExited; } @@ -87,6 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters private void OnWhiteGloveCompleted(object sender, EventArgs e) => EmitWhiteGlove(); private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType); private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete(); + private void OnAccountSetupComplete(object sender, EventArgs e) => EmitAccountSetupComplete(); private void OnHelloPolicyDetected(bool helloEnabled, string source) => EmitHelloPolicy(helloEnabled, source); private void OnEspExited(object sender, EspExitedEventArgs args) => EmitEspExiting(); @@ -95,6 +100,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters internal void TriggerWhiteGloveFromTest() => EmitWhiteGlove(); internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType); internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete(); + internal void TriggerAccountSetupCompleteFromTest() => EmitAccountSetupComplete(); internal void TriggerHelloPolicyDetectedFromTest(bool helloEnabled, string source) => EmitHelloPolicy(helloEnabled, source); internal void TriggerEspExitingFromTest() => EmitEspExiting(); @@ -349,5 +355,44 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters ["deviceSetupResolved"] = deviceSetupResolved, }); } + + /// + /// Session 330f73f3 fix: emits the strong post-AccountSetup gate signal. Coordinator + /// already deduped at the tracker layer; this method's own flag is a second-line + /// defense against duplicate Subscribe calls. + /// + private void EmitAccountSetupComplete() + { + if (_accountSetupCompletePosted) return; + _accountSetupCompletePosted = true; + + var snapshot = _coordinator.GetProvisioningCategorySnapshot(); + var accountSetupResolved = + snapshot.TryGetValue("AccountSetupCategory.Status", out var asState) && asState.HasValue + ? asState.Value.ToString().ToLowerInvariant() + : "unknown"; + var now = ResolveOccurredAt(out var derivedFromClock); + + var derivationInputs = new Dictionary(StringComparer.Ordinal) + { + ["subSource"] = "ProvisioningStatusTracker", + ["accountSetupResolved"] = accountSetupResolved, + }; + TagDerivedTimestamp(derivationInputs, derivedFromClock); + + _ingress.Post( + kind: DecisionSignalKind.AccountSetupProvisioningComplete, + occurredAtUtc: now, + sourceOrigin: SourceOrigin, + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: DetectorId, + summary: "AccountSetupCategory provisioning completed (coordinator-forwarded)", + derivationInputs: derivationInputs), + payload: new Dictionary(StringComparer.Ordinal) + { + ["accountSetupResolved"] = accountSetupResolved, + }); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs index 3430d14b..59192e41 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs @@ -9,12 +9,13 @@ using AutopilotMonitor.DecisionCore.Signals; namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters { /// - /// Adapter for → 2 DecisionSignalKinds. + /// Adapter for → 3 DecisionSignalKinds. /// Plan §2.1a / §2.2. /// /// Event mapping: /// /// DeviceSetupProvisioningComplete → + /// AccountSetupProvisioningComplete → (session 330f73f3 fix) /// EspFailureDetected → /// /// @@ -32,6 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters private readonly IClock _clock; private bool _deviceSetupCompletePosted; + private bool _accountSetupCompletePosted; private bool _espFailurePosted; public ProvisioningStatusTrackerAdapter( @@ -44,19 +46,23 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _clock = clock ?? throw new ArgumentNullException(nameof(clock)); _tracker.DeviceSetupProvisioningComplete += OnDeviceSetupComplete; + _tracker.AccountSetupProvisioningComplete += OnAccountSetupComplete; _tracker.EspFailureDetected += OnEspFailure; } public void Dispose() { _tracker.DeviceSetupProvisioningComplete -= OnDeviceSetupComplete; + _tracker.AccountSetupProvisioningComplete -= OnAccountSetupComplete; _tracker.EspFailureDetected -= OnEspFailure; } private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete(); + private void OnAccountSetupComplete(object sender, EventArgs e) => EmitAccountSetupComplete(); private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType); internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete(); + internal void TriggerAccountSetupCompleteFromTest() => EmitAccountSetupComplete(); internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType); private void EmitDeviceSetupComplete() @@ -88,6 +94,35 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters }); } + private void EmitAccountSetupComplete() + { + if (_accountSetupCompletePosted) return; + _accountSetupCompletePosted = true; + + var snapshot = _tracker.GetProvisioningCategorySnapshot(); + var accountSetupResolved = snapshot.TryGetValue("AccountSetupCategory.Status", out var asState) && asState.HasValue + ? asState.Value.ToString().ToLowerInvariant() + : "unknown"; + + _ingress.Post( + kind: DecisionSignalKind.AccountSetupProvisioningComplete, + occurredAtUtc: _clock.UtcNow, + sourceOrigin: "ProvisioningStatusTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "provisioning-status-tracker-v1", + summary: "AccountSetupCategory provisioning completed", + derivationInputs: new Dictionary(StringComparer.Ordinal) + { + ["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\AccountSetupCategory.Status", + ["accountSetupResolved"] = accountSetupResolved, + }), + payload: new Dictionary(StringComparer.Ordinal) + { + ["accountSetupResolved"] = accountSetupResolved, + }); + } + private void EmitEspFailure(string failureType) { if (_espFailurePosted) return; diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index f03c497d..dc763b65 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -756 +768 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index dda50873..1b1818ca 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1350 +1353 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/BootAnchorDeadlineFloorTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/BootAnchorDeadlineFloorTests.cs index d480dd0d..f240c3dc 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/BootAnchorDeadlineFloorTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/BootAnchorDeadlineFloorTests.cs @@ -42,9 +42,11 @@ namespace AutopilotMonitor.DecisionCore.Tests [Fact] public void HelloSafety_armed_from_replayed_EspExiting_floors_at_AgentBootUtc() { - // Setup: state already in EspAccountSetup so EspExiting takes the AwaitingHello - // path. Build it with a non-replayed AccountSetup signal so the boot-anchor floor - // only matters for the EspExiting arming step. + // Setup: state already in EspAccountSetup with the strong post-AccountSetup gate + // satisfied so EspExiting takes the AwaitingHello path (session 330f73f3 fix: + // AccountSetupEntered alone is no longer sufficient — need AccountSetupProvisioningSucceeded). + // Build it with non-replayed signals so the boot-anchor floor only matters for the + // EspExiting arming step. var engine = new DecisionEngine(); var state = FreshAtBoot(); state = engine.Reduce(state, @@ -52,9 +54,11 @@ namespace AutopilotMonitor.DecisionCore.Tests state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, AgentBoot.AddSeconds(1), new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + state = engine.Reduce(state, + MakeSignal(2, DecisionSignalKind.AccountSetupProvisioningComplete, AgentBoot.AddSeconds(2))).NewState; // Replayed CMTrace EspExiting from before the agent booted. - var replayed = MakeSignal(2, DecisionSignalKind.EspExiting, LogPast); + var replayed = MakeSignal(3, DecisionSignalKind.EspExiting, LogPast); var step = engine.Reduce(state, replayed); var helloSafety = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety); @@ -150,6 +154,7 @@ namespace AutopilotMonitor.DecisionCore.Tests deviceSetupEnteredUtc: null, accountSetupEnteredUtc: null, finalizingEnteredUtc: null, + accountSetupProvisioningSucceededUtc: null, espFinalExitUtc: null, desktopArrivedUtc: null, helloResolvedUtc: null, diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicAwaitingHelloGuardTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicAwaitingHelloGuardTests.cs index 8d5a0df1..69e05197 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicAwaitingHelloGuardTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicAwaitingHelloGuardTests.cs @@ -9,11 +9,20 @@ using Xunit; namespace AutopilotMonitor.DecisionCore.Tests { /// - /// Plan §6 Fix 8 — the Classic reducer must only promote to - /// when the promotion is legitimate: either - /// is set (i.e. we're past the post-Account-ESP final exit), or - /// is explicitly true (device-only / SkipUser - /// flow where a single esp_exiting IS the final one). + /// Plan §6 Fix 8 + session 330f73f3 fix (2026-05-18). The Classic reducer must only promote + /// to when the promotion is legitimate: + /// + /// is set + /// (AccountSetupCategory registry resolved as succeeded, OR the fallback fired), + /// i.e. ESP is genuinely done, OR + /// is observed as + /// true (device-only / SkipUser flow where a single esp_exiting IS the + /// final one). + /// + /// The pre-fix gate "AccountSetupEnteredUtc != null" was too weak — Shell-Core + /// event 62407 fires at every ESP-page transition, and the Device→Account handoff would + /// promote and arm HelloSafety just seconds after AccountSetup entered, while apps were + /// still installing. See session 330f73f3 for the production failure mode. /// /// Applies symmetrically to (FinalizingSetup) /// AND . On an early / intermediate signal the @@ -27,27 +36,38 @@ namespace AutopilotMonitor.DecisionCore.Tests private static readonly DateTime Fixed = new DateTime(2026, 4, 23, 18, 57, 45, DateTimeKind.Utc); // ======================================================= Guard matrix (shared contract) - // The AwaitingHello guard applies the SAME unlock logic to both EspPhaseChanged - // (FinalizingSetup) and EspExiting: - // unlock ⇔ (skipUser == true) OR (accountSetupEntered) - // The two theories below exercise every cell of the 3×2 matrix (skipUser ∈ - // {true, false, null} × accountSetupEntered ∈ {false, true}) for each signal. - // Rows marked NEW fill pre-existing coverage gaps (both-unlock combinations, and the - // "null skipUser but AccountSetup-entered" unlock path that wasn't tested before). + // Unlock condition (post-330f73f3): + // unlock ⇔ (skipUser == true) OR (accountSetupSucceeded) + // AccountSetup *entered* alone is NOT sufficient (the pre-fix gate that caused 330f73f3). + // The two theories below sweep skipUser ∈ {true, false, null} × accountSetupSucceeded ∈ + // {false, true}, and additionally cover an "entered-but-not-succeeded" row to pin the + // regression-blocking semantic. [Theory] - [InlineData(true, false, SessionStage.AwaitingHello)] // skipUser unlocks - [InlineData(true, true, SessionStage.AwaitingHello)] // both unlocks present — NEW coverage - [InlineData(false, false, SessionStage.EspDeviceSetup)] // neither unlock → guard blocks - [InlineData(false, true, SessionStage.AwaitingHello)] // accountSetup unlocks - [InlineData(null, false, SessionStage.EspDeviceSetup)] // skipUser unknown → guard blocks - [InlineData(null, true, SessionStage.AwaitingHello)] // accountSetup overrides unknown skipUser — NEW coverage + // skipUser=true unlocks regardless of AccountSetup status + [InlineData(true, false, false, SessionStage.AwaitingHello)] + [InlineData(true, true, true, SessionStage.AwaitingHello)] + // skipUser=false AND succeeded=true → unlock + [InlineData(false, true, true, SessionStage.AwaitingHello)] + // skipUser=false AND only entered → blocked (the 330f73f3 fix) + [InlineData(false, true, false, SessionStage.EspAccountSetup)] + // neither unlock present → blocked + [InlineData(false, false, false, SessionStage.EspDeviceSetup)] + [InlineData(null, false, false, SessionStage.EspDeviceSetup)] + // skipUser unknown + only entered → blocked + [InlineData(null, true, false, SessionStage.EspAccountSetup)] + // skipUser unknown + succeeded → unlock + [InlineData(null, true, true, SessionStage.AwaitingHello)] public void EspPhaseChanged_FinalizingSetup_guard_applies_skipUser_and_accountSetup_unlock_matrix( - bool? skipUser, bool accountSetupEntered, SessionStage expectedStage) + bool? skipUser, bool accountSetupEntered, bool accountSetupSucceeded, SessionStage expectedStage) { var engine = new DecisionEngine(); var initialStage = accountSetupEntered ? SessionStage.EspAccountSetup : SessionStage.EspDeviceSetup; - var state = BuildState(stage: initialStage, skipUser: skipUser, accountSetupEntered: accountSetupEntered); + var state = BuildState( + stage: initialStage, + skipUser: skipUser, + accountSetupEntered: accountSetupEntered, + accountSetupSucceeded: accountSetupSucceeded); var step = engine.Reduce(state, MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 5)); @@ -61,18 +81,31 @@ namespace AutopilotMonitor.DecisionCore.Tests } [Theory] - [InlineData(true, false, SessionStage.AwaitingHello, true)] - [InlineData(true, true, SessionStage.AwaitingHello, true)] // NEW coverage - [InlineData(false, false, SessionStage.EspDeviceSetup, false)] - [InlineData(false, true, SessionStage.AwaitingHello, true)] - [InlineData(null, false, SessionStage.EspDeviceSetup, false)] - [InlineData(null, true, SessionStage.AwaitingHello, true)] // NEW coverage + // skipUser=true unlocks regardless of AccountSetup + [InlineData(true, false, false, SessionStage.AwaitingHello, true)] + [InlineData(true, true, true, SessionStage.AwaitingHello, true)] + // skipUser=false + succeeded → unlock + arm + [InlineData(false, true, true, SessionStage.AwaitingHello, true)] + // skipUser=false + only entered → blocked (the 330f73f3 fix); EspFinalExitUtc still set + [InlineData(false, true, false, SessionStage.EspAccountSetup, false)] + // neither unlock → blocked + [InlineData(false, false, false, SessionStage.EspDeviceSetup, false)] + [InlineData(null, false, false, SessionStage.EspDeviceSetup, false)] + // skipUser unknown + only entered → blocked + [InlineData(null, true, false, SessionStage.EspAccountSetup, false)] + // skipUser unknown + succeeded → unlock + [InlineData(null, true, true, SessionStage.AwaitingHello, true)] public void EspExiting_guard_controls_AwaitingHello_transition_and_arms_HelloSafety_only_on_unlock( - bool? skipUser, bool accountSetupEntered, SessionStage expectedStage, bool expectHelloSafetyArm) + bool? skipUser, bool accountSetupEntered, bool accountSetupSucceeded, + SessionStage expectedStage, bool expectHelloSafetyArm) { var engine = new DecisionEngine(); var initialStage = accountSetupEntered ? SessionStage.EspAccountSetup : SessionStage.EspDeviceSetup; - var state = BuildState(stage: initialStage, skipUser: skipUser, accountSetupEntered: accountSetupEntered); + var state = BuildState( + stage: initialStage, + skipUser: skipUser, + accountSetupEntered: accountSetupEntered, + accountSetupSucceeded: accountSetupSucceeded); var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5)); @@ -94,6 +127,106 @@ namespace AutopilotMonitor.DecisionCore.Tests } } + // ====================================================== Session 330f73f3 reproduction + // The bug session in concrete terms: AccountSetup entered at 07:04:47, intermediate + // esp_exiting (Device→Account 62407) at 07:04:56 — 9 s later. The pre-fix reducer + // promoted to AwaitingHello + armed HelloSafety against an in-progress AccountSetup; + // 5 min later the HelloSafety synthetic-timeout fired enrollment_complete while apps + // were still installing. + + [Fact] + public void Session_330f73f3_intermediate_EspExiting_after_AccountSetupEntered_does_not_promote() + { + var engine = new DecisionEngine(); + var state = BuildState( + stage: SessionStage.EspAccountSetup, + skipUser: false, + accountSetupEntered: true, + accountSetupSucceeded: false); + + var step = engine.Reduce(state, MakeExitingSignal(ordinal: 5)); + + // Stage unchanged — the intermediate 62407 is not a promotion trigger anymore. + Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage); + // HelloSafety NOT armed — that was the load-bearing bit of the bug. + Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety); + // Observability fact still recorded so downstream classifiers + Inspector see it. + Assert.NotNull(step.NewState.EspFinalExitUtc); + } + + [Fact] + public void Session_330f73f3_AccountSetupProvisioningComplete_after_intermediate_EspExiting_does_deferred_promote() + { + // Continuation of the bug fix: the first esp_exiting was ignored (intermediate), + // and Windows never fires a second one in this scenario (verified in 330f73f3 events). + // When the strong fact finally arrives, the handler performs the deferred promotion + // and arms HelloSafety against the AccountSetup-succeeded instant — not the + // historical EspFinalExitUtc — so the 5-min window is meaningful. + var engine = new DecisionEngine(); + var state = BuildState( + stage: SessionStage.EspAccountSetup, + skipUser: false, + accountSetupEntered: true, + accountSetupSucceeded: false); + + // Step 1: intermediate EspExiting → ignored. + var afterExiting = engine.Reduce(state, MakeExitingSignal(ordinal: 5)).NewState; + Assert.Equal(SessionStage.EspAccountSetup, afterExiting.Stage); + Assert.NotNull(afterExiting.EspFinalExitUtc); + + // Step 2: AccountSetupProvisioningComplete arrives → deferred promote + HelloSafety arm. + var completeSignal = MakeAccountSetupCompleteSignal(ordinal: 6, occurredAtUtc: Fixed.AddMinutes(5)); + var afterComplete = engine.Reduce(afterExiting, completeSignal); + + Assert.Equal(SessionStage.AwaitingHello, afterComplete.NewState.Stage); + Assert.NotNull(afterComplete.NewState.AccountSetupProvisioningSucceededUtc); + var helloSafety = Assert.Single( + afterComplete.NewState.Deadlines, + d => d.Name == DeadlineNames.HelloSafety); + // Window is 300 s from the fact instant (not the historical EspExiting at ordinal 5). + Assert.Equal(Fixed.AddMinutes(5).AddSeconds(300), helloSafety.DueAtUtc); + Assert.Contains(afterComplete.Effects, + e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.HelloSafety); + } + + [Fact] + public void AccountSetupProvisioningComplete_after_guard_blocked_FinalizingSetup_does_deferred_promote() + { + // Sibling-ordering: ShellCoreTracker's FinalizingSetupPhaseTriggered (62404 / first + // 62407) is forwarded as EspPhaseChanged(FinalizingSetup) — the adapter fire-once- + // dedupes this. If it arrives BEFORE AccountSetupProvisioningComplete, the new strong + // gate blocks the stage transition but FinalizingEnteredUtc is still recorded. With + // no second Finalizing forward coming, the deferred-promotion path in + // HandleAccountSetupProvisioningCompleteV1 must also unlock from FinalizingEnteredUtc, + // not only EspFinalExitUtc. + var engine = new DecisionEngine(); + var state = BuildState( + stage: SessionStage.EspAccountSetup, + skipUser: false, + accountSetupEntered: true, + accountSetupSucceeded: false); + + // Step 1: guard-blocked Finalizing. + var afterFinalizing = engine.Reduce(state, + MakePhaseSignal(EnrollmentPhase.FinalizingSetup, ordinal: 5)).NewState; + Assert.Equal(SessionStage.EspAccountSetup, afterFinalizing.Stage); + Assert.NotNull(afterFinalizing.FinalizingEnteredUtc); + Assert.Null(afterFinalizing.EspFinalExitUtc); + Assert.DoesNotContain(afterFinalizing.Deadlines, d => d.Name == DeadlineNames.HelloSafety); + + // Step 2: AccountSetupProvisioningComplete → deferred promote + HelloSafety arm + // anchored at the AccountSetup-succeeded instant. + var afterComplete = engine.Reduce(afterFinalizing, + MakeAccountSetupCompleteSignal(ordinal: 6, occurredAtUtc: Fixed.AddMinutes(5))); + Assert.Equal(SessionStage.AwaitingHello, afterComplete.NewState.Stage); + var helloSafety = Assert.Single( + afterComplete.NewState.Deadlines, + d => d.Name == DeadlineNames.HelloSafety); + Assert.Equal(Fixed.AddMinutes(5).AddSeconds(300), helloSafety.DueAtUtc); + Assert.Contains(afterComplete.Effects, + e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.HelloSafety); + } + // ====================================================== Codex review — ordering contract [Fact] @@ -243,9 +376,12 @@ namespace AutopilotMonitor.DecisionCore.Tests private static DecisionState BuildState( SessionStage stage, bool? skipUser, - bool accountSetupEntered = false) + bool accountSetupEntered = false, + bool accountSetupSucceeded = false) { - var builder = DecisionState.CreateInitial("s", "t") + // Pin AgentBootUtc to a time before Fixed so EffectiveDeadlineBase doesn't floor + // the fixture's deterministic timestamps to wall-clock-now. + var builder = DecisionState.CreateInitial("s", "t", Fixed.AddDays(-1)) .ToBuilder() .WithStage(stage) .WithStepIndex(2) @@ -265,6 +401,10 @@ namespace AutopilotMonitor.DecisionCore.Tests evidenceOrdinal: 0); } if (accountSetupEntered) builder.AccountSetupEnteredUtc = new SignalFact(Fixed.AddMinutes(-1), 1); + // Session 330f73f3 fix: pre-seed the strong post-AccountSetup gate when the test + // wants to exercise the "ESP genuinely done" path. + if (accountSetupSucceeded) + builder.AccountSetupProvisioningSucceededUtc = new SignalFact(Fixed.AddSeconds(-30), 1); return builder.Build(); } @@ -298,5 +438,26 @@ namespace AutopilotMonitor.DecisionCore.Tests occurredAtUtc: Fixed, sourceOrigin: "ShellCoreTracker", evidence: new Evidence(EvidenceKind.Raw, "esp_exiting", "ESP phase exiting")); + + private static DecisionSignal MakeAccountSetupCompleteSignal(long ordinal, DateTime occurredAtUtc) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: DecisionSignalKind.AccountSetupProvisioningComplete, + kindSchemaVersion: 1, + occurredAtUtc: occurredAtUtc, + sourceOrigin: "ProvisioningStatusTracker", + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: "provisioning-status-tracker-v1", + summary: "AccountSetup succeeded", + derivationInputs: new Dictionary + { + ["accountSetupResolved"] = "true", + }), + payload: new Dictionary + { + ["accountSetupResolved"] = "true", + }); } } diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicHelloDisabledCompletionTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicHelloDisabledCompletionTests.cs index 359d3a01..161b831c 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicHelloDisabledCompletionTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicHelloDisabledCompletionTests.cs @@ -238,7 +238,9 @@ namespace AutopilotMonitor.DecisionCore.Tests /// /// Reaches the typical post-AccountSetup state for a Classic-v1 + Hello-disabled /// enrollment: SessionStarted, ESP DeviceSetup → AccountSetup, HelloPolicyDetected - /// (helloEnabled=false). No EspExiting yet. + /// (helloEnabled=false), and AccountSetupProvisioningComplete (session 330f73f3 fix — + /// the strong post-AccountSetup gate that .ShouldTransitionToAwaitingHello + /// requires before a subsequent EspExiting promotes to AwaitingHello). No EspExiting yet. /// private static DecisionState ProgressToAccountSetupWithHelloDisabled(DecisionEngine engine) { @@ -250,6 +252,8 @@ namespace AutopilotMonitor.DecisionCore.Tests new Dictionary { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState; state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(3), new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.AccountSetupProvisioningComplete, + T0.AddMinutes(4).AddSeconds(30), null)).NewState; return state; } diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs index 59acc764..92dcf55c 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs @@ -101,7 +101,9 @@ namespace AutopilotMonitor.DecisionCore.Tests // The c117946b debrief (2026-05-12) added `lastFailureTrigger` so the // EnrollmentTerminationHandler can discriminate which Failed pathway fired // and only promote in-flight installs on ESP-Apps timeout → 12 slots. - Assert.Equal(12, facts.Count); + // Session 330f73f3 fix (2026-05-18) added `accountSetupProvisioningSucceededUtc` + // as the strong post-AccountSetup gate → 13 slots. + Assert.Equal(13, facts.Count); Assert.All(facts.Values, v => Assert.Null(v)); } @@ -211,10 +213,11 @@ namespace AutopilotMonitor.DecisionCore.Tests // PR-B (2026-05-04): 11 SignalFact properties on DecisionState (was 15; // the 4 WG-Part-2 facts were removed). c117946b debrief (2026-05-12): // `lastFailureTrigger` added for the EnrollmentTerminationHandler's - // 4-check discriminator → 12. If this number ever changes, both the - // count expectation AND the actual snapshot output need to evolve in - // lockstep. - Assert.Equal(12, expectedFactKeys.Count); + // 4-check discriminator → 12. Session 330f73f3 fix (2026-05-18) added + // `accountSetupProvisioningSucceededUtc` as the strong post-AccountSetup + // gate → 13. If this number ever changes, both the count expectation AND + // the actual snapshot output need to evolve in lockstep. + Assert.Equal(13, expectedFactKeys.Count); var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t")); var facts = (Dictionary)snapshot["facts"]!; diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index 84e107da..8fa814de 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -688,11 +688,127 @@ namespace AutopilotMonitor.DecisionCore.Engine /// private static bool ShouldTransitionToAwaitingHello(DecisionState state) { - if (state.AccountSetupEnteredUtc != null) return true; + // Session 330f73f3 fix (2026-05-18): entering AccountSetup is no longer sufficient. + // Shell-Core event 62407 (CommercialOOBE_ESPProgress_Page_Exiting) fires at every + // ESP-page transition — the first occurrence is the Device→Account handoff, NOT + // the genuine final exit. Promoting on AccountSetupEnteredUtc alone armed + // HelloSafety against the wrong baseline; HelloSafety then fired its 5-min synthetic + // timeout while ESP AccountSetup and app installs were still in progress, and + // FinalizingGrace marked the session terminal mid-flight. + // + // The strong post-AccountSetup gate is now , + // posted by ProvisioningStatusTracker once AccountSetupCategory.Status resolves + // to categorySucceeded=true (or the fallback fires — analog to DeviceSetup). + if (state.AccountSetupProvisioningSucceededUtc != null) return true; + // SkipUser flow — no User-ESP page runs; the first esp_exiting IS the genuine final + // exit. Existing observation contract preserved. if (state.ScenarioObservations.SkipUserEsp?.Value == true) return true; return false; } + /// + /// Handle . Records + /// for the + /// guard. + /// + /// Two arrival orderings are handled: + /// + /// ESP terminal-handoff signal arrives later (typical): stage unchanged + /// here. The next or + /// (FinalizingSetup) reads the + /// new fact via and promotes. + /// ESP terminal-handoff signal already arrived (session 330f73f3 ordering): + /// either the intermediate Device→Account 62407 was ignored at the time + /// ( set), OR an + /// EspPhaseChanged(FinalizingSetup) from ShellCoreTracker's + /// FinalizingSetupPhaseTriggered already landed + /// ( set) but the guard + /// blocked the stage transition and the adapter's fire-once flag means no + /// second copy will arrive. With the strong gate now satisfied we perform + /// the deferred promotion here — transition to + /// and arm the Hello-safety deadline — + /// using the AccountSetupSucceeded instant as the deadline base (NOT the + /// historical, ignored EspFinalExitUtc / FinalizingEnteredUtc — that would + /// immediately fire the 5-min window). + /// + /// Set-once on the fact: re-posts only update bookkeeping. + /// + /// + private DecisionStep HandleAccountSetupProvisioningCompleteV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + var alreadyRecorded = state.AccountSetupProvisioningSucceededUtc != null; + if (!alreadyRecorded) + { + builder.AccountSetupProvisioningSucceededUtc = + new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + } + + // Deferred-promotion path. Activates only when: + // 1. This is the first time we record the fact (avoid double-arming on replay). + // 2. Some ESP terminal-handoff signal already landed but was guard-blocked: + // EspFinalExitUtc (intermediate EspExiting was ignored) OR + // FinalizingEnteredUtc (EspPhaseChanged(FinalizingSetup) arrived first; the + // ShellCoreTracker adapter fire-once-dedupes this so no replay will come). + // 3. We are still parked in an ESP stage waiting on the strong gate. + // 4. SkipUserEsp is NOT observed (that path is handled by the existing + // ShouldTransitionToAwaitingHello short-circuit). + var shouldPromote = !alreadyRecorded + && (state.EspFinalExitUtc != null || state.FinalizingEnteredUtc != null) + && (state.Stage == SessionStage.EspDeviceSetup + || state.Stage == SessionStage.EspAccountSetup + || state.Stage == SessionStage.SessionStarted) + && state.ScenarioObservations.SkipUserEsp?.Value != true; + + if (!shouldPromote) + { + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.AccountSetupProvisioningComplete)); + return new DecisionStep(newState, transition, Array.Empty()); + } + + // Mirror of HandleEspExitingV1's promote branch, using this signal's instant as + // the deadline base. EffectiveDeadlineBase still floors at AgentBootUtc to keep + // the replay-safety guarantee. + var dueAtUtc = EffectiveDeadlineBase(state, signal).Add(s_helloSafetyWindow); + var helloSafety = new ActiveDeadline( + name: DeadlineNames.HelloSafety, + dueAtUtc: dueAtUtc, + firesSignalKind: DecisionSignalKind.DeadlineFired, + firesPayload: new Dictionary + { + [SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety, + }); + + builder = builder + .WithStage(SessionStage.AwaitingHello) + .AddDeadline(helloSafety); + + var promotedState = builder.Build(); + var promotedTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.AwaitingHello, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.AccountSetupProvisioningComplete) + ":DeferredPromote"); + + var promotedEffects = new[] + { + new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: helloSafety), + }; + + return new DecisionStep(promotedState, promotedTransition, promotedEffects); + } + /// /// Floor a deadline-arming base timestamp at the current agent run's boot anchor. /// Replay-safety guard: when a signal carries a historical OccurredAtUtc from diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index 593b669c..da510e07 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -72,6 +72,9 @@ namespace AutopilotMonitor.DecisionCore.Engine // ----- SelfDeploying + Device-Only (DecisionEngine.SelfDeploying.cs) ----- (DecisionSignalKind.DeviceSetupProvisioningComplete, 1) => HandleDeviceSetupProvisioningCompleteV1(state, signal), + // ----- AccountSetup completion gate (DecisionEngine.Shared.cs) — session 330f73f3 fix ----- + (DecisionSignalKind.AccountSetupProvisioningComplete, 1) => HandleAccountSetupProvisioningCompleteV1(state, signal), + // ----- WhiteGlove Part 1 (DecisionEngine.WhiteGlove.cs) ----- (DecisionSignalKind.WhiteGloveShellCoreSuccess, 1) => HandleWhiteGloveShellCoreSuccessV1(state, signal), (DecisionSignalKind.WhiteGloveSealingPatternDetected, 1) => HandleWhiteGloveSealingPatternDetectedV1(state, signal), diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs index 4ada6737..f2510c55 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs @@ -81,6 +81,15 @@ namespace AutopilotMonitor.DecisionCore.Engine timestamps["accountSetupEntered"] = FormatUtc(state.AccountSetupEnteredUtc.Value); evidence["accountSetupEntered"] = TimestampedEvidence(state.AccountSetupEnteredUtc); } + // Session 330f73f3 fix — strong post-AccountSetup gate. Included in the census so + // enrollment_complete audit + final-status.json show whether the genuine + // AccountSetup-done observation was present when the session terminated. + if (state.AccountSetupProvisioningSucceededUtc != null) + { + seen.Add("account_setup_provisioning_complete"); + timestamps["accountSetupProvisioningComplete"] = FormatUtc(state.AccountSetupProvisioningSucceededUtc.Value); + evidence["accountSetupProvisioningComplete"] = TimestampedEvidence(state.AccountSetupProvisioningSucceededUtc); + } // WhiteGlove Part-1 sealing observations — engine-internal, no UTC fact. var obs = state.ScenarioObservations; diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs index 0f17c22a..3d3082b5 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs @@ -39,8 +39,8 @@ namespace AutopilotMonitor.DecisionCore.Engine /// /// Build a compact snapshot dictionary for the given . - /// All 15 properties of - /// are serialized under facts; absent facts appear as JSON null. + /// All properties of are + /// serialized under facts; absent facts appear as JSON null. /// public static Dictionary Build(DecisionState state) { @@ -51,6 +51,7 @@ namespace AutopilotMonitor.DecisionCore.Engine ["currentEnrollmentPhase"] = SerializeFact(state.CurrentEnrollmentPhase, v => v.ToString()), ["deviceSetupEnteredUtc"] = SerializeFact(state.DeviceSetupEnteredUtc, FormatUtc), ["accountSetupEnteredUtc"] = SerializeFact(state.AccountSetupEnteredUtc, FormatUtc), + ["accountSetupProvisioningSucceededUtc"] = SerializeFact(state.AccountSetupProvisioningSucceededUtc, FormatUtc), ["finalizingEnteredUtc"] = SerializeFact(state.FinalizingEnteredUtc, FormatUtc), ["espFinalExitUtc"] = SerializeFact(state.EspFinalExitUtc, FormatUtc), ["desktopArrivedUtc"] = SerializeFact(state.DesktopArrivedUtc, FormatUtc), diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs index 0fd3252e..8e9e0f74 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs @@ -18,6 +18,15 @@ namespace AutopilotMonitor.DecisionCore.Signals HelloResolved, ImeUserSessionCompleted, DeviceSetupProvisioningComplete, + // Session 330f73f3 fix (2026-05-18) — strong "AccountSetup truly succeeded" fact. + // Posted by ProvisioningStatusTracker once AccountSetupCategory.Status resolves to + // categorySucceeded=true OR the fallback fires (all subcategories succeeded/notRequired + // but Windows never set the boolean — analog to the existing DeviceSetup fallback). + // Consumed by ShouldTransitionToAwaitingHello: entering AccountSetup is no longer + // sufficient on its own, because Shell-Core event 62407 fires at every ESP-page + // transition and the first occurrence (Device→Account handoff) is NOT the genuine + // final exit. + AccountSetupProvisioningComplete, AppInstallCompleted, AppInstallFailed, WhiteGloveShellCoreSuccess, diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs index d256961c..700fc0d7 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs @@ -41,6 +41,7 @@ namespace AutopilotMonitor.DecisionCore.State SignalFact? deviceSetupEnteredUtc, SignalFact? accountSetupEnteredUtc, SignalFact? finalizingEnteredUtc, + SignalFact? accountSetupProvisioningSucceededUtc, SignalFact? espFinalExitUtc, SignalFact? desktopArrivedUtc, SignalFact? helloResolvedUtc, @@ -77,6 +78,7 @@ namespace AutopilotMonitor.DecisionCore.State DeviceSetupEnteredUtc = deviceSetupEnteredUtc; AccountSetupEnteredUtc = accountSetupEnteredUtc; FinalizingEnteredUtc = finalizingEnteredUtc; + AccountSetupProvisioningSucceededUtc = accountSetupProvisioningSucceededUtc; EspFinalExitUtc = espFinalExitUtc; DesktopArrivedUtc = desktopArrivedUtc; HelloResolvedUtc = helloResolvedUtc; @@ -115,6 +117,19 @@ namespace AutopilotMonitor.DecisionCore.State public SignalFact? FinalizingEnteredUtc { get; } + /// + /// Set when the ESP AccountSetupCategory.Status registry resolves to + /// categorySucceeded=true, or when the fallback fires (all subcategories + /// succeeded/notRequired but Windows never set the boolean — analog to the existing + /// DeviceSetup fallback). This is the strong "User-ESP is genuinely finished" fact; + /// only signals page-entry and is not sufficient. + /// Posted via . + /// Null while AccountSetup is still in_progress, missing entirely on flows that skip + /// AccountSetup (handled by the SkipUserEsp observation in + /// ). + /// + public SignalFact? AccountSetupProvisioningSucceededUtc { get; } + // --- Signal-induced facts (with source ordinal for evidence trace) --- public SignalFact? EspFinalExitUtc { get; } @@ -243,6 +258,7 @@ namespace AutopilotMonitor.DecisionCore.State deviceSetupEnteredUtc: null, accountSetupEnteredUtc: null, finalizingEnteredUtc: null, + accountSetupProvisioningSucceededUtc: null, espFinalExitUtc: null, desktopArrivedUtc: null, helloResolvedUtc: null, diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs index 0e181eda..53a242c5 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs @@ -27,6 +27,7 @@ namespace AutopilotMonitor.DecisionCore.State DeviceSetupEnteredUtc = source.DeviceSetupEnteredUtc; AccountSetupEnteredUtc = source.AccountSetupEnteredUtc; FinalizingEnteredUtc = source.FinalizingEnteredUtc; + AccountSetupProvisioningSucceededUtc = source.AccountSetupProvisioningSucceededUtc; EspFinalExitUtc = source.EspFinalExitUtc; DesktopArrivedUtc = source.DesktopArrivedUtc; HelloResolvedUtc = source.HelloResolvedUtc; @@ -54,6 +55,7 @@ namespace AutopilotMonitor.DecisionCore.State public SignalFact? DeviceSetupEnteredUtc { get; set; } public SignalFact? AccountSetupEnteredUtc { get; set; } public SignalFact? FinalizingEnteredUtc { get; set; } + public SignalFact? AccountSetupProvisioningSucceededUtc { get; set; } public SignalFact? EspFinalExitUtc { get; set; } public SignalFact? DesktopArrivedUtc { get; set; } public SignalFact? HelloResolvedUtc { get; set; } @@ -181,6 +183,7 @@ namespace AutopilotMonitor.DecisionCore.State deviceSetupEnteredUtc: DeviceSetupEnteredUtc, accountSetupEnteredUtc: AccountSetupEnteredUtc, finalizingEnteredUtc: FinalizingEnteredUtc, + accountSetupProvisioningSucceededUtc: AccountSetupProvisioningSucceededUtc, espFinalExitUtc: EspFinalExitUtc, desktopArrivedUtc: DesktopArrivedUtc, helloResolvedUtc: HelloResolvedUtc, diff --git a/tests/fixtures/signal-kinds/account-setup-provisioning-complete-v1.json b/tests/fixtures/signal-kinds/account-setup-provisioning-complete-v1.json new file mode 100644 index 00000000..e7b29d44 --- /dev/null +++ b/tests/fixtures/signal-kinds/account-setup-provisioning-complete-v1.json @@ -0,0 +1,9 @@ +# AccountSetupProvisioningComplete v1 — session 330f73f3 fix (2026-05-18). +# Posted by ProvisioningStatusTracker (via EspAndHelloTrackerAdapter in production) once +# AccountSetupCategory.Status registry resolves to categorySucceeded=true, or when the +# fallback fires after 30 s of all subcategories sitting at succeeded/notRequired without +# Windows ever flipping the boolean (same fallback shape as DeviceSetup). +# Reducer: HandleAccountSetupProvisioningCompleteV1 → records AccountSetupProvisioningSucceededUtc. +# If EspExiting has already been recorded (intermediate exit was ignored on the way in), +# also performs the deferred promotion to AwaitingHello + arms HelloSafety. +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AccountSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-05-18T07:30:00.0000000Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-and-hello-tracker-v1","Summary":"AccountSetupCategory provisioning completed (coordinator-forwarded)","DerivationInputs":{"subSource":"ProvisioningStatusTracker","accountSetupResolved":"true"}},"Payload":{"accountSetupResolved":"true"}}