diff --git a/internal b/internal index 621240b3..d0fdca23 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 621240b3aabcdd73e03d3c9e3fa3b257c3c09524 +Subproject commit d0fdca2321e706fbdbaa6bbcec824434480b046e diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ProvisioningStatusTrackerAccountSetupActivityTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ProvisioningStatusTrackerAccountSetupActivityTests.cs index 14f61bf4..7f0932dc 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ProvisioningStatusTrackerAccountSetupActivityTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ProvisioningStatusTrackerAccountSetupActivityTests.cs @@ -9,15 +9,21 @@ using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals { /// - /// Backlog q8n — must mean - /// observed progress, not registry presence. Windows writes - /// AccountSetupCategory.Status with every subcategory notStarted BEFORE the - /// Device-ESP page exits, so a presence check labelled the intermediate Device→Account - /// Shell-Core 62407 as the final ESP exit; the user-apps-settled synthesis then completed - /// AccountSetup minutes early and released the User-ESP keep-awake while the ESP page was - /// still up (session a2256107, standby on battery at 09:43 with the real exit at 09:47). - /// Both EspAndHelloTracker guards (IsIntermediateDeviceEspExit, - /// IsConfirmedPostAccountSetupExit) hang off this property. + /// Backlog q8n — pins the two AccountSetup probes side by side. + /// + /// keeps PRESENCE semantics: + /// Windows writes AccountSetupCategory.Status with every subcategory notStarted + /// before the Device-ESP page exits, and about one in five Classic sessions sees only that + /// single Shell-Core 62407 for the whole enrollment — those resolve AccountSetup solely via + /// the user-apps-settled synthesis, which needs the exit remembered as the edge. Tightening + /// this property strands them (verified on 485 SkipUser=false sessions, 2026-09-01..04). + /// + /// + /// is the additive PROGRESS + /// probe for the User-ESP keep-awake host: true only once a subcategory left + /// notStarted, so the host can tell the real user-ESP page exit (session a2256107: + /// 09:47:12) from the Device→Account handoff exit (09:37:53) that released the hold early. + /// /// public sealed class ProvisioningStatusTrackerAccountSetupActivityTests { @@ -72,31 +78,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals } [Fact] - public void NothingTracked_IsNotActivity() + public void NothingTracked_NeitherActivityNorProgress() { using var f = new Fixture(); Assert.False(f.Tracker.HasAccountSetupActivity); + Assert.False(f.Tracker.HasAccountSetupProgress); } [Fact] - public void PreWrittenAllNotStartedJson_IsNotActivity() + public void PreWrittenAllNotStartedJson_IsActivity_ButNotProgress() { - // The registry JSON exists (7 subcategories) but nothing has started — this is the - // state at the intermediate Device-ESP exit and must NOT confirm it as final. + // The registry JSON exists (7 subcategories) but nothing has started — the state at + // the intermediate Device-ESP exit. Activity (presence) must stay true so the + // completion guards keep remembering single-exit sessions; progress must be false so + // the keep-awake host does not treat this exit as the user-ESP page exit. using var f = new Fixture(); f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", AllNotStartedJson); - Assert.False(f.Tracker.HasAccountSetupActivity); + Assert.True(f.Tracker.HasAccountSetupActivity); + Assert.False(f.Tracker.HasAccountSetupProgress); } [Fact] - public void FirstSubcategoryLeavingNotStarted_IsActivity() + public void FirstSubcategoryLeavingNotStarted_IsProgress() { using var f = new Fixture(); f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", AllNotStartedJson); f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", FirstProgressJson); Assert.True(f.Tracker.HasAccountSetupActivity); + Assert.True(f.Tracker.HasAccountSetupProgress); } [Theory] @@ -104,7 +115,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals [InlineData("in_progress")] [InlineData("failed")] [InlineData("notRequired")] - public void AnyNonNotStartedState_IsActivity(string state) + public void AnyNonNotStartedState_IsProgress(string state) { using var f = new Fixture(); f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", @@ -112,11 +123,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals "\"AccountSetup.AppsSubcategory\":{\"subcategoryState\":\"" + state + "\"}," + "\"AccountSetup.CertificatesSubcategory\":{\"subcategoryState\":\"notStarted\"}}"); - Assert.True(f.Tracker.HasAccountSetupActivity); + Assert.True(f.Tracker.HasAccountSetupProgress); } [Fact] - public void DeviceSetupProgress_DoesNotCountAsAccountSetupActivity() + public void DeviceSetupProgress_DoesNotCountAsAccountSetupProgress() { // Adversarial: a fully progressed DeviceSetup category must not leak into the // AccountSetup answer. @@ -125,7 +136,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals "{\"categoryState\":\"succeeded\",\"DeviceSetup.AppsSubcategory\":{\"subcategoryState\":\"succeeded\"}}"); f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", AllNotStartedJson); - Assert.False(f.Tracker.HasAccountSetupActivity); + Assert.False(f.Tracker.HasAccountSetupProgress); } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/UserEspKeepAwakeHostTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/UserEspKeepAwakeHostTests.cs index 4bc83dc3..8e80744a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/UserEspKeepAwakeHostTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/UserEspKeepAwakeHostTests.cs @@ -114,14 +114,80 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration ing.Stop(); } + private static void PostEspExiting(SignalIngress ing) => + ing.Post(DecisionSignalKind.EspExiting, At, "EspAndHelloTracker", Raw("exit")); + [Fact] - public void Releases_on_AccountSetupProvisioningComplete() + public void Releases_on_EspExiting_once_AccountSetup_shows_progress() { + // q8n: the real user-ESP page exit is a 62407 that arrives after the AccountSetup + // registry left notStarted (session a2256107: 09:47:12, progress since 09:38:25). var clock = new VirtualClock(At); using var ing = BuildIngress(clock); var api = new FakeKeepAwakeApi(); var controller = new KeepAwakeController(NewLogger(), api); - var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null); + var progress = false; + var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, + espTimeoutProvider: () => null, accountSetupProgressProbe: () => Volatile.Read(ref progress)); + + ing.Start(); + host.Start(); + PostAccountSetupPhase(ing); + Assert.True(WaitFor(() => api.PreventCount == 1)); + + Volatile.Write(ref progress, true); + PostEspExiting(ing); + + Assert.True(WaitFor(() => api.AllowCount == 1), "AllowSleep should be called once on the user-ESP page exit"); + Assert.False(controller.IsEngaged); + + host.Dispose(); + ing.Stop(); + } + + [Fact] + public void Handoff_exit_before_AccountSetup_progress_keeps_the_hold() + { + // The Device→Account handoff 62407 (a2256107: 09:37:53) precedes any AccountSetup + // progress — Windows pre-writes the AccountSetup JSON all-notStarted — and must not + // release. The later exit with progress does. + var clock = new VirtualClock(At); + using var ing = BuildIngress(clock); + var api = new FakeKeepAwakeApi(); + var controller = new KeepAwakeController(NewLogger(), api); + var progress = false; + var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, + espTimeoutProvider: () => null, accountSetupProgressProbe: () => Volatile.Read(ref progress)); + + ing.Start(); + host.Start(); + PostAccountSetupPhase(ing); + Assert.True(WaitFor(() => api.PreventCount == 1)); + + PostEspExiting(ing); // handoff exit, no progress yet + Assert.False(WaitFor(() => api.AllowCount > 0, 500), "the handoff exit must not release the hold"); + Assert.True(controller.IsEngaged); + + Volatile.Write(ref progress, true); + PostEspExiting(ing); // the real user-ESP page exit + Assert.True(WaitFor(() => api.AllowCount == 1)); + Assert.False(controller.IsEngaged); + + host.Dispose(); + ing.Stop(); + } + + [Fact] + public void AccountSetupProvisioningComplete_no_longer_releases() + { + // The synthesized completion fired a median 3 min before the page closed in 77 % of + // keep-awake sessions (q8n) — it is no longer a release trigger; the cap/stop/exit are. + var clock = new VirtualClock(At); + using var ing = BuildIngress(clock); + var api = new FakeKeepAwakeApi(); + var controller = new KeepAwakeController(NewLogger(), api); + var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, + espTimeoutProvider: () => null, accountSetupProgressProbe: () => true); ing.Start(); host.Start(); @@ -130,8 +196,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration ing.Post(DecisionSignalKind.AccountSetupProvisioningComplete, At, "ProvisioningStatusTracker", Raw("done")); - Assert.True(WaitFor(() => api.AllowCount == 1), "AllowSleep should be called once on AccountSetup completion"); - Assert.False(controller.IsEngaged); + Assert.False(WaitFor(() => api.AllowCount > 0, 500), "completion must not release the hold any more"); + Assert.True(controller.IsEngaged); + + host.Dispose(); + ing.Stop(); + } + + [Fact] + public void EspExiting_before_engage_is_ignored() + { + // Exit signals while idle (e.g. a Device-ESP exit before AccountSetup) neither release + // nor poison the later engage. + var clock = new VirtualClock(At); + using var ing = BuildIngress(clock); + var api = new FakeKeepAwakeApi(); + var controller = new KeepAwakeController(NewLogger(), api); + var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, + espTimeoutProvider: () => null, accountSetupProgressProbe: () => true); + + ing.Start(); + host.Start(); + PostEspExiting(ing); + Assert.False(WaitFor(() => api.AllowCount > 0, 300)); + + PostAccountSetupPhase(ing); + Assert.True(WaitFor(() => api.PreventCount == 1)); + Assert.True(controller.IsEngaged); + Assert.Equal(0, api.AllowCount); host.Dispose(); ing.Stop(); 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 6a624e73..0a1537ff 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 @@ -201,11 +201,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false; /// - /// True once an AccountSetup subcategory has left notStarted (observed progress, not - /// registry presence — see ). + /// True when any AccountSetup subcategory has been tracked (resolved or in progress). + /// Presence semantics — see + /// for why this must stay that way. /// public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false; + /// + /// True once an AccountSetup subcategory has left notStarted — see + /// . Keep-awake input only. + /// + public bool HasAccountSetupProgress => _provisioningTracker?.HasAccountSetupProgress ?? false; + /// /// True once WhiteGlove start has been detected (EventID 509 or persisted from prior run). /// Forwarded from ModernDeploymentTracker. @@ -559,11 +566,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// /// SkipUser is unknown or explicitly true (device-only / SkipUser flow — /// AwaitingHello is legitimately reachable directly after Device-ESP). - /// The provisioning tracker has observed AccountSetup progress (at least one - /// subcategory under AccountSetupCategory.Status has left notStarted), - /// i.e. the current exit is the final post-Account-ESP one. Presence of the JSON - /// alone is not evidence — Windows writes it all-notStarted before the - /// Device-ESP exit (q8n). + /// The provisioning tracker has observed AccountSetup activity (registry JSON + /// under AccountSetupCategory.Status has surfaced at least one subcategory), + /// i.e. the current exit is the final post-Account-ESP one. /// /// private bool IsIntermediateDeviceEspExit() @@ -596,9 +601,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// it back. So this one demands positive evidence and treats "unknown" as "not confirmed": /// /// - /// the provisioning tracker has seen AccountSetup progress (a subcategory past - /// notStarted) — the page that just tore down is demonstrably the - /// AccountSetup one; or + /// the provisioning tracker has seen AccountSetup activity — the page that just + /// tore down is demonstrably the AccountSetup one; or /// SkipUser is explicitly true — the profile has no user ESP at all, so the /// Device-ESP exit IS the final one and no second exit is coming. /// 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 0ea68d15..f5a6dec8 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 @@ -208,16 +208,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals } /// - /// True when at least one AccountSetup subcategory has left notStarted — i.e. the - /// user ESP page has demonstrably started working (in progress, succeeded, failed or - /// notRequired). Mere presence of AccountSetupCategory.Status does NOT count: - /// Windows writes that JSON with every subcategory notStarted BEFORE the Device-ESP - /// page exits (observed on 25H2/26200 across tenants, session a2256107: JSON at 09:37:42, - /// intermediate Shell-Core 62407 at 09:37:53, first real progress at 09:38:25). A - /// presence check therefore mislabelled the Device→Account handoff exit as the final - /// exit, which released the User-ESP keep-awake minutes early (backlog q8n). + /// True when any AccountSetup subcategory has been tracked (resolved or in progress). + /// + /// PRESENCE semantics, deliberately (backlog q8n, 2026-09-04): Windows writes + /// AccountSetupCategory.Status with every subcategory notStarted before the + /// Device-ESP page exits, so this is true at the Device→Account handoff 62407 already. + /// That is load-bearing for completion: about one in five Classic sessions sees only ONE + /// Shell-Core 62407 for the whole enrollment (no separate user-ESP page exit), and those + /// sessions resolve AccountSetup solely through the user-apps-settled synthesis, which + /// needs that single exit to be remembered as the edge. Tightening this to "progress" + /// would strand them. Callers that need "the user ESP page has demonstrably started" + /// use instead. + /// /// public bool HasAccountSetupActivity + { + get + { + lock (_stateLock) + { + if (_lastSubcategoryStates == null) + return false; + return _lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs) + && subs != null + && subs.Count > 0; + } + } + } + + /// + /// True once at least one AccountSetup subcategory has left notStarted — the user + /// ESP page (or its background tracking) has demonstrably started working. Unlike + /// this is NOT satisfied by the pre-written + /// all-notStarted JSON that precedes the Device-ESP exit (session a2256107: JSON at + /// 09:37:42, intermediate 62407 at 09:37:53, first progress at 09:38:25; fleet: progress + /// precedes the real user-ESP exit by at least ~1 min). Used by the User-ESP keep-awake + /// host to tell the real user-ESP page exit from the Device→Account handoff exit; not a + /// completion input. + /// + public bool HasAccountSetupProgress { get { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 65e13672..7e4a0ac1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -372,7 +372,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration tenantId: tenantId, ingress: ingress, clock: clock, - logger: logger)); + logger: logger, + // q8n: release on the user-ESP page exit, i.e. a Shell-Core 62407 that arrives + // after the AccountSetup registry left notStarted — never on the handoff exit. + accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress)); } // OOBE-console / Shift+F10 detection — opt-OUT per tenant (portal toggle → diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs index 173153d8..30eccdf3 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs @@ -55,6 +55,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// public System.Collections.Generic.IReadOnlyCollection StarvedAppsReported => _tracker.StarvedAppsReported; + /// + /// True once an AccountSetup subcategory has left notStarted — the probe the + /// User-ESP keep-awake host uses to tell the real user-ESP page exit from the + /// Device→Account handoff exit (see ). + /// + public bool HasAccountSetupProgress => _tracker.HasAccountSetupProgress; + /// L6 — atomic claim for the termination sweep (see EspAndHelloTracker.TryClaimStarvedAppReport). public bool TryClaimStarvedAppReport(string appId) => _tracker.TryClaimStarvedAppReport(appId); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/UserEspKeepAwakeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/UserEspKeepAwakeHost.cs index 956152c1..04ea5293 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/UserEspKeepAwakeHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/UserEspKeepAwakeHost.cs @@ -22,15 +22,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// hook GatherRuleExecutorHost uses for phase_change triggers): /// /// Engage when a signal reports the ESP phase as AccountSetup. - /// Release on - /// (User-ESP done), on (session teardown), or on the safety-cap timer - /// (a backstop in case the completion signal is missed, so a device is never held awake - /// for the agent's full max-lifetime). + /// Release on once the AccountSetup + /// registry shows progress (the real user-ESP page exit — see below), on + /// (session teardown), or on the safety-cap timer (a backstop so a + /// device is never held awake for the agent's full max-lifetime). /// /// The hold itself is owned by ; the OS also auto-clears it /// on process exit / reboot, so it can never leak. /// /// + /// Why the ESP exit and not + /// (backlog q8n, 2026-09-04): that completion signal is, fleet-wide, almost always the + /// user-apps-settled synthesis (Windows no longer writes categorySucceeded), and the + /// synthesis builds on the Device→Account handoff 62407 — so it fired a median 3 min before + /// the user-ESP page actually closed in 77 % of keep-awake sessions, and a device on battery + /// dropped into Modern Standby with the page still up (session a2256107). The page itself is + /// the truth: a Shell-Core 62407 that arrives after the AccountSetup registry left + /// notStarted is the user-ESP page exit (progress precedes it by ≥ 1 min across the + /// fleet), whereas the handoff exit always precedes any progress. Desktop arrival is NOT a + /// substitute — explorer.exe already runs under the real user while the page is still up. + /// Sessions that only ever see the handoff exit (about one in five) keep the hold until the + /// safety cap; that is the advertised behaviour ("awake during User-ESP") erring long, never + /// early. The completion guards in EspAndHelloTracker deliberately stay on presence + /// semantics — see ProvisioningStatusTracker.HasAccountSetupActivity. + /// + /// /// Safety-cap sizing: rather than a fixed product assumption, the cap is derived at /// engage time from the tenant's own ESP policy — the Intune /// SyncFailureTimeout ("show error when installation takes longer than X minutes", @@ -62,8 +78,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// internal const int EspCapMarginMinutes = 30; - // Release reasons (carried on the keep_awake_released event payload). - internal const string ReasonAccountSetupComplete = "account_setup_complete"; + // Release reasons (carried on the keep_awake_released event payload). "account_setup_complete" + // (release on the synthesized completion signal) was retired with q8n — see the class doc. + internal const string ReasonUserEspExited = "user_esp_exited"; internal const string ReasonHostStop = "host_stop"; internal const string ReasonSafetyCap = "safety_cap"; @@ -83,6 +100,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private readonly int _fallbackCapMinutes; private readonly TimeSpan? _safetyCapOverride; private readonly Func _espTimeoutProvider; + private readonly Func _accountSetupProgressProbe; private enum HoldState { Idle, Engaging, Engaged, Released } @@ -102,7 +120,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration int fallbackCapMinutes = DefaultFallbackCapMinutes, KeepAwakeController? controller = null, TimeSpan? safetyCapOverride = null, - Func? espTimeoutProvider = null) + Func? espTimeoutProvider = null, + Func? accountSetupProgressProbe = null) { if (ingress == null) throw new ArgumentNullException(nameof(ingress)); if (clock == null) throw new ArgumentNullException(nameof(clock)); @@ -118,6 +137,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // The ESP SyncFailureTimeout is read at engage time (the ESP policy is present by then). _espTimeoutProvider = espTimeoutProvider ?? (() => EspSkipConfigurationProbe.ReadFull(_logger).SyncFailureTimeoutMinutes); + // Without a probe no exit can be told apart from the handoff exit — the hold then + // lasts until the safety cap (erring long, never early). Production always wires the + // EspAndHelloHost probe (DefaultComponentFactory). + _accountSetupProgressProbe = accountSetupProgressProbe ?? (() => false); } /// @@ -163,9 +186,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration // (which briefly waits on the keep-awake thread) is dispatched to the ThreadPool. try { - if (kind == DecisionSignalKind.AccountSetupProvisioningComplete) + if (kind == DecisionSignalKind.EspExiting) { - ThreadPool.QueueUserWorkItem(_ => SafeRelease(ReasonAccountSetupComplete, EventSeverity.Info)); + // Only the user-ESP page exit releases: the Device→Account handoff 62407 arrives + // before any AccountSetup subcategory has left notStarted, the real exit after. + // Cheap to skip while idle; the probe is a locked dictionary scan. + if (IsEngagedOrEngaging() && ProbeAccountSetupProgress()) + ThreadPool.QueueUserWorkItem(_ => SafeRelease(ReasonUserEspExited, EventSeverity.Info)); return; } @@ -188,6 +215,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: engage failed.", ex); } } + private bool IsEngagedOrEngaging() + { + lock (_gate) { return _state == HoldState.Engaging || _state == HoldState.Engaged; } + } + + private bool ProbeAccountSetupProgress() + { + try { return _accountSetupProgressProbe(); } + catch (Exception ex) + { + _logger.Debug($"UserEspKeepAwakeHost: AccountSetup progress probe threw: {ex.Message}"); + return false; + } + } + private void SafeRelease(string reason, EventSeverity severity) { try { Release(reason, severity, emit: true); } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 058cf6b4..ea6bbac8 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -428,7 +428,7 @@ namespace AutopilotMonitor.Shared public const string DiskSpaceLow = "disk_space_low"; // One-shot Warning (state-change-only, 1 GB hysteresis — NOT periodic) when the system drive drops below 2 GB free. Review MON-B10: disk-full was previously only visible inside Debug performance_snapshot payloads (filtered out of timelines), so a disk-starved enrollment carried no actionable threshold signal. public const string EspPolicyProviderStalled = "esp_policy_provider_stalled"; // One-shot-per-provider Warning: a registered EnrollmentStatusTracking CSP policy provider stayed incomplete >= 15 min (Setup/Apps TrackingPoliciesCreated != 1, or DevicePreparation InstallationState not in {2,3}). The ESP waits on Setup/Apps providers WITHOUT any OS timeout — e.g. pre-provisioning + co-management leaves only "ConfigMgr" registered (no "Sidecar" = IME) and the user ESP sits at "Apps (Identifying)" for days. Observational only; payload carries the full provider list. See EspPolicyProviderProbe. public const string KeepAwakeEngaged = "keep_awake_engaged"; // Info (state-change, NOT periodic) emitted by the opt-in UserEspKeepAwake host when it holds the device awake (system + display) on entering the User-ESP/AccountSetup phase, so idle standby cannot stall app installs / account provisioning. Reboots are unaffected. Per-tenant toggle (AnalyzerConfiguration.KeepAwakeDuringUserEsp, default off). - public const string KeepAwakeReleased = "keep_awake_released"; // Info (Warning when reason=safety_cap) emitted when the keep-awake hold is released — reason = account_setup_complete | host_stop | safety_cap. Brackets KeepAwakeEngaged; the OS also auto-clears the hold on process exit/reboot. + public const string KeepAwakeReleased = "keep_awake_released"; // Info (Warning when reason=safety_cap) emitted when the keep-awake hold is released — reason = user_esp_exited (Shell-Core 62407 after AccountSetup progress, i.e. the user-ESP page closed; agents before 2026-09-04 wrote account_setup_complete for the synthesized completion, which fired minutes early) | host_stop | safety_cap. Brackets KeepAwakeEngaged; the OS also auto-clears the hold on process exit/reboot. // IME log tracker — app / device / script telemetry (V2 single-rail plan §5.9) public const string EspPhaseChanged = "esp_phase_changed";