mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
+182
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+221
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user