From 877523594f236ab9a0087cf3af352dc04c85fb00 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 23 Apr 2026 16:35:14 +0200 Subject: [PATCH] =?UTF-8?q?v2=20single-rail=205.10:=20enforce=20single-rai?= =?UTF-8?q?l=20=E2=80=94=20internalize=20emitters=20+=20shrink=20baseline?= =?UTF-8?q?=20to=202=20callers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §5.10 — final PR of the single-rail refactor. Rail B is gone; Invariant 1 (single caller set) + Invariant 2 (compile-time enforcement) are now active. Classes internalized: - TelemetryEventEmitter (public sealed → internal sealed; Emit → internal) - EventTimelineEmitter (public sealed → internal sealed) - BackPressureEventObserver (public sealed → internal sealed) Consumers of these three live in the same Telemetry.Events namespace (plus InternalsVisibleTo for the test assembly). Any future attempt to construct or reference them from outside the assembly becomes a compile error — the single-rail invariant is now load-bearing on the type system, not review. EnrollmentOrchestrator scrubbed: - Deleted the `public TelemetryEventEmitter EventEmitter` property (no external consumer after PR #1–#9). - Deleted the `BuildTelemetryEventSink()` Action factory (last caller removed in step 14 of Start, where it wired collectors; those now post via ISignalIngressSink exclusively). - Dropped the `_eventEmitter` field — the emitter is built as a local in Start and handed to EventTimelineEmitter + BackPressureEventObserver inline. After Start returns, nothing in the orchestrator references it. Architecture baseline test shrinks by one as a direct consequence. IComponentFactory cleanup: - Removed `Action onEnrollmentEvent` parameter from CreateCollectorHosts (interface + DefaultComponentFactory + call-site in EnrollmentOrchestrator.Start). - DefaultComponentFactory.ImeLogHost constructor drops the now-dead onEnrollmentEvent parameter (declared but never used — relic from the pre-migration wire). - ICollectorHost + IComponentFactory doc comments updated to reflect the new "post-via-ingress" contract. Gather-rule internals (GatherRuleExecutor, GatherRuleContext, CommandCollector, LogParserCollector) keep their internal Action signature — they are the orthogonal standalone CLI mode (plan §9), not a kernel path; the session-mode GatherRuleExecutorHost already bridges the Action to an InformationalEventPost in PR #6. Architecture baseline test: - `TelemetryEventEmitterCallersBaselineTests` promoted from transitional baseline (3 dependents) to strict enforcement: exactly the two permitted callers EventTimelineEmitter + BackPressureEventObserver. A third entry now fails the build — the review signal for any reintroduced bypass is loud. - Dropped the `Current_baseline_is_a_superset_of_the_PR10_permitted_callers` sanity test (redundant once the enforcement set IS the permitted set). Test fixtures updated: - EnrollmentOrchestratorEventBridgeTests rewritten — the "Action bridge" contract the old tests exercised is gone, so the tests covering it (Emit-from-host-reaches-transport, emitter-exception-does-not-propagate etc.) were removed. Remaining coverage: Start without factory, Start with factory spawns + starts hosts, factory receives ingress + clock, Stop stops + disposes hosts. - EnrollmentOrchestratorWhiteGlovePatternTests fake factory updated to the new IComponentFactory signature. - EnrollmentOrchestratorTests + EnrollmentOrchestratorOnIngressReadyTests: removed the `sut.EventEmitter` / `orchestrator.EventEmitter` assertions — the property no longer exists. Verification: dotnet build AutopilotMonitor.sln clean; V2.Core test suite 559 / 559 green (down from 562 — 3 obsolete bridge tests removed, 0 new failures); DecisionCore 108 / 108 green. Soll-Zustand per plan §8 now holds in code: - Single-rail Emission: exactly 2 callers of TelemetryEventEmitter.Emit. - Compile-time enforcement: emitter class + method are internal; any bypass attempt breaks the build instead of slipping through review. - No Action parameter on ICollectorHost / IComponentFactory. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...lemetryEventEmitterCallersBaselineTests.cs | 68 +++----- .../EnrollmentOrchestratorEventBridgeTests.cs | 147 ++---------------- ...rollmentOrchestratorOnIngressReadyTests.cs | 1 - .../EnrollmentOrchestratorTests.cs | 2 - ...lmentOrchestratorWhiteGlovePatternTests.cs | 1 - .../Orchestration/DefaultComponentFactory.cs | 4 - .../Orchestration/EnrollmentOrchestrator.cs | 47 ++---- .../Orchestration/ICollectorHost.cs | 9 +- .../Orchestration/IComponentFactory.cs | 38 ++--- .../Events/BackPressureEventObserver.cs | 2 +- .../Telemetry/Events/EventTimelineEmitter.cs | 2 +- .../Telemetry/Events/TelemetryEventEmitter.cs | 17 +- 12 files changed, 71 insertions(+), 267 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Architecture/TelemetryEventEmitterCallersBaselineTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Architecture/TelemetryEventEmitterCallersBaselineTests.cs index 31adef37..e9426513 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Architecture/TelemetryEventEmitterCallersBaselineTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Architecture/TelemetryEventEmitterCallersBaselineTests.cs @@ -9,18 +9,17 @@ using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Architecture { /// - /// Single-rail refactor plan §4.4 / §8 — baseline inventory of every type in - /// AutopilotMonitor.Agent.V2.Core that can reach - /// . The test is a living document: as the migration PRs - /// (#1 through #9) remove bypass paths one by one, the baseline shrinks. PR #10 tightens - /// the expected set to only the two permitted callers — - /// (Rail A) and (documented meta exception). + /// Single-rail refactor plan §4.4 / §5.10 / §8 — enforcement gate on the + /// caller set. PR #10 promoted this test from a + /// transitional baseline (3 dependents) to strict enforcement (exactly 2 permitted callers): + /// (Rail A) and + /// (documented meta exception — it cannot route through the ingress without creating a + /// cycle because it observes the ingress itself). /// - /// Scope: this test inspects the V2.Core assembly only. Program.cs lives in the - /// V2 executable assembly and reaches the emitter via - /// ; internalizing that property in PR #10 - /// removes the reach automatically. Until then Program.cs is tracked via the plan's §2 - /// inventory (B2–B6), not this test. + /// Scope: this test inspects the V2.Core assembly only. The emitter class itself is + /// internal post-PR #10, so there is no surface for the V2 executable assembly or + /// any external library to reach it — the inventory is complete with the two types that + /// live next to it in the Telemetry.Events namespace. /// /// /// Detection strategy: reflection over fields, constructor parameters, method @@ -34,55 +33,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Architecture private static readonly Type EmitterType = typeof(TelemetryEventEmitter); /// - /// Types permitted to reach at the end of the - /// single-rail refactor (PR #10 enforcement gate). Kept separate from the transitional - /// baseline so that a single-line diff in PR #10 switches enforcement on: - /// Assert.Equal(PermittedCallers, ActualCallers). + /// The two and only two types permitted to reach + /// after the single-rail refactor. A third entry would mean someone reintroduced a + /// bypass; the test fails and reviewers are forced to reason about whether the new + /// caller belongs here (essentially never — the answer is almost always "route via + /// ISignalIngressSink + InformationalEventPost instead"). /// - private static readonly HashSet PermittedCallersAfterPr10 = new HashSet(StringComparer.Ordinal) + private static readonly HashSet PermittedCallers = new HashSet(StringComparer.Ordinal) { "AutopilotMonitor.Agent.V2.Core.Telemetry.Events.EventTimelineEmitter", "AutopilotMonitor.Agent.V2.Core.Telemetry.Events.BackPressureEventObserver", }; - /// - /// Current transitional baseline (PR #0). Every additional entry is a documented - /// bypass that a later PR will remove. Adding a new bypass requires updating this list - /// explicitly — the review signal is loud because tests fail. - /// - private static readonly HashSet ExpectedCurrentBaseline = new HashSet(StringComparer.Ordinal) - { - // Rail A — engine effect path. Permitted after PR #10. - "AutopilotMonitor.Agent.V2.Core.Telemetry.Events.EventTimelineEmitter", - // Meta exception — cannot route through the ingress without a cycle. Permitted after PR #10. - "AutopilotMonitor.Agent.V2.Core.Telemetry.Events.BackPressureEventObserver", - // Owns the field and exposes it via the public EventEmitter property (BuildTelemetryEventSink - // factory + the ServerActionDispatcher emitEvent callback in Program.cs). Removed / - // internalized in PR #10 once those bypasses are themselves migrated. - "AutopilotMonitor.Agent.V2.Core.Orchestration.EnrollmentOrchestrator", - // PR #2 (5.2) migrated StartupEnvironmentProbes to InformationalEventPost — baseline - // shrunk by one. Next to fall: the ServerActionDispatcher / EnrollmentTerminationHandler - // callbacks, Periodic collectors, Enrollment SystemSignals trackers, Gather rules, - // Analyzers, DeviceInfoCollector (PR #3 – PR #9). - }; - [Fact] - public void V2Core_types_with_structural_dependency_on_TelemetryEventEmitter_match_current_baseline() + public void V2Core_types_with_structural_dependency_on_TelemetryEventEmitter_match_permitted_callers() { var actual = FindStructuralDependents(EmitterType.Assembly); - Assert.Equal(ExpectedCurrentBaseline.OrderBy(x => x), actual.OrderBy(x => x)); - } - - [Fact] - public void Current_baseline_is_a_superset_of_the_PR10_permitted_callers() - { - // Sanity guard: every permitted-after-PR10 caller must be present today, otherwise - // we've accidentally removed Rail A or the meta exception. - foreach (var permitted in PermittedCallersAfterPr10) - { - Assert.Contains(permitted, ExpectedCurrentBaseline); - } + Assert.Equal(PermittedCallers.OrderBy(x => x), actual.OrderBy(x => x)); } private static ISet FindStructuralDependents(Assembly assembly) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs index 67b6eac8..41450411 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorEventBridgeTests.cs @@ -1,50 +1,45 @@ using System; using System.Collections.Generic; using System.IO; -using System.Threading; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Orchestration; using AutopilotMonitor.Agent.V2.Core.Tests.Harness; using AutopilotMonitor.Agent.V2.Core.Tests.Transport; -using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry; using AutopilotMonitor.DecisionCore.Classifiers; using AutopilotMonitor.DecisionCore.Engine; -using AutopilotMonitor.Shared.Models; using Xunit; #pragma warning disable xUnit1031 namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration { + /// + /// Orchestrator ↔ lifecycle wiring. Plan §5.10 (single-rail + /// enforcement): the factory no longer receives an Action<EnrollmentEvent> sink + /// — collectors post via . These tests assert Start creates + /// hosts + starts them, Stop stops + disposes them, and the factory receives the ingress + + /// clock it needs to wire its own posts. + /// public sealed class EnrollmentOrchestratorEventBridgeTests { private static DateTime At => new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc); - /// Test host that lets a test manually fire enrollment events. private sealed class FakeCollectorHost : ICollectorHost { - private readonly Action _sink; public string Name { get; } public int StartCalls { get; private set; } public int StopCalls { get; private set; } public int DisposeCalls { get; private set; } - public FakeCollectorHost(string name, Action sink) - { - Name = name; - _sink = sink; - } + public FakeCollectorHost(string name) { Name = name; } public void Start() => StartCalls++; public void Stop() => StopCalls++; public void Dispose() => DisposeCalls++; - - public void Emit(EnrollmentEvent evt) => _sink(evt); } private sealed class FakeComponentFactory : IComponentFactory { - public Action? CapturedSink { get; private set; } public readonly List Hosts = new List(); public readonly IReadOnlyList HostNames; @@ -56,7 +51,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration } public IReadOnlyCollection? CapturedWhiteGloveSealingPatternIds { get; private set; } - public ISignalIngressSink? CapturedIngress { get; private set; } public IClock? CapturedClock { get; private set; } @@ -64,18 +58,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration string sessionId, string tenantId, AgentLogger logger, - Action onEnrollmentEvent, IReadOnlyCollection whiteGloveSealingPatternIds, ISignalIngressSink ingress, IClock clock) { - CapturedSink = onEnrollmentEvent; CapturedWhiteGloveSealingPatternIds = whiteGloveSealingPatternIds; CapturedIngress = ingress; CapturedClock = clock; foreach (var name in HostNames) { - Hosts.Add(new FakeCollectorHost(name, onEnrollmentEvent)); + Hosts.Add(new FakeCollectorHost(name)); } return Hosts; } @@ -117,17 +109,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration public void Dispose() => Tmp.Dispose(); } - private static EnrollmentEvent Evt(string eventType) => new EnrollmentEvent - { - EventType = eventType, - Severity = EventSeverity.Info, - Source = "test", - Phase = EnrollmentPhase.Unknown, - Timestamp = At, - }; - - // ========================================================================= Wiring - [Fact] public void Start_without_factory_does_not_throw_and_spawns_no_hosts() { @@ -152,14 +133,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration } [Fact] - public void Start_with_factory_creates_all_hosts_and_captures_sink() + public void Start_with_factory_creates_all_hosts_and_starts_them() { using var rig = new Rig(); var sut = rig.Build(); sut.Start(); - Assert.NotNull(rig.Factory.CapturedSink); Assert.Equal(5, rig.Factory.Hosts.Count); Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StartCalls)); @@ -167,87 +147,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration } [Fact] - public void Emit_from_host_reaches_telemetry_transport() + public void Factory_receives_ingress_and_clock_from_orchestrator() { using var rig = new Rig(); var sut = rig.Build(); + sut.Start(); - var hello = rig.Factory.Hosts.Find(h => h.Name == "HelloTracker")!; - hello.Emit(Evt("hello_completed")); - - var spool = GetSpool(sut); - var pending = spool.Peek(10); - Assert.Single(pending); - Assert.Equal(TelemetryItemKind.Event, pending[0].Kind); - Assert.Contains("hello_completed", pending[0].PayloadJson); - - sut.Stop(); - } - - [Fact] - public void Emit_from_each_of_five_hosts_produces_five_transport_items() - { - using var rig = new Rig(); - var sut = rig.Build(); - sut.Start(); - - foreach (var host in rig.Factory.Hosts) - { - host.Emit(Evt($"event_from_{host.Name}")); - } - - var pending = GetSpool(sut).Peek(10); - Assert.Equal(5, pending.Count); - foreach (var host in rig.Factory.Hosts) - { - Assert.Contains(pending, p => p.PayloadJson.Contains($"event_from_{host.Name}")); - } - - sut.Stop(); - } - - [Fact] - public void Modern_deployment_host_emits_event_bridge_only_no_decision_signal() - { - using var rig = new Rig(); - var sut = rig.Build(); - sut.Start(); - - var modern = rig.Factory.Hosts.Find(h => h.Name == "ModernDeploymentTracker")!; - modern.Emit(Evt("modern_deployment_log")); - - // Event reached transport (bridge works). - var pending = GetSpool(sut).Peek(10); - Assert.Single(pending); - Assert.Contains("modern_deployment_log", pending[0].PayloadJson); - - // No decision-signal was posted — signal-log is empty (no SignalAdapter wraps - // ModernDeploymentTracker by design, Plan §4.x M4.3-Erkenntnis). - var signalLog = GetSignalLog(sut); - Assert.Empty(signalLog.ReadAll()); - - sut.Stop(); - } - - [Fact] - public void Emitter_exception_does_not_propagate_to_host_thread() - { - // Simulate: TelemetryEventEmitter throws (e.g. transport disposed mid-emit). - // Build a shared sink that throws on demand; wrap into a host that uses it directly. - // Easier path: dispose the transport post-Start so Enqueue throws, then fire an event. - using var rig = new Rig(); - var sut = rig.Build(); - sut.Start(); - - // Force a transport-level exception by disposing it mid-run. - GetTransport(sut).Dispose(); - - var hello = rig.Factory.Hosts.Find(h => h.Name == "HelloTracker")!; - - // Must not throw — orchestrator wraps the emitter call in try/catch. - var ex = Record.Exception(() => hello.Emit(Evt("hello_after_dispose"))); - Assert.Null(ex); + Assert.NotNull(rig.Factory.CapturedIngress); + Assert.NotNull(rig.Factory.CapturedClock); + Assert.Same(rig.Clock, rig.Factory.CapturedClock); sut.Stop(); } @@ -264,31 +173,5 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.StopCalls)); Assert.All(rig.Factory.Hosts, h => Assert.Equal(1, h.DisposeCalls)); } - - // ========================================================================= Helpers - - private static TelemetrySpool GetSpool(EnrollmentOrchestrator sut) - { - var field = typeof(EnrollmentOrchestrator).GetField( - "_spool", - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - return (TelemetrySpool)field!.GetValue(sut)!; - } - - private static TelemetryUploadOrchestrator GetTransport(EnrollmentOrchestrator sut) - { - var field = typeof(EnrollmentOrchestrator).GetField( - "_transport", - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - return (TelemetryUploadOrchestrator)field!.GetValue(sut)!; - } - - private static AutopilotMonitor.Agent.V2.Core.Persistence.ISignalLogWriter GetSignalLog(EnrollmentOrchestrator sut) - { - var field = typeof(EnrollmentOrchestrator).GetField( - "_signalLog", - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); - return (AutopilotMonitor.Agent.V2.Core.Persistence.ISignalLogWriter)field!.GetValue(sut)!; - } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorOnIngressReadyTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorOnIngressReadyTests.cs index a6338464..417e0a2d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorOnIngressReadyTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorOnIngressReadyTests.cs @@ -138,7 +138,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration // Post-Start, observability accessors work (= Start completed, did not throw out). Assert.NotNull(orchestrator.IngressSink); - Assert.NotNull(orchestrator.EventEmitter); } [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs index 69ed392b..b4732426 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorTests.cs @@ -93,7 +93,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration var sut = rig.Build(); Assert.Throws(() => sut.CurrentState); Assert.Throws(() => sut.IngressSink); - Assert.Throws(() => sut.EventEmitter); // Regression guard: Program.WireTelemetryServerResponse must run AFTER Start(). // Touching Transport pre-Start threw InvalidOperationException and crashed the agent // on every VM after the first-run-ghost-detect fix let it reach this far. @@ -123,7 +122,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration Assert.Equal("S1", sut.CurrentState.SessionId); Assert.Equal("T1", sut.CurrentState.TenantId); Assert.NotNull(sut.IngressSink); - Assert.NotNull(sut.EventEmitter); Assert.False(sut.IsQuarantineRequested); sut.Stop(); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs index 5663a9b2..3a9d9a8c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Orchestration/EnrollmentOrchestratorWhiteGlovePatternTests.cs @@ -24,7 +24,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration string sessionId, string tenantId, AgentLogger logger, - Action onEnrollmentEvent, IReadOnlyCollection whiteGloveSealingPatternIds, ISignalIngressSink ingress, IClock clock) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 9ef4ecea..32821de9 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -91,7 +91,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration string sessionId, string tenantId, AgentLogger logger, - Action onEnrollmentEvent, IReadOnlyCollection whiteGloveSealingPatternIds, ISignalIngressSink ingress, IClock clock) @@ -99,7 +98,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId required.", nameof(sessionId)); if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId required.", nameof(tenantId)); if (logger == null) throw new ArgumentNullException(nameof(logger)); - if (onEnrollmentEvent == null) throw new ArgumentNullException(nameof(onEnrollmentEvent)); if (ingress == null) throw new ArgumentNullException(nameof(ingress)); if (clock == null) throw new ArgumentNullException(nameof(clock)); @@ -148,7 +146,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration _imeLogHost = new ImeLogHost( sessionId: sessionId, tenantId: tenantId, - onEnrollmentEvent: onEnrollmentEvent, logger: logger, ingress: ingress, clock: clock, @@ -518,7 +515,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration public ImeLogHost( string sessionId, string tenantId, - Action onEnrollmentEvent, AgentLogger logger, ISignalIngressSink ingress, IClock clock, diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs index cd08baf6..d2bc503e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/EnrollmentOrchestrator.cs @@ -93,7 +93,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private EventSequenceCounter? _eventSequenceCounter; private TelemetrySpool? _spool; private TelemetryUploadOrchestrator? _transport; - private TelemetryEventEmitter? _eventEmitter; private TelemetrySignalEmitter? _signalEmitter; private TelemetryTransitionEmitter? _transitionEmitter; private EventTimelineEmitter? _timelineEmitter; @@ -222,10 +221,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration public ISignalIngressSink IngressSink => (ISignalIngressSink?)_ingress ?? throw new InvalidOperationException("Orchestrator not started."); - /// Exposed für Sub-c-Wiring (Collector onEventCollected-Bridge). - public TelemetryEventEmitter EventEmitter => - _eventEmitter ?? throw new InvalidOperationException("Orchestrator not started."); - /// /// Exposed so Program.cs can subscribe to /// for M4.6.ε DeviceBlocked / DeviceKillSignal / AdminAction / Actions plumbing. @@ -321,13 +316,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration _spool = new TelemetrySpool(_transportDirectory, _clock); _transport = new TelemetryUploadOrchestrator(_spool, _uploader, _clock); - // 4) Event-Emitter-Kette. + // 4) Event-Emitter-Kette. Single-rail (Plan §5.10): der TelemetryEventEmitter wird + // lokal gebaut und nur in die zwei erlaubten Caller (EventTimelineEmitter, + // BackPressureEventObserver) injiziert. Kein Feld mehr auf dem Orchestrator — + // strukturelle Abhängigkeit erlischt damit, Architektur-Baseline-Test gate + // shrinks to exactly two permitted callers. _eventSequenceCounter = new EventSequenceCounter(_eventSequencePersistence); - _eventEmitter = new TelemetryEventEmitter(_transport, _eventSequenceCounter, _sessionId, _tenantId); + var eventEmitter = new TelemetryEventEmitter(_transport, _eventSequenceCounter, _sessionId, _tenantId); _signalEmitter = new TelemetrySignalEmitter(_transport, _sessionId, _tenantId); _transitionEmitter = new TelemetryTransitionEmitter(_transport, _sessionId, _tenantId); - _timelineEmitter = new EventTimelineEmitter(_eventEmitter); - _backPressureObserver = new BackPressureEventObserver(_eventEmitter, _clock); + _timelineEmitter = new EventTimelineEmitter(eventEmitter); + _backPressureObserver = new BackPressureEventObserver(eventEmitter, _clock); // 5) Deadlines + Classifiers. _scheduler = new DeadlineScheduler(_clock); @@ -441,14 +440,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration } } - // 14) Collector-Hosts via Factory — onEnrollmentEvent bridged Telemetry-Events - // an den zentralen TelemetryEventEmitter. Exception-Swallow damit ein - // Collector-Thread nicht am Emitter-Fehler stirbt (Plan §2.7a). + // 14) Collector-Hosts via Factory — nach Plan §5.10 (single-rail enforcement) gibt + // es keine Action-Bridge mehr; jede Collector-Emission fließt + // über den Ingress als InformationalEvent. if (_componentFactory != null) { - var safeSink = BuildTelemetryEventSink(); _collectorHosts = _componentFactory.CreateCollectorHosts( - _sessionId, _tenantId, _logger, safeSink, _whiteGloveSealingPatternIds, + _sessionId, _tenantId, _logger, _whiteGloveSealingPatternIds, ingress: _ingress, clock: _clock); foreach (var host in _collectorHosts) { @@ -667,27 +665,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration if (!Directory.Exists(_transportDirectory)) Directory.CreateDirectory(_transportDirectory); } - /// - /// Shared callback that collector hosts use for onEventCollected. Exception-safe: - /// an emitter failure (disk hiccup, transport closed) must never kill a collector thread. - /// - private Action BuildTelemetryEventSink() - { - return evt => - { - if (evt == null || _eventEmitter == null) return; - try - { - _eventEmitter.Emit(evt); - } - catch (Exception ex) - { - _logger.Error( - $"EnrollmentOrchestrator: telemetry emit failed for event '{evt.EventType}'.", ex); - } - }; - } - private void OnDeadlineFired(object? sender, DeadlineFiredEventArgs e) { if (Volatile.Read(ref _stopRequested) == 1 || _ingress == null) return; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ICollectorHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ICollectorHost.cs index 2f8cbdd8..ae4c8b10 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ICollectorHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/ICollectorHost.cs @@ -5,14 +5,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration { /// /// Lifecycle-Abstraktion für einen einzelnen Collector im V2-Runtime. - /// Plan §2.1 / §4.x M4.4.5.c. + /// Plan §2.1 / §4.x M4.4.5.c / §5.10 (single-rail enforcement). /// /// Ein Host wrappt einen konkreten Collector (z.B. HelloTracker, /// ShellCoreTracker, ModernDeploymentTracker) und kapselt Start/Stop. - /// Die Action<EnrollmentEvent>-Callback für onEventCollected wird bei - /// der Host-Erstellung über die injiziert — der - /// Orchestrator übergibt dort - /// als Sink. + /// Nach PR #10 posten Collectors ihre Events ausschließlich als + /// InformationalEvent-Signals über den geteilten , + /// der über die bei der Host-Erstellung injiziert wird. /// /// /// SignalAdapter-Wiring: separat — kann die diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs index fd60fd64..eb97635e 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/IComponentFactory.cs @@ -1,53 +1,37 @@ #nullable enable -using System; using System.Collections.Generic; using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.DecisionCore.Engine; -using AutopilotMonitor.Shared.Models; namespace AutopilotMonitor.Agent.V2.Core.Orchestration { /// - /// Factory-Seam für Collector-Hosts. Plan §4.x M4.4.5.c + M4.5.b. + /// Factory-Seam für Collector-Hosts. Plan §4.x M4.4.5.c / §5.10 (single-rail enforcement). /// /// Der delegiert an diese Factory, weil die konkreten /// Collector-Ctoren heavy Production-Deps (Event-Log-Watcher, WMI, Registry) haben — Tests - /// liefern eine Fake-Factory, Prod liefert eine Default-Implementation, die HelloTracker, - /// ShellCoreTracker, ProvisioningStatusTracker, StallProbeCollector und - /// ModernDeploymentTracker baut und deren onEventCollected-Callbacks an den - /// übergebenen bindet. + /// liefern eine Fake-Factory, Prod liefert eine Default-Implementation, die die Tracker + /// + Hosts baut. Nach PR #10 gibt es keinen Action<EnrollmentEvent>-Parameter + /// mehr — alle Collectors posten via (Signal-Rail) statt + /// direkt an den . /// /// - /// ModernDeploymentTracker-Spezifikum: der einzige Kollektor ohne SignalAdapter — - /// bridged nur Events, keine DecisionSignals. Default-Factory-Impls dürfen ihn aufbauen, - /// Tests brauchen ihn nicht zwingend. + /// wird an den ImeLogTrackerAdapter + /// durchgereicht (Sealing-Emission fire-once nur für diese Pattern-IDs). Leer / null + /// = Feature off, M3-kompatibel. Plan §4.x M4.4.5.e. /// /// public interface IComponentFactory { /// - /// Baut alle Collector-Hosts und bindet deren Event-Emission an - /// (typischerweise - /// ). - /// Exceptions im Callback darf die Factory NICHT weiterreichen — der Orchestrator - /// wrappt das mit einem Exception-Swallow, damit ein Collector-Thread nicht stirbt. - /// - /// wird an den ImeLogTrackerAdapter - /// durchgereicht (Sealing-Emission fire-once nur für diese Pattern-IDs). Leer / null - /// = Feature off, M3-kompatibel. Plan §4.x M4.4.5.e. - /// - /// - /// M4.5.b-Extension: und werden - /// durchgereicht, damit die Default-Factory SignalAdapter direkt mitwiren kann (Adapters - /// brauchen einen + , die erst im - /// Orchestrator gebaut werden). - /// + /// Baut alle Collector-Hosts. Jeder Host ist selbst dafür zuständig, seine + /// InformationalEventPost aus (ingress, clock) zu konstruieren und Collector-Events + /// als InformationalEvent-Signals über zu posten. /// IReadOnlyList CreateCollectorHosts( string sessionId, string tenantId, AgentLogger logger, - Action onEnrollmentEvent, IReadOnlyCollection whiteGloveSealingPatternIds, ISignalIngressSink ingress, IClock clock); diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/BackPressureEventObserver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/BackPressureEventObserver.cs index d457aadf..6c0e2bc5 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/BackPressureEventObserver.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/BackPressureEventObserver.cs @@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events /// — hier wird jedes reingereichte Ereignis emittiert. /// /// - public sealed class BackPressureEventObserver : IBackPressureObserver + internal sealed class BackPressureEventObserver : IBackPressureObserver { internal const string EventType = "ingress_backpressure"; internal const string SourceId = "signal_ingress"; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs index 9ab2c8ab..66651f19 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventTimelineEmitter.cs @@ -25,7 +25,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events /// throw on a malformed phase value. /// /// - public sealed class EventTimelineEmitter : IEventTimelineEmitter + internal sealed class EventTimelineEmitter : IEventTimelineEmitter { /// Default when no override is provided. internal const string SourceId = "decision_engine"; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs index ebdabef1..ad9ef7eb 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs @@ -8,13 +8,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events { /// /// Zentrale Senke für alle -Emissionen im V2-Agent. - /// Plan §2.7a / L.10. + /// Plan §2.7a / L.10 / §5.10 (single-rail enforcement). /// - /// Drei Aufrufer: (Reducer-Effekte), - /// Collector-Callbacks (via Orchestrator-Wiring in M4.4.5) und - /// . Alle nutzen denselben Pfad: - /// Sequence-Vergabe → Wire-Format-Serialisierung (Newtonsoft, Legacy-kompatibel) → - /// Kind=Event → . + /// Nach PR #10 (single-rail enforcement) sind nur noch zwei Aufrufer erlaubt: + /// (Rail A, Reducer-Effekt-Pfad) und + /// (Meta-Ausnahme wegen zirkulärer + /// Ingress-Abhängigkeit). Beide wohnen im gleichen Namespace — der Typ ist + /// internal, sodass ein dritter Caller nur aus demselben Namespace + /// kompilieren kann (plus Test-Assembly via InternalsVisibleTo). /// /// /// Mutation: setzt , , @@ -22,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events /// Caller darf das Event nach nicht weiter verwenden. /// /// - public sealed class TelemetryEventEmitter + internal sealed class TelemetryEventEmitter { private readonly ITelemetryTransport _transport; private readonly EventSequenceCounter _sequenceCounter; @@ -49,7 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events /// Weist Sequence zu, serialisiert im Legacy-Wire-Format und enqueued via Transport. /// Thread-safe (EventSequenceCounter + ITelemetryTransport.Enqueue beide unter Lock). /// - public TelemetryItem Emit(EnrollmentEvent evt) + internal TelemetryItem Emit(EnrollmentEvent evt) { if (evt == null) throw new ArgumentNullException(nameof(evt)); if (string.IsNullOrEmpty(evt.EventType))