mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix(agent-v2): classify ESP Apps-subcategory failures by HRESULT
Session 080edee9 surfaced a long-standing mis-classification on the V2
EnrollmentTerminationHandler "still installing -> error" promotion path: every
terminal ESP-Apps failure was stamped as `esp_apps_timeout` with a message
claiming "ESP timed out (180 min)" - even when the actual session only ran
~27 min and the ESP terminated explicitly with HRESULT 0x87D1041C
("Application not detected after installation completed successfully"). The
"180 min" was the *configured* SyncFailureTimeoutMinutes ceiling, not the
elapsed time.
Plumb the HRESULT captured by ProvisioningStatusTracker through the host
stack (EspAndHelloTracker.LastEspTerminalErrorCode -> EspAndHelloHost ->
DefaultComponentFactory) into the termination handler, and route through a
new `AppFailureTypes.ClassifyEspAppsFailure` helper that produces:
- `esp_apps_detection_failure` for 0x87D1041C (install ran but Intune
detection rule did not match afterwards - confirmed detection failure)
- `esp_apps_install_failure` for any other HRESULT (installer reported
an error before the app finished installing)
- `esp_apps_timeout` fallback when no HRESULT is available
(genuine "ESP gave up" case - now phrased as "configured at X min",
not "timed out X min", to make clear it's a ceiling not an elapsed time)
The promoted `app_install_failed` event now carries DataJson.errorCode so
downstream rules and the UI can correlate the per-app failure to the
ESP-level HRESULT. AppPackageState.SetErrorContext already accepted an
optional errorCode argument, so this only extends the promotion delegate
signature without changing the per-app state shape.
Coverage: 86/86 Termination tests pass (incl. 2 new HRESULT-classification
cases); 6/6 new AppFailureTypesClassifierTests; 1171/1171 V2.Core.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+82
@@ -0,0 +1,82 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
{
|
||||
/// <summary>
|
||||
/// Session 080edee9 follow-up (2026-05-28) — coverage for
|
||||
/// <see cref="Constants.AppFailureTypes.ClassifyEspAppsFailure"/>. The classifier picks
|
||||
/// the right canonical failureType + message for an ESP Apps-subcategory failure based
|
||||
/// on the observed HRESULT, so the V2 EnrollmentTerminationHandler stops mis-labelling
|
||||
/// every ESP terminal failure as a timeout.
|
||||
/// </summary>
|
||||
public class AppFailureTypesClassifierTests
|
||||
{
|
||||
[Fact]
|
||||
public void Classify_0x87d1041c_returns_detection_failure()
|
||||
{
|
||||
var (failureType, message) = Constants.AppFailureTypes
|
||||
.ClassifyEspAppsFailure("0x87d1041c", espTimeoutMinutes: 180);
|
||||
|
||||
Assert.Equal(Constants.AppFailureTypes.EspAppsDetectionFailure, failureType);
|
||||
Assert.Contains("detection", message, System.StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("0x87d1041c", message);
|
||||
Assert.DoesNotContain("timeout", message, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_uppercase_0x87D1041C_is_normalised_to_lowercase()
|
||||
{
|
||||
var (failureType, message) = Constants.AppFailureTypes
|
||||
.ClassifyEspAppsFailure("0x87D1041C", espTimeoutMinutes: null);
|
||||
|
||||
Assert.Equal(Constants.AppFailureTypes.EspAppsDetectionFailure, failureType);
|
||||
Assert.Contains("0x87d1041c", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_other_hresult_returns_install_failure()
|
||||
{
|
||||
var (failureType, message) = Constants.AppFailureTypes
|
||||
.ClassifyEspAppsFailure("0x80070643", espTimeoutMinutes: 180);
|
||||
|
||||
Assert.Equal(Constants.AppFailureTypes.EspAppsInstallFailure, failureType);
|
||||
Assert.Contains("0x80070643", message);
|
||||
Assert.DoesNotContain("timeout", message, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_no_hresult_with_timeout_returns_timeout_with_configured_label()
|
||||
{
|
||||
// The 180 min is the *configured* SyncFailureTimeoutMinutes — the message MUST
|
||||
// make clear it's a ceiling, not the elapsed time (session 080edee9 root cause).
|
||||
var (failureType, message) = Constants.AppFailureTypes
|
||||
.ClassifyEspAppsFailure(errorCode: null, espTimeoutMinutes: 180);
|
||||
|
||||
Assert.Equal(Constants.AppFailureTypes.EspAppsTimeout, failureType);
|
||||
Assert.Contains("ESP gave up", message);
|
||||
Assert.Contains("180 min", message);
|
||||
Assert.Contains("configured", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_no_hresult_no_timeout_returns_generic_timeout()
|
||||
{
|
||||
var (failureType, message) = Constants.AppFailureTypes
|
||||
.ClassifyEspAppsFailure(errorCode: null, espTimeoutMinutes: null);
|
||||
|
||||
Assert.Equal(Constants.AppFailureTypes.EspAppsTimeout, failureType);
|
||||
Assert.Contains("ESP gave up", message);
|
||||
Assert.DoesNotContain("min", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_empty_hresult_treated_as_unknown()
|
||||
{
|
||||
var (failureType, _) = Constants.AppFailureTypes
|
||||
.ClassifyEspAppsFailure(errorCode: "", espTimeoutMinutes: 60);
|
||||
|
||||
Assert.Equal(Constants.AppFailureTypes.EspAppsTimeout, failureType);
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
-10
@@ -145,9 +145,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
public int CleanExitMarkerWrites;
|
||||
// c117946b debrief (2026-05-12) — captures every PromoteActiveInstallsToStuck
|
||||
// call so tests can assert the discriminator fired exactly when intended.
|
||||
public List<(string failureType, string message)> PromotionCalls { get; } =
|
||||
new List<(string failureType, string message)>();
|
||||
// Session 080edee9 follow-up (2026-05-28) — added `errorCode` slot so tests can
|
||||
// verify the HRESULT-based classification (esp_apps_detection_failure vs
|
||||
// esp_apps_install_failure vs esp_apps_timeout).
|
||||
public List<(string failureType, string message, string errorCode)> PromotionCalls { get; } =
|
||||
new List<(string failureType, string message, string errorCode)>();
|
||||
public IReadOnlyList<string> PromotionReturnValue { get; set; } = Array.Empty<string>();
|
||||
// Session 080edee9 follow-up (2026-05-28) — feeds the
|
||||
// lastEspTerminalErrorCodeAccessor in the production wiring. Defaults to null
|
||||
// (no HRESULT observed) so existing tests fall through to the
|
||||
// `esp_apps_timeout` classification.
|
||||
public string LastEspTerminalErrorCodeOverride { get; set; } = null;
|
||||
public List<string> TerminationActionLog { get; } = new List<string>();
|
||||
public TimeSpan SpoolDrainPeriodOverride { get; set; } = TimeSpan.Zero;
|
||||
// Shutdown-gap closure (2026-05-15) — when set, the constructed handler shares
|
||||
@@ -205,12 +213,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
TerminationActionLog.Add("writeCleanExitMarker");
|
||||
}),
|
||||
ingressPendingSignalCountAccessor: IngressPendingSignalCountAccessor,
|
||||
promoteActiveInstallsToStuck: (failureType, message) =>
|
||||
promoteActiveInstallsToStuck: (failureType, message, errorCode) =>
|
||||
{
|
||||
PromotionCalls.Add((failureType, message));
|
||||
PromotionCalls.Add((failureType, message, errorCode));
|
||||
return PromotionReturnValue;
|
||||
},
|
||||
tryClaimShutdownEvent: TryClaimShutdownEventOverride);
|
||||
tryClaimShutdownEvent: TryClaimShutdownEventOverride,
|
||||
lastEspTerminalErrorCodeAccessor: () => LastEspTerminalErrorCodeOverride);
|
||||
}
|
||||
|
||||
public void Dispose() => Tmp.Dispose();
|
||||
@@ -966,10 +975,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
|
||||
|
||||
// Exactly one promotion call with the canonical failureType + the user-facing message.
|
||||
// No HRESULT observed → fallback to the EspAppsTimeout classification with the
|
||||
// canonical "ESP gave up while still installing" message. errorCode is null.
|
||||
Assert.Single(rig.PromotionCalls);
|
||||
Assert.Equal(AutopilotMonitor.Shared.Constants.AppFailureTypes.EspAppsTimeout, rig.PromotionCalls[0].failureType);
|
||||
Assert.Contains("ESP timed out", rig.PromotionCalls[0].message);
|
||||
Assert.Contains("ESP gave up", rig.PromotionCalls[0].message);
|
||||
Assert.Null(rig.PromotionCalls[0].errorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1072,8 +1083,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
|
||||
|
||||
Assert.Single(rig.PromotionCalls);
|
||||
Assert.Contains("(60 min)", rig.PromotionCalls[0].message);
|
||||
Assert.Contains("ESP timed out", rig.PromotionCalls[0].message);
|
||||
// Session 080edee9 follow-up — message no longer claims a literal timeout
|
||||
// (the timeout was the *configured* ceiling, not necessarily the elapsed time).
|
||||
Assert.Equal(AutopilotMonitor.Shared.Constants.AppFailureTypes.EspAppsTimeout, rig.PromotionCalls[0].failureType);
|
||||
Assert.Contains("ESP gave up", rig.PromotionCalls[0].message);
|
||||
Assert.Contains("60 min", rig.PromotionCalls[0].message);
|
||||
Assert.Contains("configured", rig.PromotionCalls[0].message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1090,7 +1105,55 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
|
||||
Assert.Single(rig.PromotionCalls);
|
||||
Assert.DoesNotContain("min)", rig.PromotionCalls[0].message);
|
||||
Assert.Contains("ESP timed out while still installing", rig.PromotionCalls[0].message);
|
||||
Assert.DoesNotContain("min;", rig.PromotionCalls[0].message);
|
||||
Assert.Contains("ESP gave up while this app was still installing", rig.PromotionCalls[0].message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromoteActiveInstalls_classifies_0x87d1041c_as_detection_failure()
|
||||
{
|
||||
// Session 080edee9: the agent observed HRESULT 0x87D1041C in the failed
|
||||
// DeviceSetup/Apps subcategory ("App not detected after install completed
|
||||
// successfully"). The classifier must NOT label this as a timeout — it is a
|
||||
// confirmed Intune detection-rule mismatch. The errorCode is propagated to the
|
||||
// promotion so the per-app `app_install_failed` event carries DataJson.errorCode
|
||||
// and downstream rules (ANALYZE-APP-013) can correlate.
|
||||
using var rig = new Rig();
|
||||
rig.State = BuildEspTerminalFailureState();
|
||||
rig.PromotionReturnValue = new[] { "office-365" };
|
||||
rig.LastEspTerminalErrorCodeOverride = "0x87d1041c";
|
||||
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
|
||||
|
||||
Assert.Single(rig.PromotionCalls);
|
||||
Assert.Equal(
|
||||
AutopilotMonitor.Shared.Constants.AppFailureTypes.EspAppsDetectionFailure,
|
||||
rig.PromotionCalls[0].failureType);
|
||||
Assert.Contains("detection", rig.PromotionCalls[0].message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("0x87d1041c", rig.PromotionCalls[0].message);
|
||||
Assert.Equal("0x87d1041c", rig.PromotionCalls[0].errorCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PromoteActiveInstalls_classifies_other_hresult_as_install_failure()
|
||||
{
|
||||
// Any HRESULT other than 0x87D1041C means the install itself reported an error
|
||||
// (vs. a detection-rule mismatch). Must NOT be labelled as a timeout.
|
||||
using var rig = new Rig();
|
||||
rig.State = BuildEspTerminalFailureState();
|
||||
rig.PromotionReturnValue = new[] { "vpn-client" };
|
||||
rig.LastEspTerminalErrorCodeOverride = "0x80070643";
|
||||
|
||||
rig.Build().Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
|
||||
|
||||
Assert.Single(rig.PromotionCalls);
|
||||
Assert.Equal(
|
||||
AutopilotMonitor.Shared.Constants.AppFailureTypes.EspAppsInstallFailure,
|
||||
rig.PromotionCalls[0].failureType);
|
||||
Assert.Contains("0x80070643", rig.PromotionCalls[0].message);
|
||||
Assert.Equal("0x80070643", rig.PromotionCalls[0].errorCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// (idempotent at the engine — promoted apps are post-terminal).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PromoteActiveInstallsToStuck(string failureType, string message)
|
||||
public IReadOnlyList<string> PromoteActiveInstallsToStuck(string failureType, string message, string errorCode = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(failureType))
|
||||
throw new ArgumentException("failureType is mandatory.", nameof(failureType));
|
||||
@@ -256,11 +256,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
foreach (var pkg in candidates)
|
||||
{
|
||||
var oldState = pkg.InstallationState;
|
||||
pkg.SetErrorContext(failureType, message ?? string.Empty);
|
||||
pkg.SetErrorContext(failureType, message ?? string.Empty, errorCode);
|
||||
if (pkg.UpdateState(AppInstallationState.Error))
|
||||
{
|
||||
promoted.Add(pkg.Id);
|
||||
_logger?.Info($"ImeLogTracker: promoted '{pkg.Name ?? pkg.Id}' Installing -> Error ({failureType}).");
|
||||
var ecSuffix = string.IsNullOrEmpty(errorCode) ? string.Empty : $", errorCode={errorCode}";
|
||||
_logger?.Info($"ImeLogTracker: promoted '{pkg.Name ?? pkg.Id}' Installing -> Error ({failureType}{ecSuffix}).");
|
||||
OnAppStateChanged?.Invoke(pkg, oldState, AppInstallationState.Error);
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -122,6 +122,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// </summary>
|
||||
public DateTime? LastEventOccurredAtUtc { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Session 080edee9 follow-up (2026-05-28) — last HRESULT observed on an ESP failure
|
||||
/// raised by <see cref="ProvisioningStatusTracker"/> (registry-derived, e.g.
|
||||
/// <c>0x87D1041C</c> from a failed Apps subcategory). Read by
|
||||
/// <c>EnrollmentTerminationHandler</c> via <see cref="EspAndHelloHost"/> +
|
||||
/// <c>DefaultComponentFactory</c> so the late "Installing-→-Error" promotion can
|
||||
/// classify the failure correctly (detection-failure vs. install-failure vs. genuine
|
||||
/// timeout) instead of always claiming an ESP timeout. Null when no
|
||||
/// HRESULT-carrying ESP failure has fired (or all observed failures came from
|
||||
/// ShellCoreTracker, which has no HRESULT surface).
|
||||
/// </summary>
|
||||
public string LastEspTerminalErrorCode { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of Hello provisioning. Set when Hello resolves (via events, timeout, or not configured).
|
||||
/// Values: "completed", "skipped", "timeout", "not_configured", "wizard_not_started", null (not yet resolved).
|
||||
@@ -493,6 +506,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private void OnProvisioningEspFailureDetected(object sender, EspFailureDetectedEventArgs args)
|
||||
{
|
||||
LastEventOccurredAtUtc = null;
|
||||
// Session 080edee9 follow-up — stash the HRESULT before forwarding so
|
||||
// EnrollmentTerminationHandler can read it on the terminal-failure pathway even
|
||||
// if the downstream signal flow is asynchronous. Only updates on non-empty values
|
||||
// so a later ShellCore-derived failure (which carries no HRESULT) cannot wipe
|
||||
// out the previously captured registry value.
|
||||
if (!string.IsNullOrEmpty(args?.ErrorCode))
|
||||
{
|
||||
LastEspTerminalErrorCode = args.ErrorCode;
|
||||
}
|
||||
try
|
||||
{
|
||||
EspFailureDetected?.Invoke(this, args);
|
||||
|
||||
@@ -134,8 +134,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// adapter emits regular <c>app_install_failed</c> events for every promoted app.
|
||||
/// Returns an empty list when the host has not been created yet (start order).
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PromoteActiveInstallsToStuck(string failureType, string message) =>
|
||||
_imeLogHost?.PromoteActiveInstallsToStuck(failureType, message) ?? Array.Empty<string>();
|
||||
public IReadOnlyList<string> PromoteActiveInstallsToStuck(string failureType, string message, string? errorCode = null) =>
|
||||
_imeLogHost?.PromoteActiveInstallsToStuck(failureType, message, errorCode) ?? Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Session 080edee9 follow-up (2026-05-28) — last observed HRESULT from the ESP Apps
|
||||
/// subcategory failure (e.g. <c>0x87D1041C</c>). Surfaced by
|
||||
/// <see cref="EspAndHelloHost.LastEspTerminalErrorCode"/> and read by
|
||||
/// <c>EnrollmentTerminationHandler.MaybePromoteActiveInstallsAsStuck</c> so the
|
||||
/// promotion can classify the failure as <c>esp_apps_detection_failure</c> /
|
||||
/// <c>esp_apps_install_failure</c> instead of always claiming a generic timeout.
|
||||
/// Returns null when no ESP failure with a HRESULT was observed (or the host has not
|
||||
/// been created yet).
|
||||
/// </summary>
|
||||
public string? LastEspTerminalErrorCode => EspAndHelloHost?.LastEspTerminalErrorCode;
|
||||
|
||||
public DefaultComponentFactory(
|
||||
AgentConfiguration agentConfig,
|
||||
|
||||
@@ -28,6 +28,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// </summary>
|
||||
public event EventHandler? WhiteGloveCompleted;
|
||||
|
||||
/// <summary>
|
||||
/// Session 080edee9 follow-up (2026-05-28) — forwards
|
||||
/// <see cref="EspAndHelloTracker.LastEspTerminalErrorCode"/>. Read by
|
||||
/// <see cref="DefaultComponentFactory.LastEspTerminalErrorCode"/> which is in turn
|
||||
/// queried by <c>EnrollmentTerminationHandler.MaybePromoteActiveInstallsAsStuck</c>
|
||||
/// to classify ESP Apps-subcategory failures (detection-failure vs install-failure
|
||||
/// vs genuine timeout). Null until the first HRESULT-carrying ESP failure fires.
|
||||
/// </summary>
|
||||
public string? LastEspTerminalErrorCode => _tracker.LastEspTerminalErrorCode;
|
||||
|
||||
public EspAndHelloHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
|
||||
@@ -59,8 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
/// <c>OnAppStateChanged</c> path emits the per-app <c>app_install_failed</c> events.
|
||||
/// Returns the list of promoted appIds for logging.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PromoteActiveInstallsToStuck(string failureType, string message) =>
|
||||
_tracker.PromoteActiveInstallsToStuck(failureType, message);
|
||||
public IReadOnlyList<string> PromoteActiveInstallsToStuck(string failureType, string message, string? errorCode = null) =>
|
||||
_tracker.PromoteActiveInstallsToStuck(failureType, message, errorCode);
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the tracker's simulation flag so the Dev / Test CLI flag
|
||||
|
||||
+34
-8
@@ -133,7 +133,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
// 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).
|
||||
private readonly Func<string, string, IReadOnlyList<string>> _promoteActiveInstallsToStuck;
|
||||
//
|
||||
// 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 (2026-05-28): accessor returning the latest HRESULT
|
||||
// observed on an ESP failure (typically extracted by
|
||||
// <see cref="ProvisioningStatusTracker.TryExtractErrorCode"/> from the failed
|
||||
// subcategory's <c>statusText</c>). Read once during
|
||||
// <see cref="MaybePromoteActiveInstallsAsStuck"/> and passed to
|
||||
// <see cref="AppFailureTypes.ClassifyEspAppsFailure"/> so the failureType +
|
||||
// message accurately reflect "detection failure" / "install failure" vs. the
|
||||
// generic timeout fallback. Null accessor or null result = no HRESULT → fallback.
|
||||
private readonly Func<string> _lastEspTerminalErrorCodeAccessor;
|
||||
|
||||
private int _handled;
|
||||
|
||||
@@ -162,8 +178,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Action writeCleanExitMarker = null,
|
||||
Func<long> ingressPendingSignalCountAccessor = null,
|
||||
Func<bool> isWhiteGlovePart2Accessor = null,
|
||||
Func<string, string, IReadOnlyList<string>> promoteActiveInstallsToStuck = null,
|
||||
Func<bool> tryClaimShutdownEvent = null)
|
||||
Func<string, string, string, IReadOnlyList<string>> promoteActiveInstallsToStuck = null,
|
||||
Func<bool> tryClaimShutdownEvent = null,
|
||||
Func<string> lastEspTerminalErrorCodeAccessor = null)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -195,6 +212,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
_isWhiteGlovePart2Accessor = isWhiteGlovePart2Accessor;
|
||||
_promoteActiveInstallsToStuck = promoteActiveInstallsToStuck;
|
||||
_tryClaimShutdownEvent = tryClaimShutdownEvent;
|
||||
_lastEspTerminalErrorCodeAccessor = lastEspTerminalErrorCodeAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -517,15 +535,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
try
|
||||
{
|
||||
var timeoutMinutes = state.ScenarioObservations?.EspSyncFailureTimeoutMinutes?.Value;
|
||||
var message = timeoutMinutes.HasValue
|
||||
? $"Install status unconfirmed — ESP timed out ({timeoutMinutes.Value} min) while still installing."
|
||||
: "Install status unconfirmed — ESP timed out while still installing.";
|
||||
var promoted = _promoteActiveInstallsToStuck(AppFailureTypes.EspAppsTimeout, message);
|
||||
string errorCode = null;
|
||||
try { errorCode = _lastEspTerminalErrorCodeAccessor?.Invoke(); }
|
||||
catch (Exception ex) { _logger.Warning($"EnrollmentTerminationHandler: lastEspTerminalErrorCodeAccessor threw: {ex.Message}"); }
|
||||
|
||||
// Session 080edee9 follow-up (2026-05-28): classify the ESP Apps-subcategory
|
||||
// failure via the shared helper so the failureType + message accurately
|
||||
// reflect the HRESULT (detection-failure / install-failure) instead of
|
||||
// claiming a timeout for every ESP terminal pathway.
|
||||
var (failureType, message) = AppFailureTypes.ClassifyEspAppsFailure(errorCode, timeoutMinutes);
|
||||
|
||||
var promoted = _promoteActiveInstallsToStuck(failureType, message, errorCode);
|
||||
var count = promoted?.Count ?? 0;
|
||||
if (count > 0)
|
||||
{
|
||||
var ecSuffix = string.IsNullOrEmpty(errorCode) ? string.Empty : $", errorCode={errorCode}";
|
||||
_logger.Info(
|
||||
$"EnrollmentTerminationHandler: promoted {count} Installing app(s) to likely-stuck (failureType={AppFailureTypes.EspAppsTimeout}).");
|
||||
$"EnrollmentTerminationHandler: promoted {count} Installing app(s) to Error (failureType={failureType}{ecSuffix}).");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -395,14 +395,22 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
// counter. Discriminator inside the handler gates this to the
|
||||
// EspTerminalFailure pathway only — other Failed paths leave the
|
||||
// app list untouched.
|
||||
promoteActiveInstallsToStuck: (failureType, message)
|
||||
=> componentFactory.PromoteActiveInstallsToStuck(failureType, message),
|
||||
promoteActiveInstallsToStuck: (failureType, message, errorCode)
|
||||
=> componentFactory.PromoteActiveInstallsToStuck(failureType, message, errorCode),
|
||||
// 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);
|
||||
tryClaimShutdownEvent: TryClaimAgentShuttingDownEmit,
|
||||
// Session 080edee9 follow-up (2026-05-28) — feeds the latest
|
||||
// HRESULT captured by ProvisioningStatusTracker (via
|
||||
// EspAndHelloTracker.LastEspTerminalErrorCode) into the
|
||||
// termination handler's classifier so promoted apps land
|
||||
// with the right failureType (esp_apps_detection_failure /
|
||||
// esp_apps_install_failure) instead of always claiming a
|
||||
// generic timeout.
|
||||
lastEspTerminalErrorCodeAccessor: () => componentFactory.LastEspTerminalErrorCode);
|
||||
|
||||
// ServerActionDispatcher (plan §5.3) — constructed inside this
|
||||
// hook so lifecyclePost + terminationHandler are guaranteed
|
||||
|
||||
@@ -1 +1 @@
|
||||
898
|
||||
902
|
||||
|
||||
@@ -1 +1 @@
|
||||
1405
|
||||
1409
|
||||
|
||||
@@ -354,17 +354,90 @@ namespace AutopilotMonitor.Shared
|
||||
public static class AppFailureTypes
|
||||
{
|
||||
/// <summary>
|
||||
/// The Enrollment Status Page Apps-subcategory timed out while this app was still
|
||||
/// in an active install state. The agent cannot tell whether the install ultimately
|
||||
/// would have succeeded, failed, or hung — the ESP gave up first. UI renders these
|
||||
/// as "likely stuck" rather than confirmed failures.
|
||||
/// The Enrollment Status Page Apps-subcategory failed with no per-app HRESULT
|
||||
/// available, so the agent cannot tell whether the install ultimately would have
|
||||
/// succeeded, failed, or hung. UI renders these as "likely stuck" rather than
|
||||
/// confirmed failures.
|
||||
/// <para>
|
||||
/// Emitted only by the V2 EnrollmentTerminationHandler on the terminal-ESP-failure
|
||||
/// path. Apps with this failureType always carry <c>confidence: "presumed"</c> in
|
||||
/// their event payload.
|
||||
/// path when <see cref="ClassifyEspAppsFailure"/> falls through to this fallback.
|
||||
/// Apps with this failureType always carry <c>confidence: "presumed"</c> in their
|
||||
/// event payload.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public const string EspAppsTimeout = "esp_apps_timeout";
|
||||
|
||||
/// <summary>
|
||||
/// The ESP Apps-subcategory failed with HRESULT <c>0x87D1041C</c> — "Application
|
||||
/// not detected after installation completed successfully". The installer ran
|
||||
/// (often to completion) but the Intune detection rule could not verify the app.
|
||||
/// This is a confirmed detection failure, NOT a timeout — UI renders these in red
|
||||
/// ("Detection failed") instead of orange ("Likely stuck").
|
||||
/// </summary>
|
||||
public const string EspAppsDetectionFailure = "esp_apps_detection_failure";
|
||||
|
||||
/// <summary>
|
||||
/// The ESP Apps-subcategory failed with a non-detection HRESULT (anything other
|
||||
/// than <c>0x87D1041C</c>). The app install itself reported an error. UI renders
|
||||
/// these as a confirmed "Install failed" in red.
|
||||
/// </summary>
|
||||
public const string EspAppsInstallFailure = "esp_apps_install_failure";
|
||||
|
||||
/// <summary>
|
||||
/// Windows HRESULT for "Application not detected after installation completed
|
||||
/// successfully" — the canonical marker for an Intune detection-rule mismatch
|
||||
/// surfacing on the ESP Apps subcategory. Lower-case so equality checks against
|
||||
/// <see cref="ProvisioningStatusTracker.TryExtractErrorCode"/> output line up.
|
||||
/// </summary>
|
||||
public const string DetectionFailureHResult = "0x87d1041c";
|
||||
|
||||
/// <summary>
|
||||
/// Maps an ESP Apps-subcategory failure to a canonical <see cref="AppFailureTypes"/>
|
||||
/// identifier + a human-readable app-install message based on the HRESULT carried
|
||||
/// by the failed subcategory.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>0x87D1041C</c> → <see cref="EspAppsDetectionFailure"/> (install
|
||||
/// ran but Intune could not detect the app afterwards).</item>
|
||||
/// <item>Any other non-empty HRESULT → <see cref="EspAppsInstallFailure"/>
|
||||
/// (the install itself reported an error).</item>
|
||||
/// <item>No HRESULT available → <see cref="EspAppsTimeout"/> (fallback for
|
||||
/// genuine timeout cases; the ESP gave up without a per-app verdict).
|
||||
/// <paramref name="espTimeoutMinutes"/> is only mentioned in the message
|
||||
/// on this branch — quoting it elsewhere is misleading because the ESP
|
||||
/// did not necessarily run out the configured clock.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static (string FailureType, string Message) ClassifyEspAppsFailure(
|
||||
string errorCode, int? espTimeoutMinutes)
|
||||
{
|
||||
var normalised = string.IsNullOrEmpty(errorCode) ? null : errorCode.ToLowerInvariant();
|
||||
|
||||
if (string.Equals(normalised, DetectionFailureHResult, System.StringComparison.Ordinal))
|
||||
{
|
||||
return (
|
||||
EspAppsDetectionFailure,
|
||||
$"Install completed but Intune detection rule did not find the app afterwards (HRESULT {normalised}).");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(normalised))
|
||||
{
|
||||
return (
|
||||
EspAppsInstallFailure,
|
||||
$"ESP reported an Apps-subcategory failure (HRESULT {normalised}) before this app finished installing.");
|
||||
}
|
||||
|
||||
if (espTimeoutMinutes.HasValue)
|
||||
{
|
||||
return (
|
||||
EspAppsTimeout,
|
||||
$"Install status unconfirmed — ESP gave up while this app was still installing " +
|
||||
$"(ESP timeout was configured at {espTimeoutMinutes.Value} min; actual elapsed time may be lower).");
|
||||
}
|
||||
|
||||
return (
|
||||
EspAppsTimeout,
|
||||
"Install status unconfirmed — ESP gave up while this app was still installing.");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user