EspFailureDetected;
public event EventHandler DeviceSetupProvisioningComplete;
///
/// Fires once when AccountSetupCategory.Status resolves to
@@ -235,6 +256,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_provisioningFailureFired = new HashSet(StringComparer.OrdinalIgnoreCase);
_provisioningCategoriesResolved = new HashSet(StringComparer.OrdinalIgnoreCase);
_lastSubcategoryStates = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ _provisioningFailureSettleTimers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ _provisioningFailureSettleArgs = new Dictionary(StringComparer.OrdinalIgnoreCase);
_deviceSetupProvisioningCompleteFired = false;
_deviceSetupFallbackTimer = null;
_accountSetupProvisioningCompleteFired = false;
@@ -275,6 +298,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_accountSetupFallbackTimer?.Dispose();
_accountSetupFallbackTimer = null;
+ if (_provisioningFailureSettleTimers != null)
+ {
+ foreach (var t in _provisioningFailureSettleTimers.Values)
+ {
+ try { t?.Dispose(); } catch { /* swallow */ }
+ }
+ _provisioningFailureSettleTimers.Clear();
+ }
+
if (_provisioningWatcher != null)
{
_provisioningWatcher.Dispose();
@@ -354,8 +386,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
return;
}
- bool failureDetected = false;
- string failureType = null;
int changedCount = 0, unchangedCount = 0, missingCount = 0;
foreach (var categoryName in ProvisioningCategoryNames)
@@ -380,13 +410,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_logger.Trace($"CheckProvisioningStatus: {categoryName} — raw JSON: {jsonValue}");
_lastProvisioningJson[categoryName] = jsonValue;
- var result = ProcessCategoryStatus(categoryName, jsonValue);
-
- if (result.IsFailed)
- {
- failureDetected = true;
- failureType = result.FailureType;
- }
+ // TryFireProvisioningFailure (inside ProcessCategoryStatus) arms the
+ // 30 s settle timer when a failure is detected — the actual
+ // EspFailureDetected fire happens in OnProvisioningFailureSettleExpired.
+ ProcessCategoryStatus(categoryName, jsonValue);
}
_logger.Trace($"CheckProvisioningStatus: summary — changed={changedCount}, unchanged={unchangedCount}, missing={missingCount}, " +
@@ -403,13 +430,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_provisioningWatcher?.RequestStop();
}
- // Fire EspFailureDetected AFTER event emission (event in spool before agent reacts)
- if (failureDetected && failureType != null)
- {
- try { EspFailureDetected?.Invoke(this, failureType); }
- catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{failureType}'", ex); }
- }
-
// Fire DeviceSetupProvisioningComplete when DeviceSetup resolves with success.
if (!_deviceSetupProvisioningCompleteFired
&& _lastCategorySucceeded.TryGetValue("DeviceSetupCategory.Status", out var dsSucceeded)
@@ -957,16 +977,150 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_provisioningFailureFired.Add(categoryName);
- var failedSubcategory = FindFailedSubcategory(subcategories);
- var failureTypeName = failedSubcategory != null
- ? $"Provisioning_{categoryLabel}_{failedSubcategory}_Failed"
+ var failedSubcategoryInfo = FindFailedSubcategoryInfo(subcategories);
+ var failedSubcategoryName = failedSubcategoryInfo?.Name;
+ var failureTypeName = failedSubcategoryName != null
+ ? $"Provisioning_{categoryLabel}_{failedSubcategoryName}_Failed"
: $"Provisioning_{categoryLabel}_Failed";
- _logger.Info($"Provisioning subcategory failure: {categoryLabel}/{failedSubcategory ?? "category-level"} — escalating as {failureTypeName}");
+ var errorCode = TryExtractErrorCode(failedSubcategoryInfo?.StatusText);
+
+ _logger.Info(
+ $"Provisioning subcategory failure: {categoryLabel}/{failedSubcategoryName ?? "category-level"} — " +
+ $"escalating as {failureTypeName} (errorCode={errorCode ?? "n/a"})");
_logger.Warning($"Provisioning failure detected: {failureTypeName}");
- return ProvisioningResult.Failure(failureTypeName);
+
+ // Caller already holds _stateLock.
+ ArmFailureSettleTimer(
+ categoryName: categoryName,
+ args: new EspFailureDetectedEventArgs(
+ failureType: failureTypeName,
+ errorCode: errorCode,
+ failedSubcategory: failedSubcategoryName,
+ category: categoryLabel));
+
+ // Settle-window owns the actual EspFailureDetected fire. The legacy "fire-after-emit"
+ // path in CheckProvisioningStatus is now a no-op for this code-path; it stays in place
+ // only for direct callers that may still expect a synchronous failure-type string.
+ return ProvisioningResult.NoAction;
}
+ ///
+ /// Session 9d052230 fix: arms a 30 s settle timer per category. While the timer is
+ /// pending, ImeLogTracker can still surface app_install_failed events with hresult
+ /// capture so the session timeline carries the underlying app-level failure that ESP
+ /// aggregated into a single "Apps: failed (0x…)" registry write. On expiry the
+ /// EspFailureDetected event is fired with the enriched args.
+ ///
+ /// Caller holds . Pending args are stored under the same lock
+ /// so the timer callback re-acquires it before invocation. A single per-session emit
+ /// of esp_failure_settle_started is sufficient — fire-once is gated by
+ /// which TryFireProvisioningFailure checks.
+ ///
+ ///
+ private void ArmFailureSettleTimer(string categoryName, EspFailureDetectedEventArgs args)
+ {
+ _provisioningFailureSettleArgs[categoryName] = args;
+
+ EmitFailureSettleStarted(args);
+
+ var timer = new Timer(
+ _ => OnProvisioningFailureSettleExpired(categoryName),
+ state: null,
+ dueTime: TimeSpan.FromSeconds(ProvisioningFailureSettleWindowSeconds),
+ period: Timeout.InfiniteTimeSpan);
+
+ _provisioningFailureSettleTimers[categoryName] = timer;
+
+ _logger.Info(
+ $"Provisioning failure settle window armed: category={args.Category}, " +
+ $"failedSubcategory={args.FailedSubcategory ?? "n/a"}, errorCode={args.ErrorCode ?? "n/a"}, " +
+ $"settleSeconds={ProvisioningFailureSettleWindowSeconds}");
+ }
+
+ private void OnProvisioningFailureSettleExpired(string categoryName)
+ {
+ EspFailureDetectedEventArgs args = null;
+ try
+ {
+ lock (_stateLock)
+ {
+ if (_provisioningFailureSettleArgs == null
+ || !_provisioningFailureSettleArgs.TryGetValue(categoryName, out args))
+ {
+ _logger.Debug($"Failure settle timer expired but no pending args for {categoryName} — ignoring");
+ return;
+ }
+ _provisioningFailureSettleArgs.Remove(categoryName);
+
+ if (_provisioningFailureSettleTimers != null
+ && _provisioningFailureSettleTimers.TryGetValue(categoryName, out var timer))
+ {
+ try { timer?.Dispose(); } catch { /* swallow */ }
+ _provisioningFailureSettleTimers.Remove(categoryName);
+ }
+ }
+
+ _logger.Info(
+ $"Provisioning failure settle window elapsed — firing EspFailureDetected: " +
+ $"category={args.Category}, failedSubcategory={args.FailedSubcategory ?? "n/a"}, " +
+ $"errorCode={args.ErrorCode ?? "n/a"}, failureType={args.FailureType}");
+
+ try { EspFailureDetected?.Invoke(this, args); }
+ catch (Exception ex) { _logger.Error($"EspFailureDetected handler failed for '{args.FailureType}'", ex); }
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("OnProvisioningFailureSettleExpired callback failed", ex);
+ }
+ }
+
+ private void EmitFailureSettleStarted(EspFailureDetectedEventArgs args)
+ {
+ var data = new Dictionary
+ {
+ { "category", args.Category ?? "unknown" },
+ { "failedSubcategory", args.FailedSubcategory ?? "category-level" },
+ { "failureType", args.FailureType },
+ { "settleSeconds", ProvisioningFailureSettleWindowSeconds },
+ { "reason", "wait_for_late_ime_signals" }
+ };
+ if (!string.IsNullOrEmpty(args.ErrorCode))
+ data["errorCode"] = args.ErrorCode;
+
+ _post.Emit(new EnrollmentEvent
+ {
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ EventType = "esp_failure_settle_started",
+ Severity = EventSeverity.Info,
+ Source = "EspAndHelloTracker",
+ Phase = EnrollmentPhase.Unknown,
+ Message = $"ESP failure settle window started ({ProvisioningFailureSettleWindowSeconds}s) — " +
+ $"{args.Category}/{args.FailedSubcategory ?? "category-level"}" +
+ (string.IsNullOrEmpty(args.ErrorCode) ? "" : $" (errorCode={args.ErrorCode})"),
+ Data = data
+ });
+ }
+
+ ///
+ /// Extracts a Windows HRESULT (e.g. 0x87d1041c) from a subcategory statusText.
+ /// Returns the normalised lower-case hex form with 0x prefix, or null when no
+ /// HRESULT pattern is present. Pattern is language-invariant (parenthesised hex tail).
+ ///
+ internal static string TryExtractErrorCode(string statusText)
+ {
+ if (string.IsNullOrEmpty(statusText)) return null;
+ var match = HResultPattern.Match(statusText);
+ if (!match.Success) return null;
+ var raw = match.Groups[1].Value;
+ return "0x" + raw.Substring(2).ToLowerInvariant();
+ }
+
+ /// Test seam — drive the settle-timer callback synchronously.
+ internal void TriggerSettleTimerForTest(string categoryName)
+ => OnProvisioningFailureSettleExpired(categoryName);
+
private bool HasCategorySucceededChanged(string categoryName, bool? newValue)
{
if (!_lastCategorySucceeded.TryGetValue(categoryName, out var oldValue))
@@ -1069,6 +1223,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (failedNames.Count > 0)
{
eventData["failedSubcategories"] = string.Join(",", failedNames);
+
+ // Session 9d052230: surface HRESULT from the first failed subcategory's
+ // statusText at top-level so the UI / RuleEngine can match it without
+ // parsing nested statusText strings. Pattern is language-invariant.
+ var firstFailedSub = subcategories
+ .FirstOrDefault(s => string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase));
+ var firstFailedCode = TryExtractErrorCode(firstFailedSub?.StatusText);
+ if (!string.IsNullOrEmpty(firstFailedCode))
+ {
+ eventData["failedSubcategoryErrorCode"] = firstFailedCode;
+ }
}
}
@@ -1204,11 +1369,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
internal static string FindFailedSubcategory(List subcategories)
+ => FindFailedSubcategoryInfo(subcategories)?.Name;
+
+ internal static SubcategoryInfo FindFailedSubcategoryInfo(List subcategories)
{
foreach (var sub in subcategories)
{
if (string.Equals(sub.State, "failed", StringComparison.OrdinalIgnoreCase))
- return sub.Name;
+ return sub;
}
return null;
}
@@ -1324,6 +1492,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
_provisioningCategoriesResolved = new HashSet(StringComparer.OrdinalIgnoreCase);
if (_lastSubcategoryStates == null)
_lastSubcategoryStates = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ if (_provisioningFailureSettleTimers == null)
+ _provisioningFailureSettleTimers = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (_provisioningFailureSettleArgs == null)
+ _provisioningFailureSettleArgs = new Dictionary(StringComparer.OrdinalIgnoreCase);
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs
index bca9bea5..72366891 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs
@@ -89,7 +89,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private void OnHelloCompleted(object sender, EventArgs e) => EmitHello(ReadHelloOutcome());
private void OnFinalizing(object sender, string reason) => EmitFinalizing(reason);
private void OnWhiteGloveCompleted(object sender, EventArgs e) => EmitWhiteGlove();
- private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType);
+ private void OnEspFailure(object sender, EspFailureDetectedEventArgs args) => EmitEspFailure(args);
private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete();
private void OnAccountSetupComplete(object sender, EventArgs e) => EmitAccountSetupComplete();
private void OnHelloPolicyDetected(bool helloEnabled, string source) => EmitHelloPolicy(helloEnabled, source);
@@ -98,7 +98,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
internal void TriggerHelloFromTest(string? helloOutcome) => EmitHello(helloOutcome);
internal void TriggerFinalizingFromTest(string reason) => EmitFinalizing(reason);
internal void TriggerWhiteGloveFromTest() => EmitWhiteGlove();
- internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType);
+ internal void TriggerEspFailureFromTest(string failureType)
+ => EmitEspFailure(new EspFailureDetectedEventArgs(failureType));
+ internal void TriggerEspFailureFromTest(EspFailureDetectedEventArgs args) => EmitEspFailure(args);
internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete();
internal void TriggerAccountSetupCompleteFromTest() => EmitAccountSetupComplete();
internal void TriggerHelloPolicyDetectedFromTest(bool helloEnabled, string source) => EmitHelloPolicy(helloEnabled, source);
@@ -226,20 +228,41 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
derivationInputs: derivationInputs));
}
- private void EmitEspFailure(string failureType)
+ private void EmitEspFailure(EspFailureDetectedEventArgs args)
{
if (_espFailurePosted) return;
_espFailurePosted = true;
- var safeType = string.IsNullOrEmpty(failureType) ? "unknown" : failureType!;
+ var safeType = args?.FailureType ?? "unknown";
+ var errorCode = args?.ErrorCode;
+ var failedSubcategory = args?.FailedSubcategory;
+ var category = args?.Category;
+
var now = ResolveOccurredAt(out var derivedFromClock);
var derivationInputs = new Dictionary(StringComparer.Ordinal)
{
["subSource"] = "ShellCoreTracker+ProvisioningStatusTracker (merged)",
["failureType"] = safeType,
};
+ if (!string.IsNullOrEmpty(errorCode))
+ derivationInputs["errorCode"] = errorCode!;
+ if (!string.IsNullOrEmpty(failedSubcategory))
+ derivationInputs["failedSubcategory"] = failedSubcategory!;
+ if (!string.IsNullOrEmpty(category))
+ derivationInputs["category"] = category!;
TagDerivedTimestamp(derivationInputs, derivedFromClock);
+ var payload = new Dictionary(StringComparer.Ordinal)
+ {
+ ["failureType"] = safeType,
+ };
+ if (!string.IsNullOrEmpty(errorCode))
+ payload["errorCode"] = errorCode!;
+ if (!string.IsNullOrEmpty(failedSubcategory))
+ payload["failedSubcategory"] = failedSubcategory!;
+ if (!string.IsNullOrEmpty(category))
+ payload["category"] = category!;
+
_ingress.Post(
kind: DecisionSignalKind.EspTerminalFailure,
occurredAtUtc: now,
@@ -247,12 +270,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: DetectorId,
- summary: $"ESP terminal failure (coordinator-merged, type={safeType})",
+ summary: $"ESP terminal failure (coordinator-merged, type={safeType}, errorCode={errorCode ?? "n/a"})",
derivationInputs: derivationInputs),
- payload: new Dictionary(StringComparer.Ordinal)
- {
- ["failureType"] = safeType,
- });
+ payload: payload);
}
// PR4 (882fef64 debrief) — once per session: post HelloPolicyDetected so the engine
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs
index 59192e41..afe6d79f 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/ProvisioningStatusTrackerAdapter.cs
@@ -59,11 +59,13 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete();
private void OnAccountSetupComplete(object sender, EventArgs e) => EmitAccountSetupComplete();
- private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType);
+ private void OnEspFailure(object sender, EspFailureDetectedEventArgs args) => EmitEspFailure(args);
internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete();
internal void TriggerAccountSetupCompleteFromTest() => EmitAccountSetupComplete();
- internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType);
+ internal void TriggerEspFailureFromTest(string failureType)
+ => EmitEspFailure(new EspFailureDetectedEventArgs(failureType));
+ internal void TriggerEspFailureFromTest(EspFailureDetectedEventArgs args) => EmitEspFailure(args);
private void EmitDeviceSetupComplete()
{
@@ -123,12 +125,38 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
});
}
- private void EmitEspFailure(string failureType)
+ private void EmitEspFailure(EspFailureDetectedEventArgs args)
{
if (_espFailurePosted) return;
_espFailurePosted = true;
- var safeFailureType = string.IsNullOrEmpty(failureType) ? "unknown" : failureType!;
+ var safeFailureType = args?.FailureType ?? "unknown";
+ var errorCode = args?.ErrorCode;
+ var failedSubcategory = args?.FailedSubcategory;
+ var category = args?.Category;
+
+ var derivationInputs = new Dictionary(StringComparer.Ordinal)
+ {
+ ["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\*.Status",
+ ["failureType"] = safeFailureType,
+ };
+ if (!string.IsNullOrEmpty(errorCode))
+ derivationInputs["errorCode"] = errorCode!;
+ if (!string.IsNullOrEmpty(failedSubcategory))
+ derivationInputs["failedSubcategory"] = failedSubcategory!;
+ if (!string.IsNullOrEmpty(category))
+ derivationInputs["category"] = category!;
+
+ var payload = new Dictionary(StringComparer.Ordinal)
+ {
+ ["failureType"] = safeFailureType,
+ };
+ if (!string.IsNullOrEmpty(errorCode))
+ payload["errorCode"] = errorCode!;
+ if (!string.IsNullOrEmpty(failedSubcategory))
+ payload["failedSubcategory"] = failedSubcategory!;
+ if (!string.IsNullOrEmpty(category))
+ payload["category"] = category!;
_ingress.Post(
kind: DecisionSignalKind.EspTerminalFailure,
@@ -137,16 +165,9 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "provisioning-status-tracker-v1",
- summary: $"ESP terminal failure from provisioning registry (type={safeFailureType})",
- derivationInputs: new Dictionary(StringComparer.Ordinal)
- {
- ["registrySource"] = @"HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\*.Status",
- ["failureType"] = safeFailureType,
- }),
- payload: new Dictionary(StringComparer.Ordinal)
- {
- ["failureType"] = safeFailureType,
- });
+ summary: $"ESP terminal failure from provisioning registry (type={safeFailureType}, errorCode={errorCode ?? "n/a"})",
+ derivationInputs: derivationInputs),
+ payload: payload);
}
}
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 1cc5be4f..dca34d14 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-838
+842
diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs
index 54451d8b..afd08c96 100644
--- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs
+++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionAuditTrailTests.cs
@@ -332,6 +332,66 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal("policy_apply_timeout", payload["reason"]);
}
+ ///
+ /// Session 9d052230 regression — when the adapter posts a registry-derived ESP failure
+ /// with HRESULT enrichment, the terminal enrollment_failed event MUST carry
+ /// errorCode, failureType, failedSubcategory, and category
+ /// on the effect parameters so the UI can render the HRESULT badge + description.
+ ///
+ [Fact]
+ public void EspTerminalFailure_surfaces_errorCode_and_failedSubcategory_on_terminal_event()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("sess-esp-hresult", "tenant-esp-hresult");
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+
+ var step = engine.Reduce(state, MakeSignal(
+ 40, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(15),
+ new Dictionary
+ {
+ ["failureType"] = "Provisioning_DeviceSetup_Apps_Failed",
+ ["errorCode"] = "0x87d1041c",
+ ["failedSubcategory"] = "Apps",
+ ["category"] = "DeviceSetup",
+ }));
+
+ Assert.Equal(SessionStage.Failed, step.NewState.Stage);
+
+ var effect = SingleTimelineEffect(step, "enrollment_failed");
+ Assert.NotNull(effect.Parameters);
+
+ // reason defaults to the stable enum literal because the signal did not override it.
+ Assert.Equal("esp_terminal_failure", effect.Parameters!["reason"]);
+ Assert.Equal("Provisioning_DeviceSetup_Apps_Failed", effect.Parameters!["failureType"]);
+ Assert.Equal("0x87d1041c", effect.Parameters!["errorCode"]);
+ Assert.Equal("Apps", effect.Parameters!["failedSubcategory"]);
+ Assert.Equal("DeviceSetup", effect.Parameters!["category"]);
+ }
+
+ ///
+ /// Backwards-compat — a signal without HRESULT enrichment (e.g. ShellCore-derived) must
+ /// still produce a valid enrollment_failed event with just the default reason.
+ ///
+ [Fact]
+ public void EspTerminalFailure_works_without_errorCode_payload()
+ {
+ var engine = new DecisionEngine();
+ var state = DecisionState.CreateInitial("sess-esp-bare", "tenant-esp-bare");
+ state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
+
+ var step = engine.Reduce(state, MakeSignal(
+ 40, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(15),
+ new Dictionary { ["failureType"] = "ESPProgress_Timeout" }));
+
+ var effect = SingleTimelineEffect(step, "enrollment_failed");
+ Assert.NotNull(effect.Parameters);
+
+ Assert.Equal("esp_terminal_failure", effect.Parameters!["reason"]);
+ Assert.Equal("ESPProgress_Timeout", effect.Parameters!["failureType"]);
+ Assert.False(effect.Parameters!.ContainsKey("errorCode"));
+ Assert.False(effect.Parameters!.ContainsKey("failedSubcategory"));
+ }
+
[Fact]
public void EspTerminalFailure_event_enriched_with_continueAnyway_and_timeout_facts()
{
diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
index 75e38f31..a7e28f98 100644
--- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
+++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
@@ -46,12 +46,24 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// to with ,
/// clears deadlines, and emits enrollment_failed. No classifier re-run —
/// ESP terminal failure is definitive.
+ ///
+ /// The terminal-event reason is always the stable enum literal esp_terminal_failure.
+ /// Session 9d052230: registry-derived failures additionally carry failureType,
+ /// errorCode (HRESULT), failedSubcategory, and category on the signal
+ /// payload — those propagate verbatim to the timeline-event parameters so the UI can
+ /// render the HRESULT badge + description without re-parsing the ESP statusText.
+ ///
///
private DecisionStep HandleEspTerminalFailureV1(DecisionState state, DecisionSignal signal)
{
var nextStep = state.StepIndex + 1;
- var reason = signal.Payload != null && signal.Payload.TryGetValue("reason", out var r)
+ // Default to the stable enum literal so the timeline-event "reason" is predictable
+ // across all sources of EspTerminalFailure. Direct test signals (and any future
+ // adapter that wants to override) can still inject a specific reason via payload.
+ var reason = signal.Payload != null
+ && signal.Payload.TryGetValue("reason", out var r)
+ && !string.IsNullOrEmpty(r)
? r
: "esp_terminal_failure";
@@ -78,6 +90,20 @@ namespace AutopilotMonitor.DecisionCore.Engine
["reason"] = reason,
};
+ // Session 9d052230 — surface registry-derived ESP failure detail (HRESULT etc) on the
+ // terminal event so the UI can render it without parsing nested ESP statusText.
+ if (signal.Payload != null)
+ {
+ if (signal.Payload.TryGetValue("failureType", out var failureType) && !string.IsNullOrEmpty(failureType))
+ parameters["failureType"] = failureType;
+ if (signal.Payload.TryGetValue("errorCode", out var errorCode) && !string.IsNullOrEmpty(errorCode))
+ parameters["errorCode"] = errorCode;
+ if (signal.Payload.TryGetValue("failedSubcategory", out var failedSubcategory) && !string.IsNullOrEmpty(failedSubcategory))
+ parameters["failedSubcategory"] = failedSubcategory;
+ if (signal.Payload.TryGetValue("category", out var category) && !string.IsNullOrEmpty(category))
+ parameters["category"] = category;
+ }
+
// Enrich the audit payload with the FirstSync-derived ESP failure-handling settings,
// so the timeline carries the observable fact that Microsoft's ESP profile may have
// offered the user a "Continue anyway" button — the agent's terminal-failure verdict
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx
index a1a56728..b1d24f6d 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/EventTimeline.tsx
@@ -478,6 +478,34 @@ function EventRow({ event, showScriptOutput }: { event: EnrollmentEvent; showScr
);
})()}
+ {/* HRESULT badge for ESP failures (enrollment_failed via esp_terminal_failure,
+ esp_provisioning_status failed-subcategory). The HRESULT is extracted from the ESP
+ registry statusText by the agent (e.g. "Apps (0x87d1041c)") and surfaced as
+ top-level event data so the UI can render it without parsing nested text. */}
+ {(event.eventType === "enrollment_failed" || event.eventType === "esp_provisioning_status") && (() => {
+ const code = event.data?.errorCode ?? event.data?.failedSubcategoryErrorCode;
+ if (!code) return null;
+ const codeStr = String(code);
+ const entry = getErrorCodeEntry(codeStr);
+ const sub = event.data?.failedSubcategory ?? event.data?.failedSubcategories;
+ return (
+
+
+ HRESULT: {formatErrorCode(codeStr)}
+
+ {entry && (
+
+ {entry.description}
+
+ )}
+ {sub && (
+
+ subcategory: {String(sub)}
+
+ )}
+
+ );
+ })()}
Source: {event.source}
Seq: {event.sequence}