diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 48082f72..b1bd38b6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -12 +13 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 628625b6..ca883787 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1054 +1055 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/EdgeScenarioTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/EdgeScenarioTests.cs new file mode 100644 index 00000000..b071d66e --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/Scenarios/EdgeScenarioTests.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Classifiers; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.State; +using AutopilotMonitor.DecisionCore.Tests.Harness; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests.Scenarios +{ + /// + /// Plan §4 M3.5 — Edge scenarios: hybrid reboot, ESP terminal failure, restart-recovery + /// determinism. + /// + public sealed class EdgeScenarioTests : ScenarioTestBase + { + private static ClassifierAwareReplayHarness NewHarness() => + new ClassifierAwareReplayHarness( + new DecisionEngine(), + new Dictionary + { + [WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(), + [WhiteGlovePart2CompletionClassifier.ClassifierId] = new WhiteGlovePart2CompletionClassifier(), + }); + + [Fact] + public void HybridReboot_midEsp_preservesStage_recordsRebootFact_completesNormally() + { + var signals = LoadFixture("hybrid-reboot-v1.jsonl"); + var result = NewHarness().Replay("session-anon-0030", "tenant-anon-0030", signals); + + Assert.Equal(SessionStage.Completed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome); + Assert.Equal("Success", result.FinalState.HelloOutcome!.Value); + + // Reboot fact is recorded. + Assert.NotNull(result.FinalState.SystemRebootUtc); + + // Stage flow: EspDeviceSetup -> (Reboot) preserved -> EspResumed preserved -> + // EspAccountSetup on next EspPhaseChanged -> AwaitingHello -> Completed. + // Importantly, no WhiteGloveSealed — reboot alone doesn't imply WG. + Assert.NotEqual(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level); + Assert.Null(result.FinalState.ShellCoreWhiteGloveSuccessSeen); + } + + [Fact] + public void EspTerminalFailure_transitionsToFailed_withReasonFromPayload() + { + var signals = LoadFixture("esp-terminal-failure-v1.jsonl"); + var result = NewHarness().Replay("session-anon-0031", "tenant-anon-0031", signals); + + Assert.Equal(SessionStage.Failed, result.FinalState.Stage); + Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome); + Assert.Empty(result.FinalState.Deadlines); + + // The last transition carries the payload-derived reason via trigger. + var last = result.Transitions[^1]; + Assert.Equal("EspTerminalFailure", last.Trigger); + Assert.True(last.Taken); + } + + [Fact] + public void RestartRecovery_replayTwice_producesIdenticalFinalStepHash() + { + // Determinism: the same signal stream through a fresh engine + fresh state + // yields the same FinalStepHash. Simulates the Orchestrator replaying a + // persisted SignalLog after an agent crash. Plan L.2 Event-Sourcing. + var signals = LoadFixture("whiteglove-part2-happy-v1.jsonl"); + + var r1 = NewHarness().Replay("session-anon-replay", "tenant-anon-replay", signals); + var r2 = NewHarness().Replay("session-anon-replay", "tenant-anon-replay", signals); + + Assert.Equal(r1.FinalStepHash, r2.FinalStepHash); + Assert.Equal(r1.FinalState.Stage, r2.FinalState.Stage); + Assert.Equal(r1.FinalState.Outcome, r2.FinalState.Outcome); + Assert.Equal(r1.Transitions.Count, r2.Transitions.Count); + } + + [Fact] + public void RestartRecovery_prefixReplay_yieldsIntermediateState() + { + // Replay a truncated log and verify the state matches what full-replay had at + // the same point (simulates partial recovery after crash between signals). + var all = LoadFixture("userdriven-happy-v1.jsonl"); + var prefix = new List(); + for (int i = 0; i < 3 && i < all.Count; i++) prefix.Add(all[i]); + + var prefixResult = NewHarness().Replay("s", "t", prefix); + + Assert.Equal(SessionStage.EspAccountSetup, prefixResult.FinalState.Stage); + Assert.Null(prefixResult.FinalState.Outcome); + Assert.NotNull(prefixResult.FinalState.CurrentEnrollmentPhase); + Assert.Equal(EnrollmentPhase.AccountSetup, prefixResult.FinalState.CurrentEnrollmentPhase!.Value); + } + + [Fact] + public void AllSixCommittedFixtures_produceTerminalState() + { + // Global sanity: every committed anonymized fixture ends in a terminal stage. + // This catches regressions where a new reducer change breaks a scenario end. + string[] fixtures = + { + "userdriven-happy-v1.jsonl", + "userdriven-hello-timeout-v1.jsonl", + "late-aadj-v1.jsonl", + "selfdeploying-happy-v1.jsonl", + "selfdeploying-esp-exit-unknown-v1.jsonl", + "whiteglove-inline-v1.jsonl", + "whiteglove-signal-correlated-v1.jsonl", + "whiteglove-false-positive-v1.jsonl", + "whiteglove-part2-happy-v1.jsonl", + "whiteglove-part2-stuck-v1.jsonl", + "hybrid-reboot-v1.jsonl", + "esp-terminal-failure-v1.jsonl", + }; + + foreach (var fx in fixtures) + { + var signals = LoadFixture(fx); + var result = NewHarness().Replay($"session:{fx}", $"tenant:{fx}", signals); + + var isTerminal = result.FinalState.Stage == SessionStage.Completed + || result.FinalState.Stage == SessionStage.Failed + || result.FinalState.Stage == SessionStage.WhiteGloveSealed + || result.FinalState.Stage == SessionStage.WhiteGloveCompletedPart2; + + Assert.True( + isTerminal, + $"Fixture {fx} ended in non-terminal stage {result.FinalState.Stage}."); + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs new file mode 100644 index 00000000..c414e942 --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // Edge-case handlers: hybrid reboot, ESP terminal failure, system reboot observation. + // Plan §2.5 / §M3.5. + public sealed partial class DecisionEngine + { + /// + /// Handle . Emitted by + /// EspAndHelloTracker after a reboot mid-ESP. The session's stage is preserved + /// (we were already somewhere in the ESP phase sequence), but the reboot fact is + /// recorded so downstream classifiers (WhiteGloveSealingClassifier) can factor it in. + /// + private DecisionStep HandleEspResumedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + // Record the reboot fact on first observation. Subsequent EspResumed signals + // keep the original timestamp so the hash over SystemRebootUtc is stable. + if (state.SystemRebootUtc == null) + { + builder.SystemRebootUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + } + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.EspResumed)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + + /// + /// Handle . Directly transitions + /// to with , + /// clears deadlines, and emits enrollment_failed. No classifier re-run — + /// ESP terminal failure is definitive. + /// + private DecisionStep HandleEspTerminalFailureV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + + var reason = signal.Payload != null && signal.Payload.TryGetValue("reason", out var r) + ? r + : "esp_terminal_failure"; + + var builder = state.ToBuilder() + .WithStage(SessionStage.Failed) + .WithOutcome(SessionOutcome.EnrollmentFailed) + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal) + .ClearDeadlines(); + + var newState = builder.Build(); + + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: SessionStage.Failed, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.EspTerminalFailure)); + + var effects = new[] + { + new DecisionEffect( + DecisionEffectKind.EmitEventTimelineEntry, + parameters: new Dictionary + { + ["eventType"] = "enrollment_failed", + ["reason"] = reason, + }), + }; + + return new DecisionStep(newState, transition, effects); + } + + /// + /// Handle . Records the fact so + /// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight. + /// + private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal) + { + var nextStep = state.StepIndex + 1; + var builder = state.ToBuilder() + .WithStepIndex(nextStep) + .WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal); + + if (state.SystemRebootUtc == null) + { + builder.SystemRebootUtc = new SignalFact(signal.OccurredAtUtc, signal.SessionSignalOrdinal); + } + + var newState = builder.Build(); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: nextStep, + trigger: nameof(DecisionSignalKind.SystemRebootObserved)); + + return new DecisionStep(newState, transition, Array.Empty()); + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index ab35daae..6ddd9ba9 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -83,7 +83,12 @@ namespace AutopilotMonitor.DecisionCore.Engine (DecisionSignalKind.DesktopArrivedPart2, 1) => HandleDesktopArrivedPart2V1(state, signal), (DecisionSignalKind.AccountSetupCompletedPart2, 1) => HandleAccountSetupCompletedPart2V1(state, signal), - // ----- Not yet wired (edge in M3.5) ----- + // ----- Edge (DecisionEngine.Edge.cs) ----- + (DecisionSignalKind.EspResumed, 1) => HandleEspResumedV1(state, signal), + (DecisionSignalKind.EspTerminalFailure, 1) => HandleEspTerminalFailureV1(state, signal), + (DecisionSignalKind.SystemRebootObserved, 1) => HandleSystemRebootObservedV1(state, signal), + + // ----- Still unwired (AppInstall, DeviceInfo, AutopilotProfileRead — v11.1+) ----- _ => HandleUnhandledSignal(state, signal), }; } diff --git a/tests/fixtures/enrollment-sessions/esp-terminal-failure-v1.jsonl b/tests/fixtures/enrollment-sessions/esp-terminal-failure-v1.jsonl new file mode 100644 index 00000000..0f5fe10e --- /dev/null +++ b/tests/fixtures/enrollment-sessions/esp-terminal-failure-v1.jsonl @@ -0,0 +1,9 @@ +# Scenario: ESP Terminal Failure +# ESP reports a non-recoverable failure (e.g. Event 62411 failure) during AccountSetup. +# Reducer transitions to Failed directly with reason carried from the signal payload. +# enrollment_failed event emitted. +# Plan: §4 M3.5 ESP-Terminal-Failure. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:esp-fail:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:01:00Z","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-24T09:10:00Z","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":"EspTerminalFailure","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:12:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62411:recordId:900","Summary":"ESP terminal failure (Event 62411)","RawPointer":"blob://events/shell-core/900"},"Payload":{"reason":"esp_terminal_failure_account_setup"}} diff --git a/tests/fixtures/enrollment-sessions/hybrid-reboot-v1.jsonl b/tests/fixtures/enrollment-sessions/hybrid-reboot-v1.jsonl new file mode 100644 index 00000000..5324f290 --- /dev/null +++ b/tests/fixtures/enrollment-sessions/hybrid-reboot-v1.jsonl @@ -0,0 +1,15 @@ +# Scenario: Hybrid-Reboot UserDriven (ESP mid-phase reboot, session resumes cleanly) +# The device enters DeviceSetup, reboots before AccountSetup, agent restarts and emits +# EspResumed + SystemRebootObserved. The existing stage is preserved, reboot fact is +# recorded. Session then continues through AccountSetup, ESP exit, Hello, Desktop -> +# Completed. Hybrid Reboot DOES NOT cause WhiteGlove sealing (no WG facts). +# Plan: §4 M3.5 hybrid reboot scenario. +{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:hybrid-reboot:started","Summary":"Session start anchor"}} +{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:01:00Z","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":"SystemRebootObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:05:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Derived","Identifier":"boot-time-delta-detector-v1","Summary":"System boot time advanced since session start","DerivationInputs":{"priorBoot":"2026-04-24T07:59:00Z","currentBoot":"2026-04-24T08:05:00Z"}}} +{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"EspResumed","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:05:30Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-resume-detector-v1","Summary":"ESP resumed after reboot","DerivationInputs":{"previousPhase":"DeviceSetup"}}} +{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}} +{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"ImeUserSessionCompleted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:12:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Raw","Identifier":"ime-log-line-hash:ghi789","Summary":"User session completed pattern"},"Payload":{"patternId":"ime-pattern-user-session-complete-v1"}} +{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-exit-detector-v1","Summary":"ESP exit observed","DerivationInputs":{"rawValue":"exit"}}} +{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:14:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:77","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}} +{"SessionSignalOrdinal":8,"SessionTraceOrdinal":8,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:15:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0030","explorerPid":"4500"}}}