mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +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.Agent.V2.Core.Transport.Telemetry;
|
||||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||||
using AutopilotMonitor.DecisionCore.Engine;
|
using AutopilotMonitor.DecisionCore.Engine;
|
||||||
|
using AutopilotMonitor.DecisionCore.Signals;
|
||||||
using AutopilotMonitor.DecisionCore.State;
|
using AutopilotMonitor.DecisionCore.State;
|
||||||
using AutopilotMonitor.Shared;
|
using AutopilotMonitor.Shared;
|
||||||
using AutopilotMonitor.Shared.Models;
|
using AutopilotMonitor.Shared.Models;
|
||||||
@@ -712,6 +713,17 @@ namespace AutopilotMonitor.Agent.V2
|
|||||||
EmitSystemRebootDetectedEvent(orchestrator, agentConfig, previousExit, logger);
|
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"
|
// V1 parity (MonitoringService.cs:388-413 — the "ADMIN OVERRIDE on startup"
|
||||||
// block) — if the register-session response carried an AdminAction the
|
// block) — if the register-session response carried an AdminAction the
|
||||||
// session was already marked terminal by an operator via the portal. Emit
|
// 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>
|
/// <summary>
|
||||||
/// V1 parity — fire-and-forget <c>agent_started</c> event emitted after
|
/// V1 parity — fire-and-forget <c>agent_started</c> event emitted after
|
||||||
/// <see cref="EnrollmentOrchestrator.Start"/>. Carries a snapshot of the boot classification
|
/// <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