From c38f0cf2b019307bb7661372df40d59b5bb0d828 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 4 Sep 2026 13:48:40 +0200 Subject: [PATCH] fix(agent): keep-awake releases on the user-ESP page exit, not the synthesized completion; guard change reverted (q8n) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the HasAccountSetupActivity tightening from f0a3f043: measured on 485 Classic (SkipUser=false) sessions, 96 see a single Shell-Core 62407 before any AccountSetup progress and 75 of them complete AccountSetup only through the user-apps-settled synthesis, which needs that exit remembered as the edge. Windows confirmed categorySucceeded in 0/485, so the synthesis is the load-bearing completion path; presence semantics stay and are documented as such. UserEspKeepAwakeHost instead gets an additive progress probe (ProvisioningStatusTracker.HasAccountSetupProgress via EspAndHelloHost) and releases on an EspExiting that arrives after the AccountSetup registry left notStarted — the real user-ESP page exit (progress precedes it by >= 1 min fleet-wide; the Device->Account handoff exit always precedes progress). AccountSetupProvisioningComplete no longer releases; desktop arrival is deliberately not a trigger (explorer.exe ran before the final exit in all 385 two-exit sessions). Sessions with only the handoff exit hold until the safety cap. Release reason user_esp_exited replaces account_setup_complete. D-183 rewritten. Tests: host (release on exit+progress, handoff exit holds, completion no longer releases, exit before engage ignored), tracker (presence stays true / progress false on the pre-written all-notStarted JSON). Solution build 0 warnings; agent suite 2823 green. Co-Authored-By: Claude Fable 5.1 --- internal | 2 +- ...gStatusTrackerAccountSetupActivityTests.cs | 49 +++++---- .../UserEspKeepAwakeHostTests.cs | 100 +++++++++++++++++- .../SystemSignals/EspAndHelloTracker.cs | 24 +++-- .../ProvisioningStatusTracker.cs | 45 ++++++-- .../Orchestration/DefaultComponentFactory.cs | 5 +- .../Orchestration/Hosts/EspAndHelloHost.cs | 7 ++ .../Hosts/UserEspKeepAwakeHost.cs | 60 +++++++++-- .../AutopilotMonitor.Shared/Constants.cs | 2 +- 9 files changed, 241 insertions(+), 53 deletions(-) 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";