mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
+89
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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()
|
||||
{
|
||||
|
||||
+24
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user