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:
Oliver Kieselbach
2026-05-28 13:41:07 +02:00
parent 845fd862a1
commit 4dd050e05e
12 changed files with 333 additions and 36 deletions
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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
@@ -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.");
}
}
// -----------------------------------------------------------------------