diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 4e18fc9c..8a8c012a 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -848 +853 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index f2523706..80b6d67e 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1393 +1394 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicHelloDisabledCompletionTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicHelloDisabledCompletionTests.cs index 161b831c..38ba291b 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicHelloDisabledCompletionTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/ClassicHelloDisabledCompletionTests.cs @@ -15,13 +15,23 @@ namespace AutopilotMonitor.DecisionCore.Tests /// dual-path mechanisms: /// /// Fast-path in HandleDesktopArrivedV1: when HelloPolicyEnabled==false AND - /// AccountSetup has been reached, synthesize HelloOutcome="Skipped" on - /// DesktopArrived and route through Finalizing immediately. + /// the strong post-AccountSetup gate (ShouldTransitionToAwaitingHello) holds — + /// i.e. either AccountSetupProvisioningSucceededUtc is set OR + /// ScenarioObservations.SkipUserEsp is observed as true — synthesise + /// HelloOutcome="Skipped" on DesktopArrived and route through Finalizing + /// immediately. /// Reducer-path via EspExiting → HelloSafety 300s deadline → synthetic /// HelloOutcome="Timeout" fact (independent of the Hello policy). /// /// The dual-path design ensures completion even if one half regresses (missing /// HelloPolicyDetected adapter or missing EspExiting adapter). + /// + /// Session 08c99638 fix (2026-05-21): the fast-path guard was tightened from + /// AccountSetupEnteredUtc != null to the strong gate above, restoring parity with + /// HandleEspExitingV1. Without the tightening, a late desktop_arrived would + /// drive enrollment_complete on a session whose AccountSetup category never reached + /// categorySucceeded=true (apps stuck in_progress, 0/N subcategories complete). + /// /// public sealed class ClassicHelloDisabledCompletionTests { @@ -233,6 +243,93 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline); } + // -------------------------------------------- Fix 3 — session 08c99638 strong-gate parity + + [Fact] + public void HelloDisabled_FastPath_skipped_when_AccountSetup_entered_but_provisioning_never_succeeded() + { + // Session 08c99638 (2026-05-21) repro: Classic-v1 + Hello-disabled. + // ESP "exits" via Shell-Core event 62407 (errorCode=0) while AccountSetupCategory.Status + // is still categorySucceeded=in_progress (apps never finished, 0/N subcategories). + // AccountSetupProvisioningComplete is therefore NEVER raised → AccountSetupProvisioningSucceededUtc + // stays null. The pre-fix weak gate (AccountSetupEnteredUtc != null) let a late + // DesktopArrived drive enrollment_complete; the strong gate (parity with + // ShouldTransitionToAwaitingHello) MUST keep the session parked. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-08c99638", "tenant-08c99638", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState; + state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1).AddSeconds(5), + new Dictionary { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState; + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(10), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + // NO AccountSetupProvisioningComplete — AccountSetup never reached categorySucceeded=true. + // EspExiting is observed (Shell-Core 62407, errorCode=0), recording EspFinalExitUtc as + // an observability fact but not promoting to AwaitingHello (strong gate rejects). + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.EspExiting, T0.AddMinutes(25), null)).NewState; + + // Preconditions for the fast-path: Hello disabled + AccountSetup entered + Desktop not yet seen. + Assert.False(state.HelloPolicyEnabled!.Value); + Assert.NotNull(state.AccountSetupEnteredUtc); + Assert.NotNull(state.EspFinalExitUtc); + Assert.Null(state.AccountSetupProvisioningSucceededUtc); + Assert.Null(state.HelloResolvedUtc); + Assert.NotEqual(SessionStage.AwaitingHello, state.Stage); + + // Desktop arrives 35 min after EspExiting (mirroring 08c99638's 35-min gap). + var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(60), null)); + + // Strong gate rejects: no Finalizing, no synthetic HelloOutcome, no FinalizingGrace deadline. + Assert.NotEqual(SessionStage.Finalizing, step.NewState.Stage); + Assert.Null(step.NewState.HelloResolvedUtc); + Assert.Null(step.NewState.HelloOutcome); + Assert.NotNull(step.NewState.DesktopArrivedUtc); // observability fact IS recorded + Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace); + Assert.DoesNotContain(step.Effects, + e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry + && e.Parameters != null + && e.Parameters.TryGetValue("eventType", out var et) && et == "phase_transition"); + Assert.DoesNotContain(step.Effects, + e => e.Kind == DecisionEffectKind.ScheduleDeadline + && e.Deadline?.Name == DeadlineNames.FinalizingGrace); + } + + [Fact] + public void HelloDisabled_FastPath_fires_on_SkipUserEsp_flow_even_without_AccountSetupProvisioningComplete() + { + // Mirror of the strong-gate's second arm: SkipUserEsp=true (no User-ESP page on this + // flow) is sufficient to satisfy the gate. AccountSetupProvisioningSucceededUtc need + // not be set — there's no AccountSetup phase to wait for. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-skipuser", "tenant-skipuser", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState; + state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspConfigDetected, T0.AddSeconds(5), + new Dictionary + { + [SignalPayloadKeys.SkipUserEsp] = "true", + [SignalPayloadKeys.SkipDeviceEsp] = "false", + })).NewState; + state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1).AddSeconds(5), + new Dictionary { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState; + state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(3), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + // NO AccountSetupProvisioningComplete — but SkipUserEsp=true unlocks the gate. + + Assert.True(state.ScenarioObservations.SkipUserEsp?.Value); + Assert.Null(state.AccountSetupProvisioningSucceededUtc); + + var step = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.DesktopArrived, T0.AddMinutes(10), null)); + + // Fast-path fires via the SkipUserEsp arm of the strong gate. + Assert.Equal(SessionStage.Finalizing, step.NewState.Stage); + Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value); + Assert.NotNull(step.NewState.HelloResolvedUtc); + Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace); + } + // ====================================================================== test helpers /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index ce6b1a72..5433a338 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -37,11 +37,12 @@ namespace AutopilotMonitor.DecisionCore.Engine EnrollmentPhase.AccountSetup => SessionStage.EspAccountSetup, // Plan §6 Fix 8 — Finalizing is a synthetic phase derived from the Shell-Core // esp_exiting event; on Classic V1 enrollments the first exit is the Device-ESP - // handoff, not the true final. Only promote to AwaitingHello when reaching it is - // legitimate (AccountSetup already observed, OR SkipUser flow). Otherwise keep - // the current stage — the signal is still recorded as a taken transition with - // the FinalizingEnteredUtc fact + CurrentEnrollmentPhase updated below, but the - // HelloSafety deadline is NOT armed. + // handoff, not the true final. Only promote to AwaitingHello when the strong + // post-AccountSetup gate (ShouldTransitionToAwaitingHello) holds: either + // AccountSetupProvisioningSucceededUtc is set OR SkipUserEsp is observed as true. + // Otherwise keep the current stage — the signal is still recorded as a taken + // transition with the FinalizingEnteredUtc fact + CurrentEnrollmentPhase updated + // below, but the HelloSafety deadline is NOT armed. EnrollmentPhase.FinalizingSetup => ShouldTransitionToAwaitingHello(state) ? SessionStage.AwaitingHello : state.Stage, @@ -307,13 +308,27 @@ namespace AutopilotMonitor.DecisionCore.Engine /// resolved the session completes here. /// /// Hello-disabled fast-path: when is - /// explicitly false AND we have reached AccountSetup (so the Hello-skip is the - /// genuine post-ESP state rather than a premature mid-DeviceSetup classification), - /// synthesise HelloOutcome="Skipped" here and route directly through Finalizing. + /// explicitly false AND the post-AccountSetup completion gate + /// () holds — i.e. either + /// is set (ESP genuinely + /// finished AccountSetup) OR + /// is true (no User-ESP page on this flow) — synthesise + /// HelloOutcome="Skipped" here and route directly through Finalizing. /// This avoids waiting out the full 5-min HelloSafety window when the policy reader /// already told us no Hello wizard is expected. Belt-and-suspenders alongside the /// EspExiting → HelloSafety reducer path: the path that fires first wins. /// + /// + /// Session 08c99638 fix (2026-05-21): the gate was previously + /// AccountSetupEnteredUtc != null, which is too weak. Shell-Core event 62407 + /// (CommercialOOBE_ESPProgress_Page_Exiting) can fire with errorCode=0 + /// while AccountSetup is still categorySucceeded=in_progress (apps never finished, + /// 0/N subcategories complete). The old gate let a late desktop_arrived + Hello + /// disabled drive enrollment_complete for a session that should have stalled. + /// The strong gate keeps parity with 's + /// check — both paths now require the same + /// post-AccountSetup evidence before any forward transition. + /// /// private DecisionStep HandleDesktopArrivedV1(DecisionState state, DecisionSignal signal) { @@ -327,14 +342,17 @@ namespace AutopilotMonitor.DecisionCore.Engine // Hello-disabled fast-path. Both guards required: // 1. HelloPolicyEnabled?.Value == false — policy reader confirmed no Hello wizard - // 2. AccountSetupEnteredUtc != null — same gate as ShouldTransitionToAwaitingHello, - // prevents a premature pre-AccountSetup completion if HelloPolicyDetected fires - // before AccountSetup is observed. + // 2. ShouldTransitionToAwaitingHello(state) — strong post-AccountSetup gate, identical + // to the one used by HandleEspExitingV1. Requires AccountSetupProvisioningSucceededUtc + // to be set OR SkipUserEsp observed as true. The prior weak guard + // (AccountSetupEnteredUtc != null) allowed completion when ESP "exited" via + // Shell-Core 62407 while AccountSetup categorySucceeded was still in_progress + // (session 08c99638). // Skipping unknown policy (HelloPolicyEnabled == null) preserves the prior pessimistic // behaviour: keep waiting for Hello / HelloSafety. if (!helloAlreadyResolved && state.HelloPolicyEnabled?.Value == false - && state.AccountSetupEnteredUtc != null) + && ShouldTransitionToAwaitingHello(state)) { builder.HelloResolvedUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); builder.HelloOutcome = new SignalFact("Skipped", signal.SessionSignalOrdinal);