diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs
new file mode 100644
index 00000000..2bfa84cd
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/DecisionStepProcessorTerminalStageTests.cs
@@ -0,0 +1,182 @@
+using System;
+using System.Threading;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.Signals;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
+{
+ ///
+ /// M4.6.β — fires the terminal-stage callback exactly
+ /// once, at the transition from a non-terminal to a terminal .
+ ///
+ public sealed class DecisionStepProcessorTerminalStageTests
+ {
+ private static DateTime At => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
+
+ private sealed class Rig : IDisposable
+ {
+ public TempDirectory Tmp { get; } = new TempDirectory();
+ public AgentLogger Logger { get; }
+ public FakeJournalWriter Journal { get; } = new FakeJournalWriter();
+ public FakeEffectRunner Effects { get; } = new FakeEffectRunner();
+ public FakeSnapshotPersistence Snapshot { get; } = new FakeSnapshotPersistence();
+ public FakeQuarantineSink Quarantine { get; } = new FakeQuarantineSink();
+
+ public Rig() { Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info); }
+
+ public DecisionStepProcessor Build(DecisionState initial, Action? onTerminal) =>
+ new DecisionStepProcessor(
+ initialState: initial,
+ journal: Journal,
+ effectRunner: Effects,
+ snapshot: Snapshot,
+ quarantineSink: Quarantine,
+ logger: Logger,
+ quarantineThreshold: DecisionStepProcessor.DefaultQuarantineThreshold,
+ onTerminalStageReached: onTerminal);
+
+ public void Dispose() => Tmp.Dispose();
+ }
+
+ private static DecisionSignal Sig(long ordinal, DateTime at) =>
+ new DecisionSignal(
+ sessionSignalOrdinal: ordinal,
+ sessionTraceOrdinal: ordinal,
+ kind: DecisionSignalKind.SessionStarted,
+ kindSchemaVersion: 1,
+ occurredAtUtc: at,
+ sourceOrigin: "test",
+ evidence: new Evidence(EvidenceKind.Raw, $"raw-{ordinal}", $"evidence-{ordinal}"),
+ payload: null);
+
+ private static DecisionStep StepToStage(DecisionState oldState, SessionStage newStage, long signalOrdinal, DateTime at)
+ {
+ var builder = new DecisionStateBuilder(oldState) { Stage = newStage, StepIndex = oldState.StepIndex + 1 };
+ var newState = builder.Build();
+ var transition = new DecisionTransition(
+ stepIndex: newState.StepIndex,
+ sessionTraceOrdinal: signalOrdinal,
+ signalOrdinalRef: signalOrdinal,
+ occurredAtUtc: at,
+ trigger: "TestTerminalTrigger",
+ fromStage: oldState.Stage,
+ toStage: newStage,
+ taken: true,
+ deadEndReason: null,
+ reducerVersion: "2.0.0.0");
+ return new DecisionStep(newState, transition, Array.Empty());
+ }
+
+ [Theory]
+ [InlineData(SessionStage.Completed)]
+ [InlineData(SessionStage.Failed)]
+ [InlineData(SessionStage.WhiteGloveSealed)]
+ [InlineData(SessionStage.WhiteGloveCompletedPart2)]
+ public void Terminal_stage_transition_fires_callback_with_terminal_state(SessionStage terminal)
+ {
+ using var rig = new Rig();
+ DecisionState? captured = null;
+ var callCount = 0;
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
+ onTerminal: state =>
+ {
+ Interlocked.Increment(ref callCount);
+ captured = state;
+ });
+
+ var step = StepToStage(sut.CurrentState, terminal, signalOrdinal: 1, at: At);
+ sut.ApplyStep(step, Sig(1, At));
+
+ Assert.Equal(1, callCount);
+ Assert.NotNull(captured);
+ Assert.Equal(terminal, captured!.Stage);
+ }
+
+ [Fact]
+ public void Terminal_callback_does_not_fire_for_non_terminal_transitions()
+ {
+ using var rig = new Rig();
+ var callCount = 0;
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
+ onTerminal: _ => Interlocked.Increment(ref callCount));
+
+ var step = StepToStage(sut.CurrentState, SessionStage.AwaitingEspPhaseChange, signalOrdinal: 1, at: At);
+ sut.ApplyStep(step, Sig(1, At));
+
+ Assert.Equal(0, callCount);
+ }
+
+ [Fact]
+ public void Terminal_callback_fires_at_most_once_per_processor_instance()
+ {
+ using var rig = new Rig();
+ var callCount = 0;
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
+ onTerminal: _ => Interlocked.Increment(ref callCount));
+
+ // Transition to Completed, then apply a second step that leaves the stage terminal.
+ var firstStep = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At);
+ sut.ApplyStep(firstStep, Sig(1, At));
+
+ // Synthetic second step — stage still terminal (same value).
+ var secondStep = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 2, at: At.AddSeconds(1));
+ sut.ApplyStep(secondStep, Sig(2, At.AddSeconds(1)));
+
+ Assert.Equal(1, callCount);
+ }
+
+ [Fact]
+ public void Terminal_callback_suppressed_when_initial_state_is_already_terminal()
+ {
+ // Recovery scenario: snapshot loaded a state whose stage is already terminal
+ // (e.g. crash after terminal transition but before Stop). The processor must not
+ // re-fire the callback on the first post-recovery step.
+ using var rig = new Rig();
+ var callCount = 0;
+ var recoveredState = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1"))
+ {
+ Stage = SessionStage.Completed,
+ }.Build();
+
+ var sut = rig.Build(recoveredState,
+ onTerminal: _ => Interlocked.Increment(ref callCount));
+
+ // No ApplyStep needed — the ctor already observed the terminal state. Even if we
+ // apply a follow-up terminal-preserving step, callback stays silent.
+ var step = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At);
+ sut.ApplyStep(step, Sig(1, At));
+
+ Assert.Equal(0, callCount);
+ }
+
+ [Fact]
+ public void Terminal_callback_null_is_supported()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"), onTerminal: null);
+ var step = StepToStage(sut.CurrentState, SessionStage.Completed, signalOrdinal: 1, at: At);
+
+ // Should not throw when callback is null.
+ sut.ApplyStep(step, Sig(1, At));
+ Assert.Equal(SessionStage.Completed, sut.CurrentState.Stage);
+ }
+
+ [Fact]
+ public void Terminal_callback_exception_is_swallowed_and_logged()
+ {
+ using var rig = new Rig();
+ var sut = rig.Build(DecisionState.CreateInitial("S1", "T1"),
+ onTerminal: _ => throw new InvalidOperationException("handler bug"));
+
+ var step = StepToStage(sut.CurrentState, SessionStage.Failed, signalOrdinal: 1, at: At);
+ // Must not bubble the handler's exception — the step is already committed.
+ sut.ApplyStep(step, Sig(1, At));
+ Assert.Equal(SessionStage.Failed, sut.CurrentState.Stage);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs
new file mode 100644
index 00000000..e65c5168
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs
@@ -0,0 +1,221 @@
+#nullable enable
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+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.Termination;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
+{
+ public sealed class EnrollmentTerminationHandlerTests
+ {
+ private static DateTime StartUtc => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
+ private static DateTime EndUtc => StartUtc.AddMinutes(10);
+
+ ///
+ /// CleanupService is virtual for testability. ExecuteSelfDestruct is a best-effort
+ /// fire-and-forget in production — in tests we just count invocations.
+ ///
+ private sealed class RecordingCleanupService : CleanupService
+ {
+ public int Invocations;
+ public RecordingCleanupService(AgentConfiguration config, AgentLogger logger) : base(config, logger) { }
+ public override void ExecuteSelfDestruct() => Interlocked.Increment(ref Invocations);
+ }
+
+ private sealed class Rig : IDisposable
+ {
+ public TempDirectory Tmp { get; } = new TempDirectory();
+ public string StateDir { get; }
+ public AgentLogger Logger { get; }
+ public DecisionState State { get; set; } = DecisionState.CreateInitial("S1", "T1");
+ public AppPackageStateList Packages { get; }
+ public RecordingCleanupService CleanupService { get; }
+ public int DiagnosticsUploads { get; private set; }
+ public bool? LastDiagnosticsSucceededFlag { get; private set; }
+ public string? LastDiagnosticsSuffix { get; private set; }
+ public DiagnosticsUploadResult? DiagnosticsResult { get; set; }
+ public int ShutdownSignalled;
+
+ public Rig()
+ {
+ StateDir = Path.Combine(Tmp.Path, "State");
+ Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
+ Packages = new AppPackageStateList(Logger);
+ CleanupService = new RecordingCleanupService(BuildConfig(), Logger);
+ }
+
+ public AgentConfiguration BuildConfig(
+ bool selfDestruct = true,
+ bool showDialog = false,
+ bool diagEnabled = false,
+ string diagMode = "Off")
+ {
+ return new AgentConfiguration
+ {
+ SessionId = "S1",
+ TenantId = "T1",
+ ApiBaseUrl = "http://localhost",
+ SelfDestructOnComplete = selfDestruct,
+ ShowEnrollmentSummary = showDialog,
+ DiagnosticsUploadEnabled = diagEnabled,
+ DiagnosticsUploadMode = diagMode,
+ EnrollmentSummaryTimeoutSeconds = 60,
+ };
+ }
+
+ public EnrollmentTerminationHandler Build(AgentConfiguration? config = null)
+ {
+ config ??= BuildConfig();
+ return new EnrollmentTerminationHandler(
+ configuration: config,
+ logger: Logger,
+ stateDirectory: StateDir,
+ agentStartTimeUtc: StartUtc,
+ currentStateAccessor: () => State,
+ packageStatesAccessor: () => Packages,
+ cleanupServiceFactory: () => CleanupService,
+ uploadDiagnosticsAsync: (succeeded, suffix) =>
+ {
+ DiagnosticsUploads++;
+ LastDiagnosticsSucceededFlag = succeeded;
+ LastDiagnosticsSuffix = suffix;
+ return Task.FromResult(DiagnosticsResult ?? new DiagnosticsUploadResult { BlobName = "blob" });
+ },
+ signalShutdown: () => Interlocked.Increment(ref ShutdownSignalled));
+ }
+
+ public void Dispose() => Tmp.Dispose();
+ }
+
+ private static EnrollmentTerminatedEventArgs Args(
+ EnrollmentTerminationReason reason,
+ EnrollmentTerminationOutcome outcome,
+ SessionStage stage,
+ DateTime? at = null) =>
+ new EnrollmentTerminatedEventArgs(
+ reason, outcome, stage.ToString(), at ?? EndUtc);
+
+ [Fact]
+ public void Handle_writes_final_status_json_in_state_directory()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
+
+ rig.Build().Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
+
+ var statusPath = Path.Combine(rig.StateDir, SummaryDialogLauncher.FinalStatusFileName);
+ Assert.True(File.Exists(statusPath), "final-status.json should be written even when ShowEnrollmentSummary=false");
+ }
+
+ [Fact]
+ public void Handle_writes_enrollment_complete_marker_on_non_whiteglove_terminal()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
+
+ rig.Build().Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
+
+ Assert.True(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
+ }
+
+ [Fact]
+ public void Handle_skips_marker_and_cleanup_on_whiteglove_part1_exit()
+ {
+ 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));
+
+ Assert.False(File.Exists(Path.Combine(rig.StateDir, "enrollment-complete.marker")));
+ Assert.Equal(0, rig.CleanupService.Invocations);
+ }
+
+ [Fact]
+ public void Handle_runs_cleanup_on_success_when_self_destruct_enabled()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
+
+ rig.Build().Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
+
+ Assert.Equal(1, rig.CleanupService.Invocations);
+ }
+
+ [Fact]
+ public void Handle_skips_cleanup_when_self_destruct_disabled()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
+ var cfg = rig.BuildConfig(selfDestruct: false);
+
+ rig.Build(cfg).Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
+
+ Assert.Equal(0, rig.CleanupService.Invocations);
+ }
+
+ [Fact]
+ public void Handle_signals_shutdown_exactly_once_even_on_error()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
+
+ var sut = rig.Build();
+ sut.Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
+
+ // Idempotent — second Handle is a no-op.
+ sut.Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
+
+ Assert.Equal(1, rig.ShutdownSignalled);
+ }
+
+ [Theory]
+ [InlineData("Off", EnrollmentTerminationOutcome.Succeeded, false, 0)]
+ [InlineData("Always", EnrollmentTerminationOutcome.Succeeded, true, 1)]
+ [InlineData("Always", EnrollmentTerminationOutcome.Failed, true, 1)]
+ [InlineData("OnFailure", EnrollmentTerminationOutcome.Succeeded, true, 0)]
+ [InlineData("OnFailure", EnrollmentTerminationOutcome.Failed, true, 1)]
+ public void Handle_diagnostics_upload_respects_mode_and_outcome(
+ string mode, EnrollmentTerminationOutcome outcome, bool enabled, int expected)
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
+ var cfg = rig.BuildConfig(diagEnabled: enabled, diagMode: mode, selfDestruct: false);
+
+ rig.Build(cfg).Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, outcome,
+ outcome == EnrollmentTerminationOutcome.Succeeded ? SessionStage.Completed : SessionStage.Failed));
+
+ Assert.Equal(expected, rig.DiagnosticsUploads);
+ }
+
+ [Fact]
+ public void Handle_diagnostics_upload_suffix_reflects_success_vs_failure()
+ {
+ using var rig = new Rig();
+ rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
+ var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
+
+ rig.Build(cfg).Handle(sender: null!,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
+
+ Assert.Equal("failure", rig.LastDiagnosticsSuffix);
+ Assert.Equal(false, rig.LastDiagnosticsSucceededFlag);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs
new file mode 100644
index 00000000..ebad4d30
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/FinalStatusBuilderTests.cs
@@ -0,0 +1,207 @@
+#nullable enable
+using System;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Agent.V2.Core.Termination;
+using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
+using AutopilotMonitor.DecisionCore.Engine;
+using AutopilotMonitor.DecisionCore.State;
+using Newtonsoft.Json;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
+{
+ public sealed class FinalStatusBuilderTests
+ {
+ private static DateTime StartUtc => new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
+ private static DateTime EndUtc => StartUtc.AddMinutes(45);
+
+ private static DecisionState StateWith(SessionStage stage, string? helloOutcome = null)
+ {
+ var initial = DecisionState.CreateInitial("S1", "T1");
+ var builder = new DecisionStateBuilder(initial) { Stage = stage };
+ if (helloOutcome != null)
+ builder.HelloOutcome = new SignalFact(helloOutcome, sourceSignalOrdinal: 1);
+ return builder.Build();
+ }
+
+ private static EnrollmentTerminatedEventArgs Args(
+ EnrollmentTerminationReason reason,
+ EnrollmentTerminationOutcome outcome,
+ SessionStage stage) =>
+ new EnrollmentTerminatedEventArgs(
+ reason: reason,
+ outcome: outcome,
+ stageName: stage.ToString(),
+ terminatedAtUtc: EndUtc);
+
+ [Theory]
+ [InlineData(SessionStage.Completed, EnrollmentTerminationOutcome.Succeeded, "succeeded")]
+ [InlineData(SessionStage.Failed, EnrollmentTerminationOutcome.Failed, "failed")]
+ [InlineData(SessionStage.WhiteGloveSealed, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part1")]
+ [InlineData(SessionStage.WhiteGloveCompletedPart2, EnrollmentTerminationOutcome.Succeeded, "whiteglove_part2")]
+ public void Build_maps_outcome_from_terminal_stage(SessionStage stage, EnrollmentTerminationOutcome outcome, string expected)
+ {
+ var state = StateWith(stage);
+ var args = Args(EnrollmentTerminationReason.DecisionTerminalStage, outcome, stage);
+
+ var status = FinalStatusBuilder.Build(state, args, packageStates: null, agentStartTimeUtc: StartUtc);
+
+ Assert.Equal(expected, status.Outcome);
+ }
+
+ [Fact]
+ public void Build_reports_agent_uptime_in_seconds()
+ {
+ var state = StateWith(SessionStage.Completed);
+ var args = Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed);
+
+ var status = FinalStatusBuilder.Build(state, args, packageStates: null, agentStartTimeUtc: StartUtc);
+
+ Assert.Equal((EndUtc - StartUtc).TotalSeconds, status.AgentUptimeSeconds);
+ }
+
+ [Fact]
+ public void Build_records_completion_source_from_reason()
+ {
+ var state = StateWith(SessionStage.Completed);
+ var args = Args(EnrollmentTerminationReason.MaxLifetimeExceeded, EnrollmentTerminationOutcome.TimedOut, SessionStage.Completed);
+
+ var status = FinalStatusBuilder.Build(state, args, packageStates: null, agentStartTimeUtc: StartUtc);
+
+ Assert.Equal(nameof(EnrollmentTerminationReason.MaxLifetimeExceeded), status.CompletionSource);
+ }
+
+ [Fact]
+ public void Build_reports_hello_outcome_from_fact_or_unknown()
+ {
+ var state1 = StateWith(SessionStage.Completed, helloOutcome: "completed");
+ var status1 = FinalStatusBuilder.Build(state1,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed),
+ packageStates: null, agentStartTimeUtc: StartUtc);
+ Assert.Equal("completed", status1.HelloOutcome);
+
+ var state2 = StateWith(SessionStage.Completed, helloOutcome: null);
+ var status2 = FinalStatusBuilder.Build(state2,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed),
+ packageStates: null, agentStartTimeUtc: StartUtc);
+ Assert.Equal("unknown", status2.HelloOutcome);
+ }
+
+ [Fact]
+ public void Build_signalsSeen_from_decision_state_facts()
+ {
+ var initial = DecisionState.CreateInitial("S1", "T1");
+ var state = new DecisionStateBuilder(initial)
+ {
+ Stage = SessionStage.Completed,
+ HelloResolvedUtc = new SignalFact(StartUtc.AddMinutes(5), 1),
+ DesktopArrivedUtc = new SignalFact(StartUtc.AddMinutes(10), 2),
+ AadJoinedWithUser = new SignalFact(true, 3),
+ ImeMatchedPatternId = new SignalFact("esp_done", 4),
+ }.Build();
+
+ var status = FinalStatusBuilder.Build(state,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed),
+ packageStates: null, agentStartTimeUtc: StartUtc);
+
+ Assert.Contains("hello_resolved", status.SignalsSeen);
+ Assert.Contains("desktop_arrived", status.SignalsSeen);
+ Assert.Contains("aad_user_joined", status.SignalsSeen);
+ Assert.Contains("ime_pattern:esp_done", status.SignalsSeen);
+ }
+
+ [Fact]
+ public void Build_with_null_package_states_produces_empty_app_summary()
+ {
+ var state = StateWith(SessionStage.Completed);
+ var status = FinalStatusBuilder.Build(state,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed),
+ packageStates: null, agentStartTimeUtc: StartUtc);
+
+ Assert.Equal(0, status.AppSummary.TotalApps);
+ Assert.Empty(status.AppSummary.AppsByPhase);
+ Assert.Empty(status.PackageStatesByPhase);
+ }
+
+ [Fact]
+ public void Build_counts_apps_by_installation_state_and_targeted_phase()
+ {
+ using var tmp = new TempDirectory();
+ var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
+ var list = new AppPackageStateList(logger);
+
+ var okDevice = new AppPackageState("app-1", 0); // default installationState = Unknown
+ okDevice.UpdateState(AppInstallationState.Installed);
+ TestHelpers.SetTargeted(okDevice, AppTargeted.Device);
+ list.Add(okDevice);
+
+ var errDevice = new AppPackageState("app-2", 1);
+ errDevice.UpdateState(AppInstallationState.Error);
+ TestHelpers.SetTargeted(errDevice, AppTargeted.Device);
+ list.Add(errDevice);
+
+ var errUser = new AppPackageState("app-3", 2);
+ errUser.UpdateState(AppInstallationState.Error);
+ TestHelpers.SetTargeted(errUser, AppTargeted.User);
+ list.Add(errUser);
+
+ var inProgress = new AppPackageState("app-4", 3);
+ inProgress.UpdateState(AppInstallationState.InProgress);
+ TestHelpers.SetTargeted(inProgress, AppTargeted.Device);
+ list.Add(inProgress);
+
+ var state = StateWith(SessionStage.Completed);
+ var status = FinalStatusBuilder.Build(state,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed),
+ packageStates: list, agentStartTimeUtc: StartUtc);
+
+ Assert.Equal(4, status.AppSummary.TotalApps);
+ Assert.Equal(3, status.AppSummary.CompletedApps); // Installed + 2 Errors count as completed (terminal)
+ Assert.Equal(2, status.AppSummary.ErrorCount);
+ Assert.Equal(1, status.AppSummary.DeviceErrors);
+ Assert.Equal(1, status.AppSummary.UserErrors);
+ Assert.Equal(3, status.AppSummary.AppsByPhase["Device"]);
+ Assert.Equal(1, status.AppSummary.AppsByPhase["User"]);
+
+ Assert.True(status.PackageStatesByPhase.ContainsKey("Device"));
+ Assert.True(status.PackageStatesByPhase.ContainsKey("User"));
+ Assert.Equal(3, status.PackageStatesByPhase["Device"].Count);
+ Assert.Single(status.PackageStatesByPhase["User"]);
+ }
+
+ [Fact]
+ public void Build_serializes_to_json_with_expected_property_names()
+ {
+ var state = StateWith(SessionStage.Completed);
+ var status = FinalStatusBuilder.Build(state,
+ Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed),
+ packageStates: null, agentStartTimeUtc: StartUtc);
+
+ var json = JsonConvert.SerializeObject(status);
+
+ // Dialog-wire contract: all fields lower-camelCase (matches SummaryDialog's FinalStatus model).
+ Assert.Contains("\"timestamp\":", json);
+ Assert.Contains("\"outcome\":", json);
+ Assert.Contains("\"completionSource\":", json);
+ Assert.Contains("\"helloOutcome\":", json);
+ Assert.Contains("\"enrollmentType\":", json);
+ Assert.Contains("\"agentUptimeSeconds\":", json);
+ Assert.Contains("\"signalsSeen\":", json);
+ Assert.Contains("\"appSummary\":", json);
+ Assert.Contains("\"packageStatesByPhase\":", json);
+ }
+ }
+
+ /// Mini helper for reflection-based field setters — AppPackageState.Targeted is a private-set property.
+ internal static class TestHelpers
+ {
+ public static void SetTargeted(AppPackageState pkg, AppTargeted targeted)
+ {
+ var prop = typeof(AppPackageState).GetProperty(nameof(AppPackageState.Targeted))!;
+ prop.SetValue(pkg, targeted);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs
index 2914593d..cbc49356 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DecisionStepProcessor.cs
@@ -50,10 +50,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly IQuarantineSink _quarantineSink;
private readonly AgentLogger _logger;
private readonly int _quarantineThreshold;
+ private readonly Action? _onTerminalStageReached;
private DecisionState _currentState;
private int _consecutiveJournalFailures;
private bool _quarantineTriggered;
+ private bool _terminalNotified;
public DecisionStepProcessor(
DecisionState initialState,
@@ -62,7 +64,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
ISnapshotPersistence snapshot,
IQuarantineSink quarantineSink,
AgentLogger logger,
- int quarantineThreshold = DefaultQuarantineThreshold)
+ int quarantineThreshold = DefaultQuarantineThreshold,
+ Action? onTerminalStageReached = null)
{
if (quarantineThreshold <= 0)
{
@@ -78,6 +81,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_quarantineSink = quarantineSink ?? throw new ArgumentNullException(nameof(quarantineSink));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_quarantineThreshold = quarantineThreshold;
+ _onTerminalStageReached = onTerminalStageReached;
+
+ // If recovery loaded a state that already sits on a terminal stage (e.g. a crash
+ // after a success-path step but before Stop()), treat it as already-notified so we
+ // do not re-fire the hook.
+ _terminalNotified = initialState.Stage.IsTerminal();
}
public DecisionState CurrentState => _currentState;
@@ -164,6 +173,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// 4) State-Forward + Counter-Reset.
_currentState = step.NewState;
_consecutiveJournalFailures = 0;
+
+ // 5) Terminal-stage detection (M4.6.β). Fires exactly once per agent run when the
+ // DecisionEngine transitions the session into a terminal SessionStage — the
+ // orchestrator turns this into the public EnrollmentTerminated event so peripheral
+ // consumers (CleanupService, SummaryDialog, DiagnosticsPackageService) can react
+ // without touching the kernel state machine.
+ if (!_terminalNotified && _currentState.Stage.IsTerminal())
+ {
+ _terminalNotified = true;
+ try { _onTerminalStageReached?.Invoke(_currentState); }
+ catch (Exception ex)
+ {
+ _logger.Error(
+ $"DecisionStepProcessor: onTerminalStageReached handler threw for stage {_currentState.Stage}.",
+ ex);
+ }
+ }
}
private void TryTriggerQuarantine(string reason)
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
index 3f2a02a0..d490f41e 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs
@@ -59,6 +59,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly string _agentVersion;
private readonly string _stateDirectory;
+ private ImeLogHost? _imeLogHost;
+
+ ///
+ /// Exposes the IME tracker's package-state list to peripheral consumers such as the
+ /// FinalStatusBuilder in M4.6.β. Returns null before
+ /// has been called (Orchestrator start order).
+ ///
+ public Monitoring.Enrollment.Ime.AppPackageStateList? ImePackageStates => _imeLogHost?.PackageStates;
+
public DefaultComponentFactory(
AgentConfiguration agentConfig,
AgentConfigResponse remoteConfig,
@@ -113,7 +122,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
hosts.Add(new AadJoinHost(logger, ingress, clock));
- hosts.Add(new ImeLogHost(
+ _imeLogHost = new ImeLogHost(
sessionId: sessionId,
tenantId: tenantId,
onEnrollmentEvent: onEnrollmentEvent,
@@ -124,7 +133,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
imeMatchLogPath: _agentConfig.ImeMatchLogPath,
imePatterns: _remoteConfig.ImeLogPatterns,
stateDirectory: _stateDirectory,
- whiteGloveSealingPatternIds: whiteGloveSealingPatternIds));
+ whiteGloveSealingPatternIds: whiteGloveSealingPatternIds);
+ hosts.Add(_imeLogHost);
if (collectors.StallProbeEnabled)
{
@@ -275,6 +285,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly AgentLogger _logger;
private int _disposed;
+ ///
+ /// Exposes the tracker's live package-state list for peripheral consumers
+ /// (M4.6.β FinalStatusBuilder).
+ ///
+ public Monitoring.Enrollment.Ime.AppPackageStateList PackageStates => _tracker.PackageStates;
+
public ImeLogHost(
string sessionId,
string tenantId,
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs
index b5dd44e0..ec362ff6 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs
@@ -322,7 +322,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
snapshot: _snapshot,
clock: _clock);
- // 8) Processor — owns the initial state + journal + snapshot + quarantine hook.
+ // 8) Processor — owns the initial state + journal + snapshot + quarantine hook +
+ // M4.6.β terminal-stage hook (fires Terminated event when the engine reaches a
+ // terminal SessionStage).
_processor = new DecisionStepProcessor(
initialState: initialState,
journal: _journal,
@@ -330,7 +332,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
snapshot: _snapshot,
quarantineSink: this,
logger: _logger,
- quarantineThreshold: _quarantineThreshold);
+ quarantineThreshold: _quarantineThreshold,
+ onTerminalStageReached: OnDecisionTerminalStage);
// 9) Trace-Ordinal. TODO(M4.4.5.f): seed aus max(SignalLog.LastOrdinal,
// Journal.LastStepIndex, Spool.LastAssignedItemId via SessionTraceOrdinal).
@@ -449,6 +452,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
catch (Exception ex) { _logger.Error("EnrollmentOrchestrator: Terminated handler threw.", ex); }
}
+ ///
+ /// M4.6.β — DecisionStepProcessor callback: the engine has transitioned the session
+ /// into a terminal . Fires the public
+ /// event with and an
+ /// outcome derived from the stage (Completed/Part2→Succeeded, Failed→Failed,
+ /// WhiteGloveSealed→Succeeded but with
+ /// signalled to callers via the stage name — callers decide whether to self-destruct).
+ ///
+ private void OnDecisionTerminalStage(DecisionState terminalState)
+ {
+ if (Volatile.Read(ref _stopRequested) == 1) return;
+ if (Interlocked.Exchange(ref _terminatedFired, 1) == 1) return;
+
+ // Stop the max-lifetime watchdog — the real terminal arrived before it could trip.
+ try { _maxLifetimeTimer?.Dispose(); _maxLifetimeTimer = null; } catch { }
+
+ var outcome = terminalState.Stage switch
+ {
+ SessionStage.Completed or SessionStage.WhiteGloveCompletedPart2 or SessionStage.WhiteGloveSealed
+ => EnrollmentTerminationOutcome.Succeeded,
+ SessionStage.Failed => EnrollmentTerminationOutcome.Failed,
+ _ => EnrollmentTerminationOutcome.TimedOut,
+ };
+
+ _logger.Info(
+ $"EnrollmentOrchestrator: decision terminal stage reached (stage={terminalState.Stage}, outcome={outcome}) — firing Terminated(DecisionTerminalStage).");
+
+ var terminatedArgs = new EnrollmentTerminatedEventArgs(
+ reason: EnrollmentTerminationReason.DecisionTerminalStage,
+ outcome: outcome,
+ stageName: terminalState.Stage.ToString(),
+ terminatedAtUtc: _clock.UtcNow,
+ details: terminalState.Stage.IsPauseBeforePart2()
+ ? "WhiteGlove Part 1 sealed — session will resume on Part 2 post-reboot; self-destruct suppressed."
+ : null);
+
+ try { Terminated?.Invoke(this, terminatedArgs); }
+ catch (Exception ex) { _logger.Error("EnrollmentOrchestrator: Terminated handler threw.", ex); }
+ }
+
///
/// Stop the pipeline. Idempotent; calls from as well.
///
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
new file mode 100644
index 00000000..17fd02a1
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs
@@ -0,0 +1,230 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+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.DecisionCore.State;
+using AutopilotMonitor.Shared;
+
+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):
+ ///
+ ///
+ /// - 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).
+ /// - 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().
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ public sealed class EnrollmentTerminationHandler
+ {
+ private readonly AgentConfiguration _configuration;
+ private readonly AgentLogger _logger;
+ private readonly string _stateDirectory;
+ private readonly DateTime _agentStartTimeUtc;
+ private readonly Func _currentStateAccessor;
+ private readonly Func _packageStatesAccessor;
+ private readonly Func _cleanupServiceFactory;
+ private readonly Func> _uploadDiagnosticsAsync;
+ private readonly Action _signalShutdown;
+ private readonly string _dialogExePathOverride;
+
+ private int _handled;
+
+ public EnrollmentTerminationHandler(
+ AgentConfiguration configuration,
+ AgentLogger logger,
+ string stateDirectory,
+ DateTime agentStartTimeUtc,
+ Func currentStateAccessor,
+ Func packageStatesAccessor,
+ Func cleanupServiceFactory,
+ Func> uploadDiagnosticsAsync,
+ Action signalShutdown,
+ string dialogExePathOverride = null)
+ {
+ _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _stateDirectory = string.IsNullOrEmpty(stateDirectory) ? throw new ArgumentNullException(nameof(stateDirectory)) : stateDirectory;
+ _agentStartTimeUtc = agentStartTimeUtc;
+ _currentStateAccessor = currentStateAccessor ?? throw new ArgumentNullException(nameof(currentStateAccessor));
+ _packageStatesAccessor = packageStatesAccessor ?? throw new ArgumentNullException(nameof(packageStatesAccessor));
+ _cleanupServiceFactory = cleanupServiceFactory ?? throw new ArgumentNullException(nameof(cleanupServiceFactory));
+ _uploadDiagnosticsAsync = uploadDiagnosticsAsync ?? throw new ArgumentNullException(nameof(uploadDiagnosticsAsync));
+ _signalShutdown = signalShutdown ?? throw new ArgumentNullException(nameof(signalShutdown));
+ _dialogExePathOverride = dialogExePathOverride;
+ }
+
+ ///
+ /// Handler for . Idempotent — runs at most once.
+ ///
+ public void Handle(object sender, EnrollmentTerminatedEventArgs args)
+ {
+ if (Interlocked.Exchange(ref _handled, 1) == 1) return;
+
+ try
+ {
+ _logger.Info(
+ $"EnrollmentTerminationHandler: handling Terminated (reason={args.Reason}, outcome={args.Outcome}, stage={args.StageName}).");
+
+ var state = TryGetCurrentState();
+ if (state == null)
+ {
+ _logger.Warning("EnrollmentTerminationHandler: current state unavailable — skipping FinalStatus + SummaryDialog.");
+ }
+ else
+ {
+ RunBuildAndLaunchDialog(state, args);
+ }
+
+ RunUploadDiagnostics(args);
+ WriteEnrollmentCompleteMarker(args);
+ RunSelfDestructIfAppropriate(args);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("EnrollmentTerminationHandler: unhandled exception during termination sequence.", ex);
+ }
+ finally
+ {
+ try { _signalShutdown(); }
+ catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: signalShutdown threw: {ex.Message}"); }
+ }
+ }
+
+ private DecisionState TryGetCurrentState()
+ {
+ try { return _currentStateAccessor(); }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTerminationHandler: current state accessor threw: {ex.Message}");
+ return null;
+ }
+ }
+
+ private AppPackageStateList TryGetPackageStates()
+ {
+ try { return _packageStatesAccessor(); }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTerminationHandler: package states accessor threw: {ex.Message}");
+ return null;
+ }
+ }
+
+ private void RunBuildAndLaunchDialog(DecisionState state, EnrollmentTerminatedEventArgs args)
+ {
+ try
+ {
+ var packages = TryGetPackageStates();
+ var status = FinalStatusBuilder.Build(state, args, packages, _agentStartTimeUtc);
+ SummaryDialogLauncher.WriteAndLaunch(status, _configuration, _stateDirectory, _logger, _dialogExePathOverride);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTerminationHandler: FinalStatus/SummaryDialog step failed: {ex.Message}");
+ }
+ }
+
+ private void RunUploadDiagnostics(EnrollmentTerminatedEventArgs args)
+ {
+ var mode = _configuration.DiagnosticsUploadMode ?? "Off";
+ if (!_configuration.DiagnosticsUploadEnabled || string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.Debug("EnrollmentTerminationHandler: diagnostics upload skipped (disabled or mode=Off).");
+ return;
+ }
+
+ var enrollmentSucceeded = args.Outcome == EnrollmentTerminationOutcome.Succeeded;
+ if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded)
+ {
+ _logger.Debug("EnrollmentTerminationHandler: diagnostics upload skipped (mode=OnFailure + enrollment succeeded).");
+ return;
+ }
+
+ 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"}.");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTerminationHandler: diagnostics upload threw: {ex.Message}");
+ }
+ }
+
+ private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args)
+ {
+ // On WhiteGlove Part 1 exit we keep the session alive for Part 2 — DO NOT write the
+ // marker (ghost-restart detection would fire + destroy the in-flight session state).
+ if (args.StageName == SessionStage.WhiteGloveSealed.ToString())
+ {
+ _logger.Info("EnrollmentTerminationHandler: WhiteGlove Part 1 exit — enrollment-complete marker NOT written.");
+ return;
+ }
+
+ try
+ {
+ Directory.CreateDirectory(_stateDirectory);
+ var markerPath = Path.Combine(_stateDirectory, "enrollment-complete.marker");
+ File.WriteAllText(markerPath,
+ $"Terminated at {args.TerminatedAtUtc:O} (reason={args.Reason}, outcome={args.Outcome}, stage={args.StageName}).");
+ _logger.Info($"EnrollmentTerminationHandler: enrollment-complete.marker written to {markerPath}.");
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"EnrollmentTerminationHandler: enrollment-complete.marker write failed: {ex.Message}");
+ }
+ }
+
+ private void RunSelfDestructIfAppropriate(EnrollmentTerminatedEventArgs args)
+ {
+ if (!_configuration.SelfDestructOnComplete)
+ {
+ _logger.Info("EnrollmentTerminationHandler: SelfDestructOnComplete=false — cleanup skipped.");
+ return;
+ }
+
+ if (args.StageName == SessionStage.WhiteGloveSealed.ToString())
+ {
+ _logger.Info("EnrollmentTerminationHandler: WhiteGlove Part 1 exit — cleanup skipped (session resumes on reboot).");
+ return;
+ }
+
+ try
+ {
+ var service = _cleanupServiceFactory();
+ service.ExecuteSelfDestruct();
+ _logger.Info("EnrollmentTerminationHandler: CleanupService.ExecuteSelfDestruct() invoked (fire-and-forget).");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("EnrollmentTerminationHandler: cleanup service invocation threw.", ex);
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs
new file mode 100644
index 00000000..bac6025b
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs
@@ -0,0 +1,84 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Agent.V2.Core.Termination
+{
+ ///
+ /// Wire-format DTO consumed by AutopilotMonitor.SummaryDialog.exe. Plan §4.x M4.6.β.
+ ///
+ /// Mirrors AutopilotMonitor.SummaryDialog.Models.FinalStatus — the dialog deserializes
+ /// this JSON on launch and renders the summary. We cannot reference the dialog project
+ /// directly (WPF net48 WinExe), so the shape is duplicated here. Keep the JSON property names
+ /// in lockstep with the dialog — changes here require a corresponding update in the dialog.
+ ///
+ ///
+ public sealed class FinalStatus
+ {
+ [JsonProperty("timestamp")]
+ public string Timestamp { get; set; }
+
+ [JsonProperty("outcome")]
+ public string Outcome { get; set; }
+
+ [JsonProperty("completionSource")]
+ public string CompletionSource { get; set; }
+
+ [JsonProperty("helloOutcome")]
+ public string HelloOutcome { get; set; }
+
+ [JsonProperty("enrollmentType")]
+ public string EnrollmentType { get; set; }
+
+ [JsonProperty("agentUptimeSeconds")]
+ public double AgentUptimeSeconds { get; set; }
+
+ [JsonProperty("signalsSeen")]
+ public List SignalsSeen { get; set; } = new List();
+
+ [JsonProperty("appSummary")]
+ public FinalStatusAppSummary AppSummary { get; set; } = new FinalStatusAppSummary();
+
+ [JsonProperty("packageStatesByPhase")]
+ public Dictionary> PackageStatesByPhase { get; set; } =
+ new Dictionary>();
+ }
+
+ public sealed class FinalStatusAppSummary
+ {
+ [JsonProperty("totalApps")]
+ public int TotalApps { get; set; }
+
+ [JsonProperty("completedApps")]
+ public int CompletedApps { get; set; }
+
+ [JsonProperty("errorCount")]
+ public int ErrorCount { get; set; }
+
+ [JsonProperty("deviceErrors")]
+ public int DeviceErrors { get; set; }
+
+ [JsonProperty("userErrors")]
+ public int UserErrors { get; set; }
+
+ [JsonProperty("appsByPhase")]
+ public Dictionary AppsByPhase { get; set; } = new Dictionary();
+ }
+
+ public sealed class FinalStatusPackageInfo
+ {
+ [JsonProperty("appName")]
+ public string AppName { get; set; }
+
+ [JsonProperty("state")]
+ public string State { get; set; }
+
+ [JsonProperty("isError")]
+ public bool IsError { get; set; }
+
+ [JsonProperty("isCompleted")]
+ public bool IsCompleted { get; set; }
+
+ [JsonProperty("targeted")]
+ public string Targeted { get; set; }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs
new file mode 100644
index 00000000..6f76262d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatusBuilder.cs
@@ -0,0 +1,145 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.DecisionCore.State;
+
+namespace AutopilotMonitor.Agent.V2.Core.Termination
+{
+ ///
+ /// Composes a snapshot from the kernel
+ /// plus the IME tracker's live . Plan §4.x M4.6.β.
+ ///
+ /// Pure function: no I/O, no side effects. The writer ()
+ /// takes the built DTO and serialises it to JSON at the path the dialog reads. Splitting
+ /// builder from writer keeps this testable without touching the file system.
+ ///
+ ///
+ public static class FinalStatusBuilder
+ {
+ ///
+ /// Constructs a from the orchestrator outcome plus a live snapshot
+ /// of the IME package state list (may be null when the IME host was not started —
+ /// the summary then reports an empty app section).
+ ///
+ public static FinalStatus Build(
+ DecisionState state,
+ EnrollmentTerminatedEventArgs terminated,
+ AppPackageStateList? packageStates,
+ DateTime agentStartTimeUtc)
+ {
+ if (state == null) throw new ArgumentNullException(nameof(state));
+ if (terminated == null) throw new ArgumentNullException(nameof(terminated));
+
+ var uptimeSeconds = Math.Max(0, (terminated.TerminatedAtUtc - agentStartTimeUtc).TotalSeconds);
+
+ var status = new FinalStatus
+ {
+ Timestamp = terminated.TerminatedAtUtc.ToString("O"),
+ Outcome = MapOutcome(terminated.Outcome, state.Stage),
+ CompletionSource = terminated.Reason.ToString(),
+ HelloOutcome = state.HelloOutcome?.Value ?? "unknown",
+ EnrollmentType = state.EnrollmentType?.Reason ?? state.EnrollmentType?.Level.ToString() ?? "unknown",
+ AgentUptimeSeconds = uptimeSeconds,
+ SignalsSeen = BuildSignalsSeen(state),
+ AppSummary = BuildAppSummary(packageStates),
+ PackageStatesByPhase = BuildPackageStatesByPhase(packageStates),
+ };
+
+ return status;
+ }
+
+ private static string MapOutcome(EnrollmentTerminationOutcome outcome, SessionStage stage)
+ {
+ if (stage == SessionStage.WhiteGloveSealed) return "whiteglove_part1";
+ if (stage == SessionStage.WhiteGloveCompletedPart2) return "whiteglove_part2";
+ switch (outcome)
+ {
+ case EnrollmentTerminationOutcome.Succeeded: return "succeeded";
+ case EnrollmentTerminationOutcome.Failed: return "failed";
+ case EnrollmentTerminationOutcome.TimedOut: return "timed_out";
+ default: return "unknown";
+ }
+ }
+
+ private static List BuildSignalsSeen(DecisionState state)
+ {
+ var signals = new List();
+ if (state.EspFinalExitUtc != null) signals.Add("esp_final_exit");
+ if (state.HelloResolvedUtc != null) signals.Add("hello_resolved");
+ if (state.DesktopArrivedUtc != null) signals.Add("desktop_arrived");
+ if (state.SystemRebootUtc != null) signals.Add("system_reboot");
+ if (state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) signals.Add("aad_user_joined");
+ if (state.ShellCoreWhiteGloveSuccessSeen != null && state.ShellCoreWhiteGloveSuccessSeen.Value)
+ signals.Add("whiteglove_shellcore_success");
+ if (state.WhiteGloveSealingPatternSeen != null && state.WhiteGloveSealingPatternSeen.Value)
+ signals.Add("whiteglove_sealing_pattern");
+ if (state.ImeMatchedPatternId != null) signals.Add($"ime_pattern:{state.ImeMatchedPatternId.Value}");
+ if (state.UserAadSignInCompleteUtc != null) signals.Add("part2_user_aad_signin");
+ if (state.HelloResolvedPart2Utc != null) signals.Add("part2_hello_resolved");
+ if (state.DesktopArrivedPart2Utc != null) signals.Add("part2_desktop_arrived");
+ if (state.AccountSetupCompletedPart2Utc != null) signals.Add("part2_account_setup_completed");
+ return signals;
+ }
+
+ private static FinalStatusAppSummary BuildAppSummary(AppPackageStateList? packageStates)
+ {
+ var summary = new FinalStatusAppSummary();
+ if (packageStates == null || packageStates.Count == 0) return summary;
+
+ foreach (var pkg in packageStates)
+ {
+ summary.TotalApps++;
+ if (IsCompleted(pkg.InstallationState)) summary.CompletedApps++;
+ if (pkg.InstallationState == AppInstallationState.Error)
+ {
+ summary.ErrorCount++;
+ if (pkg.Targeted == AppTargeted.Device) summary.DeviceErrors++;
+ else if (pkg.Targeted == AppTargeted.User) summary.UserErrors++;
+ }
+
+ var phaseKey = pkg.Targeted.ToString();
+ if (!summary.AppsByPhase.TryGetValue(phaseKey, out var cnt)) cnt = 0;
+ summary.AppsByPhase[phaseKey] = cnt + 1;
+ }
+
+ return summary;
+ }
+
+ private static Dictionary> BuildPackageStatesByPhase(
+ AppPackageStateList? packageStates)
+ {
+ var result = new Dictionary>();
+ if (packageStates == null) return result;
+
+ foreach (var pkg in packageStates)
+ {
+ var phaseKey = pkg.Targeted.ToString();
+ if (!result.TryGetValue(phaseKey, out var bucket))
+ {
+ bucket = new List();
+ result[phaseKey] = bucket;
+ }
+
+ bucket.Add(new FinalStatusPackageInfo
+ {
+ AppName = string.IsNullOrEmpty(pkg.Name) ? pkg.Id : pkg.Name,
+ State = pkg.InstallationState.ToString(),
+ IsError = pkg.InstallationState == AppInstallationState.Error,
+ IsCompleted = IsCompleted(pkg.InstallationState),
+ Targeted = pkg.Targeted.ToString(),
+ });
+ }
+
+ return result;
+ }
+
+ private static bool IsCompleted(AppInstallationState s) =>
+ s == AppInstallationState.Installed
+ || s == AppInstallationState.Skipped
+ || s == AppInstallationState.Postponed
+ || s == AppInstallationState.Error;
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs
new file mode 100644
index 00000000..573f55d0
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs
@@ -0,0 +1,144 @@
+using System;
+using System.IO;
+using System.Reflection;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Agent.V2.Core.Termination
+{
+ ///
+ /// Launches AutopilotMonitor.SummaryDialog.exe in the signed-in user's session
+ /// with a freshly-written final-status.json. Plan §4.x M4.6.β.
+ ///
+ /// Sequence (Legacy parity):
+ ///
+ ///
+ /// - Resolve the dialog exe next to the agent binary (Assembly.Location).
+ /// - Write the final-status.json to the agent state directory.
+ /// - Copy exe + dependencies + final-status.json to a per-launch temp directory under
+ /// %ProgramData%\AutopilotMonitor-Summary\ (the dialog deletes itself via --cleanup).
+ /// - Grant the signed-in user delete permission on that temp directory so the dialog's
+ /// self-cleanup step works (runs as user).
+ /// - Invoke with the right args.
+ ///
+ ///
+ /// Fire-and-forget: the launcher does not wait for the dialog to exit. Returns false
+ /// on any failure, never throws. Result is logged by the caller so a dialog failure never
+ /// blocks the cleanup/shutdown path.
+ ///
+ ///
+ public static class SummaryDialogLauncher
+ {
+ internal const string DialogExeName = "AutopilotMonitor.SummaryDialog.exe";
+ internal const string DialogConfigName = "AutopilotMonitor.SummaryDialog.exe.config";
+ internal const string NewtonsoftDependency = "Newtonsoft.Json.dll";
+ internal const string SummaryTempRootEnvVar = "AutopilotMonitor-Summary";
+ internal const string FinalStatusFileName = "final-status.json";
+
+ ///
+ /// Writes to /final-status.json
+ /// and launches the dialog. When is
+ /// false, only the JSON is written (skipping the launch) — tests and backend-consumable
+ /// audit trail benefit from always having the file on disk.
+ ///
+ /// true when both write and launch (if enabled) succeeded.
+ public static bool WriteAndLaunch(
+ FinalStatus status,
+ AgentConfiguration configuration,
+ string stateDirectory,
+ AgentLogger logger,
+ string dialogExePathOverride = null)
+ {
+ if (status == null) throw new ArgumentNullException(nameof(status));
+ if (configuration == null) throw new ArgumentNullException(nameof(configuration));
+ if (string.IsNullOrEmpty(stateDirectory)) throw new ArgumentException("stateDirectory required.", nameof(stateDirectory));
+ if (logger == null) throw new ArgumentNullException(nameof(logger));
+
+ // 1) Write final-status.json always (cheap, useful for audit even if dialog skipped).
+ string statusPath;
+ try
+ {
+ Directory.CreateDirectory(stateDirectory);
+ statusPath = Path.Combine(stateDirectory, FinalStatusFileName);
+ File.WriteAllText(statusPath, JsonConvert.SerializeObject(status, Formatting.Indented));
+ logger.Info($"SummaryDialogLauncher: final-status.json written to {statusPath}.");
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"SummaryDialogLauncher: final-status.json write failed: {ex.Message}");
+ return false;
+ }
+
+ if (!configuration.ShowEnrollmentSummary)
+ {
+ logger.Info("SummaryDialogLauncher: ShowEnrollmentSummary=false — skipping dialog launch.");
+ return true;
+ }
+
+ // 2) Resolve dialog exe.
+ var dialogExe = dialogExePathOverride;
+ if (string.IsNullOrEmpty(dialogExe))
+ {
+ var agentDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? string.Empty;
+ dialogExe = Path.Combine(agentDir, DialogExeName);
+ }
+
+ if (!File.Exists(dialogExe))
+ {
+ logger.Warning($"SummaryDialogLauncher: dialog exe not found at '{dialogExe}' — skipping launch.");
+ return false;
+ }
+
+ // 3) Copy to temp dir (dialog self-deletes via --cleanup).
+ string tempDir;
+ string tempExe;
+ try
+ {
+ tempDir = Path.Combine(Path.GetTempPath(), SummaryTempRootEnvVar, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+
+ tempExe = Path.Combine(tempDir, DialogExeName);
+ File.Copy(dialogExe, tempExe, overwrite: true);
+
+ var sourceDir = Path.GetDirectoryName(dialogExe) ?? string.Empty;
+ TryCopy(Path.Combine(sourceDir, DialogConfigName), Path.Combine(tempDir, DialogConfigName));
+ TryCopy(Path.Combine(sourceDir, NewtonsoftDependency), Path.Combine(tempDir, NewtonsoftDependency));
+
+ var tempStatusFile = Path.Combine(tempDir, FinalStatusFileName);
+ File.Copy(statusPath, tempStatusFile, overwrite: true);
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"SummaryDialogLauncher: temp dir setup failed: {ex.Message}");
+ return false;
+ }
+
+ // 4) Compose args. Legacy contract: --status-file --timeout --cleanup [--branding-url ].
+ var tempStatusArg = Path.Combine(tempDir, FinalStatusFileName);
+ var args = $"--status-file \"{tempStatusArg}\" --timeout {configuration.EnrollmentSummaryTimeoutSeconds} --cleanup";
+ if (!string.IsNullOrWhiteSpace(configuration.EnrollmentSummaryBrandingImageUrl))
+ args += $" --branding-url \"{configuration.EnrollmentSummaryBrandingImageUrl}\"";
+
+ // 5) Launch in user session with retry.
+ var retrySeconds = configuration.EnrollmentSummaryLaunchRetrySeconds > 0
+ ? configuration.EnrollmentSummaryLaunchRetrySeconds
+ : 120;
+
+ var launched = UserSessionProcessLauncher.LaunchInUserSession(tempExe, args, logger, retrySeconds);
+ if (launched)
+ logger.Info($"SummaryDialogLauncher: dialog launched (exe='{tempExe}', retrySeconds={retrySeconds}).");
+ else
+ logger.Warning("SummaryDialogLauncher: dialog launch failed or no user session available.");
+
+ return launched;
+ }
+
+ private static void TryCopy(string source, string destination)
+ {
+ try { if (File.Exists(source)) File.Copy(source, destination, overwrite: true); }
+ catch { /* best-effort — the dialog degrades gracefully on missing config/deps */ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index 5dad498f..7377ee5e 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -4,12 +4,14 @@ using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Threading;
+using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
+using AutopilotMonitor.Agent.V2.Core.Termination;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.DecisionCore.Classifiers;
using AutopilotMonitor.DecisionCore.Engine;
@@ -334,6 +336,14 @@ namespace AutopilotMonitor.Agent.V2
? (TimeSpan?)TimeSpan.FromMinutes(agentConfig.AgentMaxLifetimeMinutes)
: null;
+ // Diagnostics upload delegate — wraps the production DiagnosticsPackageService.
+ // Instantiated lazily + per-invocation (cheap) so we always pick up current config.
+ var diagnosticsService = new DiagnosticsPackageService(agentConfig, logger, backendApiClient);
+ Func> uploadDiagnosticsAsync =
+ (succeeded, suffix) => diagnosticsService.CreateAndUploadAsync(succeeded, suffix);
+
+ var agentStartTimeUtc = DateTime.UtcNow;
+
using (var orchestrator = new EnrollmentOrchestrator(
sessionId: agentConfig.SessionId,
tenantId: agentConfig.TenantId,
@@ -349,6 +359,56 @@ namespace AutopilotMonitor.Agent.V2
{
using (var shutdown = new ManualResetEventSlim(false))
{
+ // M4.6.β — the peripheral termination sequence (FinalStatus + SummaryDialog
+ // + diagnostics upload + enrollment-complete.marker + CleanupService) lives
+ // in Program.cs, not in the kernel. We compose it here and hook it onto the
+ // orchestrator's typed Terminated event.
+ var terminationHandler = new EnrollmentTerminationHandler(
+ configuration: agentConfig,
+ logger: logger,
+ stateDirectory: stateSubdir,
+ agentStartTimeUtc: agentStartTimeUtc,
+ currentStateAccessor: () => orchestrator.CurrentState,
+ packageStatesAccessor: () => componentFactory.ImePackageStates,
+ cleanupServiceFactory: () => new CleanupService(agentConfig, logger),
+ uploadDiagnosticsAsync: uploadDiagnosticsAsync,
+ signalShutdown: () => shutdown.Set());
+
+ // ServerActionDispatcher — ready for M5 backend wiring. For now it has no
+ // ingest-response consumer (V2 BackendTelemetryUploader's batch endpoint is
+ // M5 scope). The callbacks are live: RotateConfig refetches remote config;
+ // RequestDiagnostics triggers the same diagnostics pipeline as enrollment-end;
+ // TerminateSession routes to the termination handler (reason: server-requested).
+ var serverActionDispatcher = new ServerActionDispatcher(
+ configuration: agentConfig,
+ logger: logger,
+ rotateConfigAsync: async () =>
+ {
+ try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
+ catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
+ },
+ uploadDiagnosticsAsync: async (suffix) =>
+ await diagnosticsService.CreateAndUploadAsync(enrollmentSucceeded: false, fileNameSuffix: suffix),
+ onTerminateRequested: action =>
+ {
+ logger.Warning($"ServerAction terminate_session received (ruleId={action?.RuleId}) — invoking termination handler.");
+ // Synthesise a Terminated event as if the kernel fired it.
+ terminationHandler.Handle(
+ sender: null,
+ args: new EnrollmentTerminatedEventArgs(
+ reason: EnrollmentTerminationReason.DecisionTerminalStage,
+ outcome: EnrollmentTerminationOutcome.Failed,
+ stageName: orchestrator.CurrentState?.Stage.ToString(),
+ terminatedAtUtc: DateTime.UtcNow,
+ details: $"Server-requested termination: ruleId={action?.RuleId}"));
+ return Task.CompletedTask;
+ },
+ emitEvent: evt => { orchestrator.EventEmitter.Emit(evt); });
+
+ // Keep the dispatcher alive for the duration of the run. M5 will plug it into
+ // the BackendTelemetryUploader response path; M4.6.β wires the API contract.
+ _ = serverActionDispatcher;
+
ConsoleCancelEventHandler cancelHandler = (s, e) =>
{
e.Cancel = true;
@@ -360,15 +420,10 @@ namespace AutopilotMonitor.Agent.V2
logger.Info("ProcessExit — initiating graceful shutdown.");
shutdown.Set();
};
- EventHandler terminatedHandler = (s, e) =>
- {
- logger.Info($"Orchestrator signalled termination (reason={e.Reason}, outcome={e.Outcome}).");
- shutdown.Set();
- };
Console.CancelKeyPress += cancelHandler;
AppDomain.CurrentDomain.ProcessExit += processExitHandler;
- orchestrator.Terminated += terminatedHandler;
+ orchestrator.Terminated += terminationHandler.Handle;
try
{
@@ -388,7 +443,7 @@ namespace AutopilotMonitor.Agent.V2
{
Console.CancelKeyPress -= cancelHandler;
AppDomain.CurrentDomain.ProcessExit -= processExitHandler;
- orchestrator.Terminated -= terminatedHandler;
+ orchestrator.Terminated -= terminationHandler.Handle;
try { orchestrator.Stop(); }
catch (Exception ex) { logger.Error("Orchestrator stop failed.", ex); }
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index f64f5d8d..f5c89552 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-27
+32
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 00146f25..b9650fd8 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1066
+1068
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/SessionStageExtensionsTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/SessionStageExtensionsTests.cs
new file mode 100644
index 00000000..d51abebd
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/SessionStageExtensionsTests.cs
@@ -0,0 +1,35 @@
+using AutopilotMonitor.DecisionCore.State;
+using Xunit;
+
+namespace AutopilotMonitor.DecisionCore.Tests.State
+{
+ public sealed class SessionStageExtensionsTests
+ {
+ [Theory]
+ [InlineData(SessionStage.Completed, true)]
+ [InlineData(SessionStage.Failed, true)]
+ [InlineData(SessionStage.WhiteGloveSealed, true)]
+ [InlineData(SessionStage.WhiteGloveCompletedPart2, true)]
+ [InlineData(SessionStage.Unknown, false)]
+ [InlineData(SessionStage.SessionStarted, false)]
+ [InlineData(SessionStage.AwaitingHello, false)]
+ [InlineData(SessionStage.EspAccountSetup, false)]
+ [InlineData(SessionStage.WhiteGloveCandidate, false)]
+ [InlineData(SessionStage.WhiteGloveAwaitingUserSignIn, false)]
+ public void IsTerminal_classifies_stages_correctly(SessionStage stage, bool expected)
+ {
+ Assert.Equal(expected, stage.IsTerminal());
+ }
+
+ [Theory]
+ [InlineData(SessionStage.WhiteGloveSealed, true)]
+ [InlineData(SessionStage.Completed, false)]
+ [InlineData(SessionStage.Failed, false)]
+ [InlineData(SessionStage.WhiteGloveCompletedPart2, false)]
+ [InlineData(SessionStage.WhiteGloveCandidate, false)]
+ public void IsPauseBeforePart2_only_true_for_WhiteGloveSealed(SessionStage stage, bool expected)
+ {
+ Assert.Equal(expected, stage.IsPauseBeforePart2());
+ }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStageExtensions.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStageExtensions.cs
new file mode 100644
index 00000000..61db7484
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.DecisionCore/State/SessionStageExtensions.cs
@@ -0,0 +1,42 @@
+namespace AutopilotMonitor.DecisionCore.State
+{
+ ///
+ /// Classification helpers for . Plan §4.x M4.6.β.
+ ///
+ public static class SessionStageExtensions
+ {
+ ///
+ /// true when the stage represents a session-terminal state — the orchestrator
+ /// must stop, peripheral termination handlers (CleanupService, SummaryDialog,
+ /// DiagnosticsPackageService) run, and no further signals are expected.
+ ///
+ /// is terminal for Part 1 only — the agent
+ /// exits but the session is paused (Part 2 resumes after reboot). The distinction
+ /// matters for CleanupService: Part-1 exit must NOT self-destruct; Part-2 exit
+ /// () and the classic terminals
+ /// ( / ) do.
+ ///
+ ///
+ public static bool IsTerminal(this SessionStage stage)
+ {
+ switch (stage)
+ {
+ case SessionStage.Completed:
+ case SessionStage.Failed:
+ case SessionStage.WhiteGloveSealed:
+ case SessionStage.WhiteGloveCompletedPart2:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ ///
+ /// true when the stage terminates the current agent run but leaves the session
+ /// paused for a future restart (WhiteGlove Part 1 → post-reboot Part 2 resume). Caller
+ /// (typically Program.cs) must skip CleanupService.ExecuteSelfDestruct in this case.
+ ///
+ public static bool IsPauseBeforePart2(this SessionStage stage)
+ => stage == SessionStage.WhiteGloveSealed;
+ }
+}