mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix(agent/v2,decisioncore): gate AwaitingHello on AccountSetup truly succeeded
Session 330f73f3 completed via DeadlineFired:finalizing_grace (helloOutcome= Timeout) while ESP AccountSetup was still at "4 of 5 subcategories completed" and 3 of 8 apps still pending. ShouldTransitionToAwaitingHello promoted on AccountSetupEnteredUtc != null alone, but Windows CXH event 62407 fires at every ESP-page transition — the first occurrence (Device→Account handoff, 9 s after AccountSetup entered) was treated as the genuine final exit, armed HelloSafety against an in-progress AccountSetup, fired its 5-min synthetic timeout, and FinalizingGrace declared the session terminal mid-flight. Introduce a strong "AccountSetup truly succeeded" fact sourced from ProvisioningStatusTracker (categorySucceeded=true OR fallback after 30 s of all subcategories at succeeded/notRequired — same shape as DeviceSetup). Forwarded by EspAndHelloTracker, posted as new DecisionSignalKind. AccountSetupProvisioningComplete by the V2 adapter, lands as DecisionState. AccountSetupProvisioningSucceededUtc. The reducer's ShouldTransitionToAwaitingHello now requires this fact (or SkipUserEsp); the old AccountSetupEnteredUtc gate becomes an entry-time marker only. HandleAccountSetupProvisioningCompleteV1 has a deferred-promotion path: when the strong fact arrives AFTER a guard-blocked EspExiting (EspFinalExitUtc set) OR after a guard-blocked EspPhaseChanged(Finalizing- Setup) (FinalizingEnteredUtc set, ShellCoreTracker adapter fire-once- dedupes), the handler performs the deferred promotion + arms HelloSafety anchored at the AccountSetup-succeeded instant. The new fact is registered in DecisionStateSignalCensus so enrollment_* audit trails and final-status.json show whether the strong gate was satisfied at termination — load-bearing for diagnosing future false-negative timeouts under the new gate. Side cleanup: ProvisioningStatusTracker's fallback timer callbacks (new AccountSetup AND pre-existing DeviceSetup parallel path) now reset the timer field on every abort path AND in the outer exception catch. Without this, a transient subcategory flicker between fallback arm and timer fire could permanently prevent re-arming even when the state re-stabilises. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -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<DateTime>(sourceTime.AddMinutes(-2), 1);
|
||||
seed.AccountSetupProvisioningSucceededUtc = new SignalFact<DateTime>(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<DateTime>(sourceTime.AddMinutes(-2), 1);
|
||||
seed.AccountSetupProvisioningSucceededUtc = new SignalFact<DateTime>(sourceTime.AddMinutes(-1).AddSeconds(-30), 1);
|
||||
var state = seed.Build();
|
||||
|
||||
var enriched = new DecisionSignal(
|
||||
|
||||
+19
@@ -80,6 +80,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
public event EventHandler DeviceSetupProvisioningComplete;
|
||||
|
||||
/// <summary>
|
||||
/// Session 330f73f3 fix: fired when AccountSetup provisioning resolves with success
|
||||
/// (or the fallback confirmed). Forwarded from <see cref="ProvisioningStatusTracker"/>;
|
||||
/// the V2 adapter posts it as <c>DecisionSignalKind.AccountSetupProvisioningComplete</c>
|
||||
/// so the reducer's <c>ShouldTransitionToAwaitingHello</c> gate has the strong
|
||||
/// post-AccountSetup fact and stops promoting on intermediate Shell-Core 62407 events.
|
||||
/// </summary>
|
||||
public event EventHandler AccountSetupProvisioningComplete;
|
||||
|
||||
// PR4 (882fef64 debrief) — coordinator-forwarded copy of <see cref="HelloTracker.HelloPolicyDetected"/>.
|
||||
// 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
|
||||
|
||||
+264
@@ -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<string, string> _lastProvisioningJson;
|
||||
@@ -73,6 +80,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private Dictionary<string, Dictionary<string, string>> _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<string> EspFailureDetected;
|
||||
public event EventHandler DeviceSetupProvisioningComplete;
|
||||
/// <summary>
|
||||
/// Fires once when <c>AccountSetupCategory.Status</c> resolves to
|
||||
/// <c>categorySucceeded=true</c>, 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 <c>AwaitingHello</c>; without it the
|
||||
/// reducer ignores intermediate Shell-Core 62407 events.
|
||||
/// </summary>
|
||||
public event EventHandler AccountSetupProvisioningComplete;
|
||||
|
||||
public ProvisioningStatusTracker(
|
||||
string sessionId,
|
||||
@@ -219,6 +237,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_lastSubcategoryStates = new Dictionary<string, Dictionary<string, string>>(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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session 330f73f3 fix: AccountSetup-side mirror of <see cref="OnDeviceSetupFallbackTimerExpired"/>.
|
||||
/// Confirms via a fresh registry read that all subcategories are still
|
||||
/// succeeded/notRequired and categorySucceeded is still unset, then fires
|
||||
/// <see cref="AccountSetupProvisioningComplete"/>. If Windows wrote a real
|
||||
/// categorySucceeded value in the meantime, the normal path re-runs and handles it.
|
||||
/// <para>
|
||||
/// On every abort path the timer field is reset to <c>null</c> so a later
|
||||
/// <c>CheckProvisioningStatus</c> 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).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<SubcategoryInfo> 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<string, object>();
|
||||
foreach (var sub in subcategories)
|
||||
{
|
||||
subcatData[sub.Name] = new Dictionary<string, string>
|
||||
{
|
||||
{ "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<string, object>
|
||||
{
|
||||
{ "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the AccountSetup fallback timer field after the callback consumes it. Required
|
||||
/// because the start condition in <see cref="CheckProvisioningStatus"/> gates re-arming
|
||||
/// on <c>_accountSetupFallbackTimer == null</c>; 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 <see cref="_stateLock"/>.
|
||||
/// </summary>
|
||||
private void ResetAccountSetupFallbackTimer()
|
||||
{
|
||||
_accountSetupFallbackTimer?.Dispose();
|
||||
_accountSetupFallbackTimer = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeviceSetup mirror of <see cref="ResetAccountSetupFallbackTimer"/>. Same re-arm-blocker
|
||||
/// concern, same fix. Caller already holds <see cref="_stateLock"/>.
|
||||
/// </summary>
|
||||
private void ResetDeviceSetupFallbackTimer()
|
||||
{
|
||||
_deviceSetupFallbackTimer?.Dispose();
|
||||
_deviceSetupFallbackTimer = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parity with <see cref="OnAccountSetupFallbackTimerExpired"/> — every abort path
|
||||
/// resets <see cref="_deviceSetupFallbackTimer"/> to <c>null</c> so a transient
|
||||
/// subcategory flicker that aborts the callback cannot permanently block a re-arm
|
||||
/// from <see cref="CheckProvisioningStatus"/>.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
/// <item><c>WhiteGloveCompleted</c> → <see cref="DecisionSignalKind.WhiteGloveShellCoreSuccess"/></item>
|
||||
/// <item><c>EspFailureDetected</c> → <see cref="DecisionSignalKind.EspTerminalFailure"/> (merged aus ShellCore + Provisioning)</item>
|
||||
/// <item><c>DeviceSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/></item>
|
||||
/// <item><c>AccountSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/> (session 330f73f3 fix — strong AccountSetup-done gate)</item>
|
||||
/// <item><c>EspExited</c> → <see cref="DecisionSignalKind.EspExiting"/> (no dedup — every 62407 forwarded; reducer guard decides)</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Tracked HelloOutcome (read via event; coordinator exposes property).</summary>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string, string>(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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["accountSetupResolved"] = accountSetupResolved,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-1
@@ -9,12 +9,13 @@ using AutopilotMonitor.DecisionCore.Signals;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
/// <summary>
|
||||
/// Adapter for <see cref="ProvisioningStatusTracker"/> → 2 DecisionSignalKinds.
|
||||
/// Adapter for <see cref="ProvisioningStatusTracker"/> → 3 DecisionSignalKinds.
|
||||
/// Plan §2.1a / §2.2.
|
||||
/// <para>
|
||||
/// Event mapping:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DeviceSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/></item>
|
||||
/// <item><c>AccountSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/> (session 330f73f3 fix)</item>
|
||||
/// <item><c>EspFailureDetected</c> → <see cref="DecisionSignalKind.EspTerminalFailure"/></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
@@ -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<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\AccountSetupCategory.Status",
|
||||
["accountSetupResolved"] = accountSetupResolved,
|
||||
}),
|
||||
payload: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["accountSetupResolved"] = accountSetupResolved,
|
||||
});
|
||||
}
|
||||
|
||||
private void EmitEspFailure(string failureType)
|
||||
{
|
||||
if (_espFailurePosted) return;
|
||||
|
||||
@@ -1 +1 @@
|
||||
756
|
||||
768
|
||||
|
||||
@@ -1 +1 @@
|
||||
1350
|
||||
1353
|
||||
|
||||
@@ -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<string, string> { [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,
|
||||
|
||||
@@ -9,11 +9,20 @@ using Xunit;
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan §6 Fix 8 — the Classic reducer must only promote to <see cref="SessionStage.AwaitingHello"/>
|
||||
/// when the promotion is legitimate: either <see cref="DecisionState.AccountSetupEnteredUtc"/>
|
||||
/// is set (i.e. we're past the post-Account-ESP final exit), or
|
||||
/// <see cref="DecisionState.SkipUserEsp"/> is explicitly <c>true</c> (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 <see cref="SessionStage.AwaitingHello"/> when the promotion is legitimate:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="DecisionState.AccountSetupProvisioningSucceededUtc"/> is set
|
||||
/// (AccountSetupCategory registry resolved as succeeded, OR the fallback fired),
|
||||
/// i.e. ESP is genuinely done, OR</item>
|
||||
/// <item><see cref="EnrollmentScenarioObservations.SkipUserEsp"/> is observed as
|
||||
/// <c>true</c> (device-only / SkipUser flow where a single esp_exiting IS the
|
||||
/// final one).</item>
|
||||
/// </list>
|
||||
/// The pre-fix gate "<c>AccountSetupEnteredUtc != null</c>" 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.
|
||||
/// <para>
|
||||
/// Applies symmetrically to <see cref="DecisionSignalKind.EspPhaseChanged"/>(FinalizingSetup)
|
||||
/// AND <see cref="DecisionSignalKind.EspExiting"/>. 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<DateTime>(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<DateTime>(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<string, string>
|
||||
{
|
||||
["accountSetupResolved"] = "true",
|
||||
}),
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
["accountSetupResolved"] = "true",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -238,7 +238,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
/// <summary>
|
||||
/// 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 <see cref="DecisionEngine"/>.<c>ShouldTransitionToAwaitingHello</c>
|
||||
/// requires before a subsequent EspExiting promotes to AwaitingHello). No EspExiting yet.
|
||||
/// </summary>
|
||||
private static DecisionState ProgressToAccountSetupWithHelloDisabled(DecisionEngine engine)
|
||||
{
|
||||
@@ -250,6 +252,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(3),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.AccountSetupProvisioningComplete,
|
||||
T0.AddMinutes(4).AddSeconds(30), null)).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, object?>)snapshot["facts"]!;
|
||||
|
||||
@@ -688,11 +688,127 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
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 <see cref="DecisionState.AccountSetupProvisioningSucceededUtc"/>,
|
||||
// posted by ProvisioningStatusTracker once <c>AccountSetupCategory.Status</c> resolves
|
||||
// to <c>categorySucceeded=true</c> (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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/>. Records
|
||||
/// <see cref="DecisionState.AccountSetupProvisioningSucceededUtc"/> for the
|
||||
/// <see cref="ShouldTransitionToAwaitingHello"/> guard.
|
||||
/// <para>
|
||||
/// Two arrival orderings are handled:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>ESP terminal-handoff signal arrives later (typical):</b> stage unchanged
|
||||
/// here. The next <see cref="DecisionSignalKind.EspExiting"/> or
|
||||
/// <see cref="DecisionSignalKind.EspPhaseChanged"/>(FinalizingSetup) reads the
|
||||
/// new fact via <see cref="ShouldTransitionToAwaitingHello"/> and promotes.</item>
|
||||
/// <item><b>ESP terminal-handoff signal already arrived (session 330f73f3 ordering):</b>
|
||||
/// either the intermediate Device→Account 62407 was ignored at the time
|
||||
/// (<see cref="DecisionState.EspFinalExitUtc"/> set), OR an
|
||||
/// <c>EspPhaseChanged(FinalizingSetup)</c> from <c>ShellCoreTracker</c>'s
|
||||
/// <c>FinalizingSetupPhaseTriggered</c> already landed
|
||||
/// (<see cref="DecisionState.FinalizingEnteredUtc"/> 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
|
||||
/// <see cref="SessionStage.AwaitingHello"/> 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).</item>
|
||||
/// </list>
|
||||
/// Set-once on the fact: re-posts only update bookkeeping.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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<DateTime>(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<DecisionEffect>());
|
||||
}
|
||||
|
||||
// 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<string, string>
|
||||
{
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Floor a deadline-arming base timestamp at the current agent run's boot anchor.
|
||||
/// Replay-safety guard: when a signal carries a historical <c>OccurredAtUtc</c> from
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -39,8 +39,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
|
||||
/// <summary>
|
||||
/// Build a compact snapshot dictionary for the given <paramref name="state"/>.
|
||||
/// All 15 <see cref="SignalFact{T}"/> properties of <see cref="DecisionState"/>
|
||||
/// are serialized under <c>facts</c>; absent facts appear as JSON <c>null</c>.
|
||||
/// All <see cref="SignalFact{T}"/> properties of <see cref="DecisionState"/> are
|
||||
/// serialized under <c>facts</c>; absent facts appear as JSON <c>null</c>.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> 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),
|
||||
|
||||
@@ -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 <c>ShouldTransitionToAwaitingHello</c>: 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,
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
SignalFact<DateTime>? deviceSetupEnteredUtc,
|
||||
SignalFact<DateTime>? accountSetupEnteredUtc,
|
||||
SignalFact<DateTime>? finalizingEnteredUtc,
|
||||
SignalFact<DateTime>? accountSetupProvisioningSucceededUtc,
|
||||
SignalFact<DateTime>? espFinalExitUtc,
|
||||
SignalFact<DateTime>? desktopArrivedUtc,
|
||||
SignalFact<DateTime>? 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<DateTime>? FinalizingEnteredUtc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Set when the ESP <c>AccountSetupCategory.Status</c> registry resolves to
|
||||
/// <c>categorySucceeded=true</c>, 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;
|
||||
/// <see cref="AccountSetupEnteredUtc"/> only signals page-entry and is not sufficient.
|
||||
/// Posted via <see cref="Signals.DecisionSignalKind.AccountSetupProvisioningComplete"/>.
|
||||
/// Null while AccountSetup is still in_progress, missing entirely on flows that skip
|
||||
/// AccountSetup (handled by the <c>SkipUserEsp</c> observation in
|
||||
/// <see cref="ScenarioObservations"/>).
|
||||
/// </summary>
|
||||
public SignalFact<DateTime>? AccountSetupProvisioningSucceededUtc { get; }
|
||||
|
||||
// --- Signal-induced facts (with source ordinal for evidence trace) ---
|
||||
public SignalFact<DateTime>? EspFinalExitUtc { get; }
|
||||
|
||||
@@ -243,6 +258,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
deviceSetupEnteredUtc: null,
|
||||
accountSetupEnteredUtc: null,
|
||||
finalizingEnteredUtc: null,
|
||||
accountSetupProvisioningSucceededUtc: null,
|
||||
espFinalExitUtc: null,
|
||||
desktopArrivedUtc: null,
|
||||
helloResolvedUtc: null,
|
||||
|
||||
@@ -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<DateTime>? DeviceSetupEnteredUtc { get; set; }
|
||||
public SignalFact<DateTime>? AccountSetupEnteredUtc { get; set; }
|
||||
public SignalFact<DateTime>? FinalizingEnteredUtc { get; set; }
|
||||
public SignalFact<DateTime>? AccountSetupProvisioningSucceededUtc { get; set; }
|
||||
public SignalFact<DateTime>? EspFinalExitUtc { get; set; }
|
||||
public SignalFact<DateTime>? DesktopArrivedUtc { get; set; }
|
||||
public SignalFact<DateTime>? 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,
|
||||
|
||||
@@ -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"}}
|
||||
Reference in New Issue
Block a user