feat(agent-v2): opt-in keep-awake during User-ESP phase (per-tenant, default off)

Keeps the device awake (system + display via SetThreadExecutionState) during the
User-ESP / AccountSetup phase so idle standby/sleep can't stall app installs or
account provisioning. Reboots are unaffected; the OS auto-clears the hold on
process exit, so it can never leak.

- UserEspKeepAwakeHost observes the signal rail: engage on AccountSetup phase,
  release on AccountSetupProvisioningComplete / host stop / safety cap.
- Safety cap is derived from the tenant's Intune ESP SyncFailureTimeout (+30 min
  margin), read at engage time; falls back to 90 min when unavailable. Resolved
  cap + source are reported on keep_awake_engaged for fleet observability.
- KeepAwakeController owns the hold on a dedicated thread (ES_CONTINUOUS bound to
  the thread's lifetime). Dispose guard on the engage-ready timeout path.
- Per-tenant portal toggle KeepAwakeDuringUserEsp (ConfigVersion 30 -> 31) with
  full config plumbing (Tenant/Analyzer config, table storage, agent delivery)
  and Web settings UI.
- New events keep_awake_engaged / keep_awake_released across Constants, the MCP
  resource catalog, and the Web gather-rule on_event autocomplete.
- Tests: KeepAwakeController late-confirm timeout edge, host engage/release/cap,
  and pure cap-resolution policy (V2.Core 1467 green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-16 15:01:52 +02:00
parent 4ebf661319
commit e84ac248f9
17 changed files with 921 additions and 3 deletions
@@ -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();
/// <summary>Fake execution-state API — records prevent/allow without touching OS power policy.</summary>
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; }
}
/// <summary>
/// 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.
/// </summary>
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<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" });
private static bool WaitFor(Func<bool> 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<string, string> { [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();
}
}
@@ -0,0 +1,66 @@
using System;
using System.Runtime.InteropServices;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
{
/// <summary>
/// Win32 power-availability interop (kernel32 <c>SetThreadExecutionState</c>).
/// <para>
/// 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 <b>no</b> effect on explicit
/// reboots or shutdowns, so ESP / Windows-Update reboots proceed normally.
/// </para>
/// <para>
/// <b>Thread-affinity contract</b>: a continuous requirement set with
/// <see cref="EXECUTION_STATE.ES_CONTINUOUS"/> 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
/// <c>KeepAwakeController</c>); there is no way to leak a permanent "never sleep" state.
/// </para>
/// </summary>
internal static class PowerNativeMethods
{
[Flags]
internal enum EXECUTION_STATE : uint
{
/// <summary>Keep the requirement in effect until the next call that changes it.</summary>
ES_CONTINUOUS = 0x80000000,
/// <summary>Reset the system idle timer — prevents the system from sleeping.</summary>
ES_SYSTEM_REQUIRED = 0x00000001,
/// <summary>Reset the display idle timer — keeps the screen on.</summary>
ES_DISPLAY_REQUIRED = 0x00000002,
}
[DllImport("kernel32.dll", SetLastError = true)]
private static extern EXECUTION_STATE SetThreadExecutionState(EXECUTION_STATE esFlags);
/// <summary>
/// Engage a continuous system + display keep-awake on the <b>calling thread</b>. The hold
/// stays in effect until <see cref="AllowSleep"/> is called on the same thread or the
/// thread exits. Returns <c>false</c> if the OS rejected the request (the previous state
/// is reported by Windows as <c>0</c>).
/// </summary>
public static bool PreventSleep()
{
var previous = SetThreadExecutionState(
EXECUTION_STATE.ES_CONTINUOUS
| EXECUTION_STATE.ES_SYSTEM_REQUIRED
| EXECUTION_STATE.ES_DISPLAY_REQUIRED);
return previous != 0;
}
/// <summary>
/// Clear the continuous keep-awake on the <b>calling thread</b>, restoring normal power
/// policy. Must run on the same thread that called <see cref="PreventSleep"/>.
/// </summary>
public static bool AllowSleep()
{
var previous = SetThreadExecutionState(EXECUTION_STATE.ES_CONTINUOUS);
return previous != 0;
}
}
}
@@ -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
{
/// <summary>
/// Thin abstraction over the Win32 execution-state API so the keep-awake hold can be unit
/// tested without touching real OS power policy. <see cref="PreventSleep"/> and
/// <see cref="AllowSleep"/> are always invoked on the same dedicated thread owned by
/// <see cref="KeepAwakeController"/> (the continuous execution-state requirement is bound to
/// the calling thread's lifetime — see <see cref="PowerNativeMethods"/>).
/// </summary>
internal interface IKeepAwakeApi
{
/// <summary>Engage a continuous system + display keep-awake on the calling thread.</summary>
bool PreventSleep();
/// <summary>Clear the keep-awake on the calling thread.</summary>
bool AllowSleep();
}
/// <summary>Production <see cref="IKeepAwakeApi"/> backed by <see cref="PowerNativeMethods"/>.</summary>
internal sealed class Win32KeepAwakeApi : IKeepAwakeApi
{
public bool PreventSleep() => PowerNativeMethods.PreventSleep();
public bool AllowSleep() => PowerNativeMethods.AllowSleep();
}
/// <summary>
/// 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).
/// <para>
/// The hold is owned by a dedicated background thread: it calls
/// <see cref="IKeepAwakeApi.PreventSleep"/>, parks on a release signal, then calls
/// <see cref="IKeepAwakeApi.AllowSleep"/> 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.
/// </para>
/// <para>Both <see cref="Engage"/> and <see cref="Release"/> are idempotent and thread-safe.</para>
/// </summary>
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;
/// <param name="engageReadyTimeout">
/// Test seam: how long <see cref="Engage"/> waits for the hold thread to confirm it applied
/// the execution state before returning unconfirmed. Defaults to 5s in production.
/// </param>
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; } }
}
/// <summary>
/// Engage the keep-awake hold. No-op (returns <c>false</c>) if already engaged or disposed.
/// Otherwise returns whether the OS accepted the request (logged either way).
/// </summary>
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;
}
/// <summary>
/// Release the keep-awake hold. No-op (returns <c>false</c>) if not currently engaged.
/// Blocks until the hold thread has cleared the execution state (bounded join).
/// </summary>
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); }
}
}
}
@@ -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
@@ -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
{
/// <summary>
/// Opt-in host (per-tenant <c>AnalyzerConfiguration.KeepAwakeDuringUserEsp</c>, 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.
/// <para>
/// It observes the central signal rail (<see cref="SignalIngress.SignalPosted"/>, the same
/// 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>
/// </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>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",
/// read via <see cref="EspSkipConfigurationProbe"/>) plus
/// <see cref="EspCapMarginMinutes"/>. A legitimate User-ESP cannot run longer than ESP itself
/// tolerates before it errors / offers "continue anyway", so <c>SyncFailureTimeout + margin</c>
/// 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
/// <see cref="DefaultFallbackCapMinutes"/>. The resolved cap and its source are reported on the
/// <c>keep_awake_engaged</c> event for fleet-wide observability.
/// </para>
/// </summary>
internal sealed class UserEspKeepAwakeHost : ICollectorHost
{
public string Name => "UserEspKeepAwake";
internal const string Source = "UserEspKeepAwake";
/// <summary>
/// Fallback cap (minutes) when the ESP <c>SyncFailureTimeout</c> is unavailable. Equals the
/// Intune ESP default timeout (60) + <see cref="EspCapMarginMinutes"/>.
/// </summary>
internal const int DefaultFallbackCapMinutes = 90;
/// <summary>
/// Added to the ESP <c>SyncFailureTimeout</c> to cover the window after ESP errors out: the
/// "continue anyway" → desktop settling and the completion-signal propagation that follow.
/// </summary>
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<int?> _espTimeoutProvider;
private enum HoldState { Idle, Engaged, Released }
private readonly object _gate = new object();
private Action<DecisionSignalKind, IReadOnlyDictionary<string, string>?>? _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<int?>? 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);
}
/// <summary>
/// Pure cap-resolution policy (testable in isolation): derive the safety cap from the ESP
/// <c>SyncFailureTimeout</c> when present (timeout + <see cref="EspCapMarginMinutes"/>),
/// otherwise the fallback. A non-positive ESP value is treated as "unavailable".
/// </summary>
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).");
}
/// <summary>
/// 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.
/// </summary>
private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? 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); }
}
/// <summary>Engage the hold exactly once (Idle → Engaged). No-op from any other state.</summary>
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<string, string>(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;
}
}
/// <summary>
/// Release the hold exactly once (Idle/Engaged → Released). Idempotent — a second call (or
/// a competing completion / cap / stop trigger) is a no-op.
/// </summary>
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<string, string>(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<string, string> 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 { }
}
}
}
@@ -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,
@@ -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,
@@ -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',
@@ -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";
@@ -457,5 +457,14 @@ namespace AutopilotMonitor.Shared.Models
/// Default: false (opt-in; most tenants do not deploy via RealmJoin).
/// </summary>
public bool EnableRealmJoinWatcher { get; set; } = false;
/// <summary>
/// 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).
/// </summary>
public bool KeepAwakeDuringUserEsp { get; set; } = false;
}
}
@@ -334,6 +334,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool? EnableRealmJoinWatcher { get; set; } = null;
/// <summary>
/// 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).
/// </summary>
public bool? KeepAwakeDuringUserEsp { get; set; } = null;
/// <summary>
/// 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).
@@ -13,6 +13,7 @@ export function SectionAgentChangelog() {
{/* ── June 2026 ────────────────────────────────── */}
<ChangelogBlock title="June 2026">
<Li>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&apos;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</Li>
<Li>Fewer stuck enrollments — Classic sessions where a skipped user-ESP app or an advisory &quot;continue anyway&quot; ESP failure left completion waiting now resolve through a short completion deadline instead of running into the 6-hour timeout</Li>
<Li>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</Li>
<Li>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</Li>
@@ -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",
@@ -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
@@ -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"}
@@ -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> | 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({
</div>
</div>
{/* ── Divider ─────────────────────────────────────── */}
<div className="border-t border-gray-200" />
{/* ── Keep Awake During User-ESP ──────────────────── */}
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3">Keep Awake During User-ESP</h3>
<p className="text-sm text-gray-500 mb-4">
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.
</p>
{/* Enable toggle */}
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-rose-200 transition-colors">
<div>
<p className="font-medium text-gray-900">Enable Keep Awake During User-ESP</p>
<p className="text-sm text-gray-500">Off by default. Enable for devices that drop into standby during the user phase of enrollment</p>
</div>
<button
onClick={() => setKeepAwakeDuringUserEsp(!keepAwakeDuringUserEsp)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${keepAwakeDuringUserEsp ? 'bg-rose-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${keepAwakeDuringUserEsp ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
</div>
{/* Save / Reset */}
<SaveResetBar onSave={onSave} onReset={onReset} saving={saving} />
</div>
@@ -74,6 +74,7 @@ export interface TenantConfiguration {
enableSoftwareInventoryAnalyzer?: boolean;
enableIntegrityBypassAnalyzer?: boolean;
enableRealmJoinWatcher?: boolean;
keepAwakeDuringUserEsp?: boolean;
// Bootstrap token
bootstrapTokenEnabled?: boolean;
// Unrestricted mode