diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs index 17c543d8..9a90ba59 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/ClassicScenarioTests.cs @@ -106,5 +106,44 @@ namespace AutopilotMonitor.DecisionCore.Tests.Scenarios Assert.Equal(r1.FinalStepHash, r2.FinalStepHash); Assert.Equal(16, r1.FinalStepHash.Length); } + + [Fact] + public void LateProvisioningComplete_afterAccountSetup_doesNotCompletePrematurely() + { + // Plan §6.1 regression guard — reproduces session e259c121-dc13-46d6-8e96-118f1da9845e. + // DeviceSetupProvisioningComplete arrives AFTER AccountSetup has started and + // DesktopArrived was observed, but BEFORE HelloResolved. The SelfDeploying terminal + // handler must NOT complete the session on the Classic path. + var result = RunFixture( + fixtureFilename: "userdriven-late-provisioning-complete-v1.jsonl", + sessionId: "session-anon-0006", + tenantId: "tenant-anon-0006"); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Equal("Success", result.FinalState.HelloOutcome!.Value); + Assert.NotNull(result.FinalState.HelloResolvedUtc); + Assert.NotNull(result.FinalState.DesktopArrivedUtc); + Assert.NotNull(result.FinalState.AccountSetupEnteredUtc); + Assert.Empty(result.FinalState.Deadlines); + + // Completion must come from HelloResolved (AND-gate with prior DesktopArrived), + // NOT from DeviceSetupProvisioningComplete. + var last = result.Transitions[^1]; + Assert.Equal("HelloResolved", last.Trigger); + Assert.True(last.Taken); + + // The DeviceSetupProvisioningComplete signal is observed as a taken but stage-unchanged + // step (informational on Classic path — not a terminal trigger). + var provTransition = Assert.Single( + result.Transitions, + t => t.Trigger == nameof(AutopilotMonitor.DecisionCore.Signals.DecisionSignalKind.DeviceSetupProvisioningComplete)); + Assert.True(provTransition.Taken); + Assert.Equal(SessionStage.EspAccountSetup, provTransition.FromStage); + Assert.Equal(SessionStage.EspAccountSetup, provTransition.ToStage); + + // DeviceOnlyDeployment hypothesis must stay Unknown — this is a Classic UserDriven path. + Assert.Equal(HypothesisLevel.Unknown, result.FinalState.DeviceOnlyDeployment.Level); + } } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs index 26d17e67..7fe1827a 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.SelfDeploying.cs @@ -27,18 +27,43 @@ namespace AutopilotMonitor.DecisionCore.Engine /// /// Handle . /// - /// The canonical SelfDeploying / Device-Only terminal signal. Cancels the - /// deadline if still armed, - /// finalizes the hypothesis based - /// on whether any user-presence fact was observed, and transitions the session to - /// with . + /// Terminal only on the SelfDeploying / Device-Only path (no AccountSetup phase ever + /// entered). On the Classic UserDriven-v1 path — where + /// is already set — this is just the ProvisioningStatusTracker marking DeviceSetupCategory + /// resolved; completion on that path requires + /// + (handled by Classic.cs). + /// + /// + /// Guarding on prevents the regression + /// observed in session e259c121-dc13-46d6-8e96-118f1da9845e (2026-04-22), where a late + /// DeviceSetupProvisioningComplete (payload deviceSetupResolved=unknown) fired while + /// Stage was and + /// was still null — terminating the session before Hello even ran. /// /// private DecisionStep HandleDeviceSetupProvisioningCompleteV1(DecisionState state, DecisionSignal signal) { var nextStep = state.StepIndex + 1; - // Determine DeviceOnlyDeployment verdict. + // Classic-path guard: AccountSetup already started → this signal is informational only. + if (state.AccountSetupEnteredUtc != null) + { + var passthroughState = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .Build(); + + var passthroughTransition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.DeviceSetupProvisioningComplete)); + + return new DecisionStep(passthroughState, passthroughTransition, Array.Empty()); + } + + // SelfDeploying / Device-Only terminal path. var hasUserPresence = (state.AadJoinedWithUser != null && state.AadJoinedWithUser.Value) || state.HelloResolvedUtc != null || @@ -62,10 +87,9 @@ namespace AutopilotMonitor.DecisionCore.Engine .ClearDeadlines(); builder.DeviceOnlyDeployment = updatedHypothesis; - // Also strengthen EnrollmentType: observing DeviceSetupProvisioningComplete - // without preceding AccountSetup strongly implies SelfDeploying-v1. - if (state.AccountSetupEnteredUtc == null && - state.EnrollmentType.Level < HypothesisLevel.Strong) + // Strengthen EnrollmentType: observing DeviceSetupProvisioningComplete without + // preceding AccountSetup strongly implies SelfDeploying-v1. + if (state.EnrollmentType.Level < HypothesisLevel.Strong) { builder.EnrollmentType = state.EnrollmentType.With( level: HypothesisLevel.Strong, diff --git a/tests/fixtures/enrollment-sessions/userdriven-late-provisioning-complete-v1.jsonl b/tests/fixtures/enrollment-sessions/userdriven-late-provisioning-complete-v1.jsonl new file mode 100644 index 00000000..742c344a --- /dev/null +++ b/tests/fixtures/enrollment-sessions/userdriven-late-provisioning-complete-v1.jsonl @@ -0,0 +1,19 @@ +# Scenario: UserDriven-v1 with late spurious DeviceSetupProvisioningComplete after AccountSetup started. +# Reproduces regression from session e259c121-dc13-46d6-8e96-118f1da9845e (2026-04-22): +# ProvisioningStatusTracker fired DeviceSetupProvisioningComplete (payload +# deviceSetupResolved="unknown") AFTER AccountSetup was already in progress and +# DesktopArrived had been observed — but HelloResolved had NOT yet fired. +# Before the fix: the reducer completed the session to Stage=Completed unconditionally on +# DeviceSetupProvisioningComplete, short-circuiting Hello. +# After the fix: the handler detects AccountSetupEnteredUtc != null and treats the signal as +# informational only (stage unchanged, no completion effect). Completion happens on the +# subsequent HelloResolved via the Classic Hello+Desktop AND-gate. +# Expected terminal: Stage=Completed, Outcome=EnrollmentComplete, HelloOutcome=Success, +# last trigger = HelloResolved (NOT DeviceSetupProvisioningComplete). +# Plan: §6.1 regression fixture. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:48:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:userdriven-late-provisioning-complete:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:48:30Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}} +{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:51:38Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:51:58Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0006","explorerPid":"4242"}}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"DeviceSetupProvisioningComplete","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:51:59Z","SourceOrigin":"ProvisioningStatusTracker","Evidence":{"Kind":"Derived","Identifier":"provisioning-status-tracker-v1","Summary":"DeviceSetupCategory subcategories succeeded but category unresolved","DerivationInputs":{"deviceSetupResolved":"unknown"}},"Payload":{"deviceSetupResolved":"unknown"}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T17:53:30Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:77","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}}