fix(agent): keep-awake releases on the user-ESP page exit, not the synthesized completion; guard change reverted (q8n)

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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-04 13:48:40 +02:00
parent 729ee56bde
commit c38f0cf2b0
9 changed files with 241 additions and 53 deletions
@@ -9,15 +9,21 @@ using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// Backlog q8n — <see cref="ProvisioningStatusTracker.HasAccountSetupActivity"/> must mean
/// observed progress, not registry presence. Windows writes
/// <c>AccountSetupCategory.Status</c> with every subcategory <c>notStarted</c> 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 <c>EspAndHelloTracker</c> guards (<c>IsIntermediateDeviceEspExit</c>,
/// <c>IsConfirmedPostAccountSetupExit</c>) hang off this property.
/// Backlog q8n — pins the two AccountSetup probes side by side.
/// <para>
/// <see cref="ProvisioningStatusTracker.HasAccountSetupActivity"/> keeps PRESENCE semantics:
/// Windows writes <c>AccountSetupCategory.Status</c> with every subcategory <c>notStarted</c>
/// 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).
/// </para>
/// <para>
/// <see cref="ProvisioningStatusTracker.HasAccountSetupProgress"/> is the additive PROGRESS
/// probe for the User-ESP keep-awake host: true only once a subcategory left
/// <c>notStarted</c>, 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.
/// </para>
/// </summary>
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);
}
}
}
@@ -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();
@@ -201,11 +201,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public bool DeviceSetupCategorySucceeded => _provisioningTracker?.DeviceSetupCategorySucceeded ?? false;
/// <summary>
/// True once an AccountSetup subcategory has left <c>notStarted</c> (observed progress, not
/// registry presence — see <see cref="ProvisioningStatusTracker.HasAccountSetupActivity"/>).
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
/// Presence semantics — see <see cref="ProvisioningStatusTracker.HasAccountSetupActivity"/>
/// for why this must stay that way.
/// </summary>
public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false;
/// <summary>
/// True once an AccountSetup subcategory has left <c>notStarted</c> — see
/// <see cref="ProvisioningStatusTracker.HasAccountSetupProgress"/>. Keep-awake input only.
/// </summary>
public bool HasAccountSetupProgress => _provisioningTracker?.HasAccountSetupProgress ?? false;
/// <summary>
/// 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
/// <list type="bullet">
/// <item>SkipUser is unknown or explicitly <c>true</c> (device-only / SkipUser flow —
/// AwaitingHello is legitimately reachable directly after Device-ESP).</item>
/// <item>The provisioning tracker has observed AccountSetup progress (at least one
/// subcategory under <c>AccountSetupCategory.Status</c> has left <c>notStarted</c>),
/// i.e. the current exit is the final post-Account-ESP one. Presence of the JSON
/// alone is not evidence — Windows writes it all-<c>notStarted</c> before the
/// Device-ESP exit (q8n).</item>
/// <item>The provisioning tracker has observed AccountSetup activity (registry JSON
/// under <c>AccountSetupCategory.Status</c> has surfaced at least one subcategory),
/// i.e. the current exit is the final post-Account-ESP one.</item>
/// </list>
/// </summary>
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":
/// </para>
/// <list type="bullet">
/// <item>the provisioning tracker has seen AccountSetup progress (a subcategory past
/// <c>notStarted</c>) — the page that just tore down is demonstrably the
/// AccountSetup one; or</item>
/// <item>the provisioning tracker has seen AccountSetup activity — the page that just
/// tore down is demonstrably the AccountSetup one; or</item>
/// <item>SkipUser is explicitly <c>true</c> — the profile has no user ESP at all, so the
/// Device-ESP exit IS the final one and no second exit is coming.</item>
/// </list>
@@ -208,16 +208,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
/// <summary>
/// True when at least one AccountSetup subcategory has left <c>notStarted</c> — i.e. the
/// user ESP page has demonstrably started working (in progress, succeeded, failed or
/// notRequired). Mere presence of <c>AccountSetupCategory.Status</c> does NOT count:
/// Windows writes that JSON with every subcategory <c>notStarted</c> 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).
/// <para>
/// PRESENCE semantics, deliberately (backlog q8n, 2026-09-04): Windows writes
/// <c>AccountSetupCategory.Status</c> with every subcategory <c>notStarted</c> 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 <see cref="HasAccountSetupProgress"/> instead.
/// </para>
/// </summary>
public bool HasAccountSetupActivity
{
get
{
lock (_stateLock)
{
if (_lastSubcategoryStates == null)
return false;
return _lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs)
&& subs != null
&& subs.Count > 0;
}
}
}
/// <summary>
/// True once at least one AccountSetup subcategory has left <c>notStarted</c> — the user
/// ESP page (or its background tracking) has demonstrably started working. Unlike
/// <see cref="HasAccountSetupActivity"/> this is NOT satisfied by the pre-written
/// all-<c>notStarted</c> 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.
/// </summary>
public bool HasAccountSetupProgress
{
get
{
@@ -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 →
@@ -55,6 +55,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
public System.Collections.Generic.IReadOnlyCollection<string> StarvedAppsReported => _tracker.StarvedAppsReported;
/// <summary>
/// True once an AccountSetup subcategory has left <c>notStarted</c> — the probe the
/// User-ESP keep-awake host uses to tell the real user-ESP page exit from the
/// Device→Account handoff exit (see <see cref="EspAndHelloTracker.HasAccountSetupProgress"/>).
/// </summary>
public bool HasAccountSetupProgress => _tracker.HasAccountSetupProgress;
/// <summary>L6 — atomic claim for the termination sweep (see EspAndHelloTracker.TryClaimStarvedAppReport).</summary>
public bool TryClaimStarvedAppReport(string appId) => _tracker.TryClaimStarvedAppReport(appId);
@@ -22,15 +22,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// hook <c>GatherRuleExecutorHost</c> uses for phase_change triggers):
/// <list type="bullet">
/// <item><b>Engage</b> when a signal reports the ESP phase as <c>AccountSetup</c>.</item>
/// <item><b>Release</b> on <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/>
/// (User-ESP done), on <see cref="Stop"/> (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).</item>
/// <item><b>Release</b> on <see cref="DecisionSignalKind.EspExiting"/> once the AccountSetup
/// registry shows progress (the real user-ESP page exit — see below), on
/// <see cref="Stop"/> (session teardown), or on the safety-cap timer (a backstop so a
/// device is never held awake for the agent's full max-lifetime).</item>
/// </list>
/// The hold itself is owned by <see cref="KeepAwakeController"/>; the OS also auto-clears it
/// on process exit / reboot, so it can never leak.
/// </para>
/// <para>
/// <b>Why the ESP exit and not <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/></b>
/// (backlog q8n, 2026-09-04): that completion signal is, fleet-wide, almost always the
/// user-apps-settled synthesis (Windows no longer writes <c>categorySucceeded</c>), 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
/// <c>notStarted</c> 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 <c>EspAndHelloTracker</c> deliberately stay on presence
/// semantics — see <c>ProvisioningStatusTracker.HasAccountSetupActivity</c>.
/// </para>
/// <para>
/// <b>Safety-cap sizing</b>: rather than a fixed product assumption, the cap is derived at
/// engage time from the tenant's own ESP policy — the Intune
/// <c>SyncFailureTimeout</c> ("show error when installation takes longer than X minutes",
@@ -62,8 +78,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
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<int?> _espTimeoutProvider;
private readonly Func<bool> _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<int?>? espTimeoutProvider = null)
Func<int?>? espTimeoutProvider = null,
Func<bool>? 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);
}
/// <summary>
@@ -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); }
@@ -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";