From 4a6bc656df203e40588484b3d11b01f6b6c8a7bb Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 13 May 2026 13:20:34 +0200 Subject: [PATCH] fix(agent-v2): wire EspExited through coordinator to production adapter (PR4/5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR1 added EspExited on ShellCoreTracker + a mapping on ShellCoreTrackerAdapter, but ShellCoreTrackerAdapter is only used in single-tracker test wiring. In production EspAndHelloHost instantiates the EspAndHelloTracker coordinator, and EspAndHelloTrackerAdapter subscribes to coordinator-forwarded events — the new EspExited never reached the engine on a real device. This commit reroutes EspExited through the coordinator (the same path the other ShellCore-originated events take): - EspAndHelloTracker re-raises EspExited from its inner ShellCoreTracker, with LastEventOccurredAtUtc mirrored from the EventArgs timestamp so the adapter's ResolveOccurredAt picks up the source-event time (matching the existing WhiteGloveCompleted / EspFailureDetected forwarding contract). - EspAndHelloTrackerAdapter subscribes to coordinator.EspExited and posts DecisionSignalKind.EspExiting with no per-kind dedup — Shell-Core 62407 fires on every ESP phase transition, and the reducer's ShouldTransitionToAwaitingHello guard decides which occurrence arms HelloSafety. The coordinator's existing IsIntermediateDeviceEspExit filter only governs the legacy FinalizingSetupPhaseTriggered path; the new EspExiting signal bypasses it deliberately so the engine remains the single source of truth. - EspExitedEventArgs becomes public (was internal) because the coordinator's public event surface needs an at-least-as-accessible args type. Tests: - EspAndHelloTrackerAdapterTests: +2 tests covering the coordinator-forwarded mapping and the no-dedup contract. - ShellCoreToEngineEspExitingTests: +2 tests exercising the full production topology (coordinator → adapter → ingress → engine.Reduce → HelloSafety arm). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ShellCoreToEngineEspExitingTests.cs | 100 ++++++++++++++++-- .../EspAndHelloTrackerAdapterTests.cs | 38 +++++++ .../SystemSignals/EspAndHelloTracker.cs | 24 +++++ .../SystemSignals/EspExitedEventArgs.cs | 2 +- .../EspAndHelloTrackerAdapter.cs | 35 ++++++ 5 files changed, 187 insertions(+), 12 deletions(-) diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ShellCoreToEngineEspExitingTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ShellCoreToEngineEspExitingTests.cs index 714513ea..d139a298 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ShellCoreToEngineEspExitingTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Integration/ShellCoreToEngineEspExitingTests.cs @@ -16,17 +16,17 @@ using Xunit; namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration { /// - /// Plan §EspExiting Adapter — wire-through verification: a real ShellCoreTracker - /// processing an esp_exiting record (via the internal Backfill path that mirrors - /// the live event-log handler) must drive the adapter into posting a - /// signal AND, when fed through the - /// reducer with AccountSetup already entered, that signal must arm the - /// HelloSafety deadline. - /// - /// This complements ShellCoreTrackerAdapterTests (which uses the test-seam - /// TriggerEspExitingFromTest) by exercising the real .NET event wiring - /// from tracker → adapter → ingress, plus an end-to-end reducer step. - /// + /// Plan §EspExiting Adapter — wire-through verification on TWO topologies: + /// + /// Single-tracker wiring: ShellCoreTracker.EspExited → + /// ShellCoreTrackerAdapter → ingress (test-only scenario). + /// Production wiring: ShellCoreTracker.EspExited → inner re-raise on + /// EspAndHelloTracker.EspExited → EspAndHelloTrackerAdapter → ingress. + /// This is the path the live V2-Host uses; without the coordinator forward the + /// new EspExiting signal would never reach the engine in production. + /// + /// Both paths end with a reducer step that arms the HelloSafety deadline when + /// AccountSetup is already entered. /// public sealed class ShellCoreToEngineEspExitingTests { @@ -117,5 +117,83 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration e => e.Kind == DecisionEffectKind.ScheduleDeadline && e.Deadline?.Name == DeadlineNames.HelloSafety); } + + // -------------------------------------------- Coordinator-pfad (production wiring) -- + + [Fact] + public void Coordinator_propagates_EspExited_to_EspAndHelloTrackerAdapter_as_EspExiting_signal() + { + // Production wiring: V2-Host instantiates EspAndHelloTracker (coordinator) which owns + // a private ShellCoreTracker; EspAndHelloTrackerAdapter subscribes to the + // coordinator's re-raised events. The new EspExited event must traverse the + // coordinator and emerge on the adapter as DecisionSignalKind.EspExiting. + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + var clock = new VirtualClock(ClockNow); + var ingress = new FakeSignalIngressSink(); + var coordinatorPost = new InformationalEventPost(new FakeSignalIngressSink(), clock); + using var coordinator = new EspAndHelloTracker( + sessionId: "S1", + tenantId: "T1", + post: coordinatorPost, + logger: logger); + // The coordinator's inner ShellCoreTracker comes alive on Start(); we can't drive + // a real Event-Log record from a unit test, so simulate the inner forward by + // raising EspExited on the coordinator via the test-only seam. + using var adapter = new EspAndHelloTrackerAdapter(coordinator, ingress, clock); + + adapter.TriggerEspExitingFromTest(); + + var espExiting = ingress.Posted.FirstOrDefault(p => p.Kind == DecisionSignalKind.EspExiting); + Assert.NotNull(espExiting); + Assert.Equal("EspAndHelloTracker", espExiting!.SourceOrigin); + Assert.Equal("ShellCoreTracker", espExiting.Evidence.DerivationInputs!["subSource"]); + } + + [Fact] + public void Coordinator_to_reducer_arms_HelloSafety_when_AccountSetup_observed() + { + // End-to-end through the production-wired coordinator pipeline + engine reducer. + using var tmp = new TempDirectory(); + var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info); + var clock = new VirtualClock(ClockNow); + var ingress = new FakeSignalIngressSink(); + var coordinatorPost = new InformationalEventPost(new FakeSignalIngressSink(), clock); + using var coordinator = new EspAndHelloTracker( + sessionId: "S1", + tenantId: "T1", + post: coordinatorPost, + logger: logger); + using var adapter = new EspAndHelloTrackerAdapter(coordinator, ingress, clock); + + adapter.TriggerEspExitingFromTest(); + + var espExiting = ingress.Posted.FirstOrDefault(p => p.Kind == DecisionSignalKind.EspExiting); + Assert.NotNull(espExiting); + + var engine = new DecisionEngine(); + var seed = DecisionState.CreateInitial("S1", "T1", agentBootUtc: ClockNow.AddMinutes(-5)) + .ToBuilder() + .WithStage(SessionStage.EspAccountSetup) + .WithStepIndex(2) + .WithLastAppliedSignalOrdinal(1); + seed.AccountSetupEnteredUtc = new SignalFact(ClockNow.AddMinutes(-2), 1); + var state = seed.Build(); + + var enriched = new DecisionSignal( + sessionSignalOrdinal: 2, + sessionTraceOrdinal: 2, + kind: espExiting!.Kind, + kindSchemaVersion: espExiting.KindSchemaVersion, + occurredAtUtc: espExiting.OccurredAtUtc, + sourceOrigin: espExiting.SourceOrigin, + evidence: espExiting.Evidence, + payload: espExiting.Payload); + + var step = engine.Reduce(state, enriched); + + Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage); + Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety); + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs index c339e2f5..1033957c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/EspAndHelloTrackerAdapterTests.cs @@ -140,6 +140,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Contains(DecisionSignalKind.DeviceSetupProvisioningComplete, kinds); } + // ---- EspExited (coordinator-forwarded from ShellCoreTracker.EspExited) ---- + + [Fact] + public void EspExitedEvent_emits_EspExiting_signal_via_coordinator() + { + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + + adapter.TriggerEspExitingFromTest(); + + var posted = Assert.Single(f.Ingress.Posted); + Assert.Equal(DecisionSignalKind.EspExiting, posted.Kind); + Assert.Equal("EspAndHelloTracker", posted.SourceOrigin); + Assert.Equal(EvidenceKind.Derived, posted.Evidence.Kind); + Assert.Contains("ESP exiting", posted.Evidence.Summary); + Assert.Equal("ShellCoreTracker", posted.Evidence.DerivationInputs!["subSource"]); + Assert.Equal("62407", posted.Evidence.DerivationInputs["eventId"]); + } + + [Fact] + public void EspExited_posts_every_occurrence_no_dedup_at_adapter_layer() + { + // Shell-Core 62407 fires at every ESP phase transition (Device→Account, Account→End). + // The adapter must NOT dedup — the reducer (ShouldTransitionToAwaitingHello) decides + // which occurrence is the genuine post-AccountSetup exit. (Coordinator-side + // IsIntermediateDeviceEspExit filters the legacy FinalizingSetupPhaseTriggered path, + // not this one.) + using var f = new Fixture(); + using var adapter = new EspAndHelloTrackerAdapter(f.Coordinator, f.Ingress, f.Clock); + + adapter.TriggerEspExitingFromTest(); + adapter.TriggerEspExitingFromTest(); + adapter.TriggerEspExitingFromTest(); + + var espExitingPosts = f.Ingress.Posted.Where(p => p.Kind == DecisionSignalKind.EspExiting).ToList(); + Assert.Equal(3, espExitingPosts.Count); + } + [Fact] public void Ctor_null_args_throw() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs index 33035bd6..4c1f9f69 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspAndHelloTracker.cs @@ -85,6 +85,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals // DecisionSignalKind.HelloPolicyDetected signal. public event Action HelloPolicyDetected; + // Coordinator-forwarded ESP exit (from ). Carries + // the source-event timestamp on the args so the adapter can post a + // DecisionSignalKind.EspExiting signal with the historical instant on backfill. + // No dedup at this layer — Shell-Core 62407 fires at every ESP phase transition + // (Device→Account, Account→End), and the reducer's ShouldTransitionToAwaitingHello + // guard distinguishes the genuine post-AccountSetup exit from intermediate ones. + public event EventHandler EspExited; + /// /// UTC timestamp of the most recent sub-tracker event currently being raised /// (mirrored from for ShellCore- @@ -232,6 +240,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals _shellCoreTracker.FinalizingSetupPhaseTriggered += OnFinalizingSetupPhaseTriggered; _shellCoreTracker.WhiteGloveCompleted += OnWhiteGloveCompleted; _shellCoreTracker.EspFailureDetected += OnEspFailureDetected; + _shellCoreTracker.EspExited += OnEspExited; _shellCoreTracker.Start(); _provisioningTracker = new ProvisioningStatusTracker( @@ -292,6 +301,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals _shellCoreTracker.FinalizingSetupPhaseTriggered -= OnFinalizingSetupPhaseTriggered; _shellCoreTracker.WhiteGloveCompleted -= OnWhiteGloveCompleted; _shellCoreTracker.EspFailureDetected -= OnEspFailureDetected; + _shellCoreTracker.EspExited -= OnEspExited; _shellCoreTracker.Stop(); } catch (Exception ex) { _logger.Error("Error stopping Shell-Core tracker", ex); } @@ -453,5 +463,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals try { DeviceSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); } catch (Exception ex) { _logger.Error("Error forwarding DeviceSetupProvisioningComplete", ex); } } + + private void OnEspExited(object sender, EspExitedEventArgs args) + { + // EspExited carries its source-event timestamp on the args (set by ShellCoreTracker + // for both live and backfill records). Mirror onto LastEventOccurredAtUtc so the + // adapter's ResolveOccurredAt helper picks it up the same way it does for the other + // ShellCore-originated forwards. Unlike FinalizingSetupPhaseTriggered("esp_exiting") + // this path is NOT filtered by IsIntermediateDeviceEspExit — the reducer-side + // ShouldTransitionToAwaitingHello guard decides whether HelloSafety arms. + LastEventOccurredAtUtc = args?.OccurredAtUtc; + try { EspExited?.Invoke(this, args); } + catch (Exception ex) { _logger.Error("Error forwarding EspExited", ex); } + finally { LastEventOccurredAtUtc = null; } + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspExitedEventArgs.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspExitedEventArgs.cs index 97a34115..d39f9d7d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspExitedEventArgs.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EspExitedEventArgs.cs @@ -8,7 +8,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// so subscribers can stamp downstream Decision-Signals with the historical UTC instead /// of collapsing to wall-clock-now. /// - internal sealed class EspExitedEventArgs : EventArgs + public sealed class EspExitedEventArgs : EventArgs { public EspExitedEventArgs(DateTime occurredAtUtc) { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs index 13e7bc57..c044b285 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/SignalAdapters/EspAndHelloTrackerAdapter.cs @@ -26,6 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters /// WhiteGloveCompleted → /// EspFailureDetected → (merged aus ShellCore + Provisioning) /// DeviceSetupProvisioningComplete → + /// EspExited → (no dedup — every 62407 forwarded; reducer guard decides) /// /// /// @@ -67,6 +68,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _coordinator.EspFailureDetected += OnEspFailure; _coordinator.DeviceSetupProvisioningComplete += OnDeviceSetupComplete; _coordinator.HelloPolicyDetected += OnHelloPolicyDetected; + _coordinator.EspExited += OnEspExited; } public void Dispose() @@ -77,6 +79,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters _coordinator.EspFailureDetected -= OnEspFailure; _coordinator.DeviceSetupProvisioningComplete -= OnDeviceSetupComplete; _coordinator.HelloPolicyDetected -= OnHelloPolicyDetected; + _coordinator.EspExited -= OnEspExited; } private void OnHelloCompleted(object sender, EventArgs e) => EmitHello(ReadHelloOutcome()); @@ -85,6 +88,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters private void OnEspFailure(object sender, string failureType) => EmitEspFailure(failureType); private void OnDeviceSetupComplete(object sender, EventArgs e) => EmitDeviceSetupComplete(); private void OnHelloPolicyDetected(bool helloEnabled, string source) => EmitHelloPolicy(helloEnabled, source); + private void OnEspExited(object sender, EspExitedEventArgs args) => EmitEspExiting(); internal void TriggerHelloFromTest(string? helloOutcome) => EmitHello(helloOutcome); internal void TriggerFinalizingFromTest(string reason) => EmitFinalizing(reason); @@ -92,6 +96,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters internal void TriggerEspFailureFromTest(string failureType) => EmitEspFailure(failureType); internal void TriggerDeviceSetupCompleteFromTest() => EmitDeviceSetupComplete(); internal void TriggerHelloPolicyDetectedFromTest(bool helloEnabled, string source) => EmitHelloPolicy(helloEnabled, source); + internal void TriggerEspExitingFromTest() => EmitEspExiting(); /// /// Reads HelloOutcome from the coordinator's forwarded property @@ -281,6 +286,36 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters }); } + // Coordinator-forwarded EspExiting. NO per-kind dedup — Shell-Core 62407 fires at every + // ESP phase transition (Device→Account, Account→End), and the reducer's + // ShouldTransitionToAwaitingHello guard distinguishes the genuine post-AccountSetup exit + // from intermediate ones. Posting all occurrences keeps the SignalLog complete for the + // Inspector and lets the reducer be the single source of truth for "is this the real exit". + // Source-event timestamp is read from the coordinator's LastEventOccurredAtUtc mirror + // (set by OnEspExited from the EspExitedEventArgs), with a clock fallback tagged in + // evidence so the Inspector can flag non-source-grounded signals on edge cases. + private void EmitEspExiting() + { + var now = ResolveOccurredAt(out var derivedFromClock); + var derivationInputs = new Dictionary(StringComparer.Ordinal) + { + ["subSource"] = "ShellCoreTracker", + ["eventSource"] = "Microsoft-Windows-Shell-Core", + ["eventId"] = "62407", + }; + TagDerivedTimestamp(derivationInputs, derivedFromClock); + + _ingress.Post( + kind: DecisionSignalKind.EspExiting, + occurredAtUtc: now, + sourceOrigin: SourceOrigin, + evidence: new Evidence( + kind: EvidenceKind.Derived, + identifier: DetectorId, + summary: "ESP exiting (coordinator-forwarded Shell-Core 62407)", + derivationInputs: derivationInputs)); + } + private void EmitDeviceSetupComplete() { if (_deviceSetupCompletePosted) return;