diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs new file mode 100644 index 00000000..30443fd9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostSessionStartedSignalTests.cs @@ -0,0 +1,125 @@ +using System.IO; +using System.Reflection; +using AutopilotMonitor.Agent.V2; +using AutopilotMonitor.Agent.V2.Core.Configuration; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Security; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Program +{ + /// + /// Verifies that + /// emits the SessionStarted decision signal with the registration metadata payload the + /// reducer anchor needs. Plan §M4-V2-parity PR-C. + /// + public sealed class PostSessionStartedSignalTests + { + private static AgentLogger NewLogger(string path) + => new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info); + + private static SessionRegistrationResult BuildSucceeded(ValidatorType validatedBy, string? adminAction = null) + { + var response = new RegisterSessionResponse + { + SessionId = "session-anon-1", + Success = true, + Message = "ok", + ValidatedBy = validatedBy, + AdminAction = adminAction, + }; + + // SessionRegistrationResult uses a private ctor + public static factories. Call + // Succeeded via reflection-free factory — it's public. + return SessionRegistrationResult.Succeeded(response); + } + + private static AgentConfiguration BuildConfig(bool bootstrap) + { + return new AgentConfiguration + { + ApiBaseUrl = "https://example.invalid", + SessionId = "session-anon-1", + TenantId = "tenant-anon-1", + SpoolDirectory = Path.GetTempPath(), + LogDirectory = Path.GetTempPath(), + LogLevel = AgentLogLevel.Info, + UseClientCertAuth = !bootstrap, + UseBootstrapTokenAuth = bootstrap, + BootstrapToken = bootstrap ? "dummy" : null, + }; + } + + [Fact] + public void Posts_session_started_kind_with_default_schema_version() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var logger = NewLogger(tmp.Path); + var result = BuildSucceeded(ValidatorType.AutopilotV1); + + AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger); + + Assert.Single(sink.Posted); + var posted = sink.Posted[0]; + Assert.Equal(DecisionSignalKind.SessionStarted, posted.Kind); + Assert.Equal(1, posted.KindSchemaVersion); + Assert.Equal("Program.RunAgent", posted.SourceOrigin); + Assert.Equal(EvidenceKind.Synthetic, posted.Evidence.Kind); + Assert.Equal("register_session_success", posted.Evidence.Identifier); + } + + [Fact] + public void Payload_includes_registration_metadata_keys() + { + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var logger = NewLogger(tmp.Path); + var result = BuildSucceeded(ValidatorType.CorporateIdentifier); + + AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: true), logger); + + var payload = sink.Posted[0].Payload; + Assert.NotNull(payload); + Assert.Equal("CorporateIdentifier", payload!["validatedBy"]); + Assert.Equal("true", payload["isBootstrapSession"]); + Assert.Contains("enrollmentType", (System.Collections.Generic.IDictionary)payload); + Assert.Contains("isHybridJoin", (System.Collections.Generic.IDictionary)payload); + Assert.Contains("agentVersion", (System.Collections.Generic.IDictionary)payload); + } + + [Fact] + public void Swallows_sink_exceptions() + { + // The helper is called on the hot startup path — a misbehaving sink must not + // crash the agent. + using var tmp = new TempDirectory(); + var sink = new ThrowingSink(); + var logger = NewLogger(tmp.Path); + var result = BuildSucceeded(ValidatorType.Bootstrap); + + var ex = Record.Exception(() => + AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal(sink, result, BuildConfig(bootstrap: false), logger)); + + Assert.Null(ex); + } + + private sealed class ThrowingSink : AutopilotMonitor.Agent.V2.Core.Orchestration.ISignalIngressSink + { + public void Post( + DecisionSignalKind kind, + System.DateTime occurredAtUtc, + string sourceOrigin, + Evidence evidence, + System.Collections.Generic.IReadOnlyDictionary? payload = null, + int kindSchemaVersion = 1) + { + throw new System.InvalidOperationException("ingress unavailable"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs index 567362bd..3c2cd019 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs @@ -16,6 +16,7 @@ using AutopilotMonitor.Agent.V2.Core.Termination; using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; using AutopilotMonitor.DecisionCore.Classifiers; using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; using AutopilotMonitor.DecisionCore.State; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; @@ -712,6 +713,17 @@ namespace AutopilotMonitor.Agent.V2 EmitSystemRebootDetectedEvent(orchestrator, agentConfig, previousExit, logger); } + // V2 parity — post SessionStarted so the reducer establishes the session anchor + // (HandleSessionStartedV1 in DecisionEngine.Shared.cs). Skipped on: + // - WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts + // SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge. + // - Admin preemption: the AdminPreemptionDetected signal below drives the + // session straight to a terminal stage; SessionStarted first would be noise. + if (!isWhiteGloveResume && string.IsNullOrEmpty(registrationResult.AdminAction)) + { + PostSessionStartedSignal(orchestrator.IngressSink, registrationResult, agentConfig, logger); + } + // V1 parity (MonitoringService.cs:388-413 — the "ADMIN OVERRIDE on startup" // block) — if the register-session response carried an AdminAction the // session was already marked terminal by an operator via the portal. Emit @@ -950,6 +962,49 @@ namespace AutopilotMonitor.Agent.V2 } } + /// + /// V2 parity — post a signal with the + /// tenant-registered session metadata (EnrollmentType / IsHybridJoin / ValidatedBy) so the + /// reducer's session-anchor handler runs. Without this the DecisionState.Stage stays at + /// the initial value and subsequent raw signals (ESP / Hello) see an uninitialised session. + /// + internal static void PostSessionStartedSignal( + ISignalIngressSink ingressSink, + SessionRegistrationResult registrationResult, + AgentConfiguration agentConfig, + AgentLogger logger) + { + try + { + var payload = new System.Collections.Generic.Dictionary(StringComparer.Ordinal) + { + ["enrollmentType"] = EnrollmentRegistryDetector.DetectEnrollmentType(), + ["isHybridJoin"] = EnrollmentRegistryDetector.DetectHybridJoin() ? "true" : "false", + ["validatedBy"] = registrationResult.ValidatedBy.ToString(), + ["agentVersion"] = GetAgentVersion(), + ["isBootstrapSession"] = agentConfig.UseBootstrapTokenAuth ? "true" : "false", + }; + + var evidence = new Evidence( + kind: EvidenceKind.Synthetic, + identifier: "register_session_success", + summary: "Session registration handshake succeeded; posting SessionStarted anchor for reducer."); + + ingressSink.Post( + kind: DecisionSignalKind.SessionStarted, + occurredAtUtc: DateTime.UtcNow, + sourceOrigin: "Program.RunAgent", + evidence: evidence, + payload: payload); + + logger.Debug($"SessionStarted signal posted (validatedBy={registrationResult.ValidatedBy}, enrollmentType={payload["enrollmentType"]})."); + } + catch (Exception ex) + { + logger.Warning($"SessionStarted post failed: {ex.Message}"); + } + } + /// /// V1 parity — fire-and-forget agent_started event emitted after /// . Carries a snapshot of the boot classification diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 29d6383b..257e5632 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -100 +102 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 7e179677..e255a855 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1071 +1072