mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
fix(agent): audit Q1/Q2/Q5 source fixes - timing re-arm, esp_config re-emits, serial trim
Q1 (AppInstallTiming): a terminal->active transition (IME retry path, e.g. Error->Downloading or Skipped->Downloading) re-arms CompletedAtUtc so the final terminal payload covers first-start -> last-terminal instead of freezing at the first terminal state. Plus a negative-duration guard in AppInstallTiming.DurationSeconds (inverted endpoints -> null, field omitted) - observed in production as durationSeconds=-2342 on a Skipped-then-retried user-scope app (session 66f1c5e1). Q2 (esp_config_detected): the ESP blocking lists are read too early - device lists grow progressively, user-scope S-<SID> lists appear only after sign-in (88 of 89 prod sessions had an empty user list at last emission). DeviceInfoHost now re-collects the ESP configuration as phase-driven one-shots: apps sub-phase declaration (AppsDevice/AppsUser phase_transition = first app activity) and AccountSetup detection. The StartupEventGate keeps unchanged payloads silent (fingerprint covers list contents), so only grown lists actually re-emit. F1 EspBlocking resolution (latest list-bearing emission) benefits without backend change. Q5 (HardwareInfo): WMI values (serial, manufacturer, model) trimmed at the source via NormalizeWmiValue; whitespace-only collapses to the "Unknown" sentinel the retry logic already treats as unresolved. Attach-window investigation (11a3b67a): NO agent fix - hypothesis disproved against two real agent logs (diag bundles db5ce007/2.0.1337, 4a9e9273/2.0.1379). "completed without started" first apps were already installed (WinGet detection-only, zero install lines in AppWorkload.log); pre-attach replay provably works (pattern-match log). PR0's HasMeasuredDuration already handles the class server-side. Duplicate app_install_completed (66f1c5e1): mechanism narrowed to an in-process re-read of exactly one AppWorkload rotation window (<24h-old lines, historic suppression not applicable) - documented as follow-up, no fix without a bundle-bearing repro per lessons.md. Verified: solution build clean, Agent V2 Core 2058/2058 (14 new tests), DecisionCore 493/493. No ConfigVersion bump, no new event types, decision rail untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+108
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes;
|
||||
using DeviceInfoCollector = AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.DeviceInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Audit Q2 — <see cref="DeviceInfoCollector.RefreshEspConfiguration"/>
|
||||
/// re-reads the ESP blocking lists and re-emits <c>esp_config_detected</c> exactly when the
|
||||
/// payload changed: the registry lists grow progressively (one timestamped subkey per CSP
|
||||
/// status write) and the user-scope lists appear only after sign-in, so the early emissions
|
||||
/// are structurally partial. The StartupEventGate keeps an unchanged re-read silent.
|
||||
/// Probe overrides are static slots → serialized with the other probe-driven suites.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class DeviceInfoCollectorEspRefreshTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 7, 27, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private const string DeviceApp1 = "11111111-1111-1111-1111-111111111111";
|
||||
private const string DeviceApp2 = "22222222-2222-2222-2222-222222222222";
|
||||
private const string UserApp1 = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
private static EspTrackingInfoSnapshot Snapshot(string[] deviceApps, string[] userApps)
|
||||
{
|
||||
var all = deviceApps.Concat(userApps).Distinct().ToArray();
|
||||
return new EspTrackingInfoSnapshot(
|
||||
msiProductCodes: Array.Empty<string>(),
|
||||
modernAppPfns: Array.Empty<string>(),
|
||||
win32AppIds: all,
|
||||
userWin32AppIds: userApps,
|
||||
msiCount: 0,
|
||||
modernCount: 0,
|
||||
win32Count: all.Length);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> EspConfigEvents(FakeSignalIngressSink ingress) =>
|
||||
ingress.Posted
|
||||
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == SharedEventTypes.EspConfigDetected)
|
||||
.ToList();
|
||||
|
||||
private static IReadOnlyList<string> TypedList(FakeSignalIngressSink.PostedSignal signal, string key)
|
||||
{
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(signal.TypedPayload);
|
||||
return Assert.IsAssignableFrom<IReadOnlyList<string>>(data[key]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Refresh_reemits_when_lists_grew_and_stays_silent_when_unchanged()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var ingress = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(T0);
|
||||
var collector = new DeviceInfoCollector(
|
||||
"session-1", "tenant-1",
|
||||
new InformationalEventPost(ingress, clock), logger,
|
||||
signalIngress: null, clock: clock,
|
||||
startupGate: new StartupEventGate(tmp.Path, logger));
|
||||
|
||||
using var skip = new EspSkipConfigurationProbe.ScopedFullOverride(
|
||||
_ => new EspFirstSyncSnapshot(
|
||||
skipUser: false, skipDevice: false,
|
||||
blockInStatusPage: null, syncFailureTimeoutMinutes: null));
|
||||
|
||||
// Early read (agent start / DeviceSetup detection): partial device list, empty user list.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1 }, Array.Empty<string>())))
|
||||
{
|
||||
collector.CollectEspConfiguration();
|
||||
}
|
||||
var afterEarly = EspConfigEvents(ingress);
|
||||
Assert.Single(afterEarly);
|
||||
Assert.Empty(TypedList(afterEarly[0], "espTrackedUserWin32AppIds"));
|
||||
|
||||
// Apps sub-phase opened: the device list grew and sign-in populated the user list.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAppsUser);
|
||||
}
|
||||
var afterGrowth = EspConfigEvents(ingress);
|
||||
Assert.Equal(2, afterGrowth.Count);
|
||||
Assert.Contains(DeviceApp2, TypedList(afterGrowth[1], "espTrackedWin32AppIds"));
|
||||
Assert.Equal(new[] { UserApp1 }, TypedList(afterGrowth[1], "espTrackedUserWin32AppIds"));
|
||||
|
||||
// A later trigger with an UNCHANGED registry state re-reads but emits nothing —
|
||||
// the StartupEventGate suppresses the identical payload.
|
||||
using (new EspTrackingInfoProbe.ScopedOverride(_ => Snapshot(new[] { DeviceApp1, DeviceApp2 }, new[] { UserApp1 })))
|
||||
{
|
||||
collector.RefreshEspConfiguration(DeviceInfoHost.EspConfigTriggerAccountSetup);
|
||||
}
|
||||
Assert.Equal(2, EspConfigEvents(ingress).Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -37,5 +37,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.EspPhaseChanged, null));
|
||||
Assert.False(DeviceInfoHost.IsEndTrigger(DecisionSignalKind.InformationalEvent, Phase("FinalizingSetup")));
|
||||
}
|
||||
|
||||
// Audit Q2 — the payload shape the adapter's apps sub-phase declaration produces on the
|
||||
// ingress rail (InformationalEventPost: eventType + declared phase enum name).
|
||||
private static IReadOnlyDictionary<string, string> SubPhaseDeclaration(string declaredPhase) =>
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition },
|
||||
{ AutopilotMonitor.Agent.V2.Core.Telemetry.Events.EventTimelineEmitter.PhaseParamKey, declaredPhase },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Apps_subphase_declarations_classify_as_esp_config_refresh_triggers()
|
||||
{
|
||||
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsDevice,
|
||||
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsDevice")));
|
||||
Assert.Equal(DeviceInfoHost.EspConfigTriggerAppsUser,
|
||||
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("AppsUser")));
|
||||
// Non-apps phase declarations (e.g. the reducer's Finalizing entry) never classify.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent, SubPhaseDeclaration("FinalizingSetup")));
|
||||
// A phase_transition without a declared phase never classifies.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
|
||||
new Dictionary<string, string> { { SignalPayloadKeys.EventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition } }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AccountSetup_phase_classifies_as_the_user_setup_refresh_trigger()
|
||||
{
|
||||
Assert.Equal(DeviceInfoHost.EspConfigTriggerAccountSetup,
|
||||
DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("AccountSetup")));
|
||||
// DeviceSetup is the enrollment-start trigger, not an ESP-config refresh.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, Phase("DeviceSetup")));
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.EspPhaseChanged, null));
|
||||
// Unrelated informational events never classify.
|
||||
Assert.Null(DeviceInfoHost.ClassifyEspConfigRefreshTrigger(DecisionSignalKind.InformationalEvent,
|
||||
new Dictionary<string, string> { { SignalPayloadKeys.EventType, "esp_config_detected" } }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Audit Q5 — WMI hardware values are trimmed at the source. BIOS serials become device
|
||||
/// identity keys downstream (F2 device history joins on TenantId + serial), so padding
|
||||
/// must never leave the agent; whitespace-only values collapse to the "Unknown" sentinel
|
||||
/// the retry logic already treats as unresolved.
|
||||
/// </summary>
|
||||
public sealed class HardwareInfoNormalizeTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(" PF60H941 ", "PF60H941")]
|
||||
[InlineData("PF60H941", "PF60H941")]
|
||||
[InlineData("\tLENOVO \r\n", "LENOVO")]
|
||||
[InlineData("Unknown", "Unknown")]
|
||||
public void Trims_whitespace_at_the_source(string raw, string expected)
|
||||
=> Assert.Equal(expected, HardwareInfo.NormalizeWmiValue(raw));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("\t\r\n")]
|
||||
public void Empty_and_whitespace_only_collapse_to_the_Unknown_sentinel(string raw)
|
||||
=> Assert.Equal("Unknown", HardwareInfo.NormalizeWmiValue(raw));
|
||||
|
||||
[Fact]
|
||||
public void Null_collapses_to_the_Unknown_sentinel()
|
||||
=> Assert.Equal("Unknown", HardwareInfo.NormalizeWmiValue(null));
|
||||
}
|
||||
}
|
||||
+82
@@ -243,5 +243,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
|
||||
|
||||
Assert.Equal(firstStamp, adapter.AppTimings["app-P"].StartedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Terminal_to_active_retry_rearms_CompletedAt_and_final_terminal_covers_total_occupancy()
|
||||
{
|
||||
// Audit Q1: the IME retry path (Error -> Downloading -> Installed is permitted by
|
||||
// AppPackageState.UpdateState). Pre-fix, CompletedAt froze at the first Error, so
|
||||
// the final Installed payload carried the first failure's completedAt/duration.
|
||||
using var f = new ImeLogTrackerAdapterFixture(T0);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-R", 0);
|
||||
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Installing);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(5));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Error);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(10));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Error, AppInstallationState.Downloading);
|
||||
|
||||
// Re-armed while active again: the retry's download event carries no completedAt,
|
||||
// and the FIRST start stamp is kept.
|
||||
Assert.Null(adapter.AppTimings["app-R"].CompletedAtUtc);
|
||||
var retryDownload = f.InfoEvent(SharedEventTypes.AppDownloadStarted);
|
||||
Assert.False(retryDownload.Payload!.ContainsKey("completedAt"));
|
||||
Assert.False(retryDownload.Payload.ContainsKey("durationSeconds"));
|
||||
Assert.Equal(T0.ToString("o"), retryDownload.Payload["startedAt"]);
|
||||
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(20));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installed);
|
||||
|
||||
var timing = adapter.AppTimings["app-R"];
|
||||
Assert.Equal(T0, timing.StartedAtUtc);
|
||||
Assert.Equal(T0.AddSeconds(35), timing.CompletedAtUtc);
|
||||
Assert.Equal(35.0, timing.DurationSeconds);
|
||||
|
||||
var completed = Assert.Single(f.InfoEvents(SharedEventTypes.AppInstallComplete));
|
||||
Assert.Equal("35.00", completed.Payload!["durationSeconds"]);
|
||||
Assert.Equal(T0.AddSeconds(35).ToString("o"), completed.Payload["completedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Skipped_then_retried_app_emits_positive_duration_from_retry_start()
|
||||
{
|
||||
// Production regression (session 66f1c5e1, audit Q1): a user-scope app evaluated
|
||||
// Skipped during DeviceSetup (terminal, no start observed), retried after sign-in
|
||||
// (Skipped -> Downloading is permitted), Installed later. Pre-fix payload:
|
||||
// completedAt frozen at the early Skip + startedAt from the retry ->
|
||||
// durationSeconds=-2342. Post-fix: retry re-arms, duration covers retry-start ->
|
||||
// final terminal.
|
||||
using var f = new ImeLogTrackerAdapterFixture(T0);
|
||||
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
|
||||
var app = new AppPackageState("app-S", 0);
|
||||
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.Skipped);
|
||||
Assert.Null(adapter.AppTimings["app-S"].StartedAtUtc);
|
||||
Assert.Equal(T0, adapter.AppTimings["app-S"].CompletedAtUtc);
|
||||
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(60));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Skipped, AppInstallationState.Downloading);
|
||||
f.Clock.Advance(TimeSpan.FromSeconds(30));
|
||||
adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installed);
|
||||
|
||||
var timing = adapter.AppTimings["app-S"];
|
||||
Assert.Equal(T0.AddSeconds(60), timing.StartedAtUtc);
|
||||
Assert.Equal(T0.AddSeconds(90), timing.CompletedAtUtc);
|
||||
Assert.Equal(30.0, timing.DurationSeconds);
|
||||
|
||||
var completedEvents = f.InfoEvents(SharedEventTypes.AppInstallComplete);
|
||||
Assert.Equal(2, completedEvents.Count);
|
||||
Assert.Equal("30.00", completedEvents[1].Payload!["durationSeconds"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inverted_endpoints_yield_null_DurationSeconds()
|
||||
{
|
||||
// Negative guard (audit Q1): endpoints stamped from out-of-order source lines must
|
||||
// never surface as a negative durationSeconds (observed -2342s in production). The
|
||||
// payload emitter omits the field when DurationSeconds is null.
|
||||
var inverted = new AppInstallTiming(startedAtUtc: T0.AddSeconds(100), completedAtUtc: T0);
|
||||
Assert.Null(inverted.DurationSeconds);
|
||||
|
||||
var wellOrdered = new AppInstallTiming(startedAtUtc: T0, completedAtUtc: T0.AddSeconds(100));
|
||||
Assert.Equal(100.0, wellOrdered.DurationSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -10,9 +10,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// the <c>app_tracking_summary</c> terminal event.
|
||||
/// <para>
|
||||
/// <b>StartedAtUtc</b> is recorded on the first lifecycle transition into Downloading /
|
||||
/// Installing / InProgress. <b>CompletedAtUtc</b> is recorded on the first transition to a
|
||||
/// terminal state (Installed / Skipped / Postponed / Error). Both are "set-once" — subsequent
|
||||
/// identical-state events do not overwrite an earlier stamp.
|
||||
/// Installing / InProgress and never overwritten. <b>CompletedAtUtc</b> is recorded on a
|
||||
/// transition to a terminal state (Installed / Skipped / Postponed / Error) and re-armed
|
||||
/// to <c>null</c> when the app goes terminal → active again (IME retry / re-evaluation,
|
||||
/// audit Q1) — so the final terminal event carries first-start → last-terminal.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AppInstallTiming
|
||||
@@ -30,10 +31,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>
|
||||
/// Elapsed time from first Downloading/Installing transition to terminal state, in
|
||||
/// seconds. <c>null</c> when either endpoint is missing (e.g. app still installing,
|
||||
/// or the adapter missed the start event because it came online mid-install).
|
||||
/// or the adapter missed the start event because it came online mid-install) — and
|
||||
/// <c>null</c> when the endpoints are inverted (CompletedAt before StartedAt: log lines
|
||||
/// from different source files can be processed out of chronological order; observed in
|
||||
/// production as durationSeconds=-2342 on a Skipped-then-retried user-scope app,
|
||||
/// audit Q1). A negative duration is never a real measurement, so it is not emitted.
|
||||
/// </summary>
|
||||
public double? DurationSeconds =>
|
||||
StartedAtUtc.HasValue && CompletedAtUtc.HasValue
|
||||
StartedAtUtc.HasValue && CompletedAtUtc.HasValue && CompletedAtUtc.Value >= StartedAtUtc.Value
|
||||
? (CompletedAtUtc.Value - StartedAtUtc.Value).TotalSeconds
|
||||
: (double?)null;
|
||||
}
|
||||
|
||||
+17
@@ -114,6 +114,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
CollectTpmStatus();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads the ESP configuration (skip flags + ESPTrackingInfo blocking lists) and
|
||||
/// re-emits <c>esp_config_detected</c> when the payload changed. Audit Q2: the earlier
|
||||
/// emissions (agent start, DeviceSetup detection) fire before the registry lists are
|
||||
/// complete — the ESP writes one timestamped subkey per CSP status write, so the
|
||||
/// device-scope list grows progressively and the user-scope <c>S-<SID></c> lists
|
||||
/// appear only after sign-in. Called by <see cref="Orchestration.DeviceInfoHost"/> as
|
||||
/// phase-driven one-shots (apps sub-phase opened, AccountSetup detected) — never on a
|
||||
/// timer. The StartupEventGate keeps an unchanged payload silent, so a re-collect that
|
||||
/// found nothing new emits nothing.
|
||||
/// </summary>
|
||||
public void RefreshEspConfiguration(string trigger)
|
||||
{
|
||||
_logger.Info($"EnrollmentTracker: re-collecting ESP configuration (trigger={trigger})");
|
||||
CollectEspConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-collects device info that may change during enrollment (e.g. BitLocker enabled via policy).
|
||||
/// Called at enrollment complete / FinalizingSetup transition to capture final state.
|
||||
|
||||
@@ -36,6 +36,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <item><c>CollectAtEnd</c> — once, on <c>FinalizingSetup</c> or desktop arrival
|
||||
/// (whichever comes first; desktop arrival also covers no-ESP / WDP v2). Re-fetches
|
||||
/// BitLocker (commonly enabled via policy DURING enrollment) + the active NIC.</item>
|
||||
/// <item><c>RefreshEspConfiguration</c> — audit Q2, one-shot per trigger: the ESP
|
||||
/// blocking lists in the registry grow progressively (device scope) and the user-scope
|
||||
/// lists appear only after sign-in, so the early emissions are structurally partial.
|
||||
/// Re-collected when the apps sub-phase opens (first app activity in DeviceSetup /
|
||||
/// AccountSetup — the adapter's <c>phase_transition</c> declaration) and when the
|
||||
/// AccountSetup ESP phase itself is detected. Phase-driven one-shots, never a timer
|
||||
/// (no-heartbeat policy).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Duplicate-event cost is zero: every re-collected event runs through the collector's
|
||||
@@ -46,6 +53,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
public string Name => "DeviceInfoCollector";
|
||||
|
||||
// ESP-config refresh trigger labels (audit Q2) — also the values passed to
|
||||
// DeviceInfoCollector.RefreshEspConfiguration for the agent-log line.
|
||||
internal const string EspConfigTriggerAppsDevice = "apps_phase_device";
|
||||
internal const string EspConfigTriggerAppsUser = "apps_phase_user";
|
||||
internal const string EspConfigTriggerAccountSetup = "account_setup_detected";
|
||||
|
||||
private readonly Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector _collector;
|
||||
private readonly AgentLogger _logger;
|
||||
|
||||
@@ -58,6 +71,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
private int _endCollected;
|
||||
private int _disposed;
|
||||
|
||||
// Audit Q2 — fire-once latches per ESP-config refresh trigger. Guarded by _espConfigLock
|
||||
// instead of three Interlocked ints so the membership test + add stays one atomic step.
|
||||
private readonly object _espConfigLock = new object();
|
||||
private readonly HashSet<string> _espConfigRefreshesFired = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
public DeviceInfoHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
@@ -100,6 +118,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
private void OnSignalPosted(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
HandleEspConfigRefreshTrigger(kind, payload);
|
||||
|
||||
var startTrigger = IsEnrollmentStartTrigger(kind, payload);
|
||||
var endTrigger = IsEndTrigger(kind, payload);
|
||||
if (!startTrigger && !endTrigger) return;
|
||||
@@ -111,10 +131,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
var runEndCollect = endTrigger && Interlocked.Exchange(ref _endCollected, 1) == 0;
|
||||
if (!runStartRefresh && !runEndCollect) return;
|
||||
|
||||
// Both one-shots done → nothing left to observe.
|
||||
if (Volatile.Read(ref _enrollmentStartCollected) == 1 && Volatile.Read(ref _endCollected) == 1)
|
||||
Unsubscribe();
|
||||
|
||||
var trigger = kind == DecisionSignalKind.DesktopArrived ? "desktop_arrived" : "esp_phase_changed";
|
||||
_logger.Info($"DeviceInfoHost: trigger '{trigger}' — scheduling re-collect (enrollmentStart={runStartRefresh}, end={runEndCollect}).");
|
||||
|
||||
@@ -134,6 +150,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Q2 — ESP-config refresh one-shots. Each trigger label fires at most once per
|
||||
/// agent run; the collector's StartupEventGate keeps an unchanged payload silent, so
|
||||
/// the worst case is one silent registry re-read per trigger. The subscription stays
|
||||
/// armed for the host's lifetime (unlike the start/end pair there is no "all triggers
|
||||
/// seen" point that is guaranteed to arrive — SkipUser enrollments never enter
|
||||
/// AccountSetup); the per-signal cost is a few dictionary lookups.
|
||||
/// </summary>
|
||||
private void HandleEspConfigRefreshTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
var trigger = ClassifyEspConfigRefreshTrigger(kind, payload);
|
||||
if (trigger == null) return;
|
||||
|
||||
lock (_espConfigLock)
|
||||
{
|
||||
if (!_espConfigRefreshesFired.Add(trigger)) return;
|
||||
}
|
||||
|
||||
_logger.Info($"DeviceInfoHost: ESP-config refresh trigger '{trigger}' — scheduling re-collect.");
|
||||
Task.Run(() =>
|
||||
{
|
||||
try { _collector.RefreshEspConfiguration(trigger); }
|
||||
catch (Exception ex) { _logger.Warning($"DeviceInfoHost: ESP-config refresh threw: {ex.Message}"); }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>First DeviceSetup phase signal — MDM enrollment has populated the registry surface.</summary>
|
||||
internal static bool IsEnrollmentStartTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
@@ -156,6 +198,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
&& string.Equals(phase, nameof(EnrollmentPhase.FinalizingSetup), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audit Q2 — maps a posted signal to an ESP-config refresh trigger label, or
|
||||
/// <c>null</c> when the signal is not one. Two trigger families:
|
||||
/// <list type="bullet">
|
||||
/// <item>The adapter's apps sub-phase declaration (<c>phase_transition</c>
|
||||
/// InformationalEvent with phase AppsDevice/AppsUser — fired on the first app
|
||||
/// activity in an ESP phase): by then IME is actively processing the blocking set,
|
||||
/// so the device-scope ESPTrackingInfo lists are as complete as they get.</item>
|
||||
/// <item>The AccountSetup ESP phase (UserSetup detection): sign-in has happened, so
|
||||
/// the user-scope <c>S-<SID></c> lists exist now — production showed 88 of 89
|
||||
/// sessions with an EMPTY user list at the last pre-fix emission.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal static string? ClassifyEspConfigRefreshTrigger(DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload)
|
||||
{
|
||||
if (payload == null) return null;
|
||||
|
||||
if (kind == DecisionSignalKind.InformationalEvent
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EventType, out var eventType)
|
||||
&& string.Equals(eventType, AutopilotMonitor.Shared.Constants.EventTypes.PhaseTransition, StringComparison.Ordinal)
|
||||
&& payload.TryGetValue(Telemetry.Events.EventTimelineEmitter.PhaseParamKey, out var declaredPhase))
|
||||
{
|
||||
if (string.Equals(declaredPhase, nameof(EnrollmentPhase.AppsDevice), StringComparison.Ordinal))
|
||||
return EspConfigTriggerAppsDevice;
|
||||
if (string.Equals(declaredPhase, nameof(EnrollmentPhase.AppsUser), StringComparison.Ordinal))
|
||||
return EspConfigTriggerAppsUser;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kind == DecisionSignalKind.EspPhaseChanged
|
||||
&& payload.TryGetValue(SignalPayloadKeys.EspPhase, out var espPhase)
|
||||
&& string.Equals(espPhase, nameof(EnrollmentPhase.AccountSetup), StringComparison.Ordinal))
|
||||
{
|
||||
return EspConfigTriggerAccountSetup;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Stop() => Unsubscribe();
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -93,7 +93,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
using (obj)
|
||||
{
|
||||
manufacturer = obj["Manufacturer"]?.ToString() ?? Unknown;
|
||||
manufacturer = NormalizeWmiValue(obj["Manufacturer"]?.ToString());
|
||||
if (manufacturer.IndexOf("lenovo", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
using (var lenovoSearcher = new ManagementObjectSearcher("SELECT Version FROM Win32_ComputerSystemProduct"))
|
||||
@@ -103,7 +103,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
using (lenovoObj)
|
||||
{
|
||||
model = lenovoObj["Version"]?.ToString() ?? Unknown;
|
||||
model = NormalizeWmiValue(lenovoObj["Version"]?.ToString());
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -111,7 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
else
|
||||
{
|
||||
model = obj["Model"]?.ToString() ?? Unknown;
|
||||
model = NormalizeWmiValue(obj["Model"]?.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
using (obj)
|
||||
{
|
||||
serialNumber = obj["SerialNumber"]?.ToString() ?? Unknown;
|
||||
serialNumber = NormalizeWmiValue(obj["SerialNumber"]?.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,5 +154,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private static bool IsResolved(string value)
|
||||
=> !string.IsNullOrWhiteSpace(value)
|
||||
&& !string.Equals(value, Unknown, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Trims a raw WMI string value at the source (audit Q5: BIOS serials are used as device
|
||||
/// identity keys downstream — F2 device history joins on them — so padding must never
|
||||
/// leave the agent). Whitespace-only and empty values collapse to the "Unknown" sentinel,
|
||||
/// matching the null fallback so the retry logic treats them as unresolved.
|
||||
/// </summary>
|
||||
internal static string NormalizeWmiValue(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return Unknown;
|
||||
return raw.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
|
||||
// Plan §5 Fix 4c — per-app install-lifecycle timing captured from state transitions.
|
||||
// StartedAtUtc on first Downloading/Installing/InProgress transition (set-once);
|
||||
// CompletedAtUtc on first terminal transition (Installed/Skipped/Postponed/Error).
|
||||
// CompletedAtUtc on terminal transition (Installed/Skipped/Postponed/Error), re-armed
|
||||
// to null on a terminal→active transition (IME retry, audit Q1) so the final terminal
|
||||
// stamp reflects the last outcome instead of freezing at the first one.
|
||||
// Surfaced via <see cref="AppTimings"/> to peripheral consumers (FinalStatusBuilder,
|
||||
// app_tracking_summary emission in EnrollmentTerminationHandler).
|
||||
private readonly Dictionary<string, (DateTime? Started, DateTime? Completed)> _appTimings =
|
||||
@@ -691,10 +693,20 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan §5 Fix 4c — set-once per-app install-lifecycle timing. Called for every
|
||||
/// state transition, no-op for duplicate stamps. Returns the up-to-date
|
||||
/// Plan §5 Fix 4c — per-app install-lifecycle timing. Called for every state
|
||||
/// transition, no-op for duplicate stamps. Returns the up-to-date
|
||||
/// <see cref="AppInstallTiming"/> so the emitter can inline the values on the
|
||||
/// current event's payload without a second dictionary lookup.
|
||||
/// <para>
|
||||
/// Audit Q1: a terminal→active transition (the IME retry path — <see
|
||||
/// cref="AppPackageState.UpdateState"/> permits e.g. Error→Downloading and
|
||||
/// Skipped→Downloading) re-arms <c>Completed</c> to <c>null</c>, so the NEXT terminal
|
||||
/// transition stamps a fresh CompletedAt. StartedAt keeps its first value — the final
|
||||
/// terminal payload then covers first-start → last-terminal (total occupancy across
|
||||
/// attempts) instead of freezing at the first failure. Observed pre-fix: a user-scope
|
||||
/// app Skipped early in DeviceSetup, retried after sign-in, emitted its final
|
||||
/// Installed event with the stale early CompletedAt (durationSeconds=-2342).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private AppInstallTiming UpdateAppTiming(string appId, AppInstallationState newState, DateTime now)
|
||||
{
|
||||
@@ -702,13 +714,20 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
|
||||
{
|
||||
_appTimings.TryGetValue(appId, out var current);
|
||||
|
||||
// Terminal→active (retry): the earlier terminal stamp no longer describes this
|
||||
// app's outcome — re-arm so the next terminal transition stamps fresh.
|
||||
if (current.Completed != null && IsLifecycleActive(newState))
|
||||
{
|
||||
current.Completed = null;
|
||||
}
|
||||
|
||||
// First lifecycle-active transition → record StartedAt.
|
||||
if (current.Started == null && IsLifecycleActive(newState))
|
||||
{
|
||||
current.Started = now;
|
||||
}
|
||||
|
||||
// First terminal transition → record CompletedAt.
|
||||
// Terminal transition → record CompletedAt (fresh after a re-arm).
|
||||
if (current.Completed == null && IsCompletedState(newState))
|
||||
{
|
||||
current.Completed = now;
|
||||
|
||||
Reference in New Issue
Block a user