diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs
index 56eb6b15..4f9bb69c 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigMergerTests.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Xunit;
@@ -9,8 +10,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
{
///
/// Tests for . This merger is the single source of
- /// Legacy-parity: every field delivered by the backend that has a live V2 consumer must
- /// land on after FetchConfigAsync.
+ /// V1-parity: every field delivered by the backend that has a live V2 consumer must land
+ /// on after FetchConfigAsync.
+ ///
+ /// V1 parity: remote values OVERWRITE the runtime configuration unconditionally. CLI
+ /// flags only seed the initial and yield to tenant
+ /// policy once Merge runs. This is asserted by a regression test below.
+ ///
///
public sealed class RemoteConfigMergerTests
{
@@ -46,6 +52,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
LogLevel = "Debug",
SendTraceEvents = false,
UnrestrictedMode = true,
+ MaxAuthFailures = 7,
+ AuthFailureTimeoutMinutes = 15,
+ EnableImeMatchLog = true,
Collectors = new CollectorConfiguration
{
AgentMaxLifetimeMinutes = 120,
@@ -56,12 +65,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// ============================================================= Happy path
[Fact]
- public void Merge_applies_all_tenant_fields_when_no_cli_overrides()
+ public void Merge_applies_all_tenant_fields()
{
var agent = NewAgentConfig();
var remote = FullRemote();
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
+ var result = RemoteConfigMerger.Merge(agent, remote);
Assert.False(agent.SelfDestructOnComplete);
Assert.True(agent.KeepLogFile);
@@ -85,40 +94,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(250, agent.MaxBatchSize);
Assert.Equal(120, agent.AgentMaxLifetimeMinutes);
Assert.Equal(45, agent.HelloWaitTimeoutSeconds);
+ Assert.Equal(7, agent.MaxAuthFailures);
+ Assert.Equal(15, agent.AuthFailureTimeoutMinutes);
+
+ Assert.NotNull(result);
+ Assert.True(result.UnrestrictedModeChanged);
+ Assert.False(result.OldUnrestrictedMode);
+ Assert.True(result.NewUnrestrictedMode);
+ Assert.True(result.LogLevelChanged);
+ Assert.Equal(AgentLogLevel.Info, result.OldLogLevel);
+ Assert.Equal(AgentLogLevel.Debug, result.NewLogLevel);
}
- // ============================================================= CLI override wins
+ // ============================================================= Remote-wins-always (V1 parity)
[Fact]
- public void Merge_preserves_no_cleanup_cli_flag_over_remote_true()
+ public void Merge_remote_wins_over_prior_agent_self_destruct()
{
+ // BuildAgentConfiguration may have set SelfDestructOnComplete from the --no-cleanup CLI
+ // flag. Once remote config arrives it takes over — V1 behaviour (operator cannot
+ // override tenant policy from the command line).
var agent = NewAgentConfig();
- agent.SelfDestructOnComplete = false; // as set by BuildAgentConfiguration when CLI had --no-cleanup
+ agent.SelfDestructOnComplete = false;
var remote = FullRemote();
- remote.SelfDestructOnComplete = true; // tenant enabled cleanup, dev disabled locally
+ remote.SelfDestructOnComplete = true;
- RemoteConfigMerger.Merge(agent, remote, new[] { "--no-cleanup" });
+ RemoteConfigMerger.Merge(agent, remote);
- Assert.False(agent.SelfDestructOnComplete);
+ Assert.True(agent.SelfDestructOnComplete);
}
[Fact]
- public void Merge_preserves_keep_logfile_cli_flag_over_remote_false()
- {
- var agent = NewAgentConfig();
- agent.KeepLogFile = true;
-
- var remote = FullRemote();
- remote.KeepLogFile = false;
-
- RemoteConfigMerger.Merge(agent, remote, new[] { "--keep-logfile" });
-
- Assert.True(agent.KeepLogFile);
- }
-
- [Fact]
- public void Merge_preserves_reboot_cli_flag_over_remote_false()
+ public void Merge_remote_wins_over_prior_agent_reboot_on_complete()
{
var agent = NewAgentConfig();
agent.RebootOnComplete = true;
@@ -126,27 +134,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.RebootOnComplete = false;
- RemoteConfigMerger.Merge(agent, remote, new[] { "--reboot-on-complete" });
+ RemoteConfigMerger.Merge(agent, remote);
- Assert.True(agent.RebootOnComplete);
+ Assert.False(agent.RebootOnComplete);
}
[Fact]
- public void Merge_preserves_disable_geolocation_cli_flag_over_remote_true()
- {
- var agent = NewAgentConfig();
- agent.EnableGeoLocation = false;
-
- var remote = FullRemote();
- remote.EnableGeoLocation = true;
-
- RemoteConfigMerger.Merge(agent, remote, new[] { "--disable-geolocation" });
-
- Assert.False(agent.EnableGeoLocation);
- }
-
- [Fact]
- public void Merge_preserves_log_level_cli_arg_over_remote_string()
+ public void Merge_remote_wins_over_prior_agent_log_level()
{
var agent = NewAgentConfig();
agent.LogLevel = AgentLogLevel.Verbose;
@@ -154,9 +148,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.LogLevel = "Debug";
- RemoteConfigMerger.Merge(agent, remote, new[] { "--log-level", "Verbose" });
+ RemoteConfigMerger.Merge(agent, remote);
- Assert.Equal(AgentLogLevel.Verbose, agent.LogLevel);
+ Assert.Equal(AgentLogLevel.Debug, agent.LogLevel);
}
[Fact]
@@ -166,7 +160,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.LogLevel = "trace";
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
+ RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(AgentLogLevel.Trace, agent.LogLevel);
}
@@ -180,11 +174,125 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.LogLevel = "NotAValidLevel";
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
+ RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(AgentLogLevel.Info, agent.LogLevel);
}
+ [Fact]
+ public void Merge_remote_null_ntp_server_clears_prior_value()
+ {
+ // V1 parity: remote null overwrites local without a IsNullOrWhiteSpace gate.
+ var agent = NewAgentConfig();
+ agent.NtpServer = "local.ntp";
+
+ var remote = FullRemote();
+ remote.NtpServer = null!;
+
+ RemoteConfigMerger.Merge(agent, remote);
+
+ Assert.Null(agent.NtpServer);
+ }
+
+ [Fact]
+ public void Merge_remote_off_overrides_prior_diagnostics_upload_mode()
+ {
+ // Tenant disables diagnostics upload — must take effect even when the agent was
+ // previously configured (e.g. by a prior config fetch cached on disk) to something
+ // else. V1 parity.
+ var agent = NewAgentConfig();
+ agent.DiagnosticsUploadMode = "OnFailure";
+ agent.DiagnosticsUploadEnabled = true;
+
+ var remote = FullRemote();
+ remote.DiagnosticsUploadMode = "Off";
+ remote.DiagnosticsUploadEnabled = false;
+
+ RemoteConfigMerger.Merge(agent, remote);
+
+ Assert.Equal("Off", agent.DiagnosticsUploadMode);
+ Assert.False(agent.DiagnosticsUploadEnabled);
+ }
+
+ [Fact]
+ public void Merge_unrestricted_mode_change_is_reported_via_result()
+ {
+ var agent = NewAgentConfig();
+ agent.UnrestrictedMode = false;
+
+ var remote = FullRemote();
+ remote.UnrestrictedMode = true;
+
+ var result = RemoteConfigMerger.Merge(agent, remote);
+
+ Assert.True(result.UnrestrictedModeChanged);
+ Assert.False(result.OldUnrestrictedMode);
+ Assert.True(result.NewUnrestrictedMode);
+ Assert.True(agent.UnrestrictedMode);
+ }
+
+ [Fact]
+ public void Merge_unrestricted_mode_stable_is_not_reported_as_change()
+ {
+ var agent = NewAgentConfig();
+ agent.UnrestrictedMode = true;
+
+ var remote = FullRemote();
+ remote.UnrestrictedMode = true;
+
+ var result = RemoteConfigMerger.Merge(agent, remote);
+
+ Assert.False(result.UnrestrictedModeChanged);
+ }
+
+ [Fact]
+ public void Merge_maps_enable_ime_match_log_true_to_default_path()
+ {
+ var agent = NewAgentConfig();
+ agent.ImeMatchLogPath = null;
+
+ var remote = FullRemote();
+ remote.EnableImeMatchLog = true;
+
+ RemoteConfigMerger.Merge(agent, remote);
+
+ Assert.False(string.IsNullOrEmpty(agent.ImeMatchLogPath));
+ Assert.Equal(
+ Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath),
+ agent.ImeMatchLogPath);
+ }
+
+ [Fact]
+ public void Merge_maps_enable_ime_match_log_false_clears_path()
+ {
+ var agent = NewAgentConfig();
+ agent.ImeMatchLogPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath);
+
+ var remote = FullRemote();
+ remote.EnableImeMatchLog = false;
+
+ RemoteConfigMerger.Merge(agent, remote);
+
+ Assert.Null(agent.ImeMatchLogPath);
+ }
+
+ [Fact]
+ public void Merge_preserves_custom_ime_match_log_path_when_remote_disables()
+ {
+ // A dev-time --ime-match-log override seeds a non-default path on the
+ // AgentConfiguration. When the tenant remote flips EnableImeMatchLog off we keep
+ // the custom path rather than clobbering a deliberate dev override.
+ var agent = NewAgentConfig();
+ agent.ImeMatchLogPath = @"C:\Temp\custom-ime.log";
+
+ var remote = FullRemote();
+ remote.EnableImeMatchLog = false;
+
+ RemoteConfigMerger.Merge(agent, remote);
+
+ Assert.Equal(@"C:\Temp\custom-ime.log", agent.ImeMatchLogPath);
+ }
+
// ============================================================= Null / partial tolerance
[Fact]
@@ -194,22 +302,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
agent.NtpServer = "local.ntp";
agent.SelfDestructOnComplete = true;
- RemoteConfigMerger.Merge(agent, null!, Array.Empty());
+ var result = RemoteConfigMerger.Merge(agent, null!);
Assert.Equal("local.ntp", agent.NtpServer);
Assert.True(agent.SelfDestructOnComplete);
- }
-
- [Fact]
- public void Merge_accepts_null_cli_args()
- {
- var agent = NewAgentConfig();
- var remote = FullRemote();
-
- RemoteConfigMerger.Merge(agent, remote, null!);
-
- Assert.False(agent.SelfDestructOnComplete);
- Assert.Equal("pool.ntp.org", agent.NtpServer);
+ Assert.NotNull(result);
+ Assert.False(result.UnrestrictedModeChanged);
}
[Fact]
@@ -222,40 +320,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.Collectors = null!;
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
+ RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(360, agent.AgentMaxLifetimeMinutes);
Assert.Equal(30, agent.HelloWaitTimeoutSeconds);
}
- [Fact]
- public void Merge_keeps_default_ntp_server_when_remote_is_empty()
- {
- var agent = NewAgentConfig();
- agent.NtpServer = "time.windows.com";
-
- var remote = FullRemote();
- remote.NtpServer = "";
-
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
-
- Assert.Equal("time.windows.com", agent.NtpServer);
- }
-
- [Fact]
- public void Merge_keeps_default_diagnostics_mode_when_remote_is_null()
- {
- var agent = NewAgentConfig();
- agent.DiagnosticsUploadMode = "Off";
-
- var remote = FullRemote();
- remote.DiagnosticsUploadMode = null!;
-
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
-
- Assert.Equal("Off", agent.DiagnosticsUploadMode);
- }
-
[Fact]
public void Merge_replaces_diagnostics_log_paths_with_empty_list_when_remote_null()
{
@@ -268,7 +338,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.DiagnosticsLogPaths = null!;
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
+ RemoteConfigMerger.Merge(agent, remote);
Assert.NotNull(agent.DiagnosticsLogPaths);
Assert.Empty(agent.DiagnosticsLogPaths);
@@ -276,20 +346,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
// ============================================================= Int clamping
- [Fact]
- public void Merge_skips_non_positive_upload_interval()
- {
- var agent = NewAgentConfig();
- agent.UploadIntervalSeconds = 30;
-
- var remote = FullRemote();
- remote.UploadIntervalSeconds = 0;
-
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
-
- Assert.Equal(30, agent.UploadIntervalSeconds);
- }
-
[Fact]
public void Merge_skips_non_positive_max_batch_size()
{
@@ -299,7 +355,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
var remote = FullRemote();
remote.MaxBatchSize = -1;
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
+ RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(100, agent.MaxBatchSize);
}
@@ -307,6 +363,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
[Fact]
public void Merge_skips_non_positive_hello_wait_timeout()
{
+ // V1 parity: MonitoringService.ApplyRuntimeSettingsFromRemoteConfig only applies the
+ // remote value when > 0 so an accidentally misconfigured tenant does not drop the
+ // Hello-wait to zero.
var agent = NewAgentConfig();
agent.HelloWaitTimeoutSeconds = 30;
@@ -317,7 +376,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
HelloWaitTimeoutSeconds = 0,
};
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
+ RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(30, agent.HelloWaitTimeoutSeconds);
}
@@ -335,7 +394,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
HelloWaitTimeoutSeconds = 30,
};
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
+ RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(0, agent.AgentMaxLifetimeMinutes);
}
@@ -346,26 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
public void Merge_throws_on_null_agent_config()
{
Assert.Throws(() =>
- RemoteConfigMerger.Merge(null!, FullRemote(), Array.Empty()));
- }
-
- // ============================================================= The user's scenario
-
- [Fact]
- public void Merge_flips_self_destruct_off_when_tenant_admin_disables_cleanup_and_agent_runs_without_cli_args()
- {
- // Regression guard for the VM smoke-test bug: tenant config has SelfDestructOnComplete=false
- // but agent was built with CLI-default true, and without the merger the remote value never
- // reached CleanupService / EnrollmentTerminationHandler.
- var agent = NewAgentConfig();
- agent.SelfDestructOnComplete = true; // BuildAgentConfiguration CLI-default when no --no-cleanup
-
- var remote = FullRemote();
- remote.SelfDestructOnComplete = false;
-
- RemoteConfigMerger.Merge(agent, remote, Array.Empty());
-
- Assert.False(agent.SelfDestructOnComplete);
+ RemoteConfigMerger.Merge(null!, FullRemote()));
}
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BuildAgentConfigurationReplayTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BuildAgentConfigurationReplayTests.cs
new file mode 100644
index 00000000..d205ebf3
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/BuildAgentConfigurationReplayTests.cs
@@ -0,0 +1,66 @@
+using System;
+using AutopilotMonitor.Agent.V2;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
+{
+ ///
+ /// Verifies that
+ /// propagates the dev-only --replay-log-dir + --replay-speed-factor CLI flags
+ /// onto so the IME log replay SimulationMode actually
+ /// activates downstream. V2 parity PR-A.
+ ///
+ public sealed class BuildAgentConfigurationReplayTests
+ {
+ private static AgentConfiguration Build(params string[] args) =>
+ AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
+ args: args,
+ tenantId: "tenant-t",
+ sessionId: "session-s",
+ bootstrapConfig: null,
+ awaitConfig: null);
+
+ [Fact]
+ public void Without_replay_flags_ReplayLogDir_stays_null_and_speed_factor_default()
+ {
+ var cfg = Build();
+
+ Assert.Null(cfg.ReplayLogDir);
+ Assert.Equal(50.0, cfg.ReplaySpeedFactor);
+ }
+
+ [Fact]
+ public void Replay_log_dir_populates_configuration()
+ {
+ var cfg = Build("--replay-log-dir", @"C:\tmp\ime-logs");
+
+ Assert.Equal(@"C:\tmp\ime-logs", cfg.ReplayLogDir);
+ }
+
+ [Fact]
+ public void Replay_speed_factor_is_parsed_as_invariant_double()
+ {
+ // Comma locale must NOT flip the meaning — parser uses InvariantCulture.
+ var cfg = Build("--replay-log-dir", @"C:\tmp\x", "--replay-speed-factor", "125.5");
+
+ Assert.Equal(125.5, cfg.ReplaySpeedFactor);
+ }
+
+ [Fact]
+ public void Replay_speed_factor_zero_is_rejected_and_default_kept()
+ {
+ var cfg = Build("--replay-log-dir", @"C:\tmp\x", "--replay-speed-factor", "0");
+
+ Assert.Equal(50.0, cfg.ReplaySpeedFactor);
+ }
+
+ [Fact]
+ public void Replay_speed_factor_malformed_is_rejected_and_default_kept()
+ {
+ var cfg = Build("--replay-log-dir", @"C:\tmp\x", "--replay-speed-factor", "fast");
+
+ Assert.Equal(50.0, cfg.ReplaySpeedFactor);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs
new file mode 100644
index 00000000..30443fd9
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs
@@ -0,0 +1,125 @@
+using System.IO;
+using System.Reflection;
+using AutopilotMonitor.Agent.V2;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Security;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
+{
+ ///
+ /// Verifies that
+ /// emits the SessionStarted decision signal with the registration metadata payload the
+ /// reducer anchor needs. Plan §M4-V2-parity PR-C.
+ ///
+ public sealed class PostSessionStartedSignalTests
+ {
+ private static AgentLogger NewLogger(string path)
+ => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
+
+ private static SessionRegistrationResult BuildSucceeded(ValidatorType validatedBy, string? adminAction = null)
+ {
+ var response = new RegisterSessionResponse
+ {
+ SessionId = "session-anon-1",
+ Success = true,
+ Message = "ok",
+ ValidatedBy = validatedBy,
+ AdminAction = adminAction,
+ };
+
+ // SessionRegistrationResult uses a private ctor + public static factories. Call
+ // Succeeded via reflection-free factory — it's public.
+ return SessionRegistrationResult.Succeeded(response);
+ }
+
+ private static AgentConfiguration BuildConfig(bool bootstrap)
+ {
+ return new AgentConfiguration
+ {
+ ApiBaseUrl = "https://example.invalid",
+ SessionId = "session-anon-1",
+ TenantId = "tenant-anon-1",
+ SpoolDirectory = Path.GetTempPath(),
+ LogDirectory = Path.GetTempPath(),
+ LogLevel = AgentLogLevel.Info,
+ UseClientCertAuth = !bootstrap,
+ UseBootstrapTokenAuth = bootstrap,
+ BootstrapToken = bootstrap ? "dummy" : null,
+ };
+ }
+
+ [Fact]
+ public void Posts_session_started_kind_with_default_schema_version()
+ {
+ using var tmp = new TempDirectory();
+ var sink = new FakeSignalIngressSink();
+ var logger = NewLogger(tmp.Path);
+ var result = BuildSucceeded(ValidatorType.AutopilotV1);
+
+ AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger);
+
+ Assert.Single(sink.Posted);
+ var posted = sink.Posted[0];
+ Assert.Equal(DecisionSignalKind.SessionStarted, posted.Kind);
+ Assert.Equal(1, posted.KindSchemaVersion);
+ Assert.Equal("Program.RunAgent", posted.SourceOrigin);
+ Assert.Equal(EvidenceKind.Synthetic, posted.Evidence.Kind);
+ Assert.Equal("register_session_success", posted.Evidence.Identifier);
+ }
+
+ [Fact]
+ public void Payload_includes_registration_metadata_keys()
+ {
+ using var tmp = new TempDirectory();
+ var sink = new FakeSignalIngressSink();
+ var logger = NewLogger(tmp.Path);
+ var result = BuildSucceeded(ValidatorType.CorporateIdentifier);
+
+ AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: true), logger);
+
+ var payload = sink.Posted[0].Payload;
+ Assert.NotNull(payload);
+ Assert.Equal("CorporateIdentifier", payload!["validatedBy"]);
+ Assert.Equal("true", payload["isBootstrapSession"]);
+ Assert.Contains("enrollmentType", (System.Collections.Generic.IDictionary)payload);
+ Assert.Contains("isHybridJoin", (System.Collections.Generic.IDictionary)payload);
+ Assert.Contains("agentVersion", (System.Collections.Generic.IDictionary)payload);
+ }
+
+ [Fact]
+ public void Swallows_sink_exceptions()
+ {
+ // The helper is called on the hot startup path — a misbehaving sink must not
+ // crash the agent.
+ using var tmp = new TempDirectory();
+ var sink = new ThrowingSink();
+ var logger = NewLogger(tmp.Path);
+ var result = BuildSucceeded(ValidatorType.Bootstrap);
+
+ var ex = Record.Exception(() =>
+ AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger));
+
+ Assert.Null(ex);
+ }
+
+ private sealed class ThrowingSink : AutopilotMonitor.Agent.V2.Core.Orchestration.ISignalIngressSink
+ {
+ public void Post(
+ DecisionSignalKind kind,
+ System.DateTime occurredAtUtc,
+ string sourceOrigin,
+ Evidence evidence,
+ System.Collections.Generic.IReadOnlyDictionary? payload = null,
+ int kindSchemaVersion = 1)
+ {
+ throw new System.InvalidOperationException("ingress unavailable");
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs
index f168e5a2..8b87e372 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/AuthFailureTrackerTests.cs
@@ -1,8 +1,12 @@
using System;
+using System.Collections.Generic;
using System.Threading;
+using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
@@ -226,6 +230,77 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
new AuthFailureTracker(1, 0, new FakeClock(DateTime.UtcNow), null!));
}
+ // ============================================================= Distress dispatch (V1 parity)
+
+ [Fact]
+ public void RecordFailure_dispatches_distress_only_on_first_failure_for_401()
+ {
+ var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
+ var fakeDistress = new FakeDistressReporter();
+ var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
+
+ tracker.RecordFailure(401, "agent/config");
+ tracker.RecordFailure(401, "agent/config");
+ tracker.RecordFailure(401, "agent/config");
+
+ Assert.Single(fakeDistress.Calls);
+ Assert.Equal(DistressErrorType.AuthCertificateRejected, fakeDistress.Calls[0].ErrorType);
+ Assert.Equal(401, fakeDistress.Calls[0].HttpStatusCode);
+ Assert.Contains("agent/config", fakeDistress.Calls[0].Message);
+ }
+
+ [Fact]
+ public void RecordFailure_dispatches_device_not_registered_for_403()
+ {
+ var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
+ var fakeDistress = new FakeDistressReporter();
+ var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
+
+ tracker.RecordFailure(403, "agent/telemetry");
+
+ Assert.Single(fakeDistress.Calls);
+ Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType);
+ Assert.Equal(403, fakeDistress.Calls[0].HttpStatusCode);
+ }
+
+ [Fact]
+ public void RecordFailure_without_distress_reporter_is_silent_but_still_tracks()
+ {
+ var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
+ var tracker = new AuthFailureTracker(maxFailures: 2, timeoutMinutes: 0, clock, NewLogger());
+
+ int events = 0;
+ tracker.ThresholdExceeded += (_, _) => events++;
+
+ tracker.RecordFailure(401, "op");
+ tracker.RecordFailure(401, "op");
+
+ Assert.Equal(1, events);
+ }
+
+ private sealed class FakeDistressReporter : DistressReporter
+ {
+ public sealed class Call
+ {
+ public DistressErrorType ErrorType { get; set; }
+ public string Message { get; set; } = default!;
+ public int? HttpStatusCode { get; set; }
+ }
+
+ public List Calls { get; } = new List();
+
+ public FakeDistressReporter()
+ : base(baseUrl: "https://example.test", tenantId: "t", manufacturer: "m", model: "d", serialNumber: "s", agentVersion: "0.0.0", logger: null!)
+ {
+ }
+
+ public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null)
+ {
+ Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode });
+ return Task.CompletedTask;
+ }
+ }
+
private sealed class FakeClock : AutopilotMonitor.DecisionCore.Engine.IClock
{
public DateTime Now { get; set; }
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs
index 2bd59ded..24ce8897 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/BinaryIntegrityVerifierTests.cs
@@ -1,4 +1,7 @@
+using System;
using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
@@ -96,5 +99,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hash);
}
+
+ [Fact]
+ public void Check_match_does_not_invoke_runtime_self_update_trigger()
+ {
+ BinaryIntegrityVerifier.ResetTriggerForTests();
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp);
+
+ var exe = Path.Combine(tmp.Path, "fake.exe");
+ File.WriteAllBytes(exe, new byte[] { 0x01, 0x02 });
+ var actual = BinaryIntegrityVerifier.ComputeSha256Hex(exe);
+
+ var triggerCount = 0;
+ Func trigger = (_, __) =>
+ {
+ Interlocked.Increment(ref triggerCount);
+ return Task.CompletedTask;
+ };
+
+ var result = BinaryIntegrityVerifier.Check(actual, logger, exePath: exe,
+ runtimeSelfUpdateTrigger: trigger, zipHash: "zip", allowDowngrade: false);
+
+ Assert.Equal(IntegrityCheckOutcome.Match, result.Outcome);
+ Assert.Equal(0, Volatile.Read(ref triggerCount));
+ }
+
+ [Fact]
+ public void Check_mismatch_fires_runtime_self_update_trigger_exactly_once()
+ {
+ // V1 parity + single-shot guard: repeat calls at runtime (e.g. config-rotation hash
+ // drift) must not fan out into multiple force-update attempts.
+ BinaryIntegrityVerifier.ResetTriggerForTests();
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp);
+
+ var exe = Path.Combine(tmp.Path, "fake.exe");
+ File.WriteAllBytes(exe, new byte[] { 0x01 });
+ var wrongHash = new string('f', 64);
+
+ var triggerCount = 0;
+ string capturedZipHash = null;
+ bool capturedDowngrade = false;
+ var triggerDone = new ManualResetEventSlim(false);
+ Func trigger = (zip, down) =>
+ {
+ Interlocked.Increment(ref triggerCount);
+ capturedZipHash = zip;
+ capturedDowngrade = down;
+ triggerDone.Set();
+ return Task.CompletedTask;
+ };
+
+ var r1 = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe,
+ runtimeSelfUpdateTrigger: trigger, zipHash: "zip-hash", allowDowngrade: true);
+ var r2 = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe,
+ runtimeSelfUpdateTrigger: trigger, zipHash: "zip-hash", allowDowngrade: true);
+
+ Assert.Equal(IntegrityCheckOutcome.Mismatch, r1.Outcome);
+ Assert.Equal(IntegrityCheckOutcome.Mismatch, r2.Outcome);
+
+ Assert.True(triggerDone.Wait(TimeSpan.FromSeconds(10)), "trigger did not run within 10s");
+ Assert.Equal(1, Volatile.Read(ref triggerCount));
+ Assert.Equal("zip-hash", capturedZipHash);
+ Assert.True(capturedDowngrade);
+ }
+
+ [Fact]
+ public void Check_mismatch_without_trigger_only_logs()
+ {
+ BinaryIntegrityVerifier.ResetTriggerForTests();
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp);
+
+ var exe = Path.Combine(tmp.Path, "fake.exe");
+ File.WriteAllBytes(exe, new byte[] { 0x07 });
+ var wrongHash = new string('e', 64);
+
+ var result = BinaryIntegrityVerifier.Check(wrongHash, logger, exePath: exe,
+ runtimeSelfUpdateTrigger: null);
+
+ Assert.Equal(IntegrityCheckOutcome.Mismatch, result.Outcome);
+ }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs
new file mode 100644
index 00000000..c67aa43f
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs
@@ -0,0 +1,167 @@
+using System;
+using System.IO;
+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.Security;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
+{
+ ///
+ /// V1 parity guard for . The helper's retry + short-
+ /// circuit behaviour is the single replacement for V1 MonitoringService.RegisterSessionAsync.
+ /// If these tests break, production enrollment registration is at risk.
+ ///
+ public sealed class SessionRegistrationHelperTests
+ {
+ private static AgentLogger NewLogger(TempDirectory tmp)
+ => new AgentLogger(Path.Combine(tmp.Path, "logs"), AgentLogLevel.Debug);
+
+ private static AgentConfiguration NewConfig() => new AgentConfiguration
+ {
+ ApiBaseUrl = "https://example.test",
+ SessionId = Guid.NewGuid().ToString(),
+ TenantId = Guid.NewGuid().ToString(),
+ };
+
+ [Fact]
+ public async Task Succeeds_on_first_response_success()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp);
+ var config = NewConfig();
+
+ var apiClient = new FakeApiClient(attempts => new RegisterSessionResponse
+ {
+ Success = true,
+ SessionId = config.SessionId,
+ ValidatedBy = ValidatorType.AutopilotV1,
+ });
+
+ var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
+ apiClient, config, agentVersion: "0.0.0", logger: logger,
+ backoffDelay: _ => Task.CompletedTask);
+
+ Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
+ Assert.Equal(1, apiClient.CallCount);
+ Assert.Equal(ValidatorType.AutopilotV1, result.ValidatedBy);
+ Assert.Null(result.AdminAction);
+ }
+
+ [Fact]
+ public async Task Retries_until_success_on_success_false()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp);
+ var config = NewConfig();
+
+ var apiClient = new FakeApiClient(attempt =>
+ {
+ if (attempt < 3) return new RegisterSessionResponse { Success = false, Message = "backend 500" };
+ return new RegisterSessionResponse { Success = true, SessionId = config.SessionId };
+ });
+
+ var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
+ apiClient, config, agentVersion: "0.0.0", logger: logger,
+ backoffDelay: _ => Task.CompletedTask);
+
+ Assert.Equal(SessionRegistrationOutcome.Succeeded, result.Outcome);
+ Assert.Equal(3, apiClient.CallCount);
+ }
+
+ [Fact]
+ public async Task Auth_failure_short_circuits_without_retry()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp);
+ var config = NewConfig();
+
+ var apiClient = new FakeApiClient(attempt =>
+ throw new BackendAuthException("cert rejected", statusCode: 401));
+
+ var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
+ clock: SystemClock.Instance, logger: logger);
+
+ var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
+ apiClient, config, agentVersion: "0.0.0", logger: logger, authFailureTracker: tracker);
+
+ Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome);
+ Assert.Equal(401, result.HttpStatusCode);
+ Assert.Equal(1, apiClient.CallCount);
+ Assert.Equal(1, tracker.ConsecutiveFailures);
+ }
+
+ [Fact]
+ public async Task Fails_after_five_exceptions_without_auth_error()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp);
+ var config = NewConfig();
+
+ var apiClient = new FakeApiClient(attempt =>
+ throw new InvalidOperationException($"transient attempt {attempt}"));
+
+ var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
+ apiClient, config, agentVersion: "0.0.0", logger: logger,
+ backoffDelay: _ => Task.CompletedTask);
+
+ Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
+ Assert.Equal(5, apiClient.CallCount);
+ Assert.Contains("transient attempt 5", result.ErrorMessage);
+ }
+
+ [Fact]
+ public async Task Populates_registration_with_device_and_version_details()
+ {
+ using var tmp = new TempDirectory();
+ var logger = NewLogger(tmp);
+ var config = NewConfig();
+
+ SessionRegistration captured = null!;
+ var apiClient = new FakeApiClient(
+ _ => new RegisterSessionResponse { Success = true, SessionId = config.SessionId },
+ onRegister: reg => captured = reg);
+
+ await SessionRegistrationHelper.RegisterWithRetryAsync(
+ apiClient, config, agentVersion: "9.9.9", logger: logger);
+
+ Assert.NotNull(captured);
+ Assert.Equal(config.SessionId, captured.SessionId);
+ Assert.Equal(config.TenantId, captured.TenantId);
+ Assert.Equal("9.9.9", captured.AgentVersion);
+ Assert.Equal(Environment.MachineName, captured.DeviceName);
+ Assert.False(string.IsNullOrEmpty(captured.EnrollmentType));
+ Assert.True(captured.IsUserDriven);
+ }
+
+ // Subclasses the real BackendApiClient so we inherit its public API but intercept the
+ // single virtual entry point that matters. BackendApiClient's ctor is protected-less in
+ // V2 (`public BackendApiClient(string baseUrl, ...)` + `protected BackendApiClient()`);
+ // we use the protected ctor to avoid HttpClient construction side effects in unit tests.
+ private sealed class FakeApiClient : BackendApiClient
+ {
+ private readonly Func _respond;
+ private readonly Action _onRegister;
+ public int CallCount;
+
+ public FakeApiClient(Func respond, Action onRegister = null)
+ {
+ _respond = respond;
+ _onRegister = onRegister;
+ }
+
+ public override Task RegisterSessionAsync(SessionRegistration registration)
+ {
+ CallCount++;
+ _onRegister?.Invoke(registration);
+ try { return Task.FromResult(_respond(CallCount)); }
+ catch (Exception ex) { return Task.FromException(ex); }
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs
index e65c5168..6696e6c2 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs
@@ -1,5 +1,6 @@
#nullable enable
using System;
+using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -8,9 +9,11 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Termination;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.DecisionCore.State;
+using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
@@ -44,6 +47,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
public string? LastDiagnosticsSuffix { get; private set; }
public DiagnosticsUploadResult? DiagnosticsResult { get; set; }
public int ShutdownSignalled;
+ public List EmittedEvents { get; } = new List();
+ public int RebootInvocations;
+ public int RebootDelaySeconds;
+ public SessionIdPersistence SessionPersistence { get; set; } = default!;
public Rig()
{
@@ -51,6 +58,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
Packages = new AppPackageStateList(Logger);
CleanupService = new RecordingCleanupService(BuildConfig(), Logger);
+ SessionPersistence = new SessionIdPersistence(Tmp.Path);
}
public AgentConfiguration BuildConfig(
@@ -90,7 +98,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
LastDiagnosticsSuffix = suffix;
return Task.FromResult(DiagnosticsResult ?? new DiagnosticsUploadResult { BlobName = "blob" });
},
- signalShutdown: () => Interlocked.Increment(ref ShutdownSignalled));
+ signalShutdown: () => Interlocked.Increment(ref ShutdownSignalled),
+ analyzerManager: null,
+ emitEvent: evt => EmittedEvents.Add(evt),
+ sessionPersistence: SessionPersistence,
+ triggerReboot: delay =>
+ {
+ Interlocked.Increment(ref RebootInvocations);
+ RebootDelaySeconds = delay;
+ },
+ // Zero out the timing ceremony for tests — production paths are covered by
+ // the dedicated V1-parity tests below which opt back in via their own Rig.
+ lateEventGracePeriod: TimeSpan.Zero,
+ spoolDrainPeriod: TimeSpan.Zero);
}
public void Dispose() => Tmp.Dispose();
@@ -217,5 +237,104 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
Assert.Equal("failure", rig.LastDiagnosticsSuffix);
Assert.Equal(false, rig.LastDiagnosticsSucceededFlag);
}
+
+ // ============================================================= PR #50 lifecycle events
+
+ [Fact]
+ public void Handle_emits_diagnostics_collecting_and_uploaded_events_on_success()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
+ rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-blob.zip", SasUrlPrefix = "https://example.test" };
+ var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
+
+ rig.Build(cfg).Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
+
+ Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_collecting");
+ Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_uploaded" &&
+ ((string)e.Data["blobName"]) == "diag-blob.zip");
+ Assert.DoesNotContain(rig.EmittedEvents, e => e.EventType == "diagnostics_upload_failed");
+ }
+
+ [Fact]
+ public void Handle_emits_diagnostics_upload_failed_when_upload_returns_null()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
+ rig.DiagnosticsResult = new DiagnosticsUploadResult { ErrorCode = "upload_5xx" };
+ var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
+
+ rig.Build(cfg).Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
+
+ Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_collecting");
+ Assert.Contains(rig.EmittedEvents, e => e.EventType == "diagnostics_upload_failed" &&
+ ((string)e.Data["errorCode"]) == "upload_5xx");
+ }
+
+ [Fact]
+ public void Handle_whiteglove_part1_writes_marker_and_emits_whiteglove_part1_complete()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.WhiteGloveSealed }.Build();
+
+ rig.Build().Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.WhiteGloveSealed));
+
+ // The whiteglove.complete marker must be present so the next boot classifies as Part 2 resume.
+ Assert.True(rig.SessionPersistence.IsWhiteGloveResume());
+ Assert.Contains(rig.EmittedEvents, e => e.EventType == "whiteglove_part1_complete");
+ // And — critically — self-destruct MUST have been skipped and the enrollment-complete
+ // marker MUST NOT be written, or the next Part-2 boot would ghost-detect itself.
+ Assert.Equal(0, rig.CleanupService.Invocations);
+ Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
+ }
+
+ [Fact]
+ public void Handle_standalone_reboot_fires_when_reboot_enabled_and_no_self_destruct()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
+ var cfg = rig.BuildConfig(selfDestruct: false);
+ cfg.RebootOnComplete = true;
+ cfg.RebootDelaySeconds = 15;
+
+ rig.Build(cfg).Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
+
+ Assert.Equal(1, rig.RebootInvocations);
+ Assert.Equal(15, rig.RebootDelaySeconds);
+ Assert.Contains(rig.EmittedEvents, e => e.EventType == "reboot_triggered");
+ }
+
+ [Fact]
+ public void Handle_does_not_reboot_when_self_destruct_is_enabled()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
+ var cfg = rig.BuildConfig(selfDestruct: true);
+ cfg.RebootOnComplete = true;
+
+ rig.Build(cfg).Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
+
+ // Self-destruct owns the terminal transition; reboot is the no-self-destruct path only.
+ Assert.Equal(0, rig.RebootInvocations);
+ Assert.DoesNotContain(rig.EmittedEvents, e => e.EventType == "reboot_triggered");
+ }
+
+ [Fact]
+ public void Handle_emits_enrollment_summary_shown_when_dialog_enabled()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
+ var cfg = rig.BuildConfig(showDialog: true, selfDestruct: false);
+
+ rig.Build(cfg).Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
+
+ Assert.Contains(rig.EmittedEvents, e => e.EventType == "enrollment_summary_shown");
+ }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs
index 4aed3f5e..e55b0dc0 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigMerger.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Configuration
@@ -17,100 +18,160 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// to defaults set from CLI / bootstrap-config, so tenant
/// admin settings were effectively ignored.
///
- /// CLI-override semantics: command-line flags that were explicitly passed take precedence
- /// over the remote value. In production (Scheduled Task run with no args) the remote value
- /// always wins; in local dev runs the developer can still force a specific behaviour with
- /// a CLI flag without their tenant remote config clobbering it.
+ /// Merge semantics (V1 parity): remote values OVERWRITE the runtime configuration
+ /// unconditionally (V1 ApplyRuntimeSettingsFromRemoteConfig). CLI flags seed the
+ /// initial before Merge runs and then yield to tenant
+ /// policy — if an operator deploys with --reboot-on-complete but the tenant config
+ /// disables reboots, the tenant wins. This matches the V1 behaviour and keeps tenants as
+ /// the source of truth for the knobs that have a remote equivalent.
///
///
public static class RemoteConfigMerger
{
///
/// Overwrites the relevant fields with values from
- /// . Unknown / null / empty remote fields keep the
- /// current value (partial-remote-config tolerance).
+ /// . Remote wins unconditionally for every field that has
+ /// a direct 1:1 mapping. Returns a describing which
+ /// observable knobs changed so the caller can emit audit events (e.g.
+ /// agent_unrestricted_mode_changed) after the orchestrator is running.
///
- public static void Merge(
+ public static RemoteConfigMergeResult Merge(
AgentConfiguration agentConfig,
AgentConfigResponse remoteConfig,
- string[] cliArgs)
+ AgentLogger logger = null)
{
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
- if (remoteConfig == null) return;
- cliArgs = cliArgs ?? Array.Empty();
+ if (remoteConfig == null) return RemoteConfigMergeResult.NoChange();
- // ---- Boolean flags that have a matching CLI override — CLI wins when present.
- if (!ContainsFlag(cliArgs, "--no-cleanup"))
- agentConfig.SelfDestructOnComplete = remoteConfig.SelfDestructOnComplete;
- if (!ContainsFlag(cliArgs, "--keep-logfile"))
- agentConfig.KeepLogFile = remoteConfig.KeepLogFile;
- if (!ContainsFlag(cliArgs, "--reboot-on-complete"))
- agentConfig.RebootOnComplete = remoteConfig.RebootOnComplete;
- if (!ContainsFlag(cliArgs, "--disable-geolocation"))
- agentConfig.EnableGeoLocation = remoteConfig.EnableGeoLocation;
+ var result = new RemoteConfigMergeResult();
- // ---- Log level — CLI `--log-level ` wins. Remote is a string that may not parse.
- if (!ContainsValueArg(cliArgs, "--log-level")
- && !string.IsNullOrWhiteSpace(remoteConfig.LogLevel)
- && Enum.TryParse(remoteConfig.LogLevel, ignoreCase: true, out var parsedLevel))
+ // ---------------- Simple scalar flags — remote wins.
+ agentConfig.SelfDestructOnComplete = remoteConfig.SelfDestructOnComplete;
+ agentConfig.KeepLogFile = remoteConfig.KeepLogFile;
+ agentConfig.RebootOnComplete = remoteConfig.RebootOnComplete;
+ agentConfig.EnableGeoLocation = remoteConfig.EnableGeoLocation;
+
+ // ---------------- Log level — remote wins, but we parse defensively so an invalid
+ // remote value keeps the current level (and logs a warning).
+ result.OldLogLevel = agentConfig.LogLevel;
+ if (!string.IsNullOrWhiteSpace(remoteConfig.LogLevel))
{
- agentConfig.LogLevel = parsedLevel;
+ if (Enum.TryParse(remoteConfig.LogLevel, ignoreCase: true, out var parsedLevel))
+ {
+ if (parsedLevel != agentConfig.LogLevel)
+ {
+ logger?.Info($"Remote config: LogLevel {agentConfig.LogLevel} → {parsedLevel}");
+ agentConfig.LogLevel = parsedLevel;
+ }
+ }
+ else
+ {
+ logger?.Warning($"Remote config: LogLevel '{remoteConfig.LogLevel}' is not a valid AgentLogLevel — keeping {agentConfig.LogLevel}.");
+ }
}
+ result.NewLogLevel = agentConfig.LogLevel;
+ result.LogLevelChanged = result.OldLogLevel != result.NewLogLevel;
- // ---- Fields without a CLI override — always apply remote.
+ // ---------------- Reboot / summary-dialog knobs — direct assignment.
agentConfig.RebootDelaySeconds = remoteConfig.RebootDelaySeconds;
agentConfig.ShowEnrollmentSummary = remoteConfig.ShowEnrollmentSummary;
agentConfig.EnrollmentSummaryTimeoutSeconds = remoteConfig.EnrollmentSummaryTimeoutSeconds;
agentConfig.EnrollmentSummaryBrandingImageUrl = remoteConfig.EnrollmentSummaryBrandingImageUrl;
agentConfig.EnrollmentSummaryLaunchRetrySeconds = remoteConfig.EnrollmentSummaryLaunchRetrySeconds;
- if (!string.IsNullOrWhiteSpace(remoteConfig.NtpServer))
- agentConfig.NtpServer = remoteConfig.NtpServer;
+ // ---------------- NTP / Timezone — remote wins even when the remote sends null so
+ // a tenant admin can clear a previously-applied custom NTP server.
+ agentConfig.NtpServer = remoteConfig.NtpServer;
agentConfig.EnableTimezoneAutoSet = remoteConfig.EnableTimezoneAutoSet;
+ // ---------------- Diagnostics — remote wins (including DiagnosticsUploadMode="Off"
+ // which must clear any CLI-initial default).
agentConfig.DiagnosticsUploadEnabled = remoteConfig.DiagnosticsUploadEnabled;
- if (!string.IsNullOrWhiteSpace(remoteConfig.DiagnosticsUploadMode))
- agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode;
+ agentConfig.DiagnosticsUploadMode = remoteConfig.DiagnosticsUploadMode;
agentConfig.DiagnosticsLogPaths = remoteConfig.DiagnosticsLogPaths ?? new List();
agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents;
- agentConfig.UnrestrictedMode = remoteConfig.UnrestrictedMode;
- // ---- Positive-only int knobs — a zero or negative remote value keeps the current default.
- if (remoteConfig.UploadIntervalSeconds > 0)
- agentConfig.UploadIntervalSeconds = remoteConfig.UploadIntervalSeconds;
+ // ---------------- UnrestrictedMode audit — track old/new for the caller so the
+ // agent_unrestricted_mode_changed event can fire AFTER the
+ // orchestrator emitter is alive (V1 parity with
+ // AuditUnrestrictedModeChange in MonitoringService.cs).
+ result.OldUnrestrictedMode = agentConfig.UnrestrictedMode;
+ agentConfig.UnrestrictedMode = remoteConfig.UnrestrictedMode;
+ result.NewUnrestrictedMode = agentConfig.UnrestrictedMode;
+ result.UnrestrictedModeChanged = result.OldUnrestrictedMode != result.NewUnrestrictedMode;
+
+ // ---------------- ImeMatchLog: remote ships a bool, V2 runtime uses a file path.
+ // V1 parity — `true` expands Constants.ImeMatchLogPath, `false`
+ // nulls the runtime path so the collector disables itself.
+ // A CLI override (--ime-match-log ) already populated
+ // ImeMatchLogPath in BuildAgentConfiguration; we only clobber it
+ // when the remote bool flips off, mirroring V1's unconditional
+ // assignment with tolerance for the explicit dev override.
+ if (remoteConfig.EnableImeMatchLog)
+ {
+ agentConfig.ImeMatchLogPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath);
+ }
+ else
+ {
+ // Only null the path when the user did NOT supply an explicit CLI override.
+ // A non-null value that does not match the default is a dev-time override.
+ var defaultPath = Environment.ExpandEnvironmentVariables(Constants.ImeMatchLogPath);
+ if (string.IsNullOrEmpty(agentConfig.ImeMatchLogPath)
+ || string.Equals(agentConfig.ImeMatchLogPath, defaultPath, StringComparison.OrdinalIgnoreCase))
+ {
+ agentConfig.ImeMatchLogPath = null;
+ }
+ }
+
+ // ---------------- Upload interval / batch — direct, V1 parity (no > 0 gate so a
+ // misconfigured tenant can in theory break uploads, but this
+ // surfaces the config bug instead of silently masking it).
+ agentConfig.UploadIntervalSeconds = remoteConfig.UploadIntervalSeconds;
if (remoteConfig.MaxBatchSize > 0)
agentConfig.MaxBatchSize = remoteConfig.MaxBatchSize;
- // ---- Nested: CollectorConfiguration. Only the fields that have a V2 consumer
- // on AgentConfiguration are projected here; the rest flow directly from
- // remoteConfig.Collectors via DefaultComponentFactory.
+ // ---------------- AuthFailure ceilings — tracker.UpdateLimits() called by caller
+ // so the refresh lands on the live watchdog.
+ agentConfig.MaxAuthFailures = remoteConfig.MaxAuthFailures;
+ agentConfig.AuthFailureTimeoutMinutes = remoteConfig.AuthFailureTimeoutMinutes;
+
+ // ---------------- Nested CollectorConfiguration — only the two knobs that have a
+ // V2 consumer on AgentConfiguration itself. The rest flow through
+ // the untouched remoteConfig.Collectors to DefaultComponentFactory.
var collectors = remoteConfig.Collectors;
if (collectors != null)
{
// AgentMaxLifetimeMinutes: 0 = disabled (valid); passed to the orchestrator as a TimeSpan?.
agentConfig.AgentMaxLifetimeMinutes = collectors.AgentMaxLifetimeMinutes;
+
+ // HelloWaitTimeoutSeconds: V1 parity — only apply when > 0 so an accidentally
+ // zero/negative remote value keeps the current non-zero default. V1:
+ // MonitoringService.ApplyRuntimeSettingsFromRemoteConfig preserves the current
+ // setting instead of disabling Hello-wait altogether.
if (collectors.HelloWaitTimeoutSeconds > 0)
agentConfig.HelloWaitTimeoutSeconds = collectors.HelloWaitTimeoutSeconds;
}
- }
- private static bool ContainsFlag(string[] args, string flag)
- {
- for (int i = 0; i < args.Length; i++)
- if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase))
- return true;
- return false;
- }
-
- private static bool ContainsValueArg(string[] args, string flag)
- {
- // Value-args need both the flag and a subsequent token, otherwise the CLI helper would
- // not have parsed them — mirror GetArgValue's "i < args.Length - 1" contract.
- for (int i = 0; i < args.Length - 1; i++)
- if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase))
- return true;
- return false;
+ return result;
}
}
+
+ ///
+ /// Observable side-effects of . The caller uses this
+ /// to decide whether to emit audit events / reconfigure live components (logger, watchdog)
+ /// after the orchestrator is alive.
+ ///
+ public sealed class RemoteConfigMergeResult
+ {
+ public bool UnrestrictedModeChanged { get; set; }
+ public bool OldUnrestrictedMode { get; set; }
+ public bool NewUnrestrictedMode { get; set; }
+
+ public bool LogLevelChanged { get; set; }
+ public AgentLogLevel OldLogLevel { get; set; }
+ public AgentLogLevel NewLogLevel { get; set; }
+
+ public static RemoteConfigMergeResult NoChange() => new RemoteConfigMergeResult();
+ }
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs
index 7915daa2..d5770b01 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs
@@ -83,18 +83,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
{
_logger.Warning($"Config fetch authentication failed: {ex.Message}");
- // Auth failure → use distress channel (pre-auth, works without cert)
- if (_distressReporter != null)
- {
- _ = _distressReporter.TrySendAsync(
- DistressErrorType.ConfigFetchDenied,
- ex.Message,
- httpStatusCode: ex.StatusCode);
- }
-
- // Feed the central auth-failure tracker so the agent can shut down cleanly
- // after MaxAuthFailures / AuthFailureTimeoutMinutes is exceeded, instead of
- // hammering the backend forever when the device certificate is revoked.
+ // V1 parity — AuthFailureTracker is the single dispatch point for auth-failure
+ // distress signals (see HandleAuthFailure in EventUploadOrchestrator). It sends
+ // the first-failure report via its constructor-injected DistressReporter with
+ // the correct DistressErrorType per status code (401 → AuthCertificateRejected,
+ // 403 → DeviceNotRegistered) and suppresses duplicates on subsequent failures.
_authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config");
}
catch (Exception ex)
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs
index b971054a..377f8ccf 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs
@@ -121,9 +121,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
}
///
- /// Registers a new enrollment session
+ /// Registers a new enrollment session.
+ ///
+ /// virtual so test doubles (e.g. SessionRegistrationHelperTests.FakeApiClient)
+ /// can intercept the HTTP call without spinning up a real mTLS stack. Production path is
+ /// unchanged — the virtual dispatch cost is negligible vs. the network round-trip.
+ ///
///
- public async Task RegisterSessionAsync(SessionRegistration registration)
+ public virtual async Task RegisterSessionAsync(SessionRegistration registration)
{
var request = new RegisterSessionRequest { Registration = registration };
var endpoint = _useBootstrapTokenAuth
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
index fbd80b40..aa8c319c 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
@@ -36,11 +36,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
///
/// Optional peripheral hosts (driven by toggles):
///
- /// - PerformanceHost — CPU/memory/disk samples.
- /// - AgentSelfMetricsHost — process CPU, memory and HTTP traffic counters.
- /// Wires into the instance created by the Program.cs
- /// (the V2
- /// has no metrics — accepted M4.5.b tech-debt).
+ /// - PeriodicCollectorLifecycleHost — owns PerformanceCollector (CPU /
+ /// memory / disk samples) and AgentSelfMetricsCollector (process CPU, memory and
+ /// HTTP traffic counters) under a single idle-timeout window (V1 parity with
+ /// PeriodicCollectorManager). Wires AgentSelfMetricsCollector into the
+ /// instance created by the Program.cs
+ /// .
+ /// - NetworkChangeHost — owns NetworkChangeDetector for WiFi / SSID /
+ /// default-route / MDM-reachability transitions (V1 parity with
+ /// CollectorCoordinator.StartOptionalCollectors:375-382).
///
///
///
@@ -122,6 +126,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
hosts.Add(new AadJoinHost(logger, ingress, clock));
+ // Dev / test — if --replay-log-dir is set, the tracker reads from the replay folder
+ // with SimulationMode ON + the configured SpeedFactor instead of tailing the live
+ // IME log folder. Production agents leave ReplayLogDir empty.
+ var simulationMode = !string.IsNullOrEmpty(_agentConfig.ReplayLogDir);
+ var imeLogFolder = simulationMode
+ ? _agentConfig.ReplayLogDir
+ : _agentConfig.ImeLogPathOverride;
+
_imeLogHost = new ImeLogHost(
sessionId: sessionId,
tenantId: tenantId,
@@ -129,11 +141,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
logger: logger,
ingress: ingress,
clock: clock,
- imeLogPathOverride: _agentConfig.ImeLogPathOverride,
+ imeLogPathOverride: imeLogFolder,
imeMatchLogPath: _agentConfig.ImeMatchLogPath,
imePatterns: _remoteConfig.ImeLogPatterns,
stateDirectory: _stateDirectory,
- whiteGloveSealingPatternIds: whiteGloveSealingPatternIds);
+ whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
+ simulationMode: simulationMode,
+ simulationSpeedFactor: _agentConfig.ReplaySpeedFactor);
hosts.Add(_imeLogHost);
if (collectors.StallProbeEnabled)
@@ -154,17 +168,37 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// ----- Peripheral hosts (event-only; driven by remote-config toggles) --------------
- if (collectors.EnablePerformanceCollector)
+ // V1 parity (PeriodicCollectorManager) — combine Performance + AgentSelfMetrics under
+ // a single host that stops both after CollectorIdleTimeoutMinutes of no real enrollment
+ // activity and restarts them on the next real event. Without the idle timeout the two
+ // collectors run for the agent's entire lifetime (up to AgentMaxLifetime / 6 h),
+ // which drains battery + wastes bandwidth on dormant sessions.
+ if (collectors.EnablePerformanceCollector || (collectors.EnableAgentSelfMetrics && _networkMetrics != null))
{
- hosts.Add(new PerformanceHost(sessionId, tenantId, onEnrollmentEvent, logger,
- collectors.PerformanceIntervalSeconds));
+ hosts.Add(new PeriodicCollectorLifecycleHost(
+ sessionId: sessionId,
+ tenantId: tenantId,
+ onEnrollmentEvent: onEnrollmentEvent,
+ logger: logger,
+ performanceEnabled: collectors.EnablePerformanceCollector,
+ performanceIntervalSeconds: collectors.PerformanceIntervalSeconds,
+ selfMetricsEnabled: collectors.EnableAgentSelfMetrics && _networkMetrics != null,
+ selfMetricsIntervalSeconds: collectors.AgentSelfMetricsIntervalSeconds,
+ idleTimeoutMinutes: collectors.CollectorIdleTimeoutMinutes,
+ networkMetrics: _networkMetrics,
+ agentVersion: _agentVersion));
}
- if (collectors.EnableAgentSelfMetrics && _networkMetrics != null)
- {
- hosts.Add(new AgentSelfMetricsHost(sessionId, tenantId, onEnrollmentEvent, logger,
- _networkMetrics, _agentVersion, collectors.AgentSelfMetricsIntervalSeconds));
- }
+ // V1 parity (CollectorCoordinator.StartOptionalCollectors:375-382) — wire the
+ // NetworkChangeDetector. It captures WiFi SSID / default route / IPv4 / reachability
+ // changes and emits `network_change` events. Events are already debounced internally
+ // (5s); no separate remote-config toggle in V1 either.
+ hosts.Add(new NetworkChangeHost(
+ sessionId: sessionId,
+ tenantId: tenantId,
+ onEnrollmentEvent: onEnrollmentEvent,
+ logger: logger,
+ apiBaseUrl: _agentConfig.ApiBaseUrl));
// M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: only polls when the
// IME log tracker reports an app entering Downloading/Installing (see AppStateChanged
@@ -192,7 +226,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
onEnrollmentEvent: onEnrollmentEvent,
logger: logger,
rules: _remoteConfig.GatherRules,
- imeLogPathOverride: _agentConfig.ImeLogPathOverride));
+ imeLogPathOverride: _agentConfig.ImeLogPathOverride,
+ unrestrictedMode: _agentConfig.UnrestrictedMode));
}
return hosts;
@@ -209,6 +244,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly Monitoring.Telemetry.Gather.GatherRuleExecutor _executor;
private readonly System.Collections.Generic.List _rules;
private readonly AgentLogger _logger;
+ private readonly bool _unrestrictedMode;
private int _disposed;
public GatherRuleExecutorHost(
@@ -217,18 +253,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
Action onEnrollmentEvent,
AgentLogger logger,
System.Collections.Generic.List rules,
- string? imeLogPathOverride)
+ string? imeLogPathOverride,
+ bool unrestrictedMode = false)
{
_logger = logger;
_rules = rules ?? new System.Collections.Generic.List();
+ _unrestrictedMode = unrestrictedMode;
_executor = new Monitoring.Telemetry.Gather.GatherRuleExecutor(
sessionId, tenantId, onEnrollmentEvent, logger, imeLogPathOverride);
}
public void Start()
{
+ // V1 parity (CollectorCoordinator.StartGatherRuleExecutor) — propagate the
+ // tenant-controlled UnrestrictedMode BEFORE UpdateRules so any startup-trigger
+ // rule sees the elevated policy when AllowList checks would otherwise reject it.
+ _executor.UnrestrictedMode = _unrestrictedMode;
_executor.UpdateRules(_rules);
- _logger.Info($"GatherRuleExecutorHost: started with {_rules.Count} rule(s).");
+ _logger.Info(
+ $"GatherRuleExecutorHost: started with {_rules.Count} rule(s), unrestrictedMode={_unrestrictedMode}.");
}
public void Stop()
@@ -368,6 +411,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
///
internal Monitoring.Enrollment.Ime.ImeLogTracker Tracker => _tracker;
+ ///
+ /// Exposes the tracker's simulation flag so the Dev / Test CLI flag
+ /// --replay-log-dir is testable without poking through reflection.
+ ///
+ internal bool IsSimulationMode => _tracker.SimulationMode;
+
public ImeLogHost(
string sessionId,
string tenantId,
@@ -379,7 +428,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
string? imeMatchLogPath,
List? imePatterns,
string stateDirectory,
- IReadOnlyCollection? whiteGloveSealingPatternIds)
+ IReadOnlyCollection? whiteGloveSealingPatternIds,
+ bool simulationMode = false,
+ double simulationSpeedFactor = 50)
{
_logger = logger;
var logFolder = string.IsNullOrEmpty(imeLogPathOverride) ? DefaultImeLogFolder : imeLogPathOverride!;
@@ -395,6 +446,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
matchLogPath: expandedMatchLogPath,
stateDirectory: stateDirectory);
+ if (simulationMode)
+ {
+ _tracker.SimulationMode = true;
+ _tracker.SpeedFactor = simulationSpeedFactor;
+ logger.Info($"ImeLogHost: SimulationMode ENABLED (speedFactor={simulationSpeedFactor}, path={logFolder})");
+ }
+
_adapter = new ImeLogTrackerAdapter(_tracker, ingress, clock, whiteGloveSealingPatternIds);
_processWatcher = new ImeProcessWatcher(sessionId, tenantId, onEnrollmentEvent, logger);
@@ -507,33 +565,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
- private sealed class PerformanceHost : ICollectorHost
- {
- public string Name => "PerformanceCollector";
-
- private readonly PerformanceCollector _collector;
- private int _disposed;
-
- public PerformanceHost(
- string sessionId,
- string tenantId,
- Action onEnrollmentEvent,
- AgentLogger logger,
- int intervalSeconds)
- {
- _collector = new PerformanceCollector(sessionId, tenantId, onEnrollmentEvent, logger, intervalSeconds);
- }
-
- public void Start() => _collector.Start();
- public void Stop() => _collector.Stop();
-
- public void Dispose()
- {
- if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
- try { _collector.Dispose(); } catch { }
- }
- }
-
private sealed class DeliveryOptimizationHost : ICollectorHost
{
public string Name => "DeliveryOptimizationCollector";
@@ -570,13 +601,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
logDirectory: Environment.ExpandEnvironmentVariables(Shared.Constants.LogDirectory));
}
+ private Action? _chainedHandler;
+
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) =>
+ _chainedHandler = (pkg, oldState, newState) =>
{
try { _prevStateChanged?.Invoke(pkg, oldState, newState); }
catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: previous OnAppStateChanged handler threw: {ex.Message}"); }
@@ -588,6 +621,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
catch (Exception ex) { _logger.Warning($"DeliveryOptimizationHost: WakeUp threw: {ex.Message}"); }
}
};
+ _imeHost.Tracker.OnAppStateChanged = _chainedHandler;
_collector.Start();
_logger.Info($"DeliveryOptimizationHost: started dormant (interval={_collector}, wakes on Downloading/Installing).");
@@ -596,9 +630,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
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))
+ // someone else replaced us and owns the slot now. The V1 bug here compared the
+ // handler slot against itself (always true) which silently overwrote any newer
+ // handler that had since taken over; the captured `_chainedHandler` reference
+ // lets us make the check actually meaningful.
+ if (_chainedHandler != null
+ && object.ReferenceEquals(_imeHost.Tracker.OnAppStateChanged, _chainedHandler))
+ {
_imeHost.Tracker.OnAppStateChanged = _prevStateChanged;
+ }
_collector.Stop();
}
@@ -609,33 +649,273 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
}
}
- private sealed class AgentSelfMetricsHost : ICollectorHost
+ ///
+ /// V1 parity (PeriodicCollectorManager). Owns the
+ /// and and enforces the
+ /// CollectorIdleTimeoutMinutes window: both stop after N minutes of no real event
+ /// activity and restart on the next real (non-periodic) event. Without this the two
+ /// collectors ran forever in V2 and filled the telemetry spool on dormant sessions.
+ ///
+ /// Activity is observed by wrapping the onEnrollmentEvent sink: every event that
+ /// flows through this host bumps _lastRealEventTimeUtc unless it matches a
+ /// well-known periodic event type (performance_snapshot, agent_metrics_snapshot,
+ /// stall_probe_*, session_stalled). Events emitted by the managed
+ /// collectors themselves therefore never reset their own idle window.
+ ///
+ ///
+ private sealed class PeriodicCollectorLifecycleHost : ICollectorHost
{
- public string Name => "AgentSelfMetricsCollector";
+ public string Name => "PeriodicCollectorLifecycleHost";
- private readonly AgentSelfMetricsCollector _collector;
+ private static readonly TimeSpan IdleTickInterval = TimeSpan.FromSeconds(60);
+ private static readonly HashSet PeriodicEventTypes = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "performance_snapshot",
+ "agent_metrics_snapshot",
+ "performance_collector_stopped",
+ "agent_metrics_collector_stopped",
+ "stall_probe_check",
+ "stall_probe_result",
+ "session_stalled",
+ };
+
+ private readonly string _sessionId;
+ private readonly string _tenantId;
+ private readonly Action _emitEvent;
+ private readonly AgentLogger _logger;
+ private readonly bool _perfEnabled;
+ private readonly int _perfIntervalSeconds;
+ private readonly bool _selfMetricsEnabled;
+ private readonly int _selfMetricsIntervalSeconds;
+ private readonly int _idleTimeoutMinutes;
+ private readonly NetworkMetrics? _networkMetrics;
+ private readonly string _agentVersion;
+
+ private readonly object _sync = new object();
+ private PerformanceCollector? _performanceCollector;
+ private AgentSelfMetricsCollector? _selfMetricsCollector;
+ private Timer? _idleTimer;
+ private DateTime _lastRealEventTimeUtc;
+ private bool _idleStopped;
private int _disposed;
- public AgentSelfMetricsHost(
+ public PeriodicCollectorLifecycleHost(
string sessionId,
string tenantId,
Action onEnrollmentEvent,
AgentLogger logger,
- NetworkMetrics networkMetrics,
- string agentVersion,
- int intervalSeconds)
+ bool performanceEnabled,
+ int performanceIntervalSeconds,
+ bool selfMetricsEnabled,
+ int selfMetricsIntervalSeconds,
+ int idleTimeoutMinutes,
+ NetworkMetrics? networkMetrics,
+ string agentVersion)
{
- _collector = new AgentSelfMetricsCollector(
- sessionId, tenantId, onEnrollmentEvent, networkMetrics, logger, agentVersion, intervalSeconds);
+ _sessionId = sessionId;
+ _tenantId = tenantId;
+ _emitEvent = WrapEventSink(onEnrollmentEvent);
+ _logger = logger;
+ _perfEnabled = performanceEnabled;
+ _perfIntervalSeconds = performanceIntervalSeconds;
+ _selfMetricsEnabled = selfMetricsEnabled;
+ _selfMetricsIntervalSeconds = selfMetricsIntervalSeconds;
+ _idleTimeoutMinutes = idleTimeoutMinutes;
+ _networkMetrics = networkMetrics;
+ _agentVersion = agentVersion;
+ _lastRealEventTimeUtc = DateTime.UtcNow;
}
- public void Start() => _collector.Start();
- public void Stop() => _collector.Stop();
+ public void Start()
+ {
+ lock (_sync)
+ {
+ StartCollectorsInternal();
+ if (_idleTimeoutMinutes > 0)
+ {
+ _idleTimer = new Timer(_ => IdleCheckTick(), state: null,
+ dueTime: IdleTickInterval, period: IdleTickInterval);
+ _logger.Info($"PeriodicCollectorLifecycleHost: started (idle timeout={_idleTimeoutMinutes}min).");
+ }
+ else
+ {
+ _logger.Info("PeriodicCollectorLifecycleHost: started (idle timeout disabled).");
+ }
+ }
+ }
+
+ public void Stop()
+ {
+ lock (_sync)
+ {
+ try { _idleTimer?.Dispose(); } catch { }
+ _idleTimer = null;
+ StopCollectorsInternal();
+ }
+ }
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
- try { _collector.Dispose(); } catch { }
+ Stop();
+ }
+
+ private void StartCollectorsInternal()
+ {
+ if (_perfEnabled && _performanceCollector == null)
+ {
+ _performanceCollector = new PerformanceCollector(
+ _sessionId, _tenantId, _emitEvent, _logger, _perfIntervalSeconds);
+ _performanceCollector.Start();
+ }
+ if (_selfMetricsEnabled && _selfMetricsCollector == null && _networkMetrics != null)
+ {
+ _selfMetricsCollector = new AgentSelfMetricsCollector(
+ _sessionId, _tenantId, _emitEvent, _networkMetrics, _logger, _agentVersion, _selfMetricsIntervalSeconds);
+ _selfMetricsCollector.Start();
+ }
+ _idleStopped = false;
+ }
+
+ private void StopCollectorsInternal()
+ {
+ try { _performanceCollector?.Stop(); } catch { }
+ try { _performanceCollector?.Dispose(); } catch { }
+ _performanceCollector = null;
+
+ try { _selfMetricsCollector?.Stop(); } catch { }
+ try { _selfMetricsCollector?.Dispose(); } catch { }
+ _selfMetricsCollector = null;
+ }
+
+ private Action WrapEventSink(Action inner)
+ {
+ return evt =>
+ {
+ if (evt == null)
+ {
+ inner(null!);
+ return;
+ }
+ if (!string.IsNullOrEmpty(evt.EventType)
+ && !PeriodicEventTypes.Contains(evt.EventType))
+ {
+ OnRealEvent();
+ }
+ inner(evt);
+ };
+ }
+
+ private void OnRealEvent()
+ {
+ lock (_sync)
+ {
+ _lastRealEventTimeUtc = DateTime.UtcNow;
+ if (_idleStopped)
+ {
+ _logger.Info("PeriodicCollectorLifecycleHost: real event detected — restarting periodic collectors.");
+ StartCollectorsInternal();
+ }
+ }
+ }
+
+ private void IdleCheckTick()
+ {
+ try
+ {
+ lock (_sync)
+ {
+ if (_idleStopped) return;
+ if (_idleTimeoutMinutes <= 0) return;
+
+ var idleMinutes = (DateTime.UtcNow - _lastRealEventTimeUtc).TotalMinutes;
+ if (idleMinutes < _idleTimeoutMinutes) return;
+
+ _logger.Info($"PeriodicCollectorLifecycleHost: idle for {idleMinutes:F0}min (limit={_idleTimeoutMinutes}) — stopping collectors.");
+
+ var hadPerformance = _performanceCollector != null;
+ var hadSelfMetrics = _selfMetricsCollector != null;
+ StopCollectorsInternal();
+ _idleStopped = true;
+
+ if (hadPerformance)
+ EmitIdleStopped("performance_collector_stopped", idleMinutes);
+ if (hadSelfMetrics)
+ EmitIdleStopped("agent_metrics_collector_stopped", idleMinutes);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"PeriodicCollectorLifecycleHost: idle-check tick threw: {ex.Message}");
+ }
+ }
+
+ private void EmitIdleStopped(string eventType, double idleMinutes)
+ {
+ try
+ {
+ _emitEvent(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = eventType,
+ Severity = EventSeverity.Info,
+ Source = "PeriodicCollectorLifecycleHost",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"{eventType} after {idleMinutes:F0}min idle (no real enrollment activity).",
+ Data = new Dictionary
+ {
+ { "reason", "idle_timeout" },
+ { "idleTimeoutMinutes", _idleTimeoutMinutes },
+ { "idleMinutes", Math.Round(idleMinutes, 1) },
+ },
+ });
+ }
+ catch (Exception ex) { _logger.Debug($"PeriodicCollectorLifecycleHost: emit '{eventType}' threw: {ex.Message}"); }
+ }
+ }
+
+ ///
+ /// V1 parity (CollectorCoordinator.StartOptionalCollectors:375-382) — owns the
+ /// lifecycle of . Without this host the detector class
+ /// existed in V2 but was never instantiated, so WiFi / SSID / connectivity transitions
+ /// were invisible to the backend.
+ ///
+ private sealed class NetworkChangeHost : ICollectorHost
+ {
+ public string Name => "NetworkChangeDetector";
+
+ private readonly NetworkChangeDetector _detector;
+ private readonly AgentLogger _logger;
+ private int _disposed;
+
+ public NetworkChangeHost(
+ string sessionId,
+ string tenantId,
+ Action onEnrollmentEvent,
+ AgentLogger logger,
+ string? apiBaseUrl)
+ {
+ _logger = logger;
+ _detector = new NetworkChangeDetector(sessionId, tenantId, onEnrollmentEvent, logger, apiBaseUrl);
+ }
+
+ public void Start()
+ {
+ try { _detector.Start(); }
+ catch (Exception ex) { _logger.Warning($"NetworkChangeHost: Start failed: {ex.Message}"); }
+ }
+
+ public void Stop()
+ {
+ try { _detector.Stop(); }
+ catch (Exception ex) { _logger.Warning($"NetworkChangeHost: Stop failed: {ex.Message}"); }
+ }
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
+ try { _detector.Dispose(); } catch { }
}
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs
index 801a8e5e..cfa07083 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/AuthFailureTracker.cs
@@ -1,7 +1,9 @@
using System;
using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
@@ -18,6 +20,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// AgentConfigResponse (defaults: 5 consecutive, time window disabled).
///
///
+ /// V1 parity (EventUploadOrchestrator.HandleAuthFailure): the tracker is the single
+ /// dispatch point for distress reports. The first failure emits a distress signal
+ /// via the optional with
+ /// for 401 and
+ /// for 403. Subsequent failures are
+ /// logged but do not spam the distress channel — there is no point asking the backend
+ /// twice about a certificate it already rejected.
+ ///
+ ///
/// Thread-safety: consecutive-count uses ; the first-failure timestamp
/// is protected by a single monitor lock. is raised outside the
/// lock to prevent handler re-entrancy deadlocks.
@@ -27,6 +38,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
{
private readonly IClock _clock;
private readonly AgentLogger _logger;
+ private readonly DistressReporter _distressReporter;
private readonly object _windowLock = new object();
private int _maxFailures; // 0 = disabled
@@ -39,10 +51,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
int maxFailures,
int timeoutMinutes,
IClock clock,
- AgentLogger logger)
+ AgentLogger logger,
+ DistressReporter distressReporter = null)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _distressReporter = distressReporter;
UpdateLimits(maxFailures, timeoutMinutes);
}
@@ -67,8 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
///
- /// Record a 401/403 from the given . Emits
- /// the first time either ceiling is crossed; subsequent calls after termination is armed are no-ops.
+ /// Record a 401/403 from the given . V1 parity — on the FIRST
+ /// failure this fires a single distress report via the constructor-injected
+ /// (401 → AuthCertificateRejected, 403 → DeviceNotRegistered).
+ /// Subsequent failures are logged + counted but do not dispatch further distress signals.
+ /// Emits the first time either ceiling is crossed;
+ /// subsequent calls after termination is armed are no-ops.
///
public void RecordFailure(int statusCode, string operation)
{
@@ -84,12 +102,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
firstFailureAt = _firstFailureUtc.Value;
}
+ // V1 parity — only the first consecutive failure produces a distress report. Repeat
+ // hits at 2..N use the local log only; the backend already knows from the first
+ // report that the device is unauthorized.
+ if (count == 1 && _distressReporter != null)
+ {
+ var distressType = statusCode == 403
+ ? DistressErrorType.DeviceNotRegistered
+ : DistressErrorType.AuthCertificateRejected;
+ try
+ {
+ _ = _distressReporter.TrySendAsync(
+ distressType,
+ $"Backend returned {statusCode} during {operation}",
+ httpStatusCode: statusCode);
+ }
+ catch (Exception ex)
+ {
+ _logger.Debug($"AuthFailureTracker: distress dispatch threw: {ex.Message}");
+ }
+ }
+
bool countExceeded = _maxFailures > 0 && count >= _maxFailures;
bool windowExceeded = _timeoutWindow.HasValue && (now - firstFailureAt) >= _timeoutWindow.Value;
if (!countExceeded && !windowExceeded)
{
- _logger.Warning($"AuthFailureTracker: consecutive auth failure #{count} (http {statusCode}, {operation}).");
+ _logger.Warning(
+ $"Authentication failure {count}/{_maxFailures} (first failure at {firstFailureAt:HH:mm:ss}, " +
+ $"http {statusCode}, {operation}).");
return;
}
@@ -99,7 +140,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
? $"consecutive auth failures reached limit ({count} >= {_maxFailures})"
: $"auth-failure time window exceeded ({(now - firstFailureAt).TotalMinutes:F0}min >= {_timeoutWindow.Value.TotalMinutes:F0}min)";
- _logger.Error($"AuthFailureTracker: {reason} — signalling shutdown. Last operation: {operation}, statusCode={statusCode}.");
+ _logger.Error(
+ $"=== AGENT SHUTDOWN: {count} consecutive authentication failures (401/403) — " +
+ "backend rejecting requests. Terminating agent to prevent distress spam. ===");
+ _logger.Error($"AuthFailureTracker: {reason}. Last operation: {operation}, statusCode={statusCode}.");
try
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs
index 202fe1a4..a8794ec4 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/BinaryIntegrityVerifier.cs
@@ -1,8 +1,10 @@
using System;
+using System.Diagnostics;
using System.IO;
-using System.Reflection;
using System.Security.Cryptography;
using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Security
@@ -17,35 +19,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// - self-update failed silently — a previous update swapped the ZIP but not the EXE.
///
///
- /// We intentionally do not terminate or auto-update from this check:
- /// already runs at startup and its hash-based trigger covers the "wrong EXE" case. All we do
- /// here is emit an AgentErrorType.IntegrityCheckFailed report so operators see the drift.
+ /// V1 parity: on mismatch an / AgentErrorType.IntegrityCheckFailed
+ /// report is emitted by the caller, and — if is wired —
+ /// is invoked in force-update mode to auto-heal.
+ /// The trigger is single-shot per agent process ( guard) so a noisy
+ /// config fetch cannot drive the update pipeline multiple times in a row.
///
///
public static class BinaryIntegrityVerifier
{
+ private static int _runtimeSelfUpdateFired;
+
///
- /// Computes the SHA-256 of (or the currently executing assembly
- /// when null) and compares it against .
+ /// Computes the SHA-256 of (or the current process' main-module
+ /// image when null) and compares it against .
/// Case-insensitive comparison (both lowercase and uppercase hex strings accepted).
+ ///
+ /// When is non-null and the hashes mismatch,
+ /// fires the trigger once per agent process — typically wired to
+ /// SelfUpdater.CheckAndApplyUpdateAsync(forceUpdate: true, triggerReason: "runtime_hash_mismatch").
+ ///
///
+ /// Backend-advertised SHA-256 (lowercase hex preferred).
+ /// Agent logger. Match uses Info, mismatch uses Error (V1 parity).
+ /// Optional explicit path. null = use Process.GetCurrentProcess().MainModule.FileName.
+ /// Optional auto-heal hook. Params: (zipHash, allowDowngrade) → Task.
+ /// Value of AgentConfigResponse.LatestAgentSha256 — forwarded to the trigger.
+ /// Value of AgentConfigResponse.AllowAgentDowngrade — forwarded to the trigger.
public static IntegrityCheckResult Check(
string expectedSha256,
AgentLogger logger,
- string exePath = null)
+ string exePath = null,
+ Func runtimeSelfUpdateTrigger = null,
+ string zipHash = null,
+ bool allowDowngrade = false)
{
if (logger == null) throw new ArgumentNullException(nameof(logger));
if (string.IsNullOrWhiteSpace(expectedSha256))
{
- logger.Debug("BinaryIntegrityVerifier: no expected hash provided — skipping check.");
+ logger.Debug("Post-config integrity check: no expected hash provided — skipping.");
return IntegrityCheckResult.Skipped();
}
- var path = exePath ?? Assembly.GetExecutingAssembly().Location;
+ var path = exePath ?? ResolveMainModulePath();
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
- logger.Warning($"BinaryIntegrityVerifier: executing assembly path not resolvable ({path ?? "null"}) — skipping check.");
+ logger.Warning($"Post-config integrity check: cannot resolve EXE path ({path ?? "null"}) — skipping.");
return IntegrityCheckResult.Skipped();
}
@@ -56,22 +76,79 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
}
catch (Exception ex)
{
- logger.Warning($"BinaryIntegrityVerifier: failed to hash '{path}': {ex.Message}");
+ logger.Warning($"Post-config integrity check: hashing failed — {ex.Message}. Skipping.");
return IntegrityCheckResult.Skipped();
}
if (string.Equals(actualSha, expectedSha256, StringComparison.OrdinalIgnoreCase))
{
- logger.Debug($"BinaryIntegrityVerifier: SHA-256 match (sha={Truncate(actualSha)}).");
+ logger.Info($"Post-config integrity check: SHA-256 verified OK ({Truncate(actualSha)}).");
return IntegrityCheckResult.Match(actualSha);
}
- logger.Warning(
- $"BinaryIntegrityVerifier: SHA-256 MISMATCH — running exe={Truncate(actualSha)}, backend advertises={Truncate(expectedSha256)}. " +
- "Possible tamper, stale blob, or failed self-update.");
+ logger.Error(
+ $"Post-config integrity check: SHA-256 MISMATCH — running exe={Truncate(actualSha)}, " +
+ $"backend advertises={Truncate(expectedSha256)} — possible tamper, stale blob, or failed self-update.");
+
+ TriggerRuntimeSelfUpdate(logger, runtimeSelfUpdateTrigger, zipHash, allowDowngrade);
+
return IntegrityCheckResult.Mismatch(actualSha, expectedSha256);
}
+ /// Exposed for tests — resets the single-shot trigger so each test starts clean.
+ internal static void ResetTriggerForTests() => Interlocked.Exchange(ref _runtimeSelfUpdateFired, 0);
+
+ private static void TriggerRuntimeSelfUpdate(
+ AgentLogger logger,
+ Func runtimeSelfUpdateTrigger,
+ string zipHash,
+ bool allowDowngrade)
+ {
+ if (runtimeSelfUpdateTrigger == null)
+ {
+ logger.Warning("Runtime self-update trigger not wired — mismatch reported but no auto-heal possible.");
+ return;
+ }
+
+ if (Interlocked.Exchange(ref _runtimeSelfUpdateFired, 1) == 1)
+ {
+ logger.Debug("Runtime self-update trigger already fired this session — ignoring.");
+ return;
+ }
+
+ // Run the auto-heal on the thread pool — SelfUpdater.CheckAndApplyUpdateAsync
+ // blocks for the version/ZIP fetch and, on success, calls Environment.Exit before
+ // returning, so the awaiter never resolves. Do NOT block the caller on this.
+ Task.Run(async () =>
+ {
+ try
+ {
+ logger.Info($"Runtime self-update trigger: invoking SelfUpdater (forceUpdate=true, allowDowngrade={allowDowngrade}).");
+ await runtimeSelfUpdateTrigger(zipHash, allowDowngrade).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ logger.Error("Runtime self-update trigger failed.", ex);
+ }
+ });
+ }
+
+ private static string ResolveMainModulePath()
+ {
+ try
+ {
+ // V1 parity — MonitoringService.VerifyAgentBinaryIntegrity uses
+ // Process.GetCurrentProcess().MainModule.FileName, NOT Assembly.Location.
+ // Under ngen / single-file publish scenarios MainModule resolves to the real
+ // on-disk image while Assembly.Location can be empty or point at a shadow copy.
+ return Process.GetCurrentProcess().MainModule?.FileName;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
internal static string ComputeSha256Hex(string filePath)
{
using (var stream = File.OpenRead(filePath))
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs
new file mode 100644
index 00000000..39eff4f7
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/EnrollmentRegistryDetector.cs
@@ -0,0 +1,75 @@
+using System;
+using Microsoft.Win32;
+
+namespace AutopilotMonitor.Agent.V2.Core.Security
+{
+ ///
+ /// Read-only registry probes for enrollment metadata the agent has to pin down at session
+ /// registration time. V1 parity — these mirror EnrollmentTracker.DetectHybridJoinStatic
+ /// and EnrollmentTracker.DetectEnrollmentTypeStatic from the legacy agent.
+ ///
+ /// Kept stateless and exception-swallowing so the registration pipeline can call them
+ /// before any other component is up. A missing / inaccessible registry key degrades to
+ /// the default value (v1 / non-hybrid) rather than throwing — mirrors V1 behaviour.
+ ///
+ ///
+ public static class EnrollmentRegistryDetector
+ {
+ private const string AutopilotPolicyCacheKey = @"SOFTWARE\Microsoft\Provisioning\AutopilotPolicyCache";
+ private const string AutopilotSettingsKey = @"SOFTWARE\Microsoft\Provisioning\AutopilotSettings";
+
+ ///
+ /// Reads CloudAssignedDomainJoinMethod from the Autopilot policy cache. Returns
+ /// true when the profile was deployed with Hybrid Azure AD Join
+ /// (CloudAssignedDomainJoinMethod == 1), false otherwise or on any error.
+ ///
+ public static bool DetectHybridJoin()
+ {
+ try
+ {
+ using (var key = Registry.LocalMachine.OpenSubKey(AutopilotPolicyCacheKey))
+ {
+ if (key == null) return false;
+ var domainJoinMethod = key.GetValue("CloudAssignedDomainJoinMethod")?.ToString();
+ return domainJoinMethod == "1";
+ }
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Classifies the Autopilot flow based on the AutopilotSettings registry:
+ ///
+ /// - CloudAssignedDeviceRegistration == 2 → "v2" (Windows Device Preparation).
+ /// - CloudAssignedEspEnabled == 0 → "v2" (no ESP, WDP indicator).
+ /// - Anything else → "v1" (Classic Autopilot / ESP).
+ ///
+ /// Defaults to "v1" on any error so a flaky registry does not misclassify a
+ /// classic session as WDP.
+ ///
+ public static string DetectEnrollmentType()
+ {
+ try
+ {
+ using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey))
+ {
+ if (key == null) return "v1";
+
+ var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString();
+ if (deviceReg == "2") return "v2";
+
+ var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString();
+ if (espEnabled == "0") return "v2";
+ }
+ }
+ catch
+ {
+ // Fall through to v1 default.
+ }
+ return "v1";
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs
index e9fb5325..a1e4b15c 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs
@@ -132,6 +132,38 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
lock (_lockObject) return File.Exists(_whiteGloveMarkerPath);
}
+ ///
+ /// V1 parity (SessionPersistence.SaveWhiteGloveComplete) — persists the
+ /// whiteglove.complete marker so the next agent boot recognises this as a
+ /// Part-2 resume (). Without this marker the
+ /// emergency-break watchdog would keep counting the session's wall-clock age through
+ /// the Part-1/Part-2 power-off window and eventually trip during Part-2 setup.
+ ///
+ public void SaveWhiteGloveComplete(AgentLogger logger = null)
+ {
+ lock (_lockObject)
+ {
+ try
+ {
+ File.WriteAllText(_whiteGloveMarkerPath, DateTime.UtcNow.ToString("O"));
+ logger?.Info("SessionIdPersistence: whiteglove.complete marker saved — Part 2 detection enabled.");
+ }
+ catch (Exception ex)
+ {
+ logger?.Warning($"SessionIdPersistence: SaveWhiteGloveComplete failed: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Clears the whiteglove.complete marker once Part-2 resumes so a subsequent
+ /// session does not re-enter the resume code path.
+ ///
+ public void ClearWhiteGloveComplete(AgentLogger logger = null)
+ {
+ lock (_lockObject) TryDelete(_whiteGloveMarkerPath, logger);
+ }
+
/// Deletes the persisted SessionId. Next starts a fresh session.
public void Delete(AgentLogger logger = null)
{
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs
new file mode 100644
index 00000000..fa19c424
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs
@@ -0,0 +1,194 @@
+using System;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Agent.V2.Core.Security
+{
+ ///
+ /// V1-parity wrapper around . Plan §3.9 /
+ /// PR #51. The backend's /api/agent/register-session is the authoritative point where
+ /// a session row is created in the Sessions table; without it, every subsequent
+ /// IncrementSessionEventCountAsync / UpdateSessionStatusAsync silently no-ops
+ /// (the server logs a warning and returns) so events still land but session status,
+ /// phase, admin-overrides and validator reconciliation break.
+ ///
+ /// Retry contract (V1 MonitoringService.RegisterSessionAsync):
+ ///
+ ///
+ /// - 5 attempts total; between attempts 2^attempt seconds (2s, 4s, 8s, 16s).
+ /// - On response.Success == true → stop immediately.
+ /// - On (401/403) → report to the
+ /// (which fires the first-failure distress + may trip
+ /// the shutdown threshold) and return
+ /// without retrying — the backend has definitively rejected the device cert.
+ /// - Any other exception on the last attempt →
+ /// with AgentErrorType.RegisterSessionFailed so operators see the final cause.
+ ///
+ ///
+ /// The caller (Program.RunAgent) must treat a non-
+ /// outcome as fatal — V1 parity: "=== SESSION REGISTRATION FAILED — collectors will NOT start
+ /// to prevent orphaned events ===". The agent exits cleanly instead of spinning up
+ /// the orchestrator and flooding the Events table for an unregistered session.
+ ///
+ ///
+ public static class SessionRegistrationHelper
+ {
+ private const int MaxAttempts = 5;
+
+ ///
+ /// Executes the 5-retry register-session handshake against the backend. Returns the
+ /// response + classified outcome. Never throws.
+ ///
+ ///
+ /// Optional delay-provider used by tests to avoid real-time waits. Production callers
+ /// pass null → V1 parity Task.Delay(2^attempt * 1000) between attempts.
+ ///
+ public static async Task RegisterWithRetryAsync(
+ BackendApiClient apiClient,
+ AgentConfiguration agentConfig,
+ string agentVersion,
+ AgentLogger logger,
+ AuthFailureTracker authFailureTracker = null,
+ EmergencyReporter emergencyReporter = null,
+ Func backoffDelay = null)
+ {
+ if (apiClient == null) throw new ArgumentNullException(nameof(apiClient));
+ if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
+ if (logger == null) throw new ArgumentNullException(nameof(logger));
+
+ backoffDelay ??= DefaultBackoffDelay;
+ var registration = BuildRegistration(agentConfig, agentVersion);
+ string lastError = null;
+
+ for (int attempt = 1; attempt <= MaxAttempts; attempt++)
+ {
+ try
+ {
+ logger.Info($"Registering session with backend (attempt {attempt}/{MaxAttempts})");
+ var response = await apiClient.RegisterSessionAsync(registration).ConfigureAwait(false);
+
+ if (response != null && response.Success)
+ {
+ logger.Info($"Session registered successfully: {response.SessionId} (validatedBy={response.ValidatedBy}, adminAction={response.AdminAction ?? "(none)"})");
+ return SessionRegistrationResult.Succeeded(response);
+ }
+
+ lastError = response?.Message ?? "(null response)";
+ logger.Warning($"Session registration failed: {lastError}");
+ }
+ catch (BackendAuthException ex)
+ {
+ logger.Error($"Session registration authentication failed ({ex.StatusCode}): {ex.Message}");
+ // Feed the central auth-failure tracker so the first-failure distress dispatches
+ // and the shutdown threshold advances. V1 parity: no retry on auth-failure —
+ // backend has definitively rejected the cert/token.
+ authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session");
+ return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
+ }
+ catch (Exception ex)
+ {
+ lastError = ex.Message;
+ logger.Error($"Failed to register session (attempt {attempt}/{MaxAttempts}): {ex.Message}", ex);
+
+ if (attempt == MaxAttempts)
+ {
+ try
+ {
+ if (emergencyReporter != null)
+ _ = emergencyReporter.TrySendAsync(
+ AgentErrorType.RegisterSessionFailed,
+ ex.Message);
+ }
+ catch { /* emergency channel is best-effort */ }
+ }
+ }
+
+ if (attempt < MaxAttempts)
+ {
+ var delaySeconds = (int)Math.Pow(2, attempt); // 2, 4, 8, 16
+ logger.Info($"Retrying session registration in {delaySeconds}s");
+ await backoffDelay(attempt).ConfigureAwait(false);
+ }
+ }
+
+ return SessionRegistrationResult.Failed(lastError ?? "max retries exceeded");
+ }
+
+ /// V1 parity exponential backoff: 2^attempt seconds.
+ private static Task DefaultBackoffDelay(int attempt)
+ => Task.Delay(((int)Math.Pow(2, attempt)) * 1000);
+
+ private static SessionRegistration BuildRegistration(AgentConfiguration agentConfig, string agentVersion)
+ {
+ return new SessionRegistration
+ {
+ SessionId = agentConfig.SessionId,
+ TenantId = agentConfig.TenantId,
+ SerialNumber = DeviceInfoProvider.GetSerialNumber() ?? string.Empty,
+ Manufacturer = DeviceInfoProvider.GetManufacturer() ?? string.Empty,
+ Model = DeviceInfoProvider.GetModel() ?? string.Empty,
+ DeviceName = Environment.MachineName,
+ OsName = DeviceInfoProvider.GetOsName() ?? string.Empty,
+ OsBuild = DeviceInfoProvider.GetOsBuild() ?? string.Empty,
+ OsDisplayVersion = DeviceInfoProvider.GetOsDisplayVersion() ?? string.Empty,
+ OsEdition = DeviceInfoProvider.GetOsEdition() ?? string.Empty,
+ OsLanguage = System.Globalization.CultureInfo.CurrentCulture.Name ?? string.Empty,
+ StartedAt = DateTime.UtcNow,
+ AgentVersion = agentVersion ?? string.Empty,
+ EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(),
+ IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(),
+ IsUserDriven = true,
+ };
+ }
+ }
+
+ /// Classification of .
+ public enum SessionRegistrationOutcome
+ {
+ /// Backend responded with Success=true. The session row is live.
+ Succeeded = 0,
+
+ /// 401/403 from the backend — cert/token rejected. No retry was attempted.
+ AuthFailed = 1,
+
+ /// All 5 attempts failed with non-auth errors (network, 5xx, malformed response).
+ Failed = 2,
+ }
+
+ /// Result of .
+ public sealed class SessionRegistrationResult
+ {
+ public SessionRegistrationOutcome Outcome { get; }
+ public RegisterSessionResponse Response { get; }
+ public int HttpStatusCode { get; }
+ public string ErrorMessage { get; }
+
+ public string AdminAction => Response?.AdminAction;
+ public ValidatorType ValidatedBy => Response?.ValidatedBy ?? ValidatorType.Unknown;
+
+ private SessionRegistrationResult(
+ SessionRegistrationOutcome outcome,
+ RegisterSessionResponse response,
+ int httpStatusCode,
+ string errorMessage)
+ {
+ Outcome = outcome;
+ Response = response;
+ HttpStatusCode = httpStatusCode;
+ ErrorMessage = errorMessage;
+ }
+
+ public static SessionRegistrationResult Succeeded(RegisterSessionResponse response)
+ => new SessionRegistrationResult(SessionRegistrationOutcome.Succeeded, response, 200, null);
+
+ public static SessionRegistrationResult AuthFailed(int statusCode, string message)
+ => new SessionRegistrationResult(SessionRegistrationOutcome.AuthFailed, null, statusCode, message);
+
+ public static SessionRegistrationResult Failed(string message)
+ => new SessionRegistrationResult(SessionRegistrationOutcome.Failed, null, 0, message);
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
index 9edff8da..253e13c0 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -8,8 +10,10 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Runtime;
+using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Termination
{
@@ -17,28 +21,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
/// Orchestrates peripheral termination work in response to
/// . Plan §4.x M4.6.β.
///
- /// Sequence (Legacy parity — single-writer, best-effort each step, never throws):
+ /// Sequence (V1 parity — single-writer, best-effort each step, never throws):
///
///
+ /// - Run shutdown analyzers (optional) so their delta events land before diagnostics.
/// - Compose via .
- /// - Write final-status.json + launch SummaryDialog via .
- /// - Upload diagnostics via (respects
- /// DiagnosticsUploadMode: Off / Always / OnFailure).
- /// - Write enrollment-complete.marker (so ghost-restart detection on next boot
- /// exits cleanly even if failed).
+ /// - Write final-status.json + launch if configured.
+ /// - Emit enrollment_summary_shown event once the dialog has been handed to the user session.
+ /// - 2s grace so late events can land before the next step.
+ /// - Emit diagnostics_collecting → upload diagnostics → emit diagnostics_uploaded / diagnostics_upload_failed.
+ /// - Write enrollment-complete.marker (ghost-restart guard on next boot).
+ /// - Standalone-reboot path: if RebootOnComplete && !SelfDestructOnComplete emit
+ /// reboot_triggered, drain spool, call shutdown.exe /r /t <delay>.
/// - Run — UNLESS the stage is
- /// (Part 1 exit, session resumes Part 2).
- /// - Signal the caller-owned shutdown so Program.cs'
- /// main loop exits the shutdown.Wait() and calls orchestrator.Stop().
+ /// (Part-1 exit, session resumes Part 2).
+ /// - WhiteGlove Part-1 path: emit whiteglove_part1_complete, drain spool, and
+ /// write whiteglove.complete marker via
+ /// so Part-2 resume is detected on the next boot.
+ /// - Signal the caller-owned shutdown .
///
///
/// Each step logs + continues on failure — nothing in here is allowed to prevent the agent
- /// from shutting down. CleanupService is fire-and-forget (spawns a PowerShell cleanup script
- /// that waits for process exit); this handler does not block waiting for the script.
+ /// from shutting down. is fire-and-forget (spawns a PowerShell
+ /// cleanup script that waits for process exit); this handler does not block waiting for it.
///
///
public sealed class EnrollmentTerminationHandler
{
+ private static readonly TimeSpan DefaultLateEventGrace = TimeSpan.FromMilliseconds(2000);
+ private static readonly TimeSpan DefaultSpoolDrain = TimeSpan.FromMilliseconds(10000); // V1 parity: up to 20 × 500ms drains before shutdown.exe
+
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly string _stateDirectory;
@@ -50,6 +62,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
private readonly Action _signalShutdown;
private readonly string _dialogExePathOverride;
private readonly AgentAnalyzerManager _analyzerManager;
+ private readonly Action _emitEvent;
+ private readonly SessionIdPersistence _sessionPersistence;
+ private readonly Action _triggerReboot;
+ private readonly TimeSpan _lateEventGracePeriod;
+ private readonly TimeSpan _spoolDrainPeriod;
private int _handled;
@@ -64,7 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
Func> uploadDiagnosticsAsync,
Action signalShutdown,
string dialogExePathOverride = null,
- AgentAnalyzerManager analyzerManager = null)
+ AgentAnalyzerManager analyzerManager = null,
+ Action emitEvent = null,
+ SessionIdPersistence sessionPersistence = null,
+ Action triggerReboot = null,
+ TimeSpan? lateEventGracePeriod = null,
+ TimeSpan? spoolDrainPeriod = null)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
@@ -77,6 +99,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
_signalShutdown = signalShutdown ?? throw new ArgumentNullException(nameof(signalShutdown));
_dialogExePathOverride = dialogExePathOverride;
_analyzerManager = analyzerManager;
+ _emitEvent = emitEvent;
+ _sessionPersistence = sessionPersistence;
+ _triggerReboot = triggerReboot ?? DefaultTriggerReboot;
+ _lateEventGracePeriod = lateEventGracePeriod ?? DefaultLateEventGrace;
+ _spoolDrainPeriod = spoolDrainPeriod ?? DefaultSpoolDrain;
}
///
@@ -86,6 +113,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
{
if (Interlocked.Exchange(ref _handled, 1) == 1) return;
+ var isWhiteGlovePart1 = args.StageName == SessionStage.WhiteGloveSealed.ToString();
+
try
{
_logger.Info(
@@ -107,8 +136,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
RunBuildAndLaunchDialog(state, args);
}
- RunUploadDiagnostics(args);
+ // WhiteGlove Part-1 exit: keep the session alive, but announce the handoff so
+ // the timeline clearly marks the transition. The `whiteglove.complete` marker
+ // lets the next agent boot classify itself as a Part-2 resume.
+ if (isWhiteGlovePart1)
+ {
+ EmitEventSafe(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = "whiteglove_part1_complete",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTerminationHandler",
+ Phase = EnrollmentPhase.Unknown,
+ Message = "WhiteGlove Part 1 complete — device will seal for end-user.",
+ ImmediateUpload = true,
+ });
+
+ DelayLateEventGrace();
+ DrainSpool();
+
+ TrySaveWhiteGloveComplete();
+ return;
+ }
+
+ DelayLateEventGrace();
+
+ RunUploadDiagnosticsWithEvents(args);
WriteEnrollmentCompleteMarker(args);
+ RunStandaloneRebootIfRequested();
RunSelfDestructIfAppropriate(args);
}
catch (Exception ex)
@@ -166,6 +222,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
var packages = TryGetPackageStates();
var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc);
SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride);
+
+ if (_configuration.ShowEnrollmentSummary)
+ {
+ EmitEventSafe(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = "enrollment_summary_shown",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTerminationHandler",
+ Phase = EnrollmentPhase.Unknown,
+ Message = "Enrollment summary dialog shown to user.",
+ Data = new Dictionary
+ {
+ { "totalApps", status?.AppSummary?.TotalApps ?? 0 },
+ { "errorCount", status?.AppSummary?.ErrorCount ?? 0 },
+ { "outcome", status?.Outcome ?? string.Empty },
+ { "timeoutSeconds", _configuration.EnrollmentSummaryTimeoutSeconds },
+ },
+ ImmediateUpload = true,
+ });
+ }
}
catch (Exception ex)
{
@@ -173,7 +251,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
}
- private void RunUploadDiagnostics(EnrollmentTerminatedEventArgs args)
+ private void RunUploadDiagnosticsWithEvents(EnrollmentTerminatedEventArgs args)
{
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
@@ -189,19 +267,75 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
return;
}
+ EmitEventSafe(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = "diagnostics_collecting",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTerminationHandler",
+ Phase = EnrollmentPhase.Unknown,
+ Message = "Collecting diagnostics package.",
+ Data = new Dictionary
+ {
+ { "mode", mode },
+ { "enrollmentSucceeded", enrollmentSucceeded },
+ },
+ ImmediateUpload = true,
+ });
+
+ DiagnosticsUploadResult result = null;
try
{
var suffix = enrollmentSucceeded ? "success" : "failure";
- var result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult();
- if (result != null && result.Success)
- _logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName}).");
- else
- _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload returned failure: {result?.ErrorCode ?? "null-result"}.");
+ result = _uploadDiagnosticsAsync(enrollmentSucceeded, suffix).GetAwaiter().GetResult();
}
catch (Exception ex)
{
_logger.Warning($"EnrollmentTerminationHandler: diagnostics upload threw: {ex.Message}");
}
+
+ if (result != null && result.Success)
+ {
+ _logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName}).");
+ EmitEventSafe(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = "diagnostics_uploaded",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTerminationHandler",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Diagnostics package uploaded ({result.BlobName}).",
+ Data = new Dictionary
+ {
+ { "blobName", result.BlobName ?? string.Empty },
+ { "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty },
+ },
+ ImmediateUpload = true,
+ });
+ }
+ else
+ {
+ var errorCode = result?.ErrorCode ?? "null-result";
+ _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload failed: {errorCode}.");
+ EmitEventSafe(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = "diagnostics_upload_failed",
+ Severity = EventSeverity.Warning,
+ Source = "EnrollmentTerminationHandler",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Diagnostics upload failed: {errorCode}.",
+ Data = new Dictionary
+ {
+ { "errorCode", errorCode },
+ { "blobName", result?.BlobName ?? string.Empty },
+ },
+ ImmediateUpload = true,
+ });
+ }
}
private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args)
@@ -228,6 +362,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
}
}
+ ///
+ /// V1 parity — standalone-reboot flow. When the tenant config disables self-destruct
+ /// but enables RebootOnComplete, the agent's final act is shutdown.exe /r
+ /// with the configured delay, giving the user a visible countdown.
+ ///
+ private void RunStandaloneRebootIfRequested()
+ {
+ if (_configuration.SelfDestructOnComplete) return;
+ if (!_configuration.RebootOnComplete) return;
+
+ var delay = _configuration.RebootDelaySeconds > 0 ? _configuration.RebootDelaySeconds : 10;
+
+ EmitEventSafe(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = "reboot_triggered",
+ Severity = EventSeverity.Info,
+ Source = "EnrollmentTerminationHandler",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Standalone reboot triggered (delay={delay}s).",
+ Data = new Dictionary
+ {
+ { "rebootDelaySeconds", delay },
+ { "selfDestructOnComplete", _configuration.SelfDestructOnComplete },
+ },
+ ImmediateUpload = true,
+ });
+
+ DrainSpool();
+
+ try
+ {
+ _triggerReboot(delay);
+ _logger.Info($"EnrollmentTerminationHandler: standalone reboot queued via shutdown.exe /r /t {delay}.");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTerminationHandler: standalone reboot invocation failed: {ex.Message}");
+ }
+ }
+
private void RunSelfDestructIfAppropriate(EnrollmentTerminatedEventArgs args)
{
if (!_configuration.SelfDestructOnComplete)
@@ -253,5 +429,53 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
_logger.Error("EnrollmentTerminationHandler: cleanup service invocation threw.", ex);
}
}
+
+ private void TrySaveWhiteGloveComplete()
+ {
+ if (_sessionPersistence == null)
+ {
+ _logger.Warning("EnrollmentTerminationHandler: sessionPersistence not wired — whiteglove.complete marker NOT written (Part-2 detection will fail).");
+ return;
+ }
+
+ try { _sessionPersistence.SaveWhiteGloveComplete(_logger); }
+ catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: SaveWhiteGloveComplete threw: {ex.Message}"); }
+ }
+
+ private void DelayLateEventGrace()
+ {
+ if (_lateEventGracePeriod <= TimeSpan.Zero) return;
+ try { Task.Delay(_lateEventGracePeriod).Wait(); }
+ catch { /* best-effort */ }
+ }
+
+ private void DrainSpool()
+ {
+ // V1 parity — block briefly so pending events can land before the next destructive
+ // step (shutdown.exe, self-destruct). A precise HasPending/PendingCount signal is
+ // internal to the orchestrator transport, so the V2 equivalent is a bounded wait.
+ if (_spoolDrainPeriod <= TimeSpan.Zero) return;
+ try { Task.Delay(_spoolDrainPeriod).Wait(); }
+ catch { /* best-effort */ }
+ }
+
+ private void EmitEventSafe(EnrollmentEvent evt)
+ {
+ if (_emitEvent == null) return;
+ try { _emitEvent(evt); }
+ catch (Exception ex) { _logger.Debug($"EnrollmentTerminationHandler: event emission '{evt?.EventType}' threw: {ex.Message}"); }
+ }
+
+ private static void DefaultTriggerReboot(int delaySeconds)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "shutdown.exe"),
+ Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ Process.Start(psi);
+ }
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index ad4dea54..db01c975 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -16,6 +16,8 @@ using AutopilotMonitor.Agent.V2.Core.Termination;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
@@ -206,12 +208,19 @@ namespace AutopilotMonitor.Agent.V2
// set by --install, no session.id yet) as a ghost restart and trigger
// ExecuteSelfDestruct before the remote-config fetch, deleting the ProgramData
// directory on every fresh deployment.
+ var sessionPersistence = new SessionIdPersistence(dataDirectory);
if (args.Contains("--new-session"))
{
- new SessionIdPersistence(dataDirectory).Delete(logger);
+ sessionPersistence.Delete(logger);
logger.Info("--new-session: cleared persisted SessionId.");
}
- agentConfig.SessionId = new SessionIdPersistence(dataDirectory).GetOrCreate(logger);
+ // Snapshot the WhiteGlove-resume state BEFORE GetOrCreate: on Part-2 resume we want
+ // to emit the whiteglove_resumed event AFTER orchestrator.Start. Reading the marker
+ // up front avoids racing any downstream clear. V1 parity — the Part-2 detection is
+ // marker-based, and the agent announces resume on the session timeline so that
+ // dashboards can correlate the session's two boots.
+ var isWhiteGloveResume = sessionPersistence.IsWhiteGloveResume();
+ agentConfig.SessionId = sessionPersistence.GetOrCreate(logger);
// Build a cleanup-service factory used by guards: instantiated lazily and with the
// current agentConfig so command-line overrides (e.g. --no-cleanup) take effect.
@@ -310,11 +319,14 @@ namespace AutopilotMonitor.Agent.V2
// defaults on AgentConfiguration; UpdateLimits is called after RemoteConfigMerger.Merge
// so tenant-policy overrides take effect. ThresholdExceeded is wired below, once the
// shutdown signal is available, to trigger a clean agent exit when the limit is hit.
+ // V1 parity — the distress reporter is plumbed in at construction so the tracker is
+ // the single dispatch point for auth-failure distress (first failure only).
var authFailureTracker = new AuthFailureTracker(
maxFailures: agentConfig.MaxAuthFailures,
timeoutMinutes: agentConfig.AuthFailureTimeoutMinutes,
clock: SystemClock.Instance,
- logger: logger);
+ logger: logger,
+ distressReporter: distressReporter);
var remoteConfigService = new RemoteConfigService(
backendApiClient, agentConfig.TenantId, logger, emergencyReporter, distressReporter, authFailureTracker);
@@ -323,8 +335,10 @@ namespace AutopilotMonitor.Agent.V2
// Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
// downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
// DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
- // actually respect the tenant admin settings. CLI flags stay authoritative over remote.
- RemoteConfigMerger.Merge(agentConfig, remoteConfig, args);
+ // actually respect the tenant admin settings. V1 parity — remote wins
+ // unconditionally for every knob that has a 1:1 mapping. CLI flags seed the initial
+ // AgentConfiguration in BuildAgentConfiguration and then yield to tenant policy.
+ var configMergeResult = RemoteConfigMerger.Merge(agentConfig, remoteConfig, logger);
// Refresh tracker ceilings with the tenant-specific values we just merged in.
authFailureTracker.UpdateLimits(agentConfig.MaxAuthFailures, agentConfig.AuthFailureTimeoutMinutes);
@@ -338,10 +352,32 @@ namespace AutopilotMonitor.Agent.V2
SelfUpdater.BackendExpectedSha256 = remoteConfig.LatestAgentSha256;
// Post-config binary-integrity check: verify the running EXE's SHA-256 against the
- // value advertised by the backend. Mismatch does not terminate (SelfUpdater's own
- // hash-based trigger covers the "wrong EXE" case at next startup) — we just emit an
- // IntegrityCheckFailed error report so operators see the drift.
- var integrityResult = BinaryIntegrityVerifier.Check(remoteConfig.LatestAgentExeSha256, logger);
+ // value advertised by the backend. V1 parity — on mismatch we (a) emit an
+ // IntegrityCheckFailed emergency report and (b) fire the runtime self-update trigger
+ // so the agent auto-heals (SelfUpdater.CheckAndApplyUpdateAsync force-update path).
+ // The trigger is single-shot per process (Interlocked guard inside the verifier).
+ var agentDirForTrigger = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;
+ Func runtimeSelfUpdateTrigger = (zipHash, downgrade) =>
+ {
+ if (!string.IsNullOrEmpty(zipHash))
+ SelfUpdater.BackendExpectedSha256 = zipHash;
+
+ return SelfUpdater.CheckAndApplyUpdateAsync(
+ currentVersion: GetAgentVersion(),
+ agentDir: agentDirForTrigger,
+ consoleMode: consoleMode,
+ forceUpdate: true,
+ triggerReason: "runtime_hash_mismatch",
+ downloadTimeoutMsOverride: 60000,
+ allowDowngrade: downgrade);
+ };
+
+ var integrityResult = BinaryIntegrityVerifier.Check(
+ expectedSha256: remoteConfig.LatestAgentExeSha256,
+ logger: logger,
+ runtimeSelfUpdateTrigger: runtimeSelfUpdateTrigger,
+ zipHash: remoteConfig.LatestAgentSha256,
+ allowDowngrade: remoteConfig.AllowAgentDowngrade);
if (integrityResult.IsMismatch)
{
_ = emergencyReporter.TrySendAsync(
@@ -395,6 +431,34 @@ namespace AutopilotMonitor.Agent.V2
return 5;
}
+ // V1 parity (MonitoringService.Start, MonitoringService.RegisterSessionAsync) —
+ // POST /api/agent/register-session with 5-retry exponential backoff (2s/4s/8s/16s)
+ // BEFORE the orchestrator starts. Without this call the backend's Sessions table
+ // never gets a row for this session, so IncrementSessionEventCountAsync /
+ // UpdateSessionStatusAsync silently no-op — events still land in the Events table
+ // but session status, phase, admin-overrides and validator reconcile all break.
+ // On failure we follow V1's rule: collectors MUST NOT start to prevent orphaned events.
+ var registrationResult = SessionRegistrationHelper.RegisterWithRetryAsync(
+ apiClient: backendApiClient,
+ agentConfig: agentConfig,
+ agentVersion: GetAgentVersion(),
+ logger: logger,
+ authFailureTracker: authFailureTracker,
+ emergencyReporter: emergencyReporter).GetAwaiter().GetResult();
+
+ if (registrationResult.Outcome != SessionRegistrationOutcome.Succeeded)
+ {
+ logger.Error(
+ $"=== SESSION REGISTRATION FAILED ({registrationResult.Outcome}: {registrationResult.ErrorMessage}) — " +
+ "collectors will NOT start to prevent orphaned events. ===");
+ if (consoleMode)
+ Console.Error.WriteLine($"FATAL: session registration failed ({registrationResult.Outcome}). Agent exiting.");
+ try { mtlsHttpClient?.Dispose(); } catch { }
+ try { backendApiClient?.Dispose(); } catch { }
+ // Exit code differs so the diag skill can distinguish Auth vs Network in Scheduled-Task history.
+ return registrationResult.Outcome == SessionRegistrationOutcome.AuthFailed ? 6 : 7;
+ }
+
var classifiers = new IClassifier[]
{
new WhiteGloveSealingClassifier(),
@@ -461,7 +525,9 @@ namespace AutopilotMonitor.Agent.V2
cleanupServiceFactory: () => new CleanupService(agentConfig, logger),
uploadDiagnosticsAsync: uploadDiagnosticsAsync,
signalShutdown: () => shutdown.Set(),
- analyzerManager: analyzerManager);
+ analyzerManager: analyzerManager,
+ emitEvent: evt => orchestrator.EventEmitter.Emit(evt),
+ sessionPersistence: sessionPersistence);
// ServerActionDispatcher — handlers are live and the M4.6.ε response-parse
// path below feeds it. RotateConfig refetches remote config;
@@ -522,14 +588,85 @@ namespace AutopilotMonitor.Agent.V2
Console.CancelKeyPress += cancelHandler;
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
+
+ // V1 parity — on the max-lifetime watchdog firing, emit an explicit
+ // `enrollment_failed` event with failureType=agent_timeout BEFORE the
+ // regular termination path runs. Dashboards + KQL queries key on the
+ // event type + data dictionary to distinguish a genuine enrollment failure
+ // from a timeout-triggered shutdown.
+ EventHandler maxLifetimeEmitter = (s, e) =>
+ {
+ if (e.Reason != EnrollmentTerminationReason.MaxLifetimeExceeded) return;
+ try
+ {
+ var uptimeMin = (DateTime.UtcNow - agentStartTimeUtc).TotalMinutes;
+ orchestrator.EventEmitter.Emit(new EnrollmentEvent
+ {
+ SessionId = agentConfig.SessionId,
+ TenantId = agentConfig.TenantId,
+ EventType = "enrollment_failed",
+ Severity = EventSeverity.Error,
+ Source = "EnrollmentOrchestrator",
+ Phase = EnrollmentPhase.Complete,
+ Message = $"Agent max lifetime expired ({uptimeMin:F0} min) — enrollment did not complete in time",
+ Data = new System.Collections.Generic.Dictionary
+ {
+ { "failureType", "agent_timeout" },
+ { "failureSource", "max_lifetime_timer" },
+ { "agentUptimeMinutes", Math.Round(uptimeMin, 1) },
+ { "agentMaxLifetimeMinutes", agentConfig.AgentMaxLifetimeMinutes },
+ { "stageAtTimeout", e.StageName ?? string.Empty },
+ },
+ ImmediateUpload = true,
+ });
+ }
+ catch (Exception emitEx)
+ {
+ logger.Warning($"enrollment_failed (max_lifetime) emission failed: {emitEx.Message}");
+ }
+ };
+ orchestrator.Terminated += maxLifetimeEmitter;
orchestrator.Terminated += terminationHandler.Handle;
// Auth-failure watchdog: when MaxAuthFailures / AuthFailureTimeoutMinutes
// is exceeded the agent must shut down cleanly instead of hammering a
// backend that has definitely said no. Event fires at most once.
+ // V1 parity — emit a structured `agent_shutdown` event with reason=auth_failure
+ // and the full telemetry payload before tripping the shutdown signal so the
+ // backend sees WHY the agent terminated in the session timeline.
EventHandler authThresholdHandler = (s, e) =>
{
logger.Error($"Auth-failure threshold exceeded ({e.Reason}) — initiating shutdown.");
+ try
+ {
+ orchestrator.EventEmitter.Emit(new EnrollmentEvent
+ {
+ SessionId = agentConfig.SessionId,
+ TenantId = agentConfig.TenantId,
+ EventType = "agent_shutdown",
+ Severity = EventSeverity.Error,
+ Source = "AuthFailureTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Agent shut down after {e.ConsecutiveFailures} consecutive auth failures",
+ Data = new System.Collections.Generic.Dictionary
+ {
+ { "reason", "auth_failure" },
+ { "consecutiveFailures", e.ConsecutiveFailures },
+ { "firstFailureTime", e.FirstFailureUtc.ToString("o") },
+ { "maxFailures", agentConfig.MaxAuthFailures },
+ { "timeoutMinutes", agentConfig.AuthFailureTimeoutMinutes },
+ { "lastOperation", e.LastOperation ?? string.Empty },
+ { "lastStatusCode", e.LastStatusCode },
+ { "thresholdReason", e.Reason ?? string.Empty },
+ },
+ ImmediateUpload = true,
+ });
+ }
+ catch (Exception emitEx)
+ {
+ logger.Warning($"agent_shutdown emission failed: {emitEx.Message}");
+ }
+
shutdown.Set();
};
authFailureTracker.ThresholdExceeded += authThresholdHandler;
@@ -554,6 +691,59 @@ namespace AutopilotMonitor.Agent.V2
// VersionCheckEventBuilder.TryBuild is a no-op when no markers are present.
EmitVersionCheckEventIfAny(orchestrator, agentConfig, logger);
+ // V1 parity (MonitoringService.AuditUnrestrictedModeChange) — whenever the
+ // remote tenant admin flips the UnrestrictedMode guardrail, emit an audit
+ // event so the change is visible in the session timeline and downstream
+ // alerting. Fires AFTER orchestrator.Start so the EventEmitter is alive.
+ EmitUnrestrictedModeAuditIfChanged(orchestrator, agentConfig, configMergeResult, logger);
+
+ // V1 parity — agent_started lifecycle event. Purely informational
+ // (Phase=Unknown); the session timeline and downstream alerting rely on
+ // it to classify the boot.
+ EmitAgentStartedEvent(orchestrator, agentConfig, previousExit, logger);
+
+ // WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts
+ // SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge — that
+ // reducer effect emits whiteglove_resumed on the timeline. We only need
+ // to clear the persisted marker here.
+ if (isWhiteGloveResume)
+ {
+ try { sessionPersistence.ClearWhiteGloveComplete(logger); }
+ catch (Exception ex) { logger.Debug($"ClearWhiteGloveComplete threw: {ex.Message}"); }
+ }
+
+ // Reboot mid-session: post SystemRebootObserved so the reducer records
+ // the fact and emits the system_reboot_detected timeline entry.
+ if (string.Equals(previousExit?.ExitType, "reboot_kill", StringComparison.OrdinalIgnoreCase))
+ {
+ PostSystemRebootObservedSignal(orchestrator.IngressSink, previousExit, logger);
+ }
+
+ // V2 parity — post SessionStarted so the reducer establishes the session anchor
+ // (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped on:
+ // - WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts
+ // SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge.
+ // - Admin preemption: the AdminPreemptionDetected signal below drives the
+ // session straight to a terminal stage; SessionStarted first would be noise.
+ if (!isWhiteGloveResume && string.IsNullOrEmpty(registrationResult.AdminAction))
+ {
+ PostSessionStartedSignal(orchestrator.IngressSink, registrationResult, agentConfig, logger);
+ }
+
+ // V1 parity (MonitoringService.cs:388-413 "ADMIN OVERRIDE on startup") —
+ // if the register-session response carried an AdminAction the operator
+ // has already marked the session terminal via the portal. Post
+ // AdminPreemptionDetected so the reducer transitions Stage to Completed /
+ // Failed and emits the enrollment_complete/_failed timeline event as a
+ // side effect. The orchestrator's DecisionStepProcessor picks up the
+ // terminal stage and raises Terminated, which runs the termination
+ // pipeline (cleanup + summary + self-destruct) through the subscribed
+ // handler — no direct synthesis needed.
+ if (!string.IsNullOrEmpty(registrationResult.AdminAction))
+ {
+ PostAdminPreemptionSignal(orchestrator.IngressSink, registrationResult, logger);
+ }
+
// M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory /
// IntegrityBypass). Runs on a background task inside AgentAnalyzerManager.
try { analyzerManager.RunStartup(); }
@@ -586,6 +776,7 @@ namespace AutopilotMonitor.Agent.V2
Console.CancelKeyPress -= cancelHandler;
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
orchestrator.Terminated -= terminationHandler.Handle;
+ orchestrator.Terminated -= maxLifetimeEmitter;
authFailureTracker.ThresholdExceeded -= authThresholdHandler;
try { orchestrator.Stop(); }
@@ -671,6 +862,224 @@ namespace AutopilotMonitor.Agent.V2
};
}
+ ///
+ /// V1 parity — when flips the tenant-controlled
+ /// UnrestrictedMode guardrail, surface the transition as an auditable event on
+ /// the session timeline so operators can correlate subsequent gather-rule exec with the
+ /// elevated policy. The V1 code lives in
+ /// MonitoringService.AuditUnrestrictedModeChange.
+ ///
+ private static void EmitUnrestrictedModeAuditIfChanged(
+ EnrollmentOrchestrator orchestrator,
+ AgentConfiguration agentConfig,
+ RemoteConfigMergeResult mergeResult,
+ AgentLogger logger)
+ {
+ if (mergeResult == null || !mergeResult.UnrestrictedModeChanged) return;
+
+ try
+ {
+ var newValue = mergeResult.NewUnrestrictedMode;
+ logger.Warning(
+ $"UnrestrictedMode changed: {mergeResult.OldUnrestrictedMode} → {newValue}. Emitting audit event.");
+
+ orchestrator.EventEmitter.Emit(new EnrollmentEvent
+ {
+ SessionId = agentConfig.SessionId,
+ TenantId = agentConfig.TenantId,
+ EventType = "agent_unrestricted_mode_changed",
+ Severity = newValue ? EventSeverity.Warning : EventSeverity.Info,
+ Source = "RemoteConfigMerger",
+ Phase = EnrollmentPhase.Unknown,
+ Message = newValue
+ ? "Agent unrestricted mode ENABLED — gather rules can now execute without AllowList checks"
+ : "Agent unrestricted mode disabled — gather rules revert to AllowList checks",
+ Data = new System.Collections.Generic.Dictionary
+ {
+ { "oldValue", mergeResult.OldUnrestrictedMode },
+ { "newValue", newValue },
+ { "changedAtUtc", DateTime.UtcNow.ToString("o") },
+ },
+ ImmediateUpload = true,
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"EmitUnrestrictedModeAuditIfChanged: emission failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// V2 parity — post when the
+ /// register-session response carried an AdminAction. The reducer handler at
+ /// DecisionEngine.Shared.cs#HandleAdminPreemptionDetectedV1 transitions Stage to the
+ /// terminal state and emits the enrollment_complete/_failed telemetry event; the
+ /// orchestrator's DecisionStepProcessor then raises the Terminated event, which the
+ /// subscribed EnrollmentTerminationHandler picks up — no direct synthesis needed.
+ ///
+ private static void PostAdminPreemptionSignal(
+ ISignalIngressSink ingressSink,
+ SessionRegistrationResult registrationResult,
+ AgentLogger logger)
+ {
+ try
+ {
+ var adminOutcome = registrationResult.AdminAction; // "Succeeded" | "Failed"
+ logger.Warning(
+ $"=== ADMIN OVERRIDE on startup: session already marked as {adminOutcome} by administrator — posting AdminPreemptionDetected signal ===");
+
+ ingressSink.Post(
+ kind: DecisionSignalKind.AdminPreemptionDetected,
+ occurredAtUtc: DateTime.UtcNow,
+ sourceOrigin: "register_session_response",
+ evidence: new Evidence(
+ kind: EvidenceKind.Synthetic,
+ identifier: $"admin_preemption:{adminOutcome}",
+ summary: $"Operator marked session as {adminOutcome} via portal before agent startup."),
+ payload: new System.Collections.Generic.Dictionary(StringComparer.Ordinal)
+ {
+ ["adminOutcome"] = adminOutcome,
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"AdminPreemptionDetected post failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// V2 parity — post a signal with the
+ /// tenant-registered session metadata (EnrollmentType / IsHybridJoin / ValidatedBy) so the
+ /// reducer's session-anchor handler runs. Without this the DecisionState.Stage stays at
+ /// the initial value and subsequent raw signals (ESP / Hello) see an uninitialised session.
+ ///
+ internal static void PostSessionStartedSignal(
+ ISignalIngressSink ingressSink,
+ SessionRegistrationResult registrationResult,
+ AgentConfiguration agentConfig,
+ AgentLogger logger)
+ {
+ try
+ {
+ var payload = new System.Collections.Generic.Dictionary(StringComparer.Ordinal)
+ {
+ ["enrollmentType"] = EnrollmentRegistryDetector.DetectEnrollmentType(),
+ ["isHybridJoin"] = EnrollmentRegistryDetector.DetectHybridJoin() ? "true" : "false",
+ ["validatedBy"] = registrationResult.ValidatedBy.ToString(),
+ ["agentVersion"] = GetAgentVersion(),
+ ["isBootstrapSession"] = agentConfig.UseBootstrapTokenAuth ? "true" : "false",
+ };
+
+ var evidence = new Evidence(
+ kind: EvidenceKind.Synthetic,
+ identifier: "register_session_success",
+ summary: "Session registration handshake succeeded; posting SessionStarted anchor for reducer.");
+
+ ingressSink.Post(
+ kind: DecisionSignalKind.SessionStarted,
+ occurredAtUtc: DateTime.UtcNow,
+ sourceOrigin: "Program.RunAgent",
+ evidence: evidence,
+ payload: payload);
+
+ logger.Debug($"SessionStarted signal posted (validatedBy={registrationResult.ValidatedBy}, enrollmentType={payload["enrollmentType"]}).");
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"SessionStarted post failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// V1 parity — fire-and-forget agent_started event emitted after
+ /// . Carries a snapshot of the boot classification
+ /// () and the tenant-influenced runtime knobs so dashboards
+ /// can classify crash-loops, backend-rejected sessions and forced self-destruct runs.
+ /// Phase stays — the event is NOT a phase declaration.
+ ///
+ private static void EmitAgentStartedEvent(
+ EnrollmentOrchestrator orchestrator,
+ AgentConfiguration agentConfig,
+ PreviousExitSummary previousExit,
+ AgentLogger logger)
+ {
+ try
+ {
+ var data = new System.Collections.Generic.Dictionary
+ {
+ { "agentVersion", GetAgentVersion() },
+ { "commandLineArgs", agentConfig.CommandLineArgs ?? string.Empty },
+ { "isBootstrapSession", agentConfig.UseBootstrapTokenAuth },
+ { "awaitEnrollment", agentConfig.AwaitEnrollment },
+ { "selfDestructOnComplete", agentConfig.SelfDestructOnComplete },
+ { "certAuth", !agentConfig.UseBootstrapTokenAuth },
+ { "agentMaxLifetimeMinutes", agentConfig.AgentMaxLifetimeMinutes },
+ { "diagnosticsUploadMode", agentConfig.DiagnosticsUploadMode ?? "Off" },
+ { "previousExitType", previousExit?.ExitType ?? "unknown" },
+ { "unrestrictedMode", agentConfig.UnrestrictedMode },
+ };
+
+ if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType))
+ data["previousCrashException"] = previousExit.CrashExceptionType;
+
+ if (previousExit?.LastBootUtc.HasValue == true)
+ data["previousBootUtc"] = previousExit.LastBootUtc.Value.ToString("o");
+
+ orchestrator.EventEmitter.Emit(new EnrollmentEvent
+ {
+ SessionId = agentConfig.SessionId,
+ TenantId = agentConfig.TenantId,
+ EventType = "agent_started",
+ Severity = EventSeverity.Info,
+ Source = "AutopilotMonitor.Agent.V2",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"Agent v{GetAgentVersion()} started (previousExit={previousExit?.ExitType ?? "unknown"}).",
+ Data = data,
+ ImmediateUpload = true,
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"agent_started emission failed: {ex.Message}");
+ }
+ }
+
+ ///
+ /// V2 parity — post when the prior
+ /// agent process was terminated by an OS reboot. The reducer handler at
+ /// DecisionEngine.Edge.cs#HandleSystemRebootObservedV1 records the reboot fact on the
+ /// state (used by the WhiteGlove reboot-observed scoring weight, plan §2.4) and emits
+ /// the system_reboot_detected telemetry event as a side effect.
+ ///
+ private static void PostSystemRebootObservedSignal(
+ ISignalIngressSink ingressSink,
+ PreviousExitSummary previousExit,
+ AgentLogger logger)
+ {
+ try
+ {
+ var payload = new System.Collections.Generic.Dictionary(StringComparer.Ordinal)
+ {
+ ["previousExitType"] = previousExit?.ExitType ?? string.Empty,
+ ["lastBootUtc"] = previousExit?.LastBootUtc?.ToString("o") ?? string.Empty,
+ };
+
+ ingressSink.Post(
+ kind: DecisionSignalKind.SystemRebootObserved,
+ occurredAtUtc: DateTime.UtcNow,
+ sourceOrigin: "Program.DetectPreviousExit",
+ evidence: new Evidence(
+ kind: EvidenceKind.Synthetic,
+ identifier: "previous_exit_reboot_kill",
+ summary: $"Prior agent process terminated by OS reboot (exitType={previousExit?.ExitType})."),
+ payload: payload);
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"SystemRebootObserved post failed: {ex.Message}");
+ }
+ }
+
private static void EmitVersionCheckEventIfAny(
EnrollmentOrchestrator orchestrator,
AgentConfiguration agentConfig,
@@ -704,7 +1113,7 @@ namespace AutopilotMonitor.Agent.V2
// ---------------------------------------------------------------- Configuration
- private static AgentConfiguration BuildAgentConfiguration(
+ internal static AgentConfiguration BuildAgentConfiguration(
string[] args,
string tenantId,
string sessionId,
@@ -715,6 +1124,20 @@ namespace AutopilotMonitor.Agent.V2
var imeLogPathOverride = GetArgValue(args, "--ime-log-path");
var imeMatchLogPath = GetArgValue(args, "--ime-match-log");
+ // Dev / test — IME log replay (V1 compat mode). Feeds ImeLogTracker.SimulationMode
+ // + SpeedFactor so recorded raw IME logs are replayed at an accelerated rate; signal
+ // timestamps + ingress ordinals are regenerated as the replay runs.
+ var replayLogDir = GetArgValue(args, "--replay-log-dir");
+ var replaySpeedFactorRaw = GetArgValue(args, "--replay-speed-factor");
+ var replaySpeedFactor = 50.0;
+ if (!string.IsNullOrEmpty(replaySpeedFactorRaw)
+ && double.TryParse(replaySpeedFactorRaw, System.Globalization.NumberStyles.Float,
+ System.Globalization.CultureInfo.InvariantCulture, out var parsedSpeed)
+ && parsedSpeed > 0)
+ {
+ replaySpeedFactor = parsedSpeed;
+ }
+
var bootstrapToken = GetArgValue(args, "--bootstrap-token")
?? bootstrapConfig?.BootstrapToken;
@@ -760,6 +1183,8 @@ namespace AutopilotMonitor.Agent.V2
KeepLogFile = keepLogFile,
SelfDestructOnComplete = !noCleanup,
CommandLineArgs = FormatArgsForLog(args),
+ ReplayLogDir = replayLogDir,
+ ReplaySpeedFactor = replaySpeedFactor,
};
}
@@ -860,6 +1285,10 @@ namespace AutopilotMonitor.Agent.V2
Console.Out.WriteLine(" --api-url Override backend API base URL (alias: --backend-api)");
Console.Out.WriteLine(" --ime-log-path Override IME logs directory");
Console.Out.WriteLine(" --ime-match-log Write matched IME log lines to file (debug)");
+ Console.Out.WriteLine();
+ Console.Out.WriteLine("Dev / Test:");
+ Console.Out.WriteLine(" --replay-log-dir Replay IME logs from this directory (simulation mode)");
+ Console.Out.WriteLine(" --replay-speed-factor Time-compression factor for log replay (default: 50)");
}
private static void PrintVersion()
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index c739b42c..e34885bb 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-44
+107
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 7e179677..69559761 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1071
+1075
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs
new file mode 100644
index 00000000..79619e94
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ReducerTimelineEffectTests.cs
@@ -0,0 +1,196 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests
+{
+ ///
+ /// Plan V2-parity PR-B — the reducer is the single state owner, and telemetry events
+ /// for the three lifecycle signals (SystemRebootObserved, SessionRecovered→WG bridge,
+ /// AdminPreemptionDetected) appear as
+ /// effects rather than being synthesised by the V2 host.
+ ///
+ public sealed class ReducerTimelineEffectTests
+ {
+ private static DecisionSignal BuildSignal(
+ DecisionSignalKind kind,
+ DateTime occurredAtUtc,
+ IReadOnlyDictionary? payload = null,
+ string sourceOrigin = "test",
+ int ordinal = 1)
+ {
+ return new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: ordinal,
+ kind: kind,
+ kindSchemaVersion: 1,
+ occurredAtUtc: occurredAtUtc,
+ sourceOrigin: sourceOrigin,
+ evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
+ payload: payload);
+ }
+
+ // ---------------------------------------------------------------- SystemRebootObserved
+
+ [Fact]
+ public void SystemRebootObservedV1_emits_system_reboot_detected_timeline_entry()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var payload = new Dictionary
+ {
+ ["previousExitType"] = "reboot_kill",
+ ["lastBootUtc"] = "2026-04-22T08:00:00.0000000Z",
+ };
+ var signal = BuildSignal(
+ DecisionSignalKind.SystemRebootObserved,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ payload);
+
+ var step = engine.Reduce(state, signal);
+
+ Assert.NotNull(step.NewState.SystemRebootUtc);
+ var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
+ Assert.NotNull(timelineEffect.Parameters);
+ Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]);
+ Assert.Equal("reboot_kill", timelineEffect.Parameters["previousExitType"]);
+ Assert.Equal("2026-04-22T08:00:00.0000000Z", timelineEffect.Parameters["lastBootUtc"]);
+ }
+
+ [Fact]
+ public void SystemRebootObservedV1_without_payload_still_emits_timeline_entry()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var signal = BuildSignal(
+ DecisionSignalKind.SystemRebootObserved,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc));
+
+ var step = engine.Reduce(state, signal);
+
+ var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
+ Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]);
+ Assert.False(timelineEffect.Parameters.ContainsKey("previousExitType"));
+ }
+
+ // ------------------------------------------------------ WhiteGlovePart1To2 bridge
+
+ [Fact]
+ public void WhiteGlovePart1To2Bridge_emits_whiteglove_resumed_timeline_entry()
+ {
+ var engine = new DecisionEngine();
+ var sealedState = DecisionState.CreateInitial("s", "t")
+ .ToBuilder()
+ .WithStage(SessionStage.WhiteGloveSealed)
+ .Build();
+
+ var recoverSignal = BuildSignal(
+ DecisionSignalKind.SessionRecovered,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ sourceOrigin: "EnrollmentOrchestrator");
+
+ var step = engine.Reduce(sealedState, recoverSignal);
+
+ Assert.Equal(SessionStage.WhiteGloveAwaitingUserSignIn, step.NewState.Stage);
+
+ var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
+ Assert.Equal("whiteglove_resumed", timelineEffect.Parameters!["eventType"]);
+ Assert.Equal("2026-04-22T10:00:00.0000000Z", timelineEffect.Parameters["resumedAtUtc"]);
+ Assert.Equal("EnrollmentOrchestrator", timelineEffect.Parameters["sourceOrigin"]);
+
+ // ScheduleDeadline remains.
+ Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
+ }
+
+ // ---------------------------------------------------- AdminPreemptionDetected
+
+ [Fact]
+ public void AdminPreemptionDetectedV1_succeeded_transitions_to_completed_and_emits_complete_event()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var signal = BuildSignal(
+ DecisionSignalKind.AdminPreemptionDetected,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ new Dictionary { ["adminOutcome"] = "Succeeded" },
+ sourceOrigin: "register_session_response");
+
+ var step = engine.Reduce(state, signal);
+
+ Assert.Equal(SessionStage.Completed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
+
+ var effect = Assert.Single(step.Effects);
+ Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, effect.Kind);
+ Assert.Equal("enrollment_complete", effect.Parameters!["eventType"]);
+ Assert.Equal("Succeeded", effect.Parameters["adminAction"]);
+ Assert.Equal("register_session_response", effect.Parameters["source"]);
+ }
+
+ [Fact]
+ public void AdminPreemptionDetectedV1_failed_transitions_to_failed_and_emits_failed_event()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var signal = BuildSignal(
+ DecisionSignalKind.AdminPreemptionDetected,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ new Dictionary { ["adminOutcome"] = "Failed" });
+
+ var step = engine.Reduce(state, signal);
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
+
+ var effect = Assert.Single(step.Effects);
+ Assert.Equal("enrollment_failed", effect.Parameters!["eventType"]);
+ Assert.Equal("Failed", effect.Parameters["adminAction"]);
+ }
+
+ [Fact]
+ public void AdminPreemptionDetectedV1_missing_outcome_defaults_to_failed()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("s", "t");
+ var signal = BuildSignal(
+ DecisionSignalKind.AdminPreemptionDetected,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ payload: null);
+
+ var step = engine.Reduce(state, signal);
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+ Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
+ }
+
+ [Fact]
+ public void AdminPreemptionDetectedV1_clears_active_deadlines()
+ {
+ var engine = new DecisionEngine();
+ var stateWithDeadline = DecisionState.CreateInitial("s", "t")
+ .ToBuilder()
+ .AddDeadline(new ActiveDeadline(
+ name: DeadlineNames.ClassifierTick,
+ dueAtUtc: new DateTime(2026, 4, 22, 11, 0, 0, DateTimeKind.Utc),
+ firesSignalKind: DecisionSignalKind.DeadlineFired,
+ firesPayload: new Dictionary
+ {
+ [SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick,
+ }))
+ .Build();
+
+ var signal = BuildSignal(
+ DecisionSignalKind.AdminPreemptionDetected,
+ new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
+ new Dictionary { ["adminOutcome"] = "Succeeded" });
+
+ var step = engine.Reduce(stateWithDeadline, signal);
+
+ Assert.Empty(step.NewState.Deadlines);
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
index c414e942..73183aeb 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
@@ -86,7 +86,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
///
/// Handle . Records the fact so
- /// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight.
+ /// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight, and emits
+ /// the system_reboot_detected timeline entry so the session timeline shows the
+ /// split between pre-reboot and post-reboot collection.
///
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
{
@@ -108,7 +110,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.SystemRebootObserved));
- return new DecisionStep(newState, transition, Array.Empty());
+ var timelineParams = new Dictionary
+ {
+ ["eventType"] = "system_reboot_detected",
+ ["reason"] = "prior agent process was terminated by the reboot",
+ };
+ if (signal.Payload != null)
+ {
+ if (signal.Payload.TryGetValue("previousExitType", out var previousExitType))
+ timelineParams["previousExitType"] = previousExitType ?? string.Empty;
+ if (signal.Payload.TryGetValue("lastBootUtc", out var lastBootUtc))
+ timelineParams["lastBootUtc"] = lastBootUtc ?? string.Empty;
+ }
+
+ var effects = new[]
+ {
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: timelineParams),
+ };
+
+ return new DecisionStep(newState, transition, effects);
}
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
index d2a55719..55050224 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs
@@ -139,6 +139,64 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, Array.Empty());
}
+ ///
+ /// Handle . Plan §2.7 admin-
+ /// action audit; V2 parity PR-B3.
+ ///
+ /// Emitted by Program.RunAgent when the register-session response carries an
+ /// AdminAction value (operator marked the session terminal via the portal before
+ /// the agent even started). The signal payload carries
+ /// adminOutcome=Succeeded|Failed.
+ ///
+ ///
+ /// Stage transitions to (Succeeded) or
+ /// (anything else);
+ /// captures the non-enrollment nature of the transition so dashboards + KQL can tell
+ /// an admin-override apart from a genuine enrollment_complete/_failed.
+ ///
+ ///
+ private DecisionStep HandleAdminPreemptionDetectedV1(DecisionState state, DecisionSignal signal)
+ {
+ var adminOutcome = signal.Payload != null && signal.Payload.TryGetValue("adminOutcome", out var v)
+ ? v
+ : "Failed"; // defensive default: preemption without outcome is treated as failure.
+
+ var succeeded = string.Equals(adminOutcome, "Succeeded", StringComparison.OrdinalIgnoreCase);
+ var toStage = succeeded ? SessionStage.Completed : SessionStage.Failed;
+ var eventType = succeeded ? "enrollment_complete" : "enrollment_failed";
+
+ var nextStep = state.StepIndex + 1;
+ var newState = state.ToBuilder()
+ .WithStage(toStage)
+ .WithOutcome(SessionOutcome.AdminPreempted)
+ .WithStepIndex(nextStep)
+ .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
+ .ClearDeadlines()
+ .Build();
+
+ var transition = BuildTakenTransition(
+ before: state,
+ signal: signal,
+ toStage: toStage,
+ nextStepIndex: nextStep,
+ trigger: $"AdminPreemption:{adminOutcome}");
+
+ var effects = new[]
+ {
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: new Dictionary
+ {
+ ["eventType"] = eventType,
+ ["adminAction"] = adminOutcome,
+ ["source"] = signal.SourceOrigin ?? "register_session_response",
+ ["reason"] = $"Session {adminOutcome.ToLowerInvariant()} by administrator (detected on register-session).",
+ }),
+ };
+
+ return new DecisionStep(newState, transition, effects);
+ }
+
// ============================================================== shared helpers
// Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers
// as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
index 585b8043..2fc8b068 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.WhiteGlovePart2.cs
@@ -51,6 +51,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
var effects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety),
+ new DecisionEffect(
+ DecisionEffectKind.EmitEventTimelineEntry,
+ parameters: new Dictionary
+ {
+ ["eventType"] = "whiteglove_resumed",
+ ["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o"),
+ ["sourceOrigin"] = signal.SourceOrigin ?? string.Empty,
+ }),
};
return new DecisionStep(newState, transition, effects);
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
index 72c62d73..7928461f 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs
@@ -56,10 +56,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
return (signal.Kind, signal.KindSchemaVersion) switch
{
// ----- Lifecycle (DecisionEngine.Shared.cs) -----
- (DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
- (DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
- (DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
- (DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
+ (DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
+ (DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
+ (DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
+ (DecisionSignalKind.AdminPreemptionDetected, 1) => HandleAdminPreemptionDetectedV1(state, signal),
+ (DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
// ----- Classic UserDriven-v1 (DecisionEngine.Classic.cs) -----
(DecisionSignalKind.EspPhaseChanged, 1) => HandleEspPhaseChangedV1(state, signal),
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
index a704411f..b9f28796 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Signals/DecisionSignalKind.cs
@@ -41,5 +41,8 @@ namespace AutopilotMonitor.DecisionCore.Signals
SessionStarted,
SessionAborted,
SessionRecovered,
+
+ // --- Admin-driven preemption (Plan §2.7 admin-action audit, V2 parity PR-B3) ---
+ AdminPreemptionDetected,
}
}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs
index 11694d36..ae7a5994 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionOutcome.cs
@@ -13,5 +13,12 @@ namespace AutopilotMonitor.DecisionCore.State
WhiteGlovePart1Sealed,
WhiteGlovePart2Complete,
Aborted,
+
+ // V2 parity PR-B3 / plan §2.7 admin-action audit:
+ // Set when the register-session response carries an AdminAction
+ // (operator marked the session terminal via the portal before the
+ // agent even started). Stage transitions to Completed or Failed
+ // depending on the adminOutcome payload.
+ AdminPreempted,
}
}
diff --git a/tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json
new file mode 100644
index 00000000..9bc45f23
--- /dev/null
+++ b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-failed.json
@@ -0,0 +1,3 @@
+# AdminPreemptionDetected v1 — adminOutcome=Failed → Stage.Failed,
+# Outcome=AdminPreempted, effect emits enrollment_failed timeline entry.
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Failed","Summary":"Operator marked session as Failed via portal before agent startup."},"Payload":{"adminOutcome":"Failed"}}
diff --git a/tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json
new file mode 100644
index 00000000..e324a705
--- /dev/null
+++ b/tests/fixtures/signal-kinds/admin-preemption-detected-v1-succeeded.json
@@ -0,0 +1,4 @@
+# AdminPreemptionDetected v1 — adminOutcome=Succeeded → Stage.Completed,
+# Outcome=AdminPreempted, effect emits enrollment_complete timeline entry with
+# adminAction=Succeeded + source=register_session_response.
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Succeeded","Summary":"Operator marked session as Succeeded via portal before agent startup."},"Payload":{"adminOutcome":"Succeeded"}}
diff --git a/tests/fixtures/signal-kinds/system-reboot-observed-v1.json b/tests/fixtures/signal-kinds/system-reboot-observed-v1.json
new file mode 100644
index 00000000..d07824df
--- /dev/null
+++ b/tests/fixtures/signal-kinds/system-reboot-observed-v1.json
@@ -0,0 +1,5 @@
+# SystemRebootObserved v1 — V2 parity PR-B2.
+# Reducer handler HandleSystemRebootObservedV1 records SystemRebootUtc fact and emits
+# system_reboot_detected timeline entry. Payload carries previousExitType + lastBootUtc
+# so the backend can split the session into pre-/post-reboot segments.
+{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"SystemRebootObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"Program.DetectPreviousExit","Evidence":{"Kind":"Synthetic","Identifier":"previous_exit_reboot_kill","Summary":"Prior agent process terminated by OS reboot."},"Payload":{"previousExitType":"reboot_kill","lastBootUtc":"2026-04-22T08:00:00.0000000Z"}}