mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(agent-v2): post SessionStarted signal after register-session (V2 reducer anchor)
V2 parity lack: EnrollmentOrchestrator.Start never synthesised a SessionStarted signal, so HandleSessionStartedV1 (DecisionEngine.Shared.cs:36) was unreachable in production. DecisionState.Stage stayed at Unknown for the entire session lifetime while raw signals (ESP / Hello / etc.) landed on an uninitialised state. The reducer tolerated this silently, but it left us no session-anchor timestamp and no early stage-classification hook. Program.cs now posts SessionStarted directly after register-session success, with the tenant-registered metadata (EnrollmentType, IsHybridJoin, ValidatedBy, ...) as signal payload. Skipped on WhiteGlove Part-2 resume (orchestrator posts SessionRecovered instead) and on admin preemption (subsequent AdminPreemption signal drives straight to terminal; see next commit). Follow-up: the reducer handler currently only bumps StepIndex - a later patch should have it consume the enrollmentType/isHybridJoin payload for flow routing. Logged in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+125
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="AutopilotMonitor.Agent.V2.Program.PostSessionStartedSignal"/>
|
||||
/// emits the SessionStarted decision signal with the registration metadata payload the
|
||||
/// reducer anchor needs. Plan §M4-V2-parity PR-C.
|
||||
/// </summary>
|
||||
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<string, string>)payload);
|
||||
Assert.Contains("isHybridJoin", (System.Collections.Generic.IDictionary<string, string>)payload);
|
||||
Assert.Contains("agentVersion", (System.Collections.Generic.IDictionary<string, string>)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<string, string>? payload = null,
|
||||
int kindSchemaVersion = 1)
|
||||
{
|
||||
throw new System.InvalidOperationException("ingress unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// V2 parity — post a <see cref="DecisionSignalKind.SessionStarted"/> 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.
|
||||
/// </summary>
|
||||
internal static void PostSessionStartedSignal(
|
||||
ISignalIngressSink ingressSink,
|
||||
SessionRegistrationResult registrationResult,
|
||||
AgentConfiguration agentConfig,
|
||||
AgentLogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = new System.Collections.Generic.Dictionary<string, string>(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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// V1 parity — fire-and-forget <c>agent_started</c> event emitted after
|
||||
/// <see cref="EnrollmentOrchestrator.Start"/>. Carries a snapshot of the boot classification
|
||||
|
||||
@@ -1 +1 @@
|
||||
100
|
||||
102
|
||||
|
||||
@@ -1 +1 @@
|
||||
1071
|
||||
1072
|
||||
|
||||
Reference in New Issue
Block a user