mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
refactor(agent-v2): group termination-handler ctor into 4 surfaces (ARCH-F2)
EnrollmentTerminationHandler's 25-parameter constructor (17 optional, 9 Func<> accessors, wired via ~80 lambda lines in AgentRuntimeHost) becomes 11 parameters: four cohesive surfaces plus direct infra deps. - TerminationSessionContext (concrete, pure data): configuration, stateDirectory, agentStartTimeUtc, agentVersion, sessionPersistence, dialogExePathOverride, lazy IsWhiteGlovePart2. - IAppTrackingReadModel: currentState, packageStates, appTimings, ignoredCount, promoteActiveInstallsToStuck, lastEspTerminalFailure. Production: OrchestratorAppTrackingReadModel over the orchestrator's CollectorSurfaces (builds on ARCH-F4). - IDrainStatus: spool/ingress drain counts behind CanObserve* capability flags (replaces the accessor-null wiring variants); production: OrchestratorDrainStatus with the V1-parity 10s budget. - IShutdownGate: signalShutdown, triggerReboot, writeCleanExitMarker, tryClaimShutdownEvent; production: RuntimeShutdownGate in the host project (owns the shutdown.exe logic formerly DefaultTriggerReboot). Per-parameter rationale comments (c117946b, 080edee9, 7dd4e593, Codex Finding 2, Option 1/2 hardening, plan §11) moved onto the interface members. Test rig builds recording fakes of the same interfaces, so the optional-default wiring variants production never ran are gone. No behavior change. V2.Core 1297/1297, DecisionCore 359/359, full solution build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+80
-34
@@ -176,12 +176,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
{
|
||||
config ??= BuildConfig();
|
||||
return new EnrollmentTerminationHandler(
|
||||
configuration: config,
|
||||
session: new TerminationSessionContext(
|
||||
configuration: config,
|
||||
stateDirectory: StateDir,
|
||||
agentStartTimeUtc: StartUtc,
|
||||
agentVersion: agentVersion,
|
||||
sessionPersistence: SessionPersistence),
|
||||
appTracking: new RigAppTracking(this),
|
||||
drainStatus: new RigDrainStatus(this),
|
||||
shutdownGate: new RigShutdownGate(this),
|
||||
logger: Logger,
|
||||
stateDirectory: StateDir,
|
||||
agentStartTimeUtc: StartUtc,
|
||||
currentStateAccessor: () => State,
|
||||
packageStatesAccessor: () => Packages,
|
||||
cleanupServiceFactory: () => CleanupService,
|
||||
uploadDiagnosticsAsync: (succeeded, suffix) =>
|
||||
{
|
||||
@@ -190,44 +194,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
LastDiagnosticsSuffix = suffix;
|
||||
return Task.FromResult(DiagnosticsResult ?? new DiagnosticsUploadResult { BlobName = "blob" });
|
||||
},
|
||||
signalShutdown: () =>
|
||||
{
|
||||
Interlocked.Increment(ref ShutdownSignalled);
|
||||
TerminationActionLog.Add("signalShutdown");
|
||||
},
|
||||
analyzerManager: null,
|
||||
post: Post,
|
||||
sessionPersistence: SessionPersistence,
|
||||
triggerReboot: delay =>
|
||||
{
|
||||
Interlocked.Increment(ref RebootInvocations);
|
||||
RebootDelaySeconds = delay;
|
||||
},
|
||||
// Zero out the timing ceremony for tests — production paths are covered by
|
||||
// the dedicated V1-parity tests below which opt back in via their own Rig.
|
||||
lateEventGracePeriod: TimeSpan.Zero,
|
||||
spoolDrainPeriod: SpoolDrainPeriodOverride,
|
||||
appTimingsAccessor: () => AppTimingsOverride ?? new Dictionary<string, AppInstallTiming>(),
|
||||
agentVersion: agentVersion,
|
||||
pendingItemCountAccessor: PendingItemCountAccessor,
|
||||
writeCleanExitMarker: WriteCleanExitMarkerHook ?? new Action(() =>
|
||||
{
|
||||
Interlocked.Increment(ref CleanExitMarkerWrites);
|
||||
TerminationActionLog.Add("writeCleanExitMarker");
|
||||
}),
|
||||
ingressPendingSignalCountAccessor: IngressPendingSignalCountAccessor,
|
||||
promoteActiveInstallsToStuck: (failureType, message, errorCode) =>
|
||||
{
|
||||
PromotionCalls.Add((failureType, message, errorCode));
|
||||
return PromotionReturnValue;
|
||||
},
|
||||
tryClaimShutdownEvent: TryClaimShutdownEventOverride,
|
||||
lastEspTerminalFailureAccessor: () => LastEspTerminalFailureOverride);
|
||||
lateEventGracePeriod: TimeSpan.Zero);
|
||||
}
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>ARCH-F2 — grouped read-model fake over the rig's mutable test state.</summary>
|
||||
private sealed class RigAppTracking : IAppTrackingReadModel
|
||||
{
|
||||
private readonly Rig _rig;
|
||||
public RigAppTracking(Rig rig) { _rig = rig; }
|
||||
|
||||
public DecisionState CurrentState => _rig.State;
|
||||
public IReadOnlyList<AppPackageState>? PackageStates => _rig.Packages;
|
||||
public IReadOnlyDictionary<string, AppInstallTiming>? AppTimings =>
|
||||
_rig.AppTimingsOverride ?? new Dictionary<string, AppInstallTiming>();
|
||||
public int IgnoredCount => 0;
|
||||
|
||||
public IReadOnlyList<string> PromoteActiveInstallsToStuck(string failureType, string message, string? errorCode)
|
||||
{
|
||||
_rig.PromotionCalls.Add((failureType, message, errorCode!));
|
||||
return _rig.PromotionReturnValue;
|
||||
}
|
||||
|
||||
public EspTerminalFailureSnapshot? LastEspTerminalFailure => _rig.LastEspTerminalFailureOverride;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ARCH-F2 — drain fake. The nullable rig accessors map onto the capability flags:
|
||||
/// an unset accessor means the surface is unobservable (legacy blind-delay path).
|
||||
/// </summary>
|
||||
private sealed class RigDrainStatus : IDrainStatus
|
||||
{
|
||||
private readonly Rig _rig;
|
||||
public RigDrainStatus(Rig rig) { _rig = rig; }
|
||||
|
||||
public TimeSpan SpoolDrainPeriod => _rig.SpoolDrainPeriodOverride;
|
||||
public bool CanObserveIngress => _rig.IngressPendingSignalCountAccessor != null;
|
||||
public long IngressPendingSignalCount => _rig.IngressPendingSignalCountAccessor!();
|
||||
public bool CanObserveSpool => _rig.PendingItemCountAccessor != null;
|
||||
public int SpoolPendingItemCount => _rig.PendingItemCountAccessor!();
|
||||
}
|
||||
|
||||
/// <summary>ARCH-F2 — recording shutdown gate (no real shutdown.exe in tests).</summary>
|
||||
private sealed class RigShutdownGate : IShutdownGate
|
||||
{
|
||||
private readonly Rig _rig;
|
||||
public RigShutdownGate(Rig rig) { _rig = rig; }
|
||||
|
||||
public void SignalShutdown()
|
||||
{
|
||||
Interlocked.Increment(ref _rig.ShutdownSignalled);
|
||||
_rig.TerminationActionLog.Add("signalShutdown");
|
||||
}
|
||||
|
||||
public void TriggerReboot(int delaySeconds)
|
||||
{
|
||||
Interlocked.Increment(ref _rig.RebootInvocations);
|
||||
_rig.RebootDelaySeconds = delaySeconds;
|
||||
}
|
||||
|
||||
public void WriteCleanExitMarker()
|
||||
{
|
||||
if (_rig.WriteCleanExitMarkerHook != null)
|
||||
{
|
||||
_rig.WriteCleanExitMarkerHook();
|
||||
return;
|
||||
}
|
||||
Interlocked.Increment(ref _rig.CleanExitMarkerWrites);
|
||||
_rig.TerminationActionLog.Add("writeCleanExitMarker");
|
||||
}
|
||||
|
||||
// Null override = legacy always-emit behaviour (claim always succeeds).
|
||||
public bool TryClaimShutdownEvent() => _rig.TryClaimShutdownEventOverride?.Invoke() ?? true;
|
||||
}
|
||||
|
||||
private static EnrollmentTerminatedEventArgs Args(
|
||||
EnrollmentTerminationReason reason,
|
||||
EnrollmentTerminationOutcome outcome,
|
||||
|
||||
+3
-3
@@ -122,9 +122,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic
|
||||
}
|
||||
|
||||
// --- Spool stats (P2 telemetry) ---
|
||||
// _telemetrySpool is the live transport-layer spool, plumbed through
|
||||
// DefaultComponentFactory.SetTelemetrySpool from EnrollmentOrchestrator.Start
|
||||
// step 3. Null only on test fakes that don't construct a real spool.
|
||||
// _telemetrySpool is the live transport-layer spool, passed by
|
||||
// EnrollmentOrchestrator.Start into IComponentFactory.CreateCollectorHosts
|
||||
// (ARCH-F4). Null only on test fakes that don't construct a real spool.
|
||||
int pendingItemCount = 0;
|
||||
long spoolFileSizeBytes = 0L;
|
||||
if (_telemetrySpool != null)
|
||||
|
||||
+67
-180
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -51,170 +50,73 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
public sealed class EnrollmentTerminationHandler
|
||||
{
|
||||
private static readonly TimeSpan DefaultLateEventGrace = TimeSpan.FromMilliseconds(2000);
|
||||
private static readonly TimeSpan DefaultSpoolDrain = TimeSpan.FromMilliseconds(10000); // V1 parity: up to 20 × 500ms drains before shutdown.exe
|
||||
// Option 1 (WG Part 1 graceful-exit hardening, 2026-04-30): poll cadence for the
|
||||
// active spool-empty drain. 50ms is short enough that we exit the bounded wait
|
||||
// within ~one cadence after the last upload is acknowledged, but long enough that
|
||||
// an empty spool only costs a single sleep call.
|
||||
private static readonly TimeSpan SpoolDrainPollInterval = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
// ARCH-F2 (V2 agent review 2026-06-10): the former 25-parameter constructor is
|
||||
// grouped into four cohesive surfaces (session context, app-tracking read model,
|
||||
// drain status, shutdown gate) plus a handful of direct infrastructure parameters.
|
||||
// The per-parameter rationale comments (c117946b, 080edee9, 7dd4e593, Codex
|
||||
// Finding 2, Option 1/2 hardening, plan §11) moved onto the corresponding
|
||||
// interface members.
|
||||
private readonly TerminationSessionContext _session;
|
||||
private readonly IAppTrackingReadModel _appTracking;
|
||||
private readonly IDrainStatus _drain;
|
||||
private readonly IShutdownGate _shutdownGate;
|
||||
|
||||
// Session-context scalars copied to fields once — immutable for the handler's
|
||||
// lifetime. Only IsWhiteGlovePart2 must be read lazily via _session, because the
|
||||
// orchestrator resolves the Part-2 hint after this handler is constructed.
|
||||
private readonly AgentConfiguration _configuration;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly DateTime _agentStartTimeUtc;
|
||||
private readonly Func<DecisionState> _currentStateAccessor;
|
||||
// F5 (debrief 7dd4e593) — accept any IReadOnlyList<AppPackageState> so the
|
||||
// termination summary can iterate the union of phase-snapshotted apps + live
|
||||
// _packageStates (V2's clear-on-phase-transition would otherwise drop the
|
||||
// DeviceSetup apps from app_tracking_summary and the SummaryDialog).
|
||||
private readonly Func<IReadOnlyList<AppPackageState>> _packageStatesAccessor;
|
||||
private readonly Func<IReadOnlyDictionary<string, AppInstallTiming>> _appTimingsAccessor;
|
||||
// V1-parity field: count of IME apps the tracker has marked as "ignored" (e.g. uninstall
|
||||
// intents that don't surface in the install pipeline). Lives on the live
|
||||
// <c>AppPackageStateList</c> only, hence a separate accessor — the deduped phase-snapshot
|
||||
// union accessed via <see cref="_packageStatesAccessor"/> doesn't carry it.
|
||||
private readonly Func<int> _ignoredCountAccessor;
|
||||
private readonly string _agentVersion;
|
||||
private readonly SessionIdPersistence _sessionPersistence;
|
||||
private readonly string _dialogExePathOverride;
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly Func<CleanupService> _cleanupServiceFactory;
|
||||
private readonly Func<bool, string, Task<DiagnosticsUploadResult>> _uploadDiagnosticsAsync;
|
||||
private readonly Action _signalShutdown;
|
||||
private readonly Action _stopPeripheralCollectors;
|
||||
private readonly string _dialogExePathOverride;
|
||||
private readonly AgentAnalyzerManager _analyzerManager;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly SessionIdPersistence _sessionPersistence;
|
||||
private readonly Action<int> _triggerReboot;
|
||||
private readonly TimeSpan _lateEventGracePeriod;
|
||||
private readonly TimeSpan _spoolDrainPeriod;
|
||||
private readonly string _agentVersion;
|
||||
// Option 1 (WG Part 1 graceful-exit hardening, 2026-04-30): when wired, the
|
||||
// termination handler polls this accessor every <see cref="SpoolDrainPollInterval"/>
|
||||
// during <see cref="DrainSpool"/> and exits the wait as soon as it returns 0
|
||||
// (spool fully acknowledged by the backend). Falls back to the bounded
|
||||
// <see cref="_spoolDrainPeriod"/> timeout if the spool never drains. Null = legacy
|
||||
// blind-delay behaviour (kept for tests + safety on the off-chance the orchestrator
|
||||
// wiring fails to provide it).
|
||||
private readonly Func<int> _pendingItemCountAccessor;
|
||||
// Codex Finding 2 (2026-04-30): the termination handler is now dispatched off the
|
||||
// ingress worker, so it can wait for the worker to actually process the lifecycle
|
||||
// events the handler posts (agent_shutting_down, whiteglove_part1_complete,
|
||||
// analyzer events) BEFORE polling the spool — without this the spool-empty check
|
||||
// would trivially fire on already-uploaded items while the just-posted events are
|
||||
// still in the ingress channel. Polled in <see cref="DrainSpool"/> as the first
|
||||
// wait step. Null = legacy behaviour (no ingress-drain wait, just spool-drain).
|
||||
private readonly Func<long> _ingressPendingSignalCountAccessor;
|
||||
// Option 2 (same hardening): writes the <c>clean-exit.marker</c> file directly,
|
||||
// before <see cref="_signalShutdown"/> hands control to the main thread. This wins
|
||||
// the race against an admin-triggered reseal-reboot — without this hook the marker
|
||||
// is only written by the AppDomain.ProcessExit handler, which Windows can pre-empt.
|
||||
// Null = no early write (tests / parity with the original blind-exit path).
|
||||
private readonly Action _writeCleanExitMarker;
|
||||
// V1-symmetric Part-2 hint accessor (plan §11). A Part-2 resume runs as a fresh
|
||||
// Classic enrollment after Archive-and-Reset, so the terminal Stage is
|
||||
// <c>Completed</c>/<c>Failed</c> like any first-run completion. The shutdown
|
||||
// analyzer pipeline still needs to know it was a Part-2 run so SoftwareInventoryAnalyzer
|
||||
// can tag findings with phase=2 and the backend's vulnerability-correlation pipeline
|
||||
// can filter Part-2 inventory out of the Part-1 set. Wired by AgentRuntimeHost as
|
||||
// <c>() => orchestrator.IsWhiteGlovePart2</c>; null in tests = legacy (always
|
||||
// treats as Part-1/null).
|
||||
private readonly Func<bool> _isWhiteGlovePart2Accessor;
|
||||
|
||||
// Shutdown-gap closure (2026-05-15): cross-path idempotency gate shared with the
|
||||
// AgentRuntimeHost's gap emitters (Ctrl+C, ProcessExit, unhandled exception,
|
||||
// runtime-host finally). The handler calls TryClaim() before emitting
|
||||
// <c>agent_shutting_down</c> in <see cref="EmitAgentShuttingDown"/> so a Terminated
|
||||
// event that races a Ctrl+C cannot produce two events on the wire. Null = legacy
|
||||
// behaviour (always emit; backward compat for tests + standalone construction).
|
||||
private readonly Func<bool> _tryClaimShutdownEvent;
|
||||
|
||||
// c117946b debrief (2026-05-12): on terminal ESP-Apps failure, promote any apps the
|
||||
// agent observed in <see cref="AppInstallationState.Installing"/> to Error so the
|
||||
// user sees a name (not just an opaque "installing: 1" counter) and the
|
||||
// app_install_failed event carries the canonical failureType. Accessor is invoked
|
||||
// ONLY when the discriminator in <see cref="ShouldPromoteActiveInstallsAsStuck"/>
|
||||
// matches — every other terminal path leaves app states untouched. Returns the list
|
||||
// of promoted appIds for logging; null = legacy (no promotion).
|
||||
//
|
||||
// Session 080edee9 follow-up (2026-05-28): third parameter carries the HRESULT from
|
||||
// the failed Apps subcategory (e.g. <c>0x87D1041C</c>) when available, so the
|
||||
// promotion can stamp <see cref="AppPackageState.ErrorCode"/> + emit an enriched
|
||||
// <c>app_install_failed</c> event. Null = no HRESULT observed → fallback to the
|
||||
// legacy "esp_apps_timeout" classification.
|
||||
private readonly Func<string, string, string, IReadOnlyList<string>> _promoteActiveInstallsToStuck;
|
||||
|
||||
// Session 080edee9 follow-up + Codex review (P2/P3, 2026-05-28): accessor
|
||||
// returning the latest ESP failure context (HRESULT + failedSubcategory +
|
||||
// category) observed by ProvisioningStatusTracker. Read once during
|
||||
// <see cref="MaybePromoteActiveInstallsAsStuck"/>. The classifier first
|
||||
// checks <c>snapshot.IsAppsSubcategory</c> — only then does the HRESULT
|
||||
// drive the failureType. A non-Apps ESP failure (DevicePreparation/*,
|
||||
// DeviceSetup/SecurityPolicies, AccountSetup/CertificatesAccountSetup)
|
||||
// falls through to the generic <c>esp_apps_timeout</c> wording so a
|
||||
// non-app HRESULT cannot mis-classify in-flight installs.
|
||||
// Null accessor or null snapshot = no HRESULT context → fallback.
|
||||
private readonly Func<EspTerminalFailureSnapshot> _lastEspTerminalFailureAccessor;
|
||||
|
||||
private int _handled;
|
||||
|
||||
public EnrollmentTerminationHandler(
|
||||
AgentConfiguration configuration,
|
||||
TerminationSessionContext session,
|
||||
IAppTrackingReadModel appTracking,
|
||||
IDrainStatus drainStatus,
|
||||
IShutdownGate shutdownGate,
|
||||
AgentLogger logger,
|
||||
string stateDirectory,
|
||||
DateTime agentStartTimeUtc,
|
||||
Func<DecisionState> currentStateAccessor,
|
||||
Func<IReadOnlyList<AppPackageState>> packageStatesAccessor,
|
||||
Func<CleanupService> cleanupServiceFactory,
|
||||
Func<bool, string, Task<DiagnosticsUploadResult>> uploadDiagnosticsAsync,
|
||||
Action signalShutdown,
|
||||
string dialogExePathOverride = null,
|
||||
AgentAnalyzerManager analyzerManager = null,
|
||||
InformationalEventPost post = null,
|
||||
SessionIdPersistence sessionPersistence = null,
|
||||
Action<int> triggerReboot = null,
|
||||
TimeSpan? lateEventGracePeriod = null,
|
||||
TimeSpan? spoolDrainPeriod = null,
|
||||
Func<IReadOnlyDictionary<string, AppInstallTiming>> appTimingsAccessor = null,
|
||||
string agentVersion = null,
|
||||
Action stopPeripheralCollectors = null,
|
||||
Func<int> ignoredCountAccessor = null,
|
||||
Func<int> pendingItemCountAccessor = null,
|
||||
Action writeCleanExitMarker = null,
|
||||
Func<long> ingressPendingSignalCountAccessor = null,
|
||||
Func<bool> isWhiteGlovePart2Accessor = null,
|
||||
Func<string, string, string, IReadOnlyList<string>> promoteActiveInstallsToStuck = null,
|
||||
Func<bool> tryClaimShutdownEvent = null,
|
||||
Func<EspTerminalFailureSnapshot> lastEspTerminalFailureAccessor = null)
|
||||
TimeSpan? lateEventGracePeriod = null)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_appTracking = appTracking ?? throw new ArgumentNullException(nameof(appTracking));
|
||||
_drain = drainStatus ?? throw new ArgumentNullException(nameof(drainStatus));
|
||||
_shutdownGate = shutdownGate ?? throw new ArgumentNullException(nameof(shutdownGate));
|
||||
_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));
|
||||
// Plan §5 Fix 4 — optional timing accessor (older call sites / tests without IME
|
||||
// plumbing pass null → empty timings, which is handled below).
|
||||
_appTimingsAccessor = appTimingsAccessor ?? (() => new Dictionary<string, AppInstallTiming>());
|
||||
// Default to 0 when the host doesn't supply an accessor (e.g. tests without an
|
||||
// ImeLogTracker wired up). Live ignoredCount only matters when there's a real tracker.
|
||||
_ignoredCountAccessor = ignoredCountAccessor ?? (() => 0);
|
||||
_cleanupServiceFactory = cleanupServiceFactory ?? throw new ArgumentNullException(nameof(cleanupServiceFactory));
|
||||
_uploadDiagnosticsAsync = uploadDiagnosticsAsync ?? throw new ArgumentNullException(nameof(uploadDiagnosticsAsync));
|
||||
_signalShutdown = signalShutdown ?? throw new ArgumentNullException(nameof(signalShutdown));
|
||||
_stopPeripheralCollectors = stopPeripheralCollectors;
|
||||
_dialogExePathOverride = dialogExePathOverride;
|
||||
_analyzerManager = analyzerManager;
|
||||
_post = post;
|
||||
_sessionPersistence = sessionPersistence;
|
||||
_triggerReboot = triggerReboot ?? DefaultTriggerReboot;
|
||||
_stopPeripheralCollectors = stopPeripheralCollectors;
|
||||
_lateEventGracePeriod = lateEventGracePeriod ?? DefaultLateEventGrace;
|
||||
_spoolDrainPeriod = spoolDrainPeriod ?? DefaultSpoolDrain;
|
||||
_agentVersion = agentVersion ?? string.Empty;
|
||||
_pendingItemCountAccessor = pendingItemCountAccessor;
|
||||
_writeCleanExitMarker = writeCleanExitMarker;
|
||||
_ingressPendingSignalCountAccessor = ingressPendingSignalCountAccessor;
|
||||
_isWhiteGlovePart2Accessor = isWhiteGlovePart2Accessor;
|
||||
_promoteActiveInstallsToStuck = promoteActiveInstallsToStuck;
|
||||
_tryClaimShutdownEvent = tryClaimShutdownEvent;
|
||||
_lastEspTerminalFailureAccessor = lastEspTerminalFailureAccessor;
|
||||
|
||||
_configuration = session.Configuration;
|
||||
_stateDirectory = session.StateDirectory;
|
||||
_agentStartTimeUtc = session.AgentStartTimeUtc;
|
||||
_agentVersion = session.AgentVersion;
|
||||
_sessionPersistence = session.SessionPersistence;
|
||||
_dialogExePathOverride = session.DialogExePathOverride;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -337,14 +239,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { _signalShutdown(); }
|
||||
try { _shutdownGate.SignalShutdown(); }
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: signalShutdown threw: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
|
||||
private DecisionState TryGetCurrentState()
|
||||
{
|
||||
try { return _currentStateAccessor(); }
|
||||
try { return _appTracking.CurrentState; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTerminationHandler: current state accessor threw: {ex.Message}");
|
||||
@@ -354,7 +256,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
|
||||
private IReadOnlyList<AppPackageState> TryGetPackageStates()
|
||||
{
|
||||
try { return _packageStatesAccessor(); }
|
||||
try { return _appTracking.PackageStates; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTerminationHandler: package states accessor threw: {ex.Message}");
|
||||
@@ -364,7 +266,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
|
||||
private IReadOnlyDictionary<string, AppInstallTiming> TryGetAppTimings()
|
||||
{
|
||||
try { return _appTimingsAccessor() ?? new Dictionary<string, AppInstallTiming>(); }
|
||||
try { return _appTracking.AppTimings ?? new Dictionary<string, AppInstallTiming>(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTerminationHandler: app timings accessor threw: {ex.Message}");
|
||||
@@ -397,7 +299,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
int? wgPart;
|
||||
if (args.StageName == SessionStage.WhiteGloveSealed.ToString())
|
||||
wgPart = 1;
|
||||
else if (_isWhiteGlovePart2Accessor != null && _isWhiteGlovePart2Accessor())
|
||||
else if (_session.IsWhiteGlovePart2)
|
||||
wgPart = 2;
|
||||
else
|
||||
wgPart = null;
|
||||
@@ -492,7 +394,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
|
||||
private int TryGetIgnoredCount()
|
||||
{
|
||||
try { return _ignoredCountAccessor(); }
|
||||
try { return _appTracking.IgnoredCount; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"EnrollmentTerminationHandler: ignoredCount accessor threw: {ex.Message}");
|
||||
@@ -525,20 +427,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
/// deadline; the agent has stopped monitoring, not the ESP).</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Best-effort: any accessor failure is logged and swallowed. Skipped silently when
|
||||
/// the host did not wire <see cref="_promoteActiveInstallsToStuck"/> (tests).
|
||||
/// Best-effort: any accessor failure is logged and swallowed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void MaybePromoteActiveInstallsAsStuck(DecisionState state, EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
if (_promoteActiveInstallsToStuck == null) return;
|
||||
if (!ShouldPromoteActiveInstallsAsStuck(state, args)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var timeoutMinutes = state.ScenarioObservations?.EspSyncFailureTimeoutMinutes?.Value;
|
||||
EspTerminalFailureSnapshot failureContext = null;
|
||||
try { failureContext = _lastEspTerminalFailureAccessor?.Invoke(); }
|
||||
try { failureContext = _appTracking.LastEspTerminalFailure; }
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: lastEspTerminalFailureAccessor threw: {ex.Message}"); }
|
||||
|
||||
// Codex review (P3, 2026-05-28): only let the HRESULT drive the per-app
|
||||
@@ -555,7 +455,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
|
||||
var (failureType, message) = AppFailureTypes.ClassifyEspAppsFailure(effectiveErrorCode, timeoutMinutes);
|
||||
|
||||
var promoted = _promoteActiveInstallsToStuck(failureType, message, effectiveErrorCode);
|
||||
var promoted = _appTracking.PromoteActiveInstallsToStuck(failureType, message, effectiveErrorCode);
|
||||
var count = promoted?.Count ?? 0;
|
||||
if (count > 0)
|
||||
{
|
||||
@@ -747,7 +647,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
|
||||
try
|
||||
{
|
||||
_triggerReboot(delay);
|
||||
_shutdownGate.TriggerReboot(delay);
|
||||
_logger.Info($"EnrollmentTerminationHandler: standalone reboot queued via shutdown.exe /r /t {delay}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -797,14 +697,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// stage) and for diagnostics to show how long the agent ran.
|
||||
// <para>
|
||||
// Shutdown-gap closure (2026-05-15): participates in the cross-path idempotency gate
|
||||
// owned by AgentRuntimeHost so a Terminated event that races a Ctrl+C / ProcessExit
|
||||
// does not produce two <c>agent_shutting_down</c> events on the wire. When the gate
|
||||
// is not wired (tests / standalone construction), the legacy always-emit behaviour
|
||||
// is preserved.
|
||||
// owned by AgentRuntimeHost (via <see cref="IShutdownGate.TryClaimShutdownEvent"/>)
|
||||
// so a Terminated event that races a Ctrl+C / ProcessExit does not produce two
|
||||
// <c>agent_shutting_down</c> events on the wire.
|
||||
// </para>
|
||||
private void EmitAgentShuttingDown(EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
if (_tryClaimShutdownEvent != null && !_tryClaimShutdownEvent())
|
||||
if (!_shutdownGate.TryClaimShutdownEvent())
|
||||
{
|
||||
_logger.Debug("EnrollmentTerminationHandler: agent_shutting_down already emitted via gap-path — skipping duplicate.");
|
||||
return;
|
||||
@@ -864,19 +763,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Option 2 helper — writes the <c>clean-exit.marker</c> early via the host-supplied
|
||||
/// hook so the next agent run classifies <c>previousExit=clean</c> instead of
|
||||
/// <c>reboot_kill</c>, even when Windows kills the process between
|
||||
/// <see cref="_signalShutdown"/> and the legacy <c>AppDomain.ProcessExit</c> handler
|
||||
/// (typical race in admin-triggered reseal-reboots). No-op when the host did not
|
||||
/// wire the accessor (legacy call sites + tests).
|
||||
/// Option 2 helper — writes the <c>clean-exit.marker</c> early via
|
||||
/// <see cref="IShutdownGate.WriteCleanExitMarker"/> so the next agent run classifies
|
||||
/// <c>previousExit=clean</c> instead of <c>reboot_kill</c>, even when Windows kills
|
||||
/// the process between <see cref="IShutdownGate.SignalShutdown"/> and the legacy
|
||||
/// <c>AppDomain.ProcessExit</c> handler (typical race in admin-triggered
|
||||
/// reseal-reboots).
|
||||
/// </summary>
|
||||
private void TryWriteCleanExitMarker()
|
||||
{
|
||||
if (_writeCleanExitMarker == null) return;
|
||||
try
|
||||
{
|
||||
_writeCleanExitMarker();
|
||||
_shutdownGate.WriteCleanExitMarker();
|
||||
_logger.Debug("EnrollmentTerminationHandler: clean-exit marker written (early).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -920,32 +818,33 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// Phase B — wait for the spool to acknowledge upload to the backend (the
|
||||
// pre-Codex-Finding-2 behaviour, kept).
|
||||
//
|
||||
// When neither accessor is wired (tests / paranoid fallback), we keep V1
|
||||
// When neither surface is observable (tests / paranoid fallback), we keep V1
|
||||
// parity and just sleep the bounded period.
|
||||
if (_spoolDrainPeriod <= TimeSpan.Zero) return;
|
||||
var drainPeriod = _drain.SpoolDrainPeriod;
|
||||
if (drainPeriod <= TimeSpan.Zero) return;
|
||||
|
||||
if (_ingressPendingSignalCountAccessor == null && _pendingItemCountAccessor == null)
|
||||
if (!_drain.CanObserveIngress && !_drain.CanObserveSpool)
|
||||
{
|
||||
try { Task.Delay(_spoolDrainPeriod).Wait(); }
|
||||
try { Task.Delay(drainPeriod).Wait(); }
|
||||
catch { /* best-effort */ }
|
||||
return;
|
||||
}
|
||||
|
||||
var deadline = DateTime.UtcNow + _spoolDrainPeriod;
|
||||
var deadline = DateTime.UtcNow + drainPeriod;
|
||||
|
||||
if (_ingressPendingSignalCountAccessor != null)
|
||||
if (_drain.CanObserveIngress)
|
||||
{
|
||||
WaitFor(
|
||||
label: "ingress",
|
||||
pollAccessor: () => _ingressPendingSignalCountAccessor(),
|
||||
pollAccessor: () => _drain.IngressPendingSignalCount,
|
||||
deadline: deadline);
|
||||
}
|
||||
|
||||
if (_pendingItemCountAccessor != null)
|
||||
if (_drain.CanObserveSpool)
|
||||
{
|
||||
WaitFor(
|
||||
label: "spool",
|
||||
pollAccessor: () => (long)_pendingItemCountAccessor(),
|
||||
pollAccessor: () => (long)_drain.SpoolPendingItemCount,
|
||||
deadline: deadline);
|
||||
}
|
||||
}
|
||||
@@ -1002,17 +901,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
try { _post.Emit(evt); }
|
||||
catch (Exception ex) { _logger.Debug($"EnrollmentTerminationHandler: event emission '{evt?.EventType}' threw: {ex.Message}"); }
|
||||
}
|
||||
|
||||
private static void DefaultTriggerReboot(int delaySeconds)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "shutdown.exe"),
|
||||
Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
Process.Start(psi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only view over the enrollment + app-tracking state the
|
||||
/// <see cref="EnrollmentTerminationHandler"/> needs at terminal time (ARCH-F2 grouping —
|
||||
/// replaces six loose <c>Func<></c> constructor parameters). Production implementation:
|
||||
/// <see cref="OrchestratorAppTrackingReadModel"/>; tests supply a small fake over their rig
|
||||
/// state. All members are read lazily inside <c>Handle</c> — never cached at construction —
|
||||
/// because the collector surfaces only exist after <c>EnrollmentOrchestrator.Start</c>.
|
||||
/// </summary>
|
||||
public interface IAppTrackingReadModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Current <see cref="DecisionState"/> snapshot. May throw before the orchestrator has
|
||||
/// started — the handler guards every read with try/catch.
|
||||
/// </summary>
|
||||
DecisionState CurrentState { get; }
|
||||
|
||||
/// <summary>
|
||||
/// F5 (debrief 7dd4e593) — deduped union of phase-snapshotted apps + live package
|
||||
/// states, so the termination summary can iterate the union of phase-snapshotted apps
|
||||
/// + live <c>_packageStates</c> (V2's clear-on-phase-transition would otherwise drop
|
||||
/// the DeviceSetup apps from app_tracking_summary and the SummaryDialog). May return
|
||||
/// null when no IME surface exists.
|
||||
/// </summary>
|
||||
IReadOnlyList<AppPackageState>? PackageStates { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan §5 Fix 4 — per-app install-lifecycle timings for FinalStatusBuilder +
|
||||
/// app_tracking_summary emission. May return null (handler substitutes an empty map).
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, AppInstallTiming>? AppTimings { get; }
|
||||
|
||||
/// <summary>
|
||||
/// V1-parity field: count of IME apps the tracker has marked as "ignored" (e.g.
|
||||
/// uninstall intents that don't surface in the install pipeline). Lives on the live
|
||||
/// <c>AppPackageStateList</c> only — the deduped phase-snapshot union in
|
||||
/// <see cref="PackageStates"/> doesn't carry it.
|
||||
/// </summary>
|
||||
int IgnoredCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// c117946b debrief (2026-05-12): on terminal ESP-Apps failure, promote any apps the
|
||||
/// agent observed in <c>Installing</c> to Error so the user sees a name (not just an
|
||||
/// opaque "installing: 1" counter) and the app_install_failed event carries the
|
||||
/// canonical failureType. Invoked ONLY when the discriminator in
|
||||
/// <c>ShouldPromoteActiveInstallsAsStuck</c> matches — every other terminal path
|
||||
/// leaves app states untouched. Returns the list of promoted appIds for logging.
|
||||
/// <para>
|
||||
/// Session 080edee9 follow-up (2026-05-28): <paramref name="errorCode"/> carries the
|
||||
/// HRESULT from the failed Apps subcategory (e.g. <c>0x87D1041C</c>) when available,
|
||||
/// so the promotion can stamp <c>AppPackageState.ErrorCode</c> + emit an enriched
|
||||
/// <c>app_install_failed</c> event. Null = no HRESULT observed → fallback to the
|
||||
/// legacy "esp_apps_timeout" classification.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
IReadOnlyList<string> PromoteActiveInstallsToStuck(string failureType, string message, string? errorCode);
|
||||
|
||||
/// <summary>
|
||||
/// Session 080edee9 follow-up + Codex review (P2/P3, 2026-05-28): latest ESP failure
|
||||
/// context (HRESULT + failedSubcategory + category) observed by
|
||||
/// ProvisioningStatusTracker. Read once during
|
||||
/// <c>MaybePromoteActiveInstallsAsStuck</c>. The classifier first checks
|
||||
/// <c>snapshot.IsAppsSubcategory</c> — only then does the HRESULT drive the
|
||||
/// failureType. A non-Apps ESP failure (DevicePreparation/*,
|
||||
/// DeviceSetup/SecurityPolicies, AccountSetup/CertificatesAccountSetup) falls through
|
||||
/// to the generic <c>esp_apps_timeout</c> wording so a non-app HRESULT cannot
|
||||
/// mis-classify in-flight installs. Null = no HRESULT context → fallback.
|
||||
/// </summary>
|
||||
EspTerminalFailureSnapshot? LastEspTerminalFailure { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
/// <summary>
|
||||
/// Drain observability for <see cref="EnrollmentTerminationHandler.DrainSpool"/> (ARCH-F2
|
||||
/// grouping — replaces the <c>pendingItemCountAccessor</c> /
|
||||
/// <c>ingressPendingSignalCountAccessor</c> / <c>spoolDrainPeriod</c> constructor
|
||||
/// parameters). Production implementation: <see cref="OrchestratorDrainStatus"/>.
|
||||
/// <para>
|
||||
/// The two <c>CanObserve*</c> capability flags replace the old "accessor is null" wiring
|
||||
/// variant: when neither surface is observable the handler keeps the legacy V1-parity
|
||||
/// blind delay for the full <see cref="SpoolDrainPeriod"/> instead of polling.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IDrainStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Bounded budget shared by the two drain phases (ingress, then spool). V1 parity:
|
||||
/// production uses 10 s (up to 20 × 500 ms drains before shutdown.exe);
|
||||
/// <see cref="TimeSpan.Zero"/> disables draining entirely (test default).
|
||||
/// </summary>
|
||||
TimeSpan SpoolDrainPeriod { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Codex Finding 2 (2026-04-30): the termination handler is dispatched off the ingress
|
||||
/// worker, so it can wait for the worker to actually process the lifecycle events the
|
||||
/// handler posts (agent_shutting_down, whiteglove_part1_complete, analyzer events)
|
||||
/// BEFORE polling the spool — without this the spool-empty check would trivially fire
|
||||
/// on already-uploaded items while the just-posted events are still in the ingress
|
||||
/// channel. <c>false</c> = ingress not observable → phase skipped.
|
||||
/// </summary>
|
||||
bool CanObserveIngress { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Signals accepted by the ingress but not yet fully processed. Only read when
|
||||
/// <see cref="CanObserveIngress"/> is true; a throwing getter degrades to a bounded
|
||||
/// sleep inside the handler's poll loop.
|
||||
/// </summary>
|
||||
long IngressPendingSignalCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Option 1 (WG Part 1 graceful-exit hardening, 2026-04-30): when observable, the
|
||||
/// termination handler polls the spool's pending count during <c>DrainSpool</c> and
|
||||
/// exits the wait as soon as it returns 0 (spool fully acknowledged by the backend),
|
||||
/// falling back to the bounded <see cref="SpoolDrainPeriod"/> timeout if the spool
|
||||
/// never drains. <c>false</c> = legacy blind-delay behaviour.
|
||||
/// </summary>
|
||||
bool CanObserveSpool { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Spool items not yet acknowledged by the backend. Only read when
|
||||
/// <see cref="CanObserveSpool"/> is true.
|
||||
/// </summary>
|
||||
int SpoolPendingItemCount { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#nullable enable
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
/// <summary>
|
||||
/// Host-owned shutdown actions invoked by the <see cref="EnrollmentTerminationHandler"/>
|
||||
/// (ARCH-F2 grouping — replaces the <c>signalShutdown</c> / <c>triggerReboot</c> /
|
||||
/// <c>writeCleanExitMarker</c> / <c>tryClaimShutdownEvent</c> constructor parameters).
|
||||
/// Production implementation lives in the runtime host project
|
||||
/// (<c>RuntimeShutdownGate</c>) because every member touches host-process machinery
|
||||
/// (the shutdown <c>ManualResetEventSlim</c>, <c>Program.WriteCleanExitMarker</c>, the
|
||||
/// cross-path <c>agent_shutting_down</c> idempotency gate).
|
||||
/// </summary>
|
||||
public interface IShutdownGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Releases the host's shutdown wait — the handler's very last act (invoked in the
|
||||
/// <c>finally</c> of <c>Handle</c>, best-effort, never allowed to prevent shutdown).
|
||||
/// </summary>
|
||||
void SignalShutdown();
|
||||
|
||||
/// <summary>
|
||||
/// V1 parity — standalone-reboot flow (<c>RebootOnComplete</c> without self-destruct):
|
||||
/// queue <c>shutdown.exe /r /t <delaySeconds></c> so the user gets a visible
|
||||
/// countdown. Tests record the call instead of rebooting the build machine.
|
||||
/// </summary>
|
||||
void TriggerReboot(int delaySeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Option 2 (WG Part 1 graceful-exit hardening, 2026-04-30): writes the
|
||||
/// <c>clean-exit.marker</c> file directly, before <see cref="SignalShutdown"/> hands
|
||||
/// control to the main thread. This wins the race against an admin-triggered
|
||||
/// reseal-reboot — without this hook the marker is only written by the
|
||||
/// AppDomain.ProcessExit handler, which Windows can pre-empt.
|
||||
/// </summary>
|
||||
void WriteCleanExitMarker();
|
||||
|
||||
/// <summary>
|
||||
/// Shutdown-gap closure (2026-05-15): cross-path idempotency gate shared with the
|
||||
/// runtime host's gap emitters (Ctrl+C, ProcessExit, unhandled exception,
|
||||
/// runtime-host finally). The handler calls this before emitting
|
||||
/// <c>agent_shutting_down</c> so a Terminated event that races a Ctrl+C cannot
|
||||
/// produce two events on the wire. Returns <c>false</c> when another path already
|
||||
/// claimed the slot (the handler then skips its emit). Implementations without a
|
||||
/// shared gate return <c>true</c> (always-emit).
|
||||
/// </summary>
|
||||
bool TryClaimShutdownEvent();
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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>
|
||||
/// Production <see cref="IAppTrackingReadModel"/> over the live
|
||||
/// <see cref="EnrollmentOrchestrator"/>: decision state from the processor, app-tracking
|
||||
/// surfaces from <see cref="EnrollmentOrchestrator.CollectorSurfaces"/> (ARCH-F4). All
|
||||
/// members delegate lazily — the surfaces only exist after <c>Start</c> step 14, while the
|
||||
/// termination handler is constructed earlier inside the <c>onIngressReady</c> hook.
|
||||
/// </summary>
|
||||
public sealed class OrchestratorAppTrackingReadModel : IAppTrackingReadModel
|
||||
{
|
||||
private readonly EnrollmentOrchestrator _orchestrator;
|
||||
|
||||
public OrchestratorAppTrackingReadModel(EnrollmentOrchestrator orchestrator)
|
||||
{
|
||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||
}
|
||||
|
||||
public DecisionState CurrentState => _orchestrator.CurrentState;
|
||||
|
||||
public IReadOnlyList<AppPackageState>? PackageStates =>
|
||||
_orchestrator.CollectorSurfaces?.AllKnownPackageStates;
|
||||
|
||||
public IReadOnlyDictionary<string, AppInstallTiming>? AppTimings =>
|
||||
_orchestrator.CollectorSurfaces?.ImeAppTimings;
|
||||
|
||||
public int IgnoredCount => _orchestrator.CollectorSurfaces?.ImeIgnoredCount ?? 0;
|
||||
|
||||
public IReadOnlyList<string> PromoteActiveInstallsToStuck(string failureType, string message, string? errorCode) =>
|
||||
_orchestrator.CollectorSurfaces?.PromoteActiveInstallsToStuck(failureType, message, errorCode)
|
||||
?? Array.Empty<string>();
|
||||
|
||||
public EspTerminalFailureSnapshot? LastEspTerminalFailure =>
|
||||
_orchestrator.CollectorSurfaces?.LastEspTerminalFailure;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
/// <summary>
|
||||
/// Production <see cref="IDrainStatus"/> over the live <see cref="EnrollmentOrchestrator"/>.
|
||||
/// Both drain surfaces are observable in production; the drain budget is the V1-parity
|
||||
/// default (the old optional <c>spoolDrainPeriod</c> constructor parameter was never set
|
||||
/// by the runtime host).
|
||||
/// </summary>
|
||||
public sealed class OrchestratorDrainStatus : IDrainStatus
|
||||
{
|
||||
/// <summary>V1 parity: up to 20 × 500 ms drains before shutdown.exe.</summary>
|
||||
private static readonly TimeSpan DefaultSpoolDrain = TimeSpan.FromMilliseconds(10000);
|
||||
|
||||
private readonly EnrollmentOrchestrator _orchestrator;
|
||||
|
||||
public OrchestratorDrainStatus(EnrollmentOrchestrator orchestrator)
|
||||
{
|
||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||
}
|
||||
|
||||
public TimeSpan SpoolDrainPeriod => DefaultSpoolDrain;
|
||||
|
||||
public bool CanObserveIngress => true;
|
||||
|
||||
public long IngressPendingSignalCount => _orchestrator.IngressPendingSignalCount;
|
||||
|
||||
public bool CanObserveSpool => true;
|
||||
|
||||
public int SpoolPendingItemCount => _orchestrator.PendingItemCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
/// <summary>
|
||||
/// Immutable session facts the <see cref="EnrollmentTerminationHandler"/> needs at
|
||||
/// terminal time (ARCH-F2 grouping — replaces seven loose constructor parameters).
|
||||
/// A concrete class, not an interface: it is pure data with no behavioral seam, so
|
||||
/// production and tests construct the same type and there is no fake-vs-prod divergence.
|
||||
/// </summary>
|
||||
public sealed class TerminationSessionContext
|
||||
{
|
||||
private readonly Func<bool>? _isWhiteGlovePart2;
|
||||
|
||||
public AgentConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>State directory for final-status.json + enrollment-complete.marker.</summary>
|
||||
public string StateDirectory { get; }
|
||||
|
||||
/// <summary>Used for the uptimeMinutes field on <c>agent_shutting_down</c>.</summary>
|
||||
public DateTime AgentStartTimeUtc { get; }
|
||||
|
||||
/// <summary>Normalized to <see cref="string.Empty"/> when unknown.</summary>
|
||||
public string AgentVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes the <c>whiteglove.complete</c> marker on WG Part-1 exit so Part-2 resume is
|
||||
/// detected on the next boot. Nullable — the handler warns and skips when absent.
|
||||
/// </summary>
|
||||
public SessionIdPersistence? SessionPersistence { get; }
|
||||
|
||||
/// <summary>Test override for the SummaryDialog exe path; null in production.</summary>
|
||||
public string? DialogExePathOverride { get; }
|
||||
|
||||
/// <summary>
|
||||
/// V1-symmetric Part-2 hint (plan §11). A Part-2 resume runs as a fresh Classic
|
||||
/// enrollment after Archive-and-Reset, so the terminal Stage is
|
||||
/// <c>Completed</c>/<c>Failed</c> like any first-run completion. The shutdown analyzer
|
||||
/// pipeline still needs to know it was a Part-2 run so SoftwareInventoryAnalyzer can
|
||||
/// tag findings with phase=2 and the backend's vulnerability-correlation pipeline can
|
||||
/// filter Part-2 inventory out of the Part-1 set. Evaluated lazily at terminal time
|
||||
/// (production wires <c>() => orchestrator.IsWhiteGlovePart2</c>); defaults to
|
||||
/// <c>false</c> when no accessor was supplied.
|
||||
/// </summary>
|
||||
public bool IsWhiteGlovePart2 => _isWhiteGlovePart2?.Invoke() ?? false;
|
||||
|
||||
public TerminationSessionContext(
|
||||
AgentConfiguration configuration,
|
||||
string stateDirectory,
|
||||
DateTime agentStartTimeUtc,
|
||||
string? agentVersion = null,
|
||||
SessionIdPersistence? sessionPersistence = null,
|
||||
string? dialogExePathOverride = null,
|
||||
Func<bool>? isWhiteGlovePart2 = null)
|
||||
{
|
||||
Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
StateDirectory = string.IsNullOrEmpty(stateDirectory)
|
||||
? throw new ArgumentNullException(nameof(stateDirectory))
|
||||
: stateDirectory;
|
||||
AgentStartTimeUtc = agentStartTimeUtc;
|
||||
AgentVersion = agentVersion ?? string.Empty;
|
||||
SessionPersistence = sessionPersistence;
|
||||
DialogExePathOverride = dialogExePathOverride;
|
||||
_isWhiteGlovePart2 = isWhiteGlovePart2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,88 +333,37 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// emits through the same InformationalEventPost. The terminated-
|
||||
// dispatch wrapper registered above picks this instance up via
|
||||
// closure capture.
|
||||
//
|
||||
// ARCH-F2: the former ~80-line lambda wiring is grouped into four
|
||||
// adapter objects. The per-parameter rationale comments live on
|
||||
// the interface members (IAppTrackingReadModel / IDrainStatus /
|
||||
// IShutdownGate / TerminationSessionContext).
|
||||
terminationHandler = new EnrollmentTerminationHandler(
|
||||
configuration: agentConfig,
|
||||
session: new TerminationSessionContext(
|
||||
configuration: agentConfig,
|
||||
stateDirectory: stateSubdir,
|
||||
agentStartTimeUtc: agentStartTimeUtc,
|
||||
agentVersion: agentVersion,
|
||||
sessionPersistence: sessionPersistence,
|
||||
// Read lazily — the orchestrator resolves the Part-2 hint
|
||||
// during Start, after this handler is constructed.
|
||||
isWhiteGlovePart2: () => orchestrator.IsWhiteGlovePart2),
|
||||
appTracking: new OrchestratorAppTrackingReadModel(orchestrator),
|
||||
drainStatus: new OrchestratorDrainStatus(orchestrator),
|
||||
shutdownGate: new RuntimeShutdownGate(
|
||||
signalShutdown: () => shutdown.Set(),
|
||||
writeCleanExitMarker: () => Program.WriteCleanExitMarker(dataDirectory),
|
||||
tryClaimShutdownEvent: TryClaimAgentShuttingDownEmit),
|
||||
logger: logger,
|
||||
stateDirectory: stateSubdir,
|
||||
agentStartTimeUtc: agentStartTimeUtc,
|
||||
currentStateAccessor: () => orchestrator.CurrentState,
|
||||
// F5 (debrief 7dd4e593) — pass the deduped phase-snapshot+live
|
||||
// union so DeviceSetup apps cleared from _packageStates on the
|
||||
// AccountSetup transition still appear in the SummaryDialog and
|
||||
// app_tracking_summary event.
|
||||
packageStatesAccessor: () => orchestrator.CollectorSurfaces?.AllKnownPackageStates,
|
||||
cleanupServiceFactory: bootstrap.CleanupServiceFactory,
|
||||
uploadDiagnosticsAsync: uploadDiagnosticsAsync,
|
||||
signalShutdown: () => shutdown.Set(),
|
||||
analyzerManager: analyzerManager,
|
||||
post: lifecyclePost,
|
||||
sessionPersistence: sessionPersistence,
|
||||
// Plan §5 Fix 4 — per-app timing snapshot for FinalStatusBuilder +
|
||||
// app_tracking_summary emission. Null-safe via the handler's default.
|
||||
appTimingsAccessor: () => orchestrator.CollectorSurfaces?.ImeAppTimings,
|
||||
agentVersion: agentVersion,
|
||||
// Stop periodic collectors before the diagnostics ZIP is
|
||||
// built, so no late `performance_snapshot` slips in after
|
||||
// `diagnostics_collecting`. Idempotent on the orchestrator
|
||||
// side — the full Stop() call later is a no-op for hosts.
|
||||
stopPeripheralCollectors: () => orchestrator.StopCollectorHosts(),
|
||||
// V1-parity ignoredCount for app_tracking_summary — lives on the
|
||||
// live AppPackageStateList only (phase snapshots don't carry it).
|
||||
ignoredCountAccessor: () => orchestrator.CollectorSurfaces?.ImeIgnoredCount ?? 0,
|
||||
// Option 1 (WG Part 1 graceful-exit hardening, 2026-04-30) —
|
||||
// active spool-empty polling so DrainSpool exits as soon as the
|
||||
// backend ack'd the last lifecycle event instead of waiting the
|
||||
// full 10s timeout. Critical when an admin reseal-reboot races
|
||||
// termination.
|
||||
pendingItemCountAccessor: () => orchestrator.PendingItemCount,
|
||||
// Option 2 (same hardening) — write clean-exit.marker before
|
||||
// _signalShutdown returns, instead of relying solely on
|
||||
// AppDomain.ProcessExit which Windows can pre-empt during a
|
||||
// shutdown.exe / reseal-triggered reboot.
|
||||
writeCleanExitMarker: () => Program.WriteCleanExitMarker(dataDirectory),
|
||||
// Codex Finding 2 (2026-04-30) — DrainSpool needs to wait
|
||||
// for the ingress to finish processing the lifecycle events
|
||||
// the handler just posted (agent_shutting_down,
|
||||
// whiteglove_part1_complete, analyzer events) BEFORE polling
|
||||
// spool-empty. Pair with off-worker dispatch in
|
||||
// EnrollmentOrchestrator.OnDecisionTerminalStage so the
|
||||
// wait can actually make progress.
|
||||
ingressPendingSignalCountAccessor: () => orchestrator.IngressPendingSignalCount,
|
||||
// V1-symmetric Part-2 hint accessor (plan §11). The orchestrator
|
||||
// does not reach a dedicated terminal stage for Part 2; instead
|
||||
// Part 2 runs as a fresh Classic enrollment after Archive-and-Reset
|
||||
// and ends on Completed/Failed. The shutdown analyzer pipeline
|
||||
// still needs the hint to tag findings with phase=2 so the backend
|
||||
// vulnerability correlation pipeline can filter Part-2 inventory
|
||||
// out of the Part-1 set.
|
||||
isWhiteGlovePart2Accessor: () => orchestrator.IsWhiteGlovePart2,
|
||||
// c117646b debrief (2026-05-12) — on terminal ESP-Apps failure,
|
||||
// promote any apps in `Installing` to Error with the canonical
|
||||
// `esp_apps_timeout` failureType so the user sees a name + a
|
||||
// hedged "likely stuck" label, not just an opaque `installing: 1`
|
||||
// counter. Discriminator inside the handler gates this to the
|
||||
// EspTerminalFailure pathway only — other Failed paths leave the
|
||||
// app list untouched.
|
||||
promoteActiveInstallsToStuck: (failureType, message, errorCode)
|
||||
=> orchestrator.CollectorSurfaces?.PromoteActiveInstallsToStuck(failureType, message, errorCode)
|
||||
?? Array.Empty<string>(),
|
||||
// Shutdown-gap closure (2026-05-15) — share the cross-path
|
||||
// idempotency gate so a Terminated event that races a Ctrl+C /
|
||||
// ProcessExit gap-path does not emit two agent_shutting_down
|
||||
// events on the wire. The handler skips its emit when the
|
||||
// gate already claimed the slot.
|
||||
tryClaimShutdownEvent: TryClaimAgentShuttingDownEmit,
|
||||
// Session 080edee9 follow-up + Codex review (P2/P3, 2026-05-28)
|
||||
// — feeds the latest ESP failure context (HRESULT +
|
||||
// failedSubcategory + category) captured by
|
||||
// ProvisioningStatusTracker (via
|
||||
// EspAndHelloTracker.LastEspTerminalFailure) into the
|
||||
// termination handler's classifier. The handler only treats
|
||||
// the HRESULT as an app-level cause when the failure came
|
||||
// from the Apps subcategory; non-Apps ESP failures fall
|
||||
// back to the generic esp_apps_timeout classification.
|
||||
lastEspTerminalFailureAccessor: () => orchestrator.CollectorSurfaces?.LastEspTerminalFailure);
|
||||
stopPeripheralCollectors: () => orchestrator.StopCollectorHosts());
|
||||
|
||||
// ServerActionDispatcher (plan §5.3) — constructed inside this
|
||||
// hook so lifecyclePost + terminationHandler are guaranteed
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using AutopilotMonitor.Agent.V2.Core.Termination;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
{
|
||||
/// <summary>
|
||||
/// Production <see cref="IShutdownGate"/> for <see cref="AgentRuntimeHost"/> (ARCH-F2).
|
||||
/// Wraps the host-process shutdown machinery that only exists as locals inside
|
||||
/// <c>AgentRuntimeHost.Run</c>: the shutdown <c>ManualResetEventSlim</c>, the early
|
||||
/// <c>clean-exit.marker</c> write (<c>Program.WriteCleanExitMarker</c>), and the
|
||||
/// cross-path <c>agent_shutting_down</c> idempotency gate shared with the Ctrl+C /
|
||||
/// ProcessExit / unhandled-exception gap emitters.
|
||||
/// </summary>
|
||||
internal sealed class RuntimeShutdownGate : IShutdownGate
|
||||
{
|
||||
private readonly Action _signalShutdown;
|
||||
private readonly Action _writeCleanExitMarker;
|
||||
private readonly Func<bool> _tryClaimShutdownEvent;
|
||||
|
||||
public RuntimeShutdownGate(
|
||||
Action signalShutdown,
|
||||
Action writeCleanExitMarker,
|
||||
Func<bool> tryClaimShutdownEvent)
|
||||
{
|
||||
_signalShutdown = signalShutdown ?? throw new ArgumentNullException(nameof(signalShutdown));
|
||||
_writeCleanExitMarker = writeCleanExitMarker ?? throw new ArgumentNullException(nameof(writeCleanExitMarker));
|
||||
_tryClaimShutdownEvent = tryClaimShutdownEvent ?? throw new ArgumentNullException(nameof(tryClaimShutdownEvent));
|
||||
}
|
||||
|
||||
public void SignalShutdown() => _signalShutdown();
|
||||
|
||||
public void WriteCleanExitMarker() => _writeCleanExitMarker();
|
||||
|
||||
public bool TryClaimShutdownEvent() => _tryClaimShutdownEvent();
|
||||
|
||||
/// <summary>
|
||||
/// V1 parity — queues <c>shutdown.exe /r /t <delaySeconds></c> with a visible
|
||||
/// countdown message. (Formerly <c>EnrollmentTerminationHandler.DefaultTriggerReboot</c>.)
|
||||
/// </summary>
|
||||
public void TriggerReboot(int delaySeconds)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "shutdown.exe"),
|
||||
Arguments = $"/r /t {delaySeconds} /c \"Autopilot enrollment completed - rebooting\"",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
Process.Start(psi);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
1047
|
||||
1058
|
||||
|
||||
Reference in New Issue
Block a user