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";