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:
Oliver Kieselbach
2026-04-22 15:52:49 +02:00
parent bcd3c23168
commit a63512d139
4 changed files with 182 additions and 2 deletions
@@ -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