diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/UserEspKeepAwakeHostTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/UserEspKeepAwakeHostTests.cs new file mode 100644 index 00000000..4bc83dc3 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/UserEspKeepAwakeHostTests.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Persistence; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; + +// ManualResetEventSlim.Wait inside the controller is driven deterministically here; SpinUntil is +// used to await the host's ThreadPool-dispatched engage/release. +#pragma warning disable xUnit1031 + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration +{ + [Collection("SerialThreading")] + public sealed class UserEspKeepAwakeHostTests : IDisposable + { + private static readonly DateTime At = new DateTime(2026, 6, 16, 10, 0, 0, DateTimeKind.Utc); + + private readonly TempDirectory _tmp = new TempDirectory(); + + /// Fake execution-state API — records prevent/allow without touching OS power policy. + private sealed class FakeKeepAwakeApi : IKeepAwakeApi + { + private int _preventCount; + private int _allowCount; + public int PreventCount => Volatile.Read(ref _preventCount); + public int AllowCount => Volatile.Read(ref _allowCount); + public bool PreventSleep() { Interlocked.Increment(ref _preventCount); return true; } + public bool AllowSleep() { Interlocked.Increment(ref _allowCount); return true; } + } + + /// + /// Fake whose PreventSleep blocks on a test-controlled gate, so the hold thread can be held + /// past the controller's engage-ready timeout to exercise the late-Set() path. + /// + private sealed class GatedKeepAwakeApi : IKeepAwakeApi + { + private readonly ManualResetEventSlim _preventGate; + private int _preventCount; + private int _allowCount; + public GatedKeepAwakeApi(ManualResetEventSlim preventGate) { _preventGate = preventGate; } + public int PreventCount => Volatile.Read(ref _preventCount); + public int AllowCount => Volatile.Read(ref _allowCount); + public bool PreventSleep() { _preventGate.Wait(); Interlocked.Increment(ref _preventCount); return true; } + public bool AllowSleep() { Interlocked.Increment(ref _allowCount); return true; } + } + + private AgentLogger NewLogger() => new AgentLogger(_tmp.Path); + + private SignalIngress BuildIngress(VirtualClock clock) => + new SignalIngress( + engine: new DecisionEngine(), + signalLog: new SignalLogWriter(_tmp.File("signal-log.jsonl")), + traceCounter: new SessionTraceOrdinalProvider(), + processor: new FakeDecisionStepProcessor(), + clock: clock); + + private static Evidence Raw(string id = "kw") => new Evidence(EvidenceKind.Raw, id, $"evidence-{id}"); + + private static void PostAccountSetupPhase(SignalIngress ing) => + ing.Post( + DecisionSignalKind.EspPhaseChanged, At, "ImeLogTracker", Raw("phase"), + payload: new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" }); + + private static bool WaitFor(Func condition, int timeoutMs = 5000) => + SpinWait.SpinUntil(condition, timeoutMs); + + [Fact] + public void Engages_keep_awake_on_AccountSetup_phase() + { + 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); + + ing.Start(); + host.Start(); + PostAccountSetupPhase(ing); + + Assert.True(WaitFor(() => api.PreventCount == 1), "PreventSleep should be called once on AccountSetup entry"); + Assert.True(controller.IsEngaged); + Assert.Equal(0, api.AllowCount); + + host.Dispose(); + ing.Stop(); + } + + [Fact] + public void Does_not_engage_on_DeviceSetup_phase() + { + 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); + + ing.Start(); + host.Start(); + ing.Post( + DecisionSignalKind.EspPhaseChanged, At, "ImeLogTracker", Raw("dev"), + payload: new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" }); + + // Give any (incorrect) dispatch a chance to run, then assert nothing engaged. + Assert.False(WaitFor(() => api.PreventCount > 0, 500)); + Assert.False(controller.IsEngaged); + + host.Dispose(); + ing.Stop(); + } + + [Fact] + public void Releases_on_AccountSetupProvisioningComplete() + { + 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); + + ing.Start(); + host.Start(); + PostAccountSetupPhase(ing); + Assert.True(WaitFor(() => api.PreventCount == 1)); + + 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); + + host.Dispose(); + ing.Stop(); + } + + [Fact] + public void Engage_is_idempotent_across_repeated_phase_signals() + { + 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); + + ing.Start(); + host.Start(); + PostAccountSetupPhase(ing); + PostAccountSetupPhase(ing); + PostAccountSetupPhase(ing); + + Assert.True(WaitFor(() => api.PreventCount == 1)); + // Allow the pool to settle; a second prevent would be a bug. + Assert.False(WaitFor(() => api.PreventCount > 1, 500)); + + host.Dispose(); + ing.Stop(); + } + + [Fact] + public void Stop_releases_hold() + { + 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); + + ing.Start(); + host.Start(); + PostAccountSetupPhase(ing); + Assert.True(WaitFor(() => api.PreventCount == 1)); + + host.Stop(); + + Assert.Equal(1, api.AllowCount); + Assert.False(controller.IsEngaged); + + ing.Stop(); + } + + [Fact] + public void Safety_cap_releases_hold_without_completion_signal() + { + 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, + safetyCapOverride: TimeSpan.FromMilliseconds(75), + espTimeoutProvider: () => null); + + ing.Start(); + host.Start(); + PostAccountSetupPhase(ing); + Assert.True(WaitFor(() => api.PreventCount == 1)); + + // No completion signal — the safety cap must release the hold on its own. + Assert.True(WaitFor(() => api.AllowCount == 1), "safety cap should release the keep-awake hold"); + Assert.False(controller.IsEngaged); + + host.Dispose(); + ing.Stop(); + } + + // -------- KeepAwakeController: late-confirm (ready-timeout) edge -------- + + [Fact] + public void Controller_engage_survives_hold_thread_confirming_after_ready_timeout() + { + using var preventGate = new ManualResetEventSlim(false); + var api = new GatedKeepAwakeApi(preventGate); + using var controller = new KeepAwakeController( + NewLogger(), api, engageReadyTimeout: TimeSpan.FromMilliseconds(100)); + + // PreventSleep is gated shut, so the hold thread is parked inside it and cannot reach + // ready.Set() before Engage()'s ready-wait times out. + var confirmed = controller.Engage(); + Assert.False(confirmed); // timeout path — not confirmed + Assert.True(controller.IsEngaged); // hold was still requested + Assert.Equal(0, api.PreventCount); // hold thread still parked in PreventSleep + + // Now let the hold thread proceed: it returns from PreventSleep and calls ready.Set() + // AFTER Engage() already returned on the timeout path. This pins down the late-confirm + // contract — the hold still applies and a later Release still works. + // + // (Note: on net48 ManualResetEventSlim.Set() after Dispose() is a benign no-op — verified + // empirically — so the controller's "don't dispose `ready` on timeout" guard is + // defense-in-depth, not a crash fix here. The guard still matters if the wait primitive + // is ever swapped for one whose Set() rejects after Dispose; this test would then catch + // it because the hold thread would die at ready.Set() and never reach AllowSleep below.) + preventGate.Set(); + Assert.True(WaitFor(() => api.PreventCount == 1), "hold thread should apply PreventSleep once the gate opens"); + + // Release still works cleanly after the late confirm. + Assert.True(controller.Release()); + Assert.True(WaitFor(() => api.AllowCount == 1)); + Assert.False(controller.IsEngaged); + } + + // -------- Cap-resolution policy (pure) -------- + + [Theory] + [InlineData(60, 90, 90, "esp_timeout")] // Intune ESP default 60 + 30 margin + [InlineData(120, 90, 150, "esp_timeout")] // longer ESP policy scales the cap + [InlineData(45, 90, 75, "esp_timeout")] + [InlineData(null, 90, 90, "default")] // ESP timeout unavailable → fallback + [InlineData(0, 90, 90, "default")] // non-positive ESP value treated as unavailable + [InlineData(null, 120, 120, "default")] // custom fallback honoured + public void ResolveSafetyCapMinutes_derives_cap_from_esp_timeout_or_fallback( + int? espTimeout, int fallback, int expectedCap, string expectedSource) + { + var (capMinutes, capSource) = UserEspKeepAwakeHost.ResolveSafetyCapMinutes(espTimeout, fallback); + Assert.Equal(expectedCap, capMinutes); + Assert.Equal(expectedSource, capSource); + } + + public void Dispose() => _tmp.Dispose(); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/PowerNativeMethods.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/PowerNativeMethods.cs new file mode 100644 index 00000000..c6541072 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/PowerNativeMethods.cs @@ -0,0 +1,66 @@ +using System; +using System.Runtime.InteropServices; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop +{ + /// + /// Win32 power-availability interop (kernel32 SetThreadExecutionState). + /// + /// Used to keep the device awake during the User-ESP (AccountSetup) phase so that app + /// installs and account provisioning are not stalled by idle standby/sleep. The execution + /// state only resets the system / display idle timers — it has no effect on explicit + /// reboots or shutdowns, so ESP / Windows-Update reboots proceed normally. + /// + /// + /// Thread-affinity contract: a continuous requirement set with + /// is bound to the lifetime of the calling + /// thread — it is automatically released when that thread exits (and, like all execution + /// states, when the process exits or the machine reboots). Callers that need to hold the + /// requirement therefore keep a dedicated long-lived thread alive (see + /// KeepAwakeController); there is no way to leak a permanent "never sleep" state. + /// + /// + internal static class PowerNativeMethods + { + [Flags] + internal enum EXECUTION_STATE : uint + { + /// Keep the requirement in effect until the next call that changes it. + ES_CONTINUOUS = 0x80000000, + + /// Reset the system idle timer — prevents the system from sleeping. + ES_SYSTEM_REQUIRED = 0x00000001, + + /// Reset the display idle timer — keeps the screen on. + ES_DISPLAY_REQUIRED = 0x00000002, + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern EXECUTION_STATE SetThreadExecutionState(EXECUTION_STATE esFlags); + + /// + /// Engage a continuous system + display keep-awake on the calling thread. The hold + /// stays in effect until is called on the same thread or the + /// thread exits. Returns false if the OS rejected the request (the previous state + /// is reported by Windows as 0). + /// + public static bool PreventSleep() + { + var previous = SetThreadExecutionState( + EXECUTION_STATE.ES_CONTINUOUS + | EXECUTION_STATE.ES_SYSTEM_REQUIRED + | EXECUTION_STATE.ES_DISPLAY_REQUIRED); + return previous != 0; + } + + /// + /// Clear the continuous keep-awake on the calling thread, restoring normal power + /// policy. Must run on the same thread that called . + /// + public static bool AllowSleep() + { + var previous = SetThreadExecutionState(EXECUTION_STATE.ES_CONTINUOUS); + return previous != 0; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/KeepAwakeController.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/KeepAwakeController.cs new file mode 100644 index 00000000..e7e7233b --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/KeepAwakeController.cs @@ -0,0 +1,174 @@ +#nullable enable +using System; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime +{ + /// + /// Thin abstraction over the Win32 execution-state API so the keep-awake hold can be unit + /// tested without touching real OS power policy. and + /// are always invoked on the same dedicated thread owned by + /// (the continuous execution-state requirement is bound to + /// the calling thread's lifetime — see ). + /// + internal interface IKeepAwakeApi + { + /// Engage a continuous system + display keep-awake on the calling thread. + bool PreventSleep(); + + /// Clear the keep-awake on the calling thread. + bool AllowSleep(); + } + + /// Production backed by . + internal sealed class Win32KeepAwakeApi : IKeepAwakeApi + { + public bool PreventSleep() => PowerNativeMethods.PreventSleep(); + public bool AllowSleep() => PowerNativeMethods.AllowSleep(); + } + + /// + /// Holds a system + display keep-awake for the duration of the User-ESP (AccountSetup) phase + /// so idle standby/sleep cannot stall app installs or account provisioning. Reboots are + /// unaffected (the execution state only resets idle timers). + /// + /// The hold is owned by a dedicated background thread: it calls + /// , parks on a release signal, then calls + /// and exits. A continuous requirement is released + /// automatically when its owning thread (or the whole process) exits, so there is no way to + /// leak a permanent "never sleep" state even if the agent crashes. + /// + /// Both and are idempotent and thread-safe. + /// + internal sealed class KeepAwakeController : IDisposable + { + private static readonly TimeSpan DefaultEngageReadyTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan ReleaseJoinTimeout = TimeSpan.FromSeconds(5); + + private readonly IKeepAwakeApi _api; + private readonly AgentLogger _logger; + private readonly TimeSpan _engageReadyTimeout; + private readonly object _sync = new object(); + + private Thread? _holdThread; + private ManualResetEventSlim? _releaseSignal; + private bool _engaged; + private volatile bool _lastPreventResult; + private int _disposed; + + /// + /// Test seam: how long waits for the hold thread to confirm it applied + /// the execution state before returning unconfirmed. Defaults to 5s in production. + /// + public KeepAwakeController(AgentLogger logger, IKeepAwakeApi? api = null, TimeSpan? engageReadyTimeout = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _api = api ?? new Win32KeepAwakeApi(); + _engageReadyTimeout = engageReadyTimeout ?? DefaultEngageReadyTimeout; + } + + public bool IsEngaged + { + get { lock (_sync) { return _engaged; } } + } + + /// + /// Engage the keep-awake hold. No-op (returns false) if already engaged or disposed. + /// Otherwise returns whether the OS accepted the request (logged either way). + /// + public bool Engage() + { + ManualResetEventSlim ready; + lock (_sync) + { + if (Volatile.Read(ref _disposed) == 1 || _engaged) return false; + + ready = new ManualResetEventSlim(false); + var releaseLocal = new ManualResetEventSlim(false); + _releaseSignal = releaseLocal; + _holdThread = new Thread(() => HoldLoop(ready, releaseLocal)) + { + IsBackground = true, + Name = "AutopilotMonitor.KeepAwake", + }; + _engaged = true; + _holdThread.Start(); + } + + // Wait (off the lock) for the hold thread to apply the execution state so the caller + // can report the real OS outcome. The PreventSleep syscall is sub-millisecond; the + // timeout only guards against a pathological thread-start stall. + var confirmed = ready.Wait(_engageReadyTimeout); + if (confirmed) + { + // Wait returned true => HoldLoop has finished with `ready` (it does not touch it + // after Set()), so disposal here cannot race the background thread. + ready.Dispose(); + } + else + { + // Pathological: the hold thread hasn't reached PreventSleep within the timeout + // (e.g. ThreadPool/scheduler starvation under OOBE boot load). Do NOT dispose + // `ready` — HoldLoop may still call ready.Set() afterwards. On net48 + // ManualResetEventSlim.Set() after Dispose() happens to be a benign no-op, but + // disposing an object another thread is about to touch is fragile (and would throw + // for wait primitives that reject post-dispose), so we simply leave `ready` for the + // GC. The hold still applies once the thread is scheduled. + _logger.Warning("KeepAwakeController: engage not confirmed within timeout — hold will apply once the background thread is scheduled."); + } + + var ok = confirmed && _lastPreventResult; + _logger.Info($"KeepAwakeController: keep-awake engaged (confirmed={confirmed}, osAccepted={ok})."); + return ok; + } + + /// + /// Release the keep-awake hold. No-op (returns false) if not currently engaged. + /// Blocks until the hold thread has cleared the execution state (bounded join). + /// + public bool Release() + { + Thread? thread; + ManualResetEventSlim? signal; + lock (_sync) + { + if (!_engaged) return false; + _engaged = false; + thread = _holdThread; + signal = _releaseSignal; + _holdThread = null; + _releaseSignal = null; + } + + try { signal?.Set(); } catch { } + try { thread?.Join(ReleaseJoinTimeout); } catch { } + try { signal?.Dispose(); } catch { } + + _logger.Info("KeepAwakeController: keep-awake released."); + return true; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + Release(); + } + + private void HoldLoop(ManualResetEventSlim ready, ManualResetEventSlim releaseSignal) + { + bool ok = false; + try { ok = _api.PreventSleep(); } + catch (Exception ex) { _logger.Error("KeepAwakeController: PreventSleep threw.", ex); } + _lastPreventResult = ok; + ready.Set(); + + try { releaseSignal.Wait(); } + catch { /* disposed during shutdown — fall through to clear the state */ } + + try { _api.AllowSleep(); } + catch (Exception ex) { _logger.Error("KeepAwakeController: AllowSleep threw.", ex); } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 631d1ec3..141c5e8f 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -186,6 +186,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration logger.Info($"DefaultComponentFactory: RealmJoinHost not created (EnableRealmJoinWatcher={analyzers.EnableRealmJoinWatcher}, RealmJoinTrackingEnabled={RealmJoinHost.RealmJoinTrackingEnabled})"); } + // Keep-awake during User-ESP — opt-in per tenant (portal toggle → AnalyzerConfiguration. + // KeepAwakeDuringUserEsp, default off). The host observes the signal rail and holds the + // device awake (system + display) from AccountSetup entry until provisioning completes + // (or a safety cap), so idle standby cannot stall app installs / account setup. Reboots + // are unaffected; the OS auto-clears the hold on process exit. + if (analyzers.KeepAwakeDuringUserEsp) + { + hosts.Add(new UserEspKeepAwakeHost( + sessionId: sessionId, + tenantId: tenantId, + ingress: ingress, + clock: clock, + logger: logger)); + } + // Single-rail refactor (plan §5.8) — DeviceInfoCollector existed in V2.Core but had // no host so the Device-Details UI block was empty in V2 sessions (V1-Parity Issue #2). // Kernel host: always on, not remote-config-gated; fires CollectAll on Start on a diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/UserEspKeepAwakeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/UserEspKeepAwakeHost.cs new file mode 100644 index 00000000..bab95ec7 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/UserEspKeepAwakeHost.cs @@ -0,0 +1,328 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Opt-in host (per-tenant AnalyzerConfiguration.KeepAwakeDuringUserEsp, default off) + /// that keeps the device awake for the duration of the User-ESP (AccountSetup) phase so idle + /// standby/sleep cannot stall app installs or account provisioning. Reboots are unaffected — + /// the hold only resets the system + display idle timers. + /// + /// It observes the central signal rail (, the same + /// 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). + /// + /// The hold itself is owned by ; the OS also auto-clears it + /// on process exit / reboot, so it can never leak. + /// + /// + /// 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", + /// read via ) plus + /// . A legitimate User-ESP cannot run longer than ESP itself + /// tolerates before it errors / offers "continue anyway", so SyncFailureTimeout + margin + /// always outlives a real run while still bounding a hung one. Reading at engage time (rather + /// than host construction) means the ESP policy is already present in the registry. When the + /// value is unavailable (ESP skipped, WDP v2 with no ESP, or unreadable) the cap falls back to + /// . The resolved cap and its source are reported on the + /// keep_awake_engaged event for fleet-wide observability. + /// + /// + internal sealed class UserEspKeepAwakeHost : ICollectorHost + { + public string Name => "UserEspKeepAwake"; + + internal const string Source = "UserEspKeepAwake"; + + /// + /// Fallback cap (minutes) when the ESP SyncFailureTimeout is unavailable. Equals the + /// Intune ESP default timeout (60) + . + /// + internal const int DefaultFallbackCapMinutes = 90; + + /// + /// Added to the ESP SyncFailureTimeout to cover the window after ESP errors out: the + /// "continue anyway" → desktop settling and the completion-signal propagation that follow. + /// + internal const int EspCapMarginMinutes = 30; + + // Release reasons (carried on the keep_awake_released event payload). + internal const string ReasonAccountSetupComplete = "account_setup_complete"; + internal const string ReasonHostStop = "host_stop"; + internal const string ReasonSafetyCap = "safety_cap"; + + // Cap-source labels (carried on the keep_awake_engaged event payload). + internal const string CapSourceEspTimeout = "esp_timeout"; + internal const string CapSourceDefault = "default"; + internal const string CapSourceOverride = "override"; + + private static readonly string AccountSetupPhaseName = EnrollmentPhase.AccountSetup.ToString(); + + private readonly string _sessionId; + private readonly string _tenantId; + private readonly AgentLogger _logger; + private readonly InformationalEventPost _post; + private readonly KeepAwakeController _controller; + private readonly SignalIngress? _observableIngress; + private readonly int _fallbackCapMinutes; + private readonly TimeSpan? _safetyCapOverride; + private readonly Func _espTimeoutProvider; + + private enum HoldState { Idle, Engaged, Released } + + private readonly object _gate = new object(); + private Action?>? _signalPostedHandler; + private Timer? _safetyCapTimer; + private HoldState _state = HoldState.Idle; // guarded by _gate + private int _activeCapMinutes; // guarded by _gate — for the cap-elapsed log line + private int _disposed; + + public UserEspKeepAwakeHost( + string sessionId, + string tenantId, + ISignalIngressSink ingress, + IClock clock, + AgentLogger logger, + int fallbackCapMinutes = DefaultFallbackCapMinutes, + KeepAwakeController? controller = null, + TimeSpan? safetyCapOverride = null, + Func? espTimeoutProvider = null) + { + if (ingress == null) throw new ArgumentNullException(nameof(ingress)); + if (clock == null) throw new ArgumentNullException(nameof(clock)); + _sessionId = sessionId ?? string.Empty; + _tenantId = tenantId ?? string.Empty; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _post = new InformationalEventPost(ingress, clock, logger); + _controller = controller ?? new KeepAwakeController(logger); + _observableIngress = ingress as SignalIngress; + _fallbackCapMinutes = fallbackCapMinutes > 0 ? fallbackCapMinutes : DefaultFallbackCapMinutes; + // safetyCapOverride is a test seam for a fixed (e.g. sub-second) cap. + _safetyCapOverride = safetyCapOverride; + // The ESP SyncFailureTimeout is read at engage time (the ESP policy is present by then). + _espTimeoutProvider = espTimeoutProvider + ?? (() => EspSkipConfigurationProbe.ReadFull(_logger).SyncFailureTimeoutMinutes); + } + + /// + /// Pure cap-resolution policy (testable in isolation): derive the safety cap from the ESP + /// SyncFailureTimeout when present (timeout + ), + /// otherwise the fallback. A non-positive ESP value is treated as "unavailable". + /// + internal static (int capMinutes, string capSource) ResolveSafetyCapMinutes( + int? espSyncFailureTimeoutMinutes, + int fallbackCapMinutes) + { + if (espSyncFailureTimeoutMinutes.HasValue && espSyncFailureTimeoutMinutes.Value > 0) + return (espSyncFailureTimeoutMinutes.Value + EspCapMarginMinutes, CapSourceEspTimeout); + return (fallbackCapMinutes, CapSourceDefault); + } + + public void Start() + { + if (_observableIngress == null) + { + // Non-SignalIngress sink (test fakes) — no phase stream to observe; degrade off. + _logger.Info("UserEspKeepAwakeHost: ingress is not observable — keep-awake disabled this run."); + return; + } + + if (_signalPostedHandler == null) + { + _signalPostedHandler = OnSignalPosted; + _observableIngress.SignalPosted += _signalPostedHandler; + } + _logger.Info($"UserEspKeepAwakeHost: started (cap derived from ESP SyncFailureTimeout at engage, fallback={_fallbackCapMinutes} min)."); + } + + /// + /// Translates posted signals into engage/release. Must be fast and must not throw (the + /// ingress swallows handler exceptions, but we still keep this off the hot path): the + /// actual engage/release work — which briefly blocks on the keep-awake thread — is + /// dispatched to the ThreadPool. + /// + private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary? payload) + { + // Fast, allocation-free triage on the posting thread; the blocking engage/release work + // (which briefly waits on the keep-awake thread) is dispatched to the ThreadPool. + try + { + if (kind == DecisionSignalKind.AccountSetupProvisioningComplete) + { + ThreadPool.QueueUserWorkItem(_ => SafeRelease(ReasonAccountSetupComplete, EventSeverity.Info)); + return; + } + + if (payload != null + && payload.TryGetValue(SignalPayloadKeys.EspPhase, out var phaseName) + && string.Equals(phaseName, AccountSetupPhaseName, StringComparison.OrdinalIgnoreCase)) + { + ThreadPool.QueueUserWorkItem(_ => SafeEngage()); + } + } + catch (Exception ex) + { + _logger.Verbose($"UserEspKeepAwakeHost: signal dispatch failed: {ex.Message}"); + } + } + + private void SafeEngage() + { + try { Engage(); } + catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: engage failed.", ex); } + } + + private void SafeRelease(string reason, EventSeverity severity) + { + try { Release(reason, severity, emit: true); } + catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: release failed.", ex); } + } + + /// Engage the hold exactly once (Idle → Engaged). No-op from any other state. + private void Engage() + { + bool osAccepted; + int capMinutes; + string capSource; + lock (_gate) + { + if (_state != HoldState.Idle) return; + + // Size the cap now (AccountSetup has started, so the ESP policy is in the registry). + TimeSpan cap; + if (_safetyCapOverride.HasValue) + { + cap = _safetyCapOverride.Value; + capMinutes = (int)Math.Round(cap.TotalMinutes); + capSource = CapSourceOverride; + } + else + { + (capMinutes, capSource) = ResolveSafetyCapMinutes(SafeReadEspTimeoutMinutes(), _fallbackCapMinutes); + cap = TimeSpan.FromMinutes(capMinutes); + } + + osAccepted = _controller.Engage(); + _activeCapMinutes = capMinutes; + // One-shot backstop (NOT periodic): fire once after the cap, then release. + _safetyCapTimer = new Timer(OnSafetyCapElapsed, null, cap, Timeout.InfiniteTimeSpan); + _state = HoldState.Engaged; + } + + Emit(Constants.EventTypes.KeepAwakeEngaged, EventSeverity.Info, new Dictionary(StringComparer.Ordinal) + { + ["osAccepted"] = osAccepted ? "true" : "false", + ["scope"] = "system+display", + ["capMinutes"] = capMinutes.ToString(), + ["capSource"] = capSource, + }); + } + + private int? SafeReadEspTimeoutMinutes() + { + try { return _espTimeoutProvider(); } + catch (Exception ex) + { + _logger.Debug($"UserEspKeepAwakeHost: ESP SyncFailureTimeout read threw: {ex.Message}"); + return null; + } + } + + /// + /// Release the hold exactly once (Idle/Engaged → Released). Idempotent — a second call (or + /// a competing completion / cap / stop trigger) is a no-op. + /// + private void Release(string reason, EventSeverity severity, bool emit) + { + bool wasEngaged; + lock (_gate) + { + if (_state == HoldState.Released) return; + DisarmSafetyCap(); + wasEngaged = _controller.Release(); + _state = HoldState.Released; + } + + if (wasEngaged && emit) + { + Emit(Constants.EventTypes.KeepAwakeReleased, severity, new Dictionary(StringComparer.Ordinal) + { + ["reason"] = reason, + }); + } + } + + private void OnSafetyCapElapsed(object? state) + { + _logger.Warning($"UserEspKeepAwakeHost: safety cap ({_activeCapMinutes} min) elapsed without User-ESP completion — releasing keep-awake."); + SafeRelease(ReasonSafetyCap, EventSeverity.Warning); + } + + // Must be called under _gate. + private void DisarmSafetyCap() + { + var timer = _safetyCapTimer; + _safetyCapTimer = null; + try { timer?.Dispose(); } catch { } + } + + private void Emit(string eventType, EventSeverity severity, Dictionary data) + { + try + { + // NOTE: deliberately no `phase:` argument — InformationalEventPost maps phase to the + // same payload key ("phase") that the ESP phase stream uses, which would make this + // lifecycle event re-trigger our own OnSignalPosted handler. + _post.Emit( + eventType: eventType, + source: Source, + severity: severity, + immediateUpload: true, + data: data, + sourceOrigin: Source); + } + catch (Exception ex) + { + // Ingress may already be stopped during teardown — best-effort. + _logger.Debug($"UserEspKeepAwakeHost: emit '{eventType}' threw: {ex.Message}"); + } + } + + public void Stop() + { + if (_observableIngress != null && _signalPostedHandler != null) + { + try { _observableIngress.SignalPosted -= _signalPostedHandler; } + catch { /* best-effort unsubscribe during shutdown */ } + _signalPostedHandler = null; + } + + // Release synchronously on teardown. If a completion/cap release already won, this is a + // no-op (state already Released). Emit is best-effort — the ingress may already be down. + Release(ReasonHostStop, EventSeverity.Info, emit: true); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) == 1) return; + Stop(); + try { _controller.Dispose(); } catch { } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 13395d64..974491e9 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -386,6 +386,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "EnableSoftwareInventoryAnalyzer", config.EnableSoftwareInventoryAnalyzer }, { "EnableIntegrityBypassAnalyzer", config.EnableIntegrityBypassAnalyzer }, { "EnableRealmJoinWatcher", config.EnableRealmJoinWatcher }, + { "KeepAwakeDuringUserEsp", config.KeepAwakeDuringUserEsp }, { "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson }, { "BootstrapTokenEnabled", config.BootstrapTokenEnabled }, { "UnrestrictedModeEnabled", config.UnrestrictedModeEnabled }, @@ -473,6 +474,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage EnableSoftwareInventoryAnalyzer = entity.GetBoolean("EnableSoftwareInventoryAnalyzer"), EnableIntegrityBypassAnalyzer = entity.GetBoolean("EnableIntegrityBypassAnalyzer"), EnableRealmJoinWatcher = entity.GetBoolean("EnableRealmJoinWatcher"), + KeepAwakeDuringUserEsp = entity.GetBoolean("KeepAwakeDuringUserEsp"), LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"), BootstrapTokenEnabled = entity.GetBoolean("BootstrapTokenEnabled") ?? false, UnrestrictedModeEnabled = entity.GetBoolean("UnrestrictedModeEnabled") ?? false, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 8973a487..4eeccf80 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -181,7 +181,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 30, // V2 RealmJoin watcher portal toggle (EnableRealmJoinWatcher; tenant-scoped, default off; gates RealmJoinHost creation) + ConfigVersion = 31, // V2 keep-awake-during-User-ESP portal toggle (KeepAwakeDuringUserEsp; tenant-scoped, default off; gates UserEspKeepAwake host) UploadIntervalSeconds = 10, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, @@ -207,7 +207,8 @@ namespace AutopilotMonitor.Functions.Functions.Config LocalAdminAllowedAccounts = tenantConfig.GetLocalAdminAllowedAccounts(), EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? false, EnableIntegrityBypassAnalyzer = tenantConfig.EnableIntegrityBypassAnalyzer ?? true, - EnableRealmJoinWatcher = tenantConfig.EnableRealmJoinWatcher ?? false + EnableRealmJoinWatcher = tenantConfig.EnableRealmJoinWatcher ?? false, + KeepAwakeDuringUserEsp = tenantConfig.KeepAwakeDuringUserEsp ?? false }, LatestAgentSha256 = latestAgentSha256, LatestAgentExeSha256 = latestAgentExeSha256, diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 57b079a3..ae089f11 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -131,6 +131,8 @@ export const EVENT_TYPES_CATALOG = { 'previous_crash_detected', 'performance_collector_stopped', 'agent_metrics_collector_stopped', + 'keep_awake_engaged', + 'keep_awake_released', 'agent_unrestricted_mode_changed', 'remote_config_fetch_failed', 'agent_trace', diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index caa4d8c6..462ad407 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -305,6 +305,8 @@ namespace AutopilotMonitor.Shared public const string TelemetryUploadBlocked = "telemetry_upload_blocked"; // Warning (one-shot) when the drain is blocked by a retained unknown permanent 4xx (400 contract bug / 404 route mismatch / tenant-validation). Data is NOT discarded. LOCAL diagnostic (agent log + diag ZIP): this marker is queued behind the blocking batch and only reaches the backend after the block clears — no out-of-band bypass by design. public const string SessionParkedWithoutDeadline = "session_parked_without_deadline"; // One-shot Warning tripwire (liveness plan PR1): a non-terminal session entered the post-AccountSetup dead-end zone (ESP final exit or advisory failure) with NO resolution-capable deadline armed. After the 2026-06-12 dead-end fixes this must never fire in production — every occurrence is a bug report for an unknown parking variant. 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 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. // IME log tracker — app / device / script telemetry (V2 single-rail plan §5.9) public const string EspPhaseChanged = "esp_phase_changed"; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs index 4a9b3ef2..e0d7e80f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs @@ -457,5 +457,14 @@ namespace AutopilotMonitor.Shared.Models /// Default: false (opt-in; most tenants do not deploy via RealmJoin). /// public bool EnableRealmJoinWatcher { get; set; } = false; + + /// + /// Whether to keep the device awake (system + display) during the User-ESP (AccountSetup) + /// phase so idle standby/sleep cannot stall app installs or account provisioning. This is + /// not an IAgentAnalyzer; the flag gates creation of the UserEspKeepAwake host in the agent + /// runtime. Reboots are unaffected (the hold only resets idle timers). + /// Default: false (opt-in per tenant). + /// + public bool KeepAwakeDuringUserEsp { get; set; } = false; } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 6f277965..85926a94 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -334,6 +334,13 @@ namespace AutopilotMonitor.Shared.Models /// public bool? EnableRealmJoinWatcher { get; set; } = null; + /// + /// Whether to keep the device awake during the User-ESP (AccountSetup) phase for this + /// tenant's devices. Prevents idle standby/sleep from stalling app installs / account + /// provisioning; reboots are unaffected. Off by default. null = use agent default (false). + /// + public bool? KeepAwakeDuringUserEsp { get; set; } = null; + /// /// JSON-serialized list of additional local account names that are considered expected /// on a newly enrolled device (merged with built-in defaults on the agent). diff --git a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentChangelog.tsx b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentChangelog.tsx index fb57131d..34c42491 100644 --- a/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentChangelog.tsx +++ b/src/Web/autopilot-monitor-web/app/docs/sections/SectionAgentChangelog.tsx @@ -13,6 +13,7 @@ export function SectionAgentChangelog() { {/* ── June 2026 ────────────────────────────────── */} +
  • Optional keep-awake during the User-ESP (Account Setup) phase — when enabled per tenant (off by default), the agent keeps the device awake so it can't drop into standby and stall app installs or account setup; the hold is released automatically once the phase completes, and device reboots are unaffected
  • Fewer stuck enrollments — Classic sessions where a skipped user-ESP app or an advisory "continue anyway" ESP failure left completion waiting now resolve through a short completion deadline instead of running into the 6-hour timeout
  • Desktop-arrival detection no longer stalls on devices where the owner lookup failed — the agent resolves the signed-in user via a Windows session (WTS) query, with the previous method as fallback
  • When an app still installing blocks enrollment completion, the agent now names the specific app holding up the AccountSetup gate instead of just reporting a stall
  • diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index 19f20164..34314baa 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -44,6 +44,10 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ description: "Agent process is shutting down. V2 canonical event; reason field disambiguates (decision_terminal / max_lifetime / auth_failure / ctrl_c / process_exit / unhandled_exception)." }, { value: "phase_transition", label: "phase_transition", category: "enrollment", description: "Enrollment phase transition detected." }, + { value: "keep_awake_engaged", label: "keep_awake_engaged", category: "enrollment", + description: "Opt-in keep-awake hold engaged on entering the User-ESP (AccountSetup) phase — device is kept awake (system + display) so standby/sleep cannot stall app installs or account setup. Reboots are unaffected. Off by default per tenant." }, + { value: "keep_awake_released", label: "keep_awake_released", category: "enrollment", + description: "Keep-awake hold released — reason is account_setup_complete (User-ESP finished), host_stop (session teardown), or safety_cap (the backstop timer elapsed without completion)." }, // -------- Stall detection (Ebene 2) -------- { value: "session_stalled", label: "session_stalled", category: "stall", diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index bcc86159..b52fd857 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -108,6 +108,8 @@ interface TenantConfigContextValue { setEnableIntegrityBypassAnalyzer: (v: boolean) => void; enableRealmJoinWatcher: boolean; setEnableRealmJoinWatcher: (v: boolean) => void; + keepAwakeDuringUserEsp: boolean; + setKeepAwakeDuringUserEsp: (v: boolean) => void; handleSaveAgentAnalyzers: () => void; handleResetAgentAnalyzers: () => void; @@ -340,6 +342,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const [enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer] = useState(false); const [enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer] = useState(true); const [enableRealmJoinWatcher, setEnableRealmJoinWatcher] = useState(false); + const [keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp] = useState(false); // Unrestricted mode const [unrestrictedMode, setUnrestrictedMode] = useState(false); @@ -452,6 +455,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setEnableSoftwareInventoryAnalyzer(data.enableSoftwareInventoryAnalyzer ?? false); setEnableIntegrityBypassAnalyzer(data.enableIntegrityBypassAnalyzer ?? true); setEnableRealmJoinWatcher(data.enableRealmJoinWatcher ?? false); + setKeepAwakeDuringUserEsp(data.keepAwakeDuringUserEsp ?? false); setUnrestrictedMode(data.unrestrictedMode ?? false); // Parse SAS expiry and fire notification to bell if needed @@ -643,6 +647,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableRealmJoinWatcher, + keepAwakeDuringUserEsp, unrestrictedMode, }; @@ -691,7 +696,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold, diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination, tenantDiagPaths, enableLocalAdminAnalyzer, localAdminAllowedAccounts, enableSoftwareInventoryAnalyzer, - enableIntegrityBypassAnalyzer, enableRealmJoinWatcher, unrestrictedMode, + enableIntegrityBypassAnalyzer, enableRealmJoinWatcher, keepAwakeDuringUserEsp, unrestrictedMode, ]); // ----------------------------------------------------------------------- @@ -896,6 +901,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setEnableSoftwareInventoryAnalyzer(config.enableSoftwareInventoryAnalyzer ?? false); setEnableIntegrityBypassAnalyzer(config.enableIntegrityBypassAnalyzer ?? true); setEnableRealmJoinWatcher(config.enableRealmJoinWatcher ?? false); + setKeepAwakeDuringUserEsp(config.keepAwakeDuringUserEsp ?? false); }, [config]); const handleSaveNotifications = useCallback(() => saveConfiguration("notifications"), [saveConfiguration]); @@ -1278,6 +1284,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer, enableRealmJoinWatcher, setEnableRealmJoinWatcher, + keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp, handleSaveAgentAnalyzers, handleResetAgentAnalyzers, // Unrestricted mode diff --git a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentAnalyzers.tsx b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentAnalyzers.tsx index b530e006..d2d5bb01 100644 --- a/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentAnalyzers.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/agent/sections/SectionAgentAnalyzers.tsx @@ -12,6 +12,7 @@ export function SectionAgentAnalyzers() { enableSoftwareInventoryAnalyzer, setEnableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, setEnableIntegrityBypassAnalyzer, enableRealmJoinWatcher, setEnableRealmJoinWatcher, + keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp, handleSaveAgentAnalyzers, handleResetAgentAnalyzers, savingSection, } = useTenantConfig(); @@ -32,6 +33,8 @@ export function SectionAgentAnalyzers() { setEnableIntegrityBypassAnalyzer={setEnableIntegrityBypassAnalyzer} enableRealmJoinWatcher={enableRealmJoinWatcher} setEnableRealmJoinWatcher={setEnableRealmJoinWatcher} + keepAwakeDuringUserEsp={keepAwakeDuringUserEsp} + setKeepAwakeDuringUserEsp={setKeepAwakeDuringUserEsp} onSave={handleSaveAgentAnalyzers} onReset={handleResetAgentAnalyzers} saving={savingSection === "agentAnalyzers"} diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx index 9f7a2e75..c8f630f8 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AgentAnalyzersSection.tsx @@ -15,6 +15,8 @@ interface AgentAnalyzersSectionProps { setEnableIntegrityBypassAnalyzer: (value: boolean) => void; enableRealmJoinWatcher: boolean; setEnableRealmJoinWatcher: (value: boolean) => void; + keepAwakeDuringUserEsp: boolean; + setKeepAwakeDuringUserEsp: (value: boolean) => void; onSave: () => Promise | void; onReset: () => void; saving: boolean; @@ -47,6 +49,8 @@ export default function AgentAnalyzersSection({ setEnableIntegrityBypassAnalyzer, enableRealmJoinWatcher, setEnableRealmJoinWatcher, + keepAwakeDuringUserEsp, + setKeepAwakeDuringUserEsp, onSave, onReset, saving, @@ -256,6 +260,33 @@ export default function AgentAnalyzersSection({ + {/* ── Divider ─────────────────────────────────────── */} +
    + + {/* ── Keep Awake During User-ESP ──────────────────── */} +
    +

    Keep Awake During User-ESP

    +

    + Keeps the device awake (system and display) during the User-ESP (Account Setup) phase so idle + standby or sleep cannot stall app installs and account provisioning. Reboots are unaffected. + The hold is released automatically once the User-ESP phase completes. +

    + + {/* Enable toggle */} +
    +
    +

    Enable Keep Awake During User-ESP

    +

    Off by default. Enable for devices that drop into standby during the user phase of enrollment

    +
    + +
    +
    + {/* Save / Reset */}
    diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index 44ea2cd1..94684843 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -74,6 +74,7 @@ export interface TenantConfiguration { enableSoftwareInventoryAnalyzer?: boolean; enableIntegrityBypassAnalyzer?: boolean; enableRealmJoinWatcher?: boolean; + keepAwakeDuringUserEsp?: boolean; // Bootstrap token bootstrapTokenEnabled?: boolean; // Unrestricted mode