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;