mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+265
@@ -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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -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'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 "continue anyway" 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
|
||||
|
||||
Reference in New Issue
Block a user