fix(agent-v2): wire EspExited through coordinator to production adapter (PR4/5)

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-13 13:20:34 +02:00
parent 4853f8c6d1
commit 4a6bc656df
5 changed files with 187 additions and 12 deletions
@@ -16,17 +16,17 @@ using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
{
/// <summary>
/// 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
/// <see cref="DecisionSignalKind.EspExiting"/> signal AND, when fed through the
/// reducer with AccountSetup already entered, that signal must arm the
/// <c>HelloSafety</c> deadline.
/// <para>
/// This complements <c>ShellCoreTrackerAdapterTests</c> (which uses the test-seam
/// <c>TriggerEspExitingFromTest</c>) by exercising the real .NET event wiring
/// from tracker → adapter → ingress, plus an end-to-end reducer step.
/// </para>
/// Plan §EspExiting Adapter — wire-through verification on TWO topologies:
/// <list type="number">
/// <item>Single-tracker wiring: <c>ShellCoreTracker.EspExited</c> →
/// <c>ShellCoreTrackerAdapter</c> → ingress (test-only scenario).</item>
/// <item>Production wiring: <c>ShellCoreTracker.EspExited</c> → inner re-raise on
/// <c>EspAndHelloTracker.EspExited</c> → <c>EspAndHelloTrackerAdapter</c> → 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.</item>
/// </list>
/// Both paths end with a reducer step that arms the <c>HelloSafety</c> deadline when
/// AccountSetup is already entered.
/// </summary>
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<DateTime>(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);
}
}
}
@@ -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()
{
@@ -85,6 +85,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
// DecisionSignalKind.HelloPolicyDetected signal.
public event Action<bool, string> HelloPolicyDetected;
// Coordinator-forwarded ESP exit (from <see cref="ShellCoreTracker.EspExited"/>). Carries
// the source-event timestamp on the args so the adapter can post a
// <c>DecisionSignalKind.EspExiting</c> 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 <c>ShouldTransitionToAwaitingHello</c>
// guard distinguishes the genuine post-AccountSetup exit from intermediate ones.
public event EventHandler<EspExitedEventArgs> EspExited;
/// <summary>
/// UTC timestamp of the most recent sub-tracker event currently being raised
/// (mirrored from <see cref="ShellCoreTracker.LastEventOccurredAtUtc"/> 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; }
}
}
}
@@ -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.
/// </summary>
internal sealed class EspExitedEventArgs : EventArgs
public sealed class EspExitedEventArgs : EventArgs
{
public EspExitedEventArgs(DateTime occurredAtUtc)
{
@@ -26,6 +26,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
/// <item><c>WhiteGloveCompleted</c> → <see cref="DecisionSignalKind.WhiteGloveShellCoreSuccess"/></item>
/// <item><c>EspFailureDetected</c> → <see cref="DecisionSignalKind.EspTerminalFailure"/> (merged aus ShellCore + Provisioning)</item>
/// <item><c>DeviceSetupProvisioningComplete</c> → <see cref="DecisionSignalKind.DeviceSetupProvisioningComplete"/></item>
/// <item><c>EspExited</c> → <see cref="DecisionSignalKind.EspExiting"/> (no dedup — every 62407 forwarded; reducer guard decides)</item>
/// </list>
/// </para>
/// <para>
@@ -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();
/// <summary>
/// Reads <c>HelloOutcome</c> 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<string, string>(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;