feat(agent-v2): M4.6.β terminal dispatch — FinalStatus, SummaryDialog, cleanup, server actions

Closes the second batch of peripheral-parity gaps so the V2 agent actually
terminates like Legacy does: write FinalStatus.json, launch the WPF summary
dialog in the user session, upload diagnostics (respecting mode/outcome),
write the enrollment-complete marker, run self-destruct, and signal shutdown.
All of this is driven off a single typed kernel event so nothing leaks back
into the DecisionEngine.

Terminal-stage detection (kernel hook)
- DecisionCore/State/SessionStageExtensions.IsTerminal() + IsPauseBeforePart2()
  — Completed, Failed, WhiteGloveSealed, WhiteGloveCompletedPart2 are terminal;
  WhiteGloveSealed is the "pause before Part 2" special case (session resumes
  on reboot, cleanup MUST be suppressed).
- DecisionStepProcessor ctor gains Action<DecisionState>? onTerminalStageReached;
  fires at most once per processor lifetime, swallows handler exceptions,
  suppressed when initial state was already terminal (crash-recovery race).
- EnrollmentOrchestrator.OnDecisionTerminalStage translates the hook into the
  public Terminated event with Reason=DecisionTerminalStage + Outcome mapped
  from stage (Completed/Part2/Sealed→Succeeded, Failed→Failed, else TimedOut).
  MaxLifetime watchdog is cancelled on the real terminal so the two paths can't
  race each other.

Peripheral termination (V2.Core/Termination/*)
- FinalStatus DTO — wire-format mirror of SummaryDialog's Models/FinalStatus
  (same JSON property names). Lives in V2.Core because the dialog is a
  separate net48 WPF exe we can't ProjectReference.
- FinalStatusBuilder — pure function: DecisionState + AppPackageStateList +
  TerminatedArgs + agent start time → FinalStatus. Maps outcome string
  ("succeeded" | "failed" | "timed_out" | "whiteglove_part1" | "whiteglove_part2"),
  derives signalsSeen from DecisionState facts, counts apps by AppTargeted
  phase (Device/User), tallies device vs user errors.
- SummaryDialogLauncher — writes final-status.json, copies dialog exe + deps
  + status file to a per-launch temp dir (%TEMP%\AutopilotMonitor-Summary\<guid>),
  composes the Legacy-compatible CLI (--status-file, --timeout, --cleanup,
  optional --branding-url), invokes UserSessionProcessLauncher.LaunchInUserSession
  with the configured retry window. Fire-and-forget, swallows all errors.
- EnrollmentTerminationHandler — the Terminated-event handler that sequences:
  build FinalStatus → write JSON + launch dialog → upload diagnostics
  (respects DiagnosticsUploadMode: Off/Always/OnFailure) → write
  enrollment-complete.marker (suppressed on WhiteGloveSealed) → run
  CleanupService.ExecuteSelfDestruct (suppressed on WhiteGloveSealed OR when
  SelfDestructOnComplete=false) → signal shutdown MRE. Idempotent; each step
  is best-effort and logs rather than throws — nothing is allowed to prevent
  agent shutdown.

DefaultComponentFactory
- Stores the ImeLogHost it creates and exposes its PackageStates to the
  termination pipeline (factory.ImePackageStates) so FinalStatusBuilder has
  the live package list without breaking host encapsulation.

Program.cs wiring
- Instantiates DiagnosticsPackageService, EnrollmentTerminationHandler, and
  ServerActionDispatcher with all callbacks live:
  • rotate_config → RemoteConfigService.FetchConfigAsync()
  • request_diagnostics → DiagnosticsPackageService.CreateAndUploadAsync()
  • terminate_session → EnrollmentTerminationHandler.Handle() synthesising a
    DecisionTerminalStage/Failed termination
- Terminated event routed to the handler; the handler signals the shutdown
  MRE so the main thread cleanly calls orchestrator.Stop().
- ServerActionDispatcher is alive but has no ingest-response consumer yet —
  V2 batch endpoint is M5 scope, we plumb the API contract here so M5 can
  drop in the response parser.

Tests (+47 total)
- DecisionCore.Tests: +15 SessionStageExtensionsTests (IsTerminal + IsPauseBeforePart2)
- V2.Core.Tests: +32 across terminal-stage hook, FinalStatusBuilder, handler
  • DecisionStepProcessorTerminalStageTests: fires once per stage, skips
    non-terminal, respects initial-terminal-state (recovery), swallows
    handler exceptions, works with null callback
  • FinalStatusBuilderTests: outcome mapping per stage, uptime, HelloOutcome
    fact path, signalsSeen composition, empty package list, device/user
    error tallying, JSON property-name contract
  • EnrollmentTerminationHandlerTests: writes FinalStatus.json + marker,
    runs/skips cleanup per config + WhiteGlove rule, idempotent shutdown
    signal, diagnostics mode matrix (Off/Always/OnFailure × Succeeded/Failed),
    suffix ("success" | "failure") per outcome

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

Remaining M4.6 scope: γ (Geo/NTP/Timezone/Emergency/Distress services wiring,
RegistryWatcher admin-action detector, DeliveryOptimizationCollector in
DefaultComponentFactory) + δ (--run-gather-rules + --run-ime-matching modes,
AnalyzerManager + 3 analyzers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-21 00:37:36 +02:00
parent 02bbb5490c
commit 17063be600
15 changed files with 1444 additions and 14 deletions
@@ -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
{
/// <summary>
/// M4.6.β — <see cref="DecisionStepProcessor"/> fires the terminal-stage callback exactly
/// once, at the transition from a non-terminal to a terminal <see cref="SessionStage"/>.
/// </summary>
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<DecisionState>? 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<DecisionEffect>());
}
[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);
}
}
}
@@ -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);
/// <summary>
/// CleanupService is virtual for testability. <c>ExecuteSelfDestruct</c> is a best-effort
/// fire-and-forget in production — in tests we just count invocations.
/// </summary>
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);
}
}
}
@@ -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<string>(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<DateTime>(StartUtc.AddMinutes(5), 1),
DesktopArrivedUtc = new SignalFact<DateTime>(StartUtc.AddMinutes(10), 2),
AadJoinedWithUser = new SignalFact<bool>(true, 3),
ImeMatchedPatternId = new SignalFact<string>("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);
}
}
/// <summary>Mini helper for reflection-based field setters — AppPackageState.Targeted is a private-set property.</summary>
internal static class TestHelpers
{
public static void SetTargeted(AppPackageState pkg, AppTargeted targeted)
{
var prop = typeof(AppPackageState).GetProperty(nameof(AppPackageState.Targeted))!;
prop.SetValue(pkg, targeted);
}
}
}
@@ -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<DecisionState>? _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<DecisionState>? 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)
@@ -59,6 +59,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly string _agentVersion;
private readonly string _stateDirectory;
private ImeLogHost? _imeLogHost;
/// <summary>
/// Exposes the IME tracker's package-state list to peripheral consumers such as the
/// <c>FinalStatusBuilder</c> in M4.6.β. Returns <c>null</c> before
/// <see cref="CreateCollectorHosts"/> has been called (Orchestrator start order).
/// </summary>
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;
/// <summary>
/// Exposes the tracker's live package-state list for peripheral consumers
/// (M4.6.β <c>FinalStatusBuilder</c>).
/// </summary>
public Monitoring.Enrollment.Ime.AppPackageStateList PackageStates => _tracker.PackageStates;
public ImeLogHost(
string sessionId,
string tenantId,
@@ -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); }
}
/// <summary>
/// M4.6.β — DecisionStepProcessor callback: the engine has transitioned the session
/// into a terminal <see cref="SessionStage"/>. Fires the public <see cref="Terminated"/>
/// event with <see cref="EnrollmentTerminationReason.DecisionTerminalStage"/> and an
/// outcome derived from the stage (Completed/Part2→Succeeded, Failed→Failed,
/// WhiteGloveSealed→Succeeded but with <see cref="SessionStageExtensions.IsPauseBeforePart2"/>
/// signalled to callers via the stage name — callers decide whether to self-destruct).
/// </summary>
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); }
}
/// <summary>
/// Stop the pipeline. Idempotent; calls <see cref="Stop"/> from <see cref="Dispose"/> as well.
/// </summary>
@@ -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
{
/// <summary>
/// Orchestrates peripheral termination work in response to
/// <see cref="EnrollmentOrchestrator.Terminated"/>. Plan §4.x M4.6.β.
/// <para>
/// <b>Sequence</b> (Legacy parity — single-writer, best-effort each step, never throws):
/// </para>
/// <list type="number">
/// <item>Compose <see cref="FinalStatus"/> via <see cref="FinalStatusBuilder"/>.</item>
/// <item>Write final-status.json + launch SummaryDialog via <see cref="SummaryDialogLauncher"/>.</item>
/// <item>Upload diagnostics via <see cref="DiagnosticsPackageService"/> (respects
/// <c>DiagnosticsUploadMode</c>: Off / Always / OnFailure).</item>
/// <item>Write <c>enrollment-complete.marker</c> (so ghost-restart detection on next boot
/// exits cleanly even if <see cref="CleanupService"/> failed).</item>
/// <item>Run <see cref="CleanupService.ExecuteSelfDestruct"/> — UNLESS the stage is
/// <see cref="SessionStage.WhiteGloveSealed"/> (Part 1 exit, session resumes Part 2).</item>
/// <item>Signal the caller-owned shutdown <see cref="ManualResetEventSlim"/> so Program.cs'
/// main loop exits the <c>shutdown.Wait()</c> and calls <c>orchestrator.Stop()</c>.</item>
/// </list>
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class EnrollmentTerminationHandler
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly string _stateDirectory;
private readonly DateTime _agentStartTimeUtc;
private readonly Func<DecisionState> _currentStateAccessor;
private readonly Func<AppPackageStateList> _packageStatesAccessor;
private readonly Func<CleanupService> _cleanupServiceFactory;
private readonly Func<bool, string, Task<DiagnosticsUploadResult>> _uploadDiagnosticsAsync;
private readonly Action _signalShutdown;
private readonly string _dialogExePathOverride;
private int _handled;
public EnrollmentTerminationHandler(
AgentConfiguration configuration,
AgentLogger logger,
string stateDirectory,
DateTime agentStartTimeUtc,
Func<DecisionState> currentStateAccessor,
Func<AppPackageStateList> packageStatesAccessor,
Func<CleanupService> cleanupServiceFactory,
Func<bool, string, Task<DiagnosticsUploadResult>> 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;
}
/// <summary>
/// Handler for <see cref="EnrollmentOrchestrator.Terminated"/>. Idempotent — runs at most once.
/// </summary>
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);
}
}
}
}
@@ -0,0 +1,84 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Termination
{
/// <summary>
/// Wire-format DTO consumed by <c>AutopilotMonitor.SummaryDialog.exe</c>. Plan §4.x M4.6.β.
/// <para>
/// Mirrors <c>AutopilotMonitor.SummaryDialog.Models.FinalStatus</c> — 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.
/// </para>
/// </summary>
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<string> SignalsSeen { get; set; } = new List<string>();
[JsonProperty("appSummary")]
public FinalStatusAppSummary AppSummary { get; set; } = new FinalStatusAppSummary();
[JsonProperty("packageStatesByPhase")]
public Dictionary<string, List<FinalStatusPackageInfo>> PackageStatesByPhase { get; set; } =
new Dictionary<string, List<FinalStatusPackageInfo>>();
}
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<string, int> AppsByPhase { get; set; } = new Dictionary<string, int>();
}
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; }
}
}
@@ -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
{
/// <summary>
/// Composes a <see cref="FinalStatus"/> snapshot from the kernel <see cref="DecisionState"/>
/// plus the IME tracker's live <see cref="AppPackageStateList"/>. Plan §4.x M4.6.β.
/// <para>
/// Pure function: no I/O, no side effects. The writer (<see cref="SummaryDialogLauncher"/>)
/// 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.
/// </para>
/// </summary>
public static class FinalStatusBuilder
{
/// <summary>
/// Constructs a <see cref="FinalStatus"/> from the orchestrator outcome plus a live snapshot
/// of the IME package state list (may be <c>null</c> when the IME host was not started —
/// the summary then reports an empty app section).
/// </summary>
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<string> BuildSignalsSeen(DecisionState state)
{
var signals = new List<string>();
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<string, List<FinalStatusPackageInfo>> BuildPackageStatesByPhase(
AppPackageStateList? packageStates)
{
var result = new Dictionary<string, List<FinalStatusPackageInfo>>();
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<FinalStatusPackageInfo>();
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;
}
}
@@ -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
{
/// <summary>
/// Launches <c>AutopilotMonitor.SummaryDialog.exe</c> in the signed-in user's session
/// with a freshly-written <c>final-status.json</c>. Plan §4.x M4.6.β.
/// <para>
/// Sequence (Legacy parity):
/// </para>
/// <list type="number">
/// <item>Resolve the dialog exe next to the agent binary (<c>Assembly.Location</c>).</item>
/// <item>Write the final-status.json to the agent state directory.</item>
/// <item>Copy exe + dependencies + final-status.json to a per-launch temp directory under
/// <c>%ProgramData%\AutopilotMonitor-Summary\</c> (the dialog deletes itself via <c>--cleanup</c>).</item>
/// <item>Grant the signed-in user delete permission on that temp directory so the dialog's
/// self-cleanup step works (runs as user).</item>
/// <item>Invoke <see cref="UserSessionProcessLauncher.LaunchInUserSession"/> with the right args.</item>
/// </list>
/// <para>
/// Fire-and-forget: the launcher does not wait for the dialog to exit. Returns <c>false</c>
/// on any failure, never throws. Result is logged by the caller so a dialog failure never
/// blocks the cleanup/shutdown path.
/// </para>
/// </summary>
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";
/// <summary>
/// Writes <paramref name="status"/> to <paramref name="stateDirectory"/>/<c>final-status.json</c>
/// and launches the dialog. When <see cref="AgentConfiguration.ShowEnrollmentSummary"/> is
/// false, only the JSON is written (skipping the launch) — tests and backend-consumable
/// audit trail benefit from always having the file on disk.
/// </summary>
/// <returns><c>true</c> when both write and launch (if enabled) succeeded.</returns>
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 <path> --timeout <s> --cleanup [--branding-url <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 */ }
}
}
}
+62 -7
View File
@@ -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<bool, string, Task<DiagnosticsUploadResult>> 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<EnrollmentTerminatedEventArgs> 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); }
@@ -1 +1 @@
27
32
@@ -1 +1 @@
1066
1068
@@ -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());
}
}
}
@@ -0,0 +1,42 @@
namespace AutopilotMonitor.DecisionCore.State
{
/// <summary>
/// Classification helpers for <see cref="SessionStage"/>. Plan §4.x M4.6.β.
/// </summary>
public static class SessionStageExtensions
{
/// <summary>
/// <c>true</c> 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.
/// <para>
/// <see cref="SessionStage.WhiteGloveSealed"/> 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
/// (<see cref="SessionStage.WhiteGloveCompletedPart2"/>) and the classic terminals
/// (<see cref="SessionStage.Completed"/> / <see cref="SessionStage.Failed"/>) do.
/// </para>
/// </summary>
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;
}
}
/// <summary>
/// <c>true</c> 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.
/// </summary>
public static bool IsPauseBeforePart2(this SessionStage stage)
=> stage == SessionStage.WhiteGloveSealed;
}
}