fix(agent-v2): emit non-failure subcategory_state_change transitions

DetectSubcategoryTransitions silently dropped every non-failure, non-completion,
non-noise subcategory transition (e.g. succeeded -> in_progress retry, failed ->
in_progress recovery, in_progress -> notStarted reset) due to a dead inner
'if (isFailure)' guard that is always false after the outer failure/completion
branches. The esp_provisioning_status event downgraded to a plain progress update
without a subcategory_state_change changeType. Record those as non-failure
transitions so they surface; add regression coverage.
This commit is contained in:
Oliver Kieselbach
2026-06-05 20:54:54 +02:00
parent 192a1e939b
commit 39ffa93ee7
3 changed files with 135 additions and 3 deletions
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// Regression coverage for the previously-unreachable non-failure branch in
/// <c>DetectSubcategoryTransitions</c>. The branch was guarded by an inner
/// <c>if (isFailure)</c> that is always false once the outer
/// <c>isFailure || isCompletion</c> has been excluded, so every non-failure,
/// non-completion, non-noise transition (e.g. <c>succeeded -&gt; in_progress</c> retry)
/// was silently dropped — the <c>esp_provisioning_status</c> event downgraded to a
/// plain progress update without a <c>subcategory_state_change</c> changeType.
/// After the fix those transitions must surface in the event.
/// </summary>
public sealed class SubcategoryTransitionDetectionTests
{
private static readonly DateTime Fixed = new DateTime(2026, 6, 5, 9, 0, 0, DateTimeKind.Utc);
private sealed class Fixture : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
public FakeSignalIngressSink Sink { get; } = new FakeSignalIngressSink();
public ProvisioningStatusTracker Tracker { get; }
public Fixture()
{
var clock = new VirtualClock(Fixed);
var post = new InformationalEventPost(Sink, clock);
var logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
Tracker = new ProvisioningStatusTracker(
sessionId: "S1",
tenantId: "T1",
post: post,
logger: logger);
}
public IReadOnlyList<(string EventType, IReadOnlyDictionary<string, object> Data)> CapturedEvents()
{
return Sink.Posted
.Where(p => p.Kind == DecisionSignalKind.InformationalEvent && p.Payload != null)
.Select(p =>
{
var eventType = p.Payload!.TryGetValue(SignalPayloadKeys.EventType, out var et) ? et : "";
var data = p.TypedPayload as IReadOnlyDictionary<string, object>
?? (IReadOnlyDictionary<string, object>)new Dictionary<string, object>();
return (eventType, data);
})
.ToList();
}
public void Dispose()
{
Tracker.Dispose();
Tmp.Dispose();
}
}
[Fact]
public void NonFailureSubcategoryTransition_IsRecorded_AndSurfacedAsStateChange()
{
using var f = new Fixture();
// First seen — Apps already succeeded.
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""AppsSubcategory"": {""subcategoryState"":""succeeded"",""subcategoryStatusText"":""Apps (Identified)""}
}");
// Apps regresses succeeded -> in_progress (retry). categorySucceeded unchanged (null),
// so processing falls through to subcategory-transition detection.
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""AppsSubcategory"": {""subcategoryState"":""in_progress"",""subcategoryStatusText"":""Apps (Downloading)""}
}");
var captured = f.CapturedEvents();
var statusEvents = captured
.Where(e => e.EventType == AutopilotMonitor.Shared.Constants.EventTypes.EspProvisioningStatus)
.ToList();
// A subcategory_state_change event must be emitted carrying the transition.
var transitionEvent = statusEvents.Single(e =>
e.Data.TryGetValue("changeType", out var ct) && (string)ct == "subcategory_state_change");
var transitions = Assert.IsType<List<Dictionary<string, string>>>(transitionEvent.Data["transitions"]);
var appsTransition = Assert.Single(transitions, t => t["subcategory"] == "Apps");
Assert.Equal("succeeded", appsTransition["previousState"]);
Assert.Equal("in_progress", appsTransition["newState"]);
// Non-failure transition: no failed-subcategory enrichment.
Assert.False(transitionEvent.Data.ContainsKey("failedSubcategories"));
}
[Fact]
public void NotStartedToInProgress_IsTreatedAsNoise_NotRecorded()
{
using var f = new Fixture();
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""AppsSubcategory"": {""subcategoryState"":""notStarted"",""subcategoryStatusText"":""Apps (Waiting)""}
}");
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status", @"{
""categorySucceeded"": null,
""AppsSubcategory"": {""subcategoryState"":""in_progress"",""subcategoryStatusText"":""Apps (Downloading)""}
}");
var captured = f.CapturedEvents();
var statusEvents = captured
.Where(e => e.EventType == AutopilotMonitor.Shared.Constants.EventTypes.EspProvisioningStatus)
.ToList();
// notStarted -> in_progress is benign progress noise: no state-change event.
Assert.DoesNotContain(statusEvents, e =>
e.Data.TryGetValue("changeType", out var ct) && (string)ct == "subcategory_state_change");
}
}
}
@@ -1164,8 +1164,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
else if (!isNoise)
{
if (isFailure)
transitions.Add(new SubcategoryTransition(sub.Name, oldState, sub.State, true));
// Any other real state change (e.g. succeeded -> in_progress retry,
// failed -> in_progress recovery, in_progress -> notStarted reset) is a
// non-failure transition. Record it so the subcategory_state_change event
// carries it; without this the change is silently dropped (observability gap).
transitions.Add(new SubcategoryTransition(sub.Name, oldState, sub.State, false));
}
}
@@ -1 +1 @@
920
923