feat(agent-v2): M4.6.γ diagnostic probes, reporters, DO collector wired

Closes the third batch of peripheral-parity gaps — Legacy's fire-and-forget
startup probes, the two cert-less + authenticated error-reporting channels,
and Delivery-Optimization telemetry all run in V2.

StartupEnvironmentProbes (V2.Core/Runtime/)
- New helper that replicates the Legacy MonitoringService startup diagnostic
  trio: device_location / timezone_auto_set / ntp_time_check.
- Sequential RunAsync(config, logger, emitter): geo lookup → (optional)
  tzutil auto-set → NTP offset check. Each step is best-effort and feeds
  through the orchestrator's TelemetryEventEmitter.
- Geo probe respects EnableGeoLocation; timezone probe respects
  EnableTimezoneAutoSet AND requires an IANA tz from geo. NTP probe always
  runs (diagnostic-only; Warning on offset >60s or failure).
- Program.cs wires this via Task.Run right after orchestrator.Start so a
  slow network never delays the critical path.

EmergencyReporter + DistressReporter (live in Program.cs)
- Previously instantiated nowhere — now built once at startup with the
  shared hardware info. RemoteConfigService ctor takes both so config-fetch
  failures flow to the right channel (auth → distress, network → emergency).
- Initial AuthCertificateMissing distress fires when UseClientCertAuth=true
  but BackendApiClient.ClientCertificate is null — surfaces pre-MDM-
  enrollment dead-ends via the cert-less channel (Legacy parity).
- BackendTelemetryUploader uses the same hardware info (dedup'd the second
  HardwareInfo.GetHardwareInfo call that was duplicated since M4.5.b).

DeliveryOptimizationHost (DefaultComponentFactory nested)
- New ICollectorHost wrapping DeliveryOptimizationCollector with:
  • getPackageStates → ImeLogHost.PackageStates (existing accessor)
  • onDoTelemetryReceived → ImeLogHost.Tracker.OnDoTelemetryReceived pass-
    through (new internal accessor; keeps encapsulation)
  • OnAppStateChanged chained into ImeLogTracker so WakeUp() fires when
    any app enters Downloading or Installing — before any fire we preserve
    the existing handler (the adapter's signal-ingest path must keep
    running unchanged).
- Gated by CollectorConfiguration.EnableDeliveryOptimizationCollector.

Tests (+8 in V2.Core.Tests → 353 → 361)
- StartupEnvironmentProbesTests: event-shape for all three probes + geo
  success/failure/null attempt, timezone success/failure severity, NTP
  small/large offset + failure-with-detail.

All 4 suites green: 88 DecisionCore + 361 V2.Core + 507 Agent.Core + 557 Functions
= 1513 tests green.

Note: the "RegistryWatcher standalone admin-action-detector" from the
original M4.6 gap list was a Subagent fabrication — verified no such usage
exists in either Legacy or V2 (the class is only consumed transitively
inside AadJoinWatcher + ProvisioningStatusTracker, both already wired via
DefaultComponentFactory since M4.5.b). Removed from the M4.6.γ scope.

Remaining: M4.6.δ (--run-gather-rules + --run-ime-matching modes,
AnalyzerManager + 3 Analyzers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-21 00:50:15 +02:00
parent 17063be600
commit 7e199019ce
6 changed files with 562 additions and 8 deletions
@@ -0,0 +1,182 @@
#nullable enable
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Runtime;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
{
/// <summary>
/// M4.6.γ — event-shape tests for the Legacy-parity startup probes. We cover the pure event
/// builders directly; the end-to-end probe runner itself needs live HTTP/UDP/tzutil (no value
/// in unit testing — excercised via the V2-agent test-VM smoke run deferred to Follow-Up).
/// </summary>
public sealed class StartupEnvironmentProbesTests
{
private static AgentConfiguration Config(bool geoEnabled = true, bool tzAutoSet = false, string? ntpServer = null) =>
new AgentConfiguration
{
SessionId = "S1",
TenantId = "T1",
EnableGeoLocation = geoEnabled,
EnableTimezoneAutoSet = tzAutoSet,
NtpServer = ntpServer,
};
// ================================================================= Geo success
[Fact]
public void BuildGeoEvent_returns_info_device_location_event_with_all_fields()
{
var cfg = Config();
var loc = new GeoLocationResult
{
Country = "DE",
Region = "BW",
City = "Stuttgart",
Loc = "48.7,9.1",
Timezone = "Europe/Berlin",
Source = "ipinfo.io",
};
var evt = StartupEnvironmentProbes.BuildGeoEvent(cfg, loc);
Assert.Equal("device_location", evt.EventType);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Equal("Network", evt.Source);
Assert.Equal("S1", evt.SessionId);
Assert.Equal("T1", evt.TenantId);
Assert.True(evt.ImmediateUpload);
Assert.Contains("Stuttgart", evt.Message);
Assert.Equal("DE", evt.Data["country"]);
Assert.Equal("ipinfo.io", evt.Data["source"]);
}
// ================================================================= Geo failure
[Fact]
public void BuildGeoFailureEvent_returns_warning_agent_trace_with_provider_errors()
{
var cfg = Config();
var attempt = new GeoLocationAttemptResult
{
PrimaryError = "connection refused",
PrimaryRetryError = "DNS failure",
FallbackError = "timeout",
};
var evt = StartupEnvironmentProbes.BuildGeoFailureEvent(cfg, attempt);
Assert.Equal("agent_trace", evt.EventType);
Assert.Equal(EventSeverity.Warning, evt.Severity);
Assert.Equal("geo_location_failed", evt.Data["decision"]);
Assert.Equal("connection refused", evt.Data["primaryError"]);
Assert.Equal("DNS failure", evt.Data["primaryRetryError"]);
Assert.Equal("timeout", evt.Data["fallbackError"]);
}
[Fact]
public void BuildGeoFailureEvent_handles_null_attempt_gracefully()
{
var evt = StartupEnvironmentProbes.BuildGeoFailureEvent(Config(), attempt: null);
Assert.Equal("agent_trace", evt.EventType);
Assert.Equal("unknown", evt.Data["primaryError"]);
Assert.Equal("unknown", evt.Data["primaryRetryError"]);
Assert.Equal("unknown", evt.Data["fallbackError"]);
}
// ================================================================= Timezone
[Fact]
public void BuildTimezoneEvent_info_on_success()
{
var tz = new TimezoneSetResult
{
Success = true,
IanaTimezone = "Europe/Berlin",
WindowsTimezoneId = "W. Europe Standard Time",
PreviousTimezone = "UTC",
};
var evt = StartupEnvironmentProbes.BuildTimezoneEvent(Config(), tz);
Assert.Equal("timezone_auto_set", evt.EventType);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Contains("W. Europe Standard Time", evt.Message);
Assert.Equal(true, evt.Data["success"]);
}
[Fact]
public void BuildTimezoneEvent_warning_on_failure()
{
var tz = new TimezoneSetResult
{
Success = false,
IanaTimezone = "Europe/Berlin",
Error = "tzutil failed",
};
var evt = StartupEnvironmentProbes.BuildTimezoneEvent(Config(), tz);
Assert.Equal(EventSeverity.Warning, evt.Severity);
Assert.Contains("tzutil failed", evt.Message);
Assert.Equal("tzutil failed", evt.Data["error"]);
}
// ================================================================= NTP
[Fact]
public void BuildNtpEvent_info_for_small_offset()
{
var r = new NtpCheckResult
{
Success = true,
OffsetSeconds = 2.3,
NtpTime = new System.DateTime(2026, 4, 21, 10, 0, 0, System.DateTimeKind.Utc),
LocalTime = new System.DateTime(2026, 4, 21, 10, 0, 2, System.DateTimeKind.Utc),
};
var evt = StartupEnvironmentProbes.BuildNtpEvent(Config(), "time.windows.com", r);
Assert.Equal("ntp_time_check", evt.EventType);
Assert.Equal(EventSeverity.Info, evt.Severity);
Assert.Equal("time.windows.com", evt.Data["ntpServer"]);
Assert.Equal(2.3, evt.Data["offsetSeconds"]);
}
[Fact]
public void BuildNtpEvent_warning_for_large_offset()
{
var r = new NtpCheckResult
{
Success = true,
OffsetSeconds = 120.0,
NtpTime = System.DateTime.UtcNow,
LocalTime = System.DateTime.UtcNow,
};
var evt = StartupEnvironmentProbes.BuildNtpEvent(Config(), "time.windows.com", r);
Assert.Equal(EventSeverity.Warning, evt.Severity);
}
[Fact]
public void BuildNtpEvent_warning_on_failure_with_error_detail()
{
var r = new NtpCheckResult
{
Success = false,
Error = "DNS resolution failed",
};
var evt = StartupEnvironmentProbes.BuildNtpEvent(Config(), "time.windows.com", r);
Assert.Equal(EventSeverity.Warning, evt.Severity);
Assert.Contains("DNS resolution failed", evt.Message);
Assert.Equal("DNS resolution failed", evt.Data["error"]);
}
}
}
@@ -166,6 +166,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_networkMetrics, _agentVersion, collectors.AgentSelfMetricsIntervalSeconds));
}
// M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: only polls when the
// IME log tracker reports an app entering Downloading/Installing (see AppStateChanged
// chain below). Needs the IME tracker's PackageStates + OnDoTelemetryReceived hook.
if (collectors.EnableDeliveryOptimizationCollector && _imeLogHost != null)
{
var doHost = new DeliveryOptimizationHost(
sessionId: sessionId,
tenantId: tenantId,
onEnrollmentEvent: onEnrollmentEvent,
logger: logger,
intervalSeconds: collectors.DeliveryOptimizationIntervalSeconds,
imeHost: _imeLogHost);
hosts.Add(doHost);
}
return hosts;
}
@@ -287,10 +302,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// <summary>
/// Exposes the tracker's live package-state list for peripheral consumers
/// (M4.6.β <c>FinalStatusBuilder</c>).
/// (M4.6.β <c>FinalStatusBuilder</c>, M4.6.γ <c>DeliveryOptimizationHost</c>).
/// </summary>
public Monitoring.Enrollment.Ime.AppPackageStateList PackageStates => _tracker.PackageStates;
/// <summary>
/// Reference to the wrapped IME tracker for co-collector wiring. Used by
/// <c>DeliveryOptimizationHost</c> to set <c>OnDoTelemetryReceived</c> and to chain
/// <c>OnAppStateChanged</c> for dormant/wake-up transitions.
/// </summary>
internal Monitoring.Enrollment.Ime.ImeLogTracker Tracker => _tracker;
public ImeLogHost(
string sessionId,
string tenantId,
@@ -457,6 +479,81 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
private sealed class DeliveryOptimizationHost : ICollectorHost
{
public string Name => "DeliveryOptimizationCollector";
private readonly DeliveryOptimizationCollector _collector;
private readonly ImeLogHost _imeHost;
private readonly AgentLogger _logger;
private Action<Monitoring.Enrollment.Ime.AppPackageState, Monitoring.Enrollment.Ime.AppInstallationState, Monitoring.Enrollment.Ime.AppInstallationState>? _prevStateChanged;
private int _disposed;
public DeliveryOptimizationHost(
string sessionId,
string tenantId,
Action<EnrollmentEvent> onEnrollmentEvent,
AgentLogger logger,
int intervalSeconds,
ImeLogHost imeHost)
{
_imeHost = imeHost;
_logger = logger;
_collector = new DeliveryOptimizationCollector(
sessionId: sessionId,
tenantId: tenantId,
emitEvent: onEnrollmentEvent,
logger: logger,
intervalSeconds: intervalSeconds,
getPackageStates: () => imeHost.PackageStates,
onDoTelemetryReceived: pkg =>
{
try { imeHost.Tracker.OnDoTelemetryReceived?.Invoke(pkg); }
catch (Exception ex) { logger.Warning($"DeliveryOptimizationHost: OnDoTelemetryReceived invocation threw: {ex.Message}"); }
},
logDirectory: Environment.ExpandEnvironmentVariables(Shared.Constants.LogDirectory));
}
public void Start()
{
// Chain ourselves into the tracker's OnAppStateChanged so the DO collector wakes
// up on the first Downloading/Installing transition (Legacy parity). We preserve
// any existing handler — the IME adapter's own listener must keep running.
_prevStateChanged = _imeHost.Tracker.OnAppStateChanged;
_imeHost.Tracker.OnAppStateChanged = (pkg, oldState, newState) =>
{
try { _prevStateChanged?.Invoke(pkg, oldState, newState); }
catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: previous OnAppStateChanged handler threw: {ex.Message}"); }
if (newState >= Monitoring.Enrollment.Ime.AppInstallationState.Downloading &&
newState <= Monitoring.Enrollment.Ime.AppInstallationState.Installing)
{
try { _collector.WakeUp(); }
catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: WakeUp threw: {ex.Message}"); }
}
};
_collector.Start();
_logger.Info($"DeliveryOptimizationHost: started dormant (interval={_collector}, wakes on Downloading/Installing).");
}
public void Stop()
{
// Restore the previous handler only if we're still the current one — otherwise
// someone else replaced us and owns the slot now.
if (object.ReferenceEquals(_imeHost.Tracker.OnAppStateChanged, _imeHost.Tracker.OnAppStateChanged))
_imeHost.Tracker.OnAppStateChanged = _prevStateChanged;
_collector.Stop();
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
try { _collector.Dispose(); } catch { }
}
}
private sealed class AgentSelfMetricsHost : ICollectorHost
{
public string Name => "AgentSelfMetricsCollector";
@@ -0,0 +1,229 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Telemetry.Events;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Runtime
{
/// <summary>
/// Fire-and-forget startup probes ported from the Legacy <c>MonitoringService</c>.
/// Plan §4.x M4.6.γ.
/// <para>
/// Emits three diagnostic events when the orchestrator is live:
/// </para>
/// <list type="bullet">
/// <item><b>device_location</b> (or <c>agent_trace</c> on failure) — IP-based geo via
/// <see cref="GeoLocationService"/>. Skipped when <c>EnableGeoLocation=false</c>.</item>
/// <item><b>timezone_auto_set</b> — only when <c>EnableTimezoneAutoSet=true</c> AND the
/// geo lookup returned an IANA timezone. Uses <see cref="TimezoneService"/> (tzutil).</item>
/// <item><b>ntp_time_check</b> — NTP offset from <c>NtpServer</c> (default time.windows.com).
/// Always emitted — offset &gt;60s is Warning, smaller is Info, failure is Warning.</item>
/// </list>
/// <para>
/// Each probe runs best-effort: failures are logged + optionally emitted as a Warning event,
/// never thrown. Designed to run on a background task so they never block the critical path.
/// </para>
/// </summary>
public static class StartupEnvironmentProbes
{
/// <summary>
/// Runs all three probes sequentially and emits the resulting events via
/// <paramref name="emitter"/>. Returns a <see cref="Task"/> that completes when all
/// probes have finished (typical runtime &lt;10s; longer on slow/blocked networks).
/// </summary>
public static async Task RunAsync(
AgentConfiguration configuration,
AgentLogger logger,
TelemetryEventEmitter emitter)
{
if (configuration == null) throw new ArgumentNullException(nameof(configuration));
if (logger == null) throw new ArgumentNullException(nameof(logger));
if (emitter == null) throw new ArgumentNullException(nameof(emitter));
if (configuration.EnableGeoLocation)
await RunGeoAndTimezone(configuration, logger, emitter).ConfigureAwait(false);
else
logger.Debug("StartupEnvironmentProbes: EnableGeoLocation=false — skipping geo + timezone probes.");
await RunNtpCheck(configuration, logger, emitter).ConfigureAwait(false);
}
private static async Task RunGeoAndTimezone(
AgentConfiguration configuration,
AgentLogger logger,
TelemetryEventEmitter emitter)
{
GeoLocationAttemptResult? attempt = null;
try
{
attempt = await GeoLocationService.GetLocationAsync(logger).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.Warning($"StartupEnvironmentProbes: geo-location lookup threw: {ex.Message}");
}
if (attempt?.Location != null)
{
SafeEmit(emitter, logger, BuildGeoEvent(configuration, attempt.Location));
}
else
{
SafeEmit(emitter, logger, BuildGeoFailureEvent(configuration, attempt));
}
if (configuration.EnableTimezoneAutoSet && !string.IsNullOrEmpty(attempt?.Location?.Timezone))
{
try
{
var tzResult = TimezoneService.TrySetTimezone(attempt!.Location!.Timezone, logger);
SafeEmit(emitter, logger, BuildTimezoneEvent(configuration, tzResult));
}
catch (Exception tzEx)
{
logger.Warning($"StartupEnvironmentProbes: timezone auto-set threw: {tzEx.Message}");
}
}
}
private static async Task RunNtpCheck(
AgentConfiguration configuration,
AgentLogger logger,
TelemetryEventEmitter emitter)
{
try
{
var ntpServer = string.IsNullOrEmpty(configuration.NtpServer) ? "time.windows.com" : configuration.NtpServer;
// NtpTimeCheckService.CheckTime is synchronous — wrap on a background thread so the
// probe runner stays async-friendly (avoids blocking the continuation thread when
// Program.cs awaits RunAsync with ConfigureAwait(false)).
var result = await Task.Run(() => NtpTimeCheckService.CheckTime(ntpServer, logger))
.ConfigureAwait(false);
SafeEmit(emitter, logger, BuildNtpEvent(configuration, ntpServer, result));
}
catch (Exception ex)
{
logger.Warning($"StartupEnvironmentProbes: NTP check threw: {ex.Message}");
}
}
// --------------------------------------------------------------- Event builders (internal for tests)
internal static EnrollmentEvent BuildGeoEvent(AgentConfiguration configuration, GeoLocationResult location) =>
new EnrollmentEvent
{
SessionId = configuration.SessionId,
TenantId = configuration.TenantId,
EventType = "device_location",
Severity = EventSeverity.Info,
Source = "Network",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
Message = $"Device location: {location.City}, {location.Region}, {location.Country} (via {location.Source})",
Data = location.ToDictionary(),
ImmediateUpload = true,
};
internal static EnrollmentEvent BuildGeoFailureEvent(AgentConfiguration configuration, GeoLocationAttemptResult? attempt) =>
new EnrollmentEvent
{
SessionId = configuration.SessionId,
TenantId = configuration.TenantId,
EventType = "agent_trace",
Severity = EventSeverity.Warning,
Source = "Network",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
Message = "Geo-location lookup failed: all providers unreachable",
Data = new Dictionary<string, object>
{
{ "decision", "geo_location_failed" },
{ "reason", "All geo-location providers failed after retry" },
{ "primaryError", attempt?.PrimaryError ?? "unknown" },
{ "primaryRetryError", attempt?.PrimaryRetryError ?? "unknown" },
{ "fallbackError", attempt?.FallbackError ?? "unknown" },
{ "primaryProvider", "ipinfo.io" },
{ "fallbackProvider", "ifconfig.co" },
},
};
internal static EnrollmentEvent BuildTimezoneEvent(AgentConfiguration configuration, TimezoneSetResult tz) =>
new EnrollmentEvent
{
SessionId = configuration.SessionId,
TenantId = configuration.TenantId,
EventType = "timezone_auto_set",
Severity = tz.Success ? EventSeverity.Info : EventSeverity.Warning,
Source = "Network",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
Message = tz.Success
? $"Timezone set to {tz.WindowsTimezoneId} (from {tz.IanaTimezone})"
: $"Timezone auto-set failed: {tz.Error}",
Data = new Dictionary<string, object>
{
{ "ianaTimezone", tz.IanaTimezone ?? "" },
{ "windowsTimezoneId", tz.WindowsTimezoneId ?? "unknown" },
{ "previousTimezone", tz.PreviousTimezone ?? "unknown" },
{ "success", tz.Success },
{ "error", tz.Error ?? "" },
},
};
internal static EnrollmentEvent BuildNtpEvent(AgentConfiguration configuration, string ntpServer, NtpCheckResult result)
{
if (result.Success)
{
var severity = Math.Abs(result.OffsetSeconds) > 60 ? EventSeverity.Warning : EventSeverity.Info;
return new EnrollmentEvent
{
SessionId = configuration.SessionId,
TenantId = configuration.TenantId,
EventType = "ntp_time_check",
Severity = severity,
Source = "Network",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
Message = $"NTP time check: offset {result.OffsetSeconds:F2}s from {ntpServer}",
Data = new Dictionary<string, object>
{
{ "ntpServer", ntpServer },
{ "offsetSeconds", result.OffsetSeconds },
{ "ntpTimeUtc", result.NtpTime?.ToString("o") ?? "" },
{ "localTimeUtc", result.LocalTime?.ToString("o") ?? "" },
},
};
}
return new EnrollmentEvent
{
SessionId = configuration.SessionId,
TenantId = configuration.TenantId,
EventType = "ntp_time_check",
Severity = EventSeverity.Warning,
Source = "Network",
Phase = EnrollmentPhase.Unknown,
Timestamp = DateTime.UtcNow,
Message = $"NTP time check failed: {result.Error}",
Data = new Dictionary<string, object>
{
{ "ntpServer", ntpServer },
{ "error", result.Error ?? "unknown" },
},
};
}
private static void SafeEmit(TelemetryEventEmitter emitter, AgentLogger logger, EnrollmentEvent evt)
{
try { emitter.Emit(evt); }
catch (Exception ex) { logger.Warning($"StartupEnvironmentProbes: emit failed for '{evt.EventType}': {ex.Message}"); }
}
}
}
+51 -5
View File
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Runtime;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Termination;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
@@ -259,7 +260,37 @@ namespace AutopilotMonitor.Agent.V2
logger: logger,
agentVersion: GetAgentVersion());
var remoteConfigService = new RemoteConfigService(backendApiClient, agentConfig.TenantId, logger);
// M4.6.γ — Emergency + Distress reporters. Plumbed into RemoteConfigService so
// Config-fetch failures (auth vs network) flow to the correct channel. Also fires
// an initial AuthCertificateMissing distress when the MDM cert was expected but
// not found (Legacy parity — surfaces pre-MDM-enrollment dead-ends to the backend
// via the cert-less distress channel).
var hardwareForReporters = HardwareInfo.GetHardwareInfo(logger);
var distressReporter = new DistressReporter(
baseUrl: agentConfig.ApiBaseUrl,
tenantId: agentConfig.TenantId,
manufacturer: hardwareForReporters.Manufacturer,
model: hardwareForReporters.Model,
serialNumber: hardwareForReporters.SerialNumber,
agentVersion: GetAgentVersion(),
logger: logger);
var emergencyReporter = new EmergencyReporter(
apiClient: backendApiClient,
sessionId: agentConfig.SessionId,
tenantId: agentConfig.TenantId,
agentVersion: GetAgentVersion(),
logger: logger);
if (agentConfig.UseClientCertAuth && backendApiClient.ClientCertificate == null)
{
_ = distressReporter.TrySendAsync(
DistressErrorType.AuthCertificateMissing,
"MDM certificate not found in LocalMachine or CurrentUser store");
}
var remoteConfigService = new RemoteConfigService(
backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter);
var remoteConfig = remoteConfigService.FetchConfigAsync().GetAwaiter().GetResult();
logger.SetLogLevel(agentConfig.LogLevel);
@@ -299,14 +330,13 @@ namespace AutopilotMonitor.Agent.V2
try
{
var hardware = HardwareInfo.GetHardwareInfo(logger);
uploader = new BackendTelemetryUploader(
httpClient: mtlsHttpClient,
baseUrl: agentConfig.ApiBaseUrl,
tenantId: agentConfig.TenantId,
manufacturer: hardware.Manufacturer,
model: hardware.Model,
serialNumber: hardware.SerialNumber,
manufacturer: hardwareForReporters.Manufacturer,
model: hardwareForReporters.Model,
serialNumber: hardwareForReporters.SerialNumber,
bootstrapToken: agentConfig.UseBootstrapTokenAuth ? agentConfig.BootstrapToken : null,
agentVersion: GetAgentVersion());
}
@@ -433,6 +463,22 @@ namespace AutopilotMonitor.Agent.V2
// VersionCheckEventBuilder.TryBuild is a no-op when no markers are present.
EmitVersionCheckEventIfAny(orchestrator, agentConfig, logger);
// M4.6.γ — fire-and-forget startup probes (geo / timezone / NTP). Runs on
// the ThreadPool so a slow network never delays the critical path.
_ = Task.Run(async () =>
{
try
{
await StartupEnvironmentProbes
.RunAsync(agentConfig, logger, orchestrator.EventEmitter)
.ConfigureAwait(false);
}
catch (Exception ex)
{
logger.Warning($"StartupEnvironmentProbes: outer exception: {ex.Message}");
}
});
logger.Info($"V2 agent runtime ready (session={agentConfig.SessionId}, tenant={agentConfig.TenantId}).");
if (consoleMode)
Console.Out.WriteLine("AutopilotMonitor.Agent.V2 running. Press Ctrl+C to stop.");
@@ -1 +1 @@
32
35
@@ -1 +1 @@
1068
1069