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.");
+ }
}
// -----------------------------------------------------------------------