diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/AppFailureTypesClassifierTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/AppFailureTypesClassifierTests.cs new file mode 100644 index 00000000..6a84a239 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/AppFailureTypesClassifierTests.cs @@ -0,0 +1,82 @@ +using AutopilotMonitor.Shared; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination +{ + /// + /// Session 080edee9 follow-up (2026-05-28) — coverage for + /// . 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. + /// + 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); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs index b298edfd..a1443801 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Termination/EnrollmentTerminationHandlerTests.cs @@ -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 PromotionReturnValue { get; set; } = Array.Empty(); + // 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 TerminationActionLog { get; } = new List(); 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); } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs index 142a4988..a1006ea2 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs @@ -241,7 +241,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime /// (idempotent at the engine — promoted apps are post-terminal). /// /// - public IReadOnlyList PromoteActiveInstallsToStuck(string failureType, string message) + public IReadOnlyList 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); } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs index 3daa0468..1de55212 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs @@ -122,6 +122,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// public DateTime? LastEventOccurredAtUtc { get; private set; } + /// + /// Session 080edee9 follow-up (2026-05-28) — last HRESULT observed on an ESP failure + /// raised by (registry-derived, e.g. + /// 0x87D1041C from a failed Apps subcategory). Read by + /// EnrollmentTerminationHandler via + + /// DefaultComponentFactory 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). + /// + public string LastEspTerminalErrorCode { get; private set; } + /// /// 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); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 5ce1dec2..3ba79872 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -134,8 +134,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// adapter emits regular app_install_failed events for every promoted app. /// Returns an empty list when the host has not been created yet (start order). /// - public IReadOnlyList PromoteActiveInstallsToStuck(string failureType, string message) => - _imeLogHost?.PromoteActiveInstallsToStuck(failureType, message) ?? Array.Empty(); + public IReadOnlyList PromoteActiveInstallsToStuck(string failureType, string message, string? errorCode = null) => + _imeLogHost?.PromoteActiveInstallsToStuck(failureType, message, errorCode) ?? Array.Empty(); + + /// + /// Session 080edee9 follow-up (2026-05-28) — last observed HRESULT from the ESP Apps + /// subcategory failure (e.g. 0x87D1041C). Surfaced by + /// and read by + /// EnrollmentTerminationHandler.MaybePromoteActiveInstallsAsStuck so the + /// promotion can classify the failure as esp_apps_detection_failure / + /// esp_apps_install_failure 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). + /// + public string? LastEspTerminalErrorCode => EspAndHelloHost?.LastEspTerminalErrorCode; public DefaultComponentFactory( AgentConfiguration agentConfig, diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs index b2c9bd82..32c7627f 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/EspAndHelloHost.cs @@ -28,6 +28,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// public event EventHandler? WhiteGloveCompleted; + /// + /// Session 080edee9 follow-up (2026-05-28) — forwards + /// . Read by + /// which is in turn + /// queried by EnrollmentTerminationHandler.MaybePromoteActiveInstallsAsStuck + /// to classify ESP Apps-subcategory failures (detection-failure vs install-failure + /// vs genuine timeout). Null until the first HRESULT-carrying ESP failure fires. + /// + public string? LastEspTerminalErrorCode => _tracker.LastEspTerminalErrorCode; + public EspAndHelloHost( string sessionId, string tenantId, diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeLogHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeLogHost.cs index 7487b0e6..409d1dca 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeLogHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeLogHost.cs @@ -59,8 +59,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration /// OnAppStateChanged path emits the per-app app_install_failed events. /// Returns the list of promoted appIds for logging. /// - public IReadOnlyList PromoteActiveInstallsToStuck(string failureType, string message) => - _tracker.PromoteActiveInstallsToStuck(failureType, message); + public IReadOnlyList PromoteActiveInstallsToStuck(string failureType, string message, string? errorCode = null) => + _tracker.PromoteActiveInstallsToStuck(failureType, message, errorCode); /// /// Exposes the tracker's simulation flag so the Dev / Test CLI flag diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs index bd6c0ab3..a36b0296 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Termination/EnrollmentTerminationHandler.cs @@ -133,7 +133,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination // ONLY when the discriminator in // matches — every other terminal path leaves app states untouched. Returns the list // of promoted appIds for logging; null = legacy (no promotion). - private readonly Func> _promoteActiveInstallsToStuck; + // + // Session 080edee9 follow-up (2026-05-28): third parameter carries the HRESULT from + // the failed Apps subcategory (e.g. 0x87D1041C) when available, so the + // promotion can stamp + emit an enriched + // app_install_failed event. Null = no HRESULT observed → fallback to the + // legacy "esp_apps_timeout" classification. + private readonly Func> _promoteActiveInstallsToStuck; + + // Session 080edee9 follow-up (2026-05-28): accessor returning the latest HRESULT + // observed on an ESP failure (typically extracted by + // from the failed + // subcategory's statusText). Read once during + // and passed to + // 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 _lastEspTerminalErrorCodeAccessor; private int _handled; @@ -162,8 +178,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination Action writeCleanExitMarker = null, Func ingressPendingSignalCountAccessor = null, Func isWhiteGlovePart2Accessor = null, - Func> promoteActiveInstallsToStuck = null, - Func tryClaimShutdownEvent = null) + Func> promoteActiveInstallsToStuck = null, + Func tryClaimShutdownEvent = null, + Func 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; } /// @@ -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 { diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs index 18beb883..db143a1e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs @@ -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 diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index a2fa28f5..ed8cc244 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -898 +902 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 504121e7..08507bb2 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1405 +1409 diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 6ae5de96..b115a451 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -354,17 +354,90 @@ namespace AutopilotMonitor.Shared public static class AppFailureTypes { /// - /// 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. /// /// Emitted only by the V2 EnrollmentTerminationHandler on the terminal-ESP-failure - /// path. Apps with this failureType always carry confidence: "presumed" in - /// their event payload. + /// path when falls through to this fallback. + /// Apps with this failureType always carry confidence: "presumed" in their + /// event payload. /// /// public const string EspAppsTimeout = "esp_apps_timeout"; + + /// + /// The ESP Apps-subcategory failed with HRESULT 0x87D1041C — "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"). + /// + public const string EspAppsDetectionFailure = "esp_apps_detection_failure"; + + /// + /// The ESP Apps-subcategory failed with a non-detection HRESULT (anything other + /// than 0x87D1041C). The app install itself reported an error. UI renders + /// these as a confirmed "Install failed" in red. + /// + public const string EspAppsInstallFailure = "esp_apps_install_failure"; + + /// + /// 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 + /// output line up. + /// + public const string DetectionFailureHResult = "0x87d1041c"; + + /// + /// Maps an ESP Apps-subcategory failure to a canonical + /// identifier + a human-readable app-install message based on the HRESULT carried + /// by the failed subcategory. + /// + /// 0x87D1041C → (install + /// ran but Intune could not detect the app afterwards). + /// Any other non-empty HRESULT → + /// (the install itself reported an error). + /// No HRESULT available → (fallback for + /// genuine timeout cases; the ESP gave up without a per-app verdict). + /// 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. + /// + /// + 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."); + } } // -----------------------------------------------------------------------