diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DiagnosticSignalHandlerTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DiagnosticSignalHandlerTests.cs new file mode 100644 index 00000000..e91536c4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DiagnosticSignalHandlerTests.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Plan §4.x M4.4.3 — and + /// are diagnostic signals that + /// previously fell through to HandleUnhandledSignal and produced + /// unhandled_signal_kind:* dead-ends in the journal. They are now first-class + /// neutral observations: bookkeeping advances, the transition is Taken, the stage + /// is unchanged, no effects, no hypothesis impact. + /// + public sealed class DiagnosticSignalHandlerTests + { + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_records_taken_transition_without_deadend(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step = engine.Reduce(state, MakeSignal(kind, ordinal: 3)); + + Assert.True(step.Transition.Taken); + Assert.Null(step.Transition.DeadEndReason); + Assert.Equal(kind.ToString(), step.Transition.Trigger); + } + + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_leaves_stage_and_hypotheses_unchanged(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var midFlight = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .WithStage(SessionStage.EspAccountSetup) + .WithStepIndex(10) + .WithLastAppliedSignalOrdinal(9) + .Build(); + + var step = engine.Reduce(midFlight, MakeSignal(kind, ordinal: 10)); + + Assert.Equal(SessionStage.EspAccountSetup, step.NewState.Stage); + Assert.Same(midFlight.EnrollmentType, step.NewState.EnrollmentType); + Assert.Same(midFlight.WhiteGloveSealing, step.NewState.WhiteGloveSealing); + Assert.Same(midFlight.DeviceOnlyDeployment, step.NewState.DeviceOnlyDeployment); + } + + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_advances_bookkeeping(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .WithStepIndex(7) + .WithLastAppliedSignalOrdinal(6) + .Build(); + + var step = engine.Reduce(state, MakeSignal(kind, ordinal: 7)); + + Assert.Equal(8, step.NewState.StepIndex); + Assert.Equal(7, step.NewState.LastAppliedSignalOrdinal); + } + + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_emits_no_effects(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step = engine.Reduce(state, MakeSignal(kind)); + + Assert.Empty(step.Effects); + } + + [Theory] + [InlineData(DecisionSignalKind.DeviceInfoCollected)] + [InlineData(DecisionSignalKind.AutopilotProfileRead)] + public void Diagnostic_signal_does_not_cancel_active_deadlines(DecisionSignalKind kind) + { + var engine = new DecisionEngine(); + var stateWithDeadline = DecisionState.CreateInitial("s", "t") + .ToBuilder() + .AddDeadline(new ActiveDeadline( + name: DeadlineNames.HelloSafety, + dueAtUtc: new DateTime(2026, 4, 20, 10, 5, 0, DateTimeKind.Utc), + firesSignalKind: DecisionSignalKind.DeadlineFired)) + .Build(); + + var step = engine.Reduce(stateWithDeadline, MakeSignal(kind)); + + Assert.Single(step.NewState.Deadlines); + Assert.Equal(DeadlineNames.HelloSafety, step.NewState.Deadlines[0].Name); + } + + [Fact] + public void Diagnostic_signals_at_higher_schema_version_fall_through_to_unhandled() + { + // §2.2 L.6 — dispatch is keyed on (Kind, SchemaVersion). If a collector ever emits + // a v2 variant before the reducer handles it, the kernel must dead-end (not + // silently record), so replay flags the version gap. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("s", "t"); + + var step = engine.Reduce(state, MakeSignal(DecisionSignalKind.DeviceInfoCollected, schemaVersion: 2)); + + Assert.False(step.Transition.Taken); + Assert.Equal("unhandled_signal_kind:DeviceInfoCollected:v2", step.Transition.DeadEndReason); + } + + private static DecisionSignal MakeSignal( + DecisionSignalKind kind, + long ordinal = 0, + int schemaVersion = 1, + IReadOnlyDictionary? payload = null) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: schemaVersion, + occurredAtUtc: new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc), + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Raw, $"test:{kind}", "test"), + payload: payload); + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index f74e9b4e..ed3857e9 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -235,6 +235,39 @@ namespace AutopilotMonitor.DecisionCore.Engine return new DecisionStep(newState, transition, effects); } + // ============================================================== diagnostic signals + // Plan §4.x M4.4.3 — close the reducer-handler gap for signals that carry useful + // telemetry but do NOT influence the state machine. Previously these fell through to + // HandleUnhandledSignal, which wrote a DeadEnd transition every time — noise in the + // journal. Now they record as neutral taken transitions; full payload/evidence + // remains in the SignalLog for Inspector analysis. + + /// + /// Handle . Diagnostic-only — + /// carries hardware inventory in payload, does not drive stage or hypothesis. + /// + private DecisionStep HandleDeviceInfoCollectedV1(DecisionState state, DecisionSignal signal) => + RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.DeviceInfoCollected)); + + /// + /// Handle . Diagnostic-only — + /// carries Autopilot profile registry contents, does not drive stage or hypothesis. + /// + private DecisionStep HandleAutopilotProfileReadV1(DecisionState state, DecisionSignal signal) => + RecordDiagnosticObservation(state, signal, nameof(DecisionSignalKind.AutopilotProfileRead)); + + private DecisionStep RecordDiagnosticObservation(DecisionState state, DecisionSignal signal, string trigger) + { + var newState = BumpStepBookkeeping(state, signal); + var transition = BuildTakenTransition( + before: state, + signal: signal, + toStage: state.Stage, + nextStepIndex: newState.StepIndex, + trigger: trigger); + return new DecisionStep(newState, transition, Array.Empty()); + } + /// /// Determine the user-visible enrollment phase implied by an ESP phase-change signal. /// Plan §2.3 phase-fact mapping. Populated in M3.1 as Classic handlers come online. diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs index 6ddd9ba9..72c62d73 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.cs @@ -88,7 +88,11 @@ namespace AutopilotMonitor.DecisionCore.Engine (DecisionSignalKind.EspTerminalFailure, 1) => HandleEspTerminalFailureV1(state, signal), (DecisionSignalKind.SystemRebootObserved, 1) => HandleSystemRebootObservedV1(state, signal), - // ----- Still unwired (AppInstall, DeviceInfo, AutopilotProfileRead — v11.1+) ----- + // ----- Diagnostic observations (DecisionEngine.Shared.cs) — Plan §4.x M4.4.3 ----- + (DecisionSignalKind.DeviceInfoCollected, 1) => HandleDeviceInfoCollectedV1(state, signal), + (DecisionSignalKind.AutopilotProfileRead, 1) => HandleAutopilotProfileReadV1(state, signal), + + // ----- Still unwired (AppInstall — v11.1+) ----- _ => HandleUnhandledSignal(state, signal), }; }