refactor(v2core): M4.3.4 signal adapter (ime log tracker)

Plan §2.1a / §2.2 — schwerster Adapter: ImeLogTracker mit Action-Property-
Callbacks (nicht Events); 3+ Signal-Mappings + Phase-Value-Dedup + per-App-
Terminal-State-Dedup.

New:
- ImeLogTrackerAdapter
  - OnEspPhaseChanged(phase) → EspPhaseChanged (dedup per distinct phase value,
    §2.1a Idempotenz-Anforderung; null/empty → skip)
  - OnUserSessionCompleted() → ImeUserSessionCompleted (fire-once)
  - OnAppStateChanged(app, old, new) → AppInstallCompleted (Installed/Skipped/
    Postponed) oder AppInstallFailed (Error); non-terminal states ignoriert;
    per-AppId Dedup
- Chain-preserve: vorherige Action-Handler bleiben callable (Orchestrator kann
  diagnostische Callbacks parallel wiren); Dispose restauriert wenn wir noch
  der current handler sind
- Delegate-instance caching (_our* Felder) weil implizite method-group-conversion
  jedes Mal neue Delegate-Instanz baut → ReferenceEquals in Dispose sonst fail

NICHT Teil M4.3.4 (Tech Debt noted):
- WhiteGloveSealingPatternDetected Signal-Emission — ImeLogTracker hat keinen
  öffentlichen Pattern-Match-Hook; LastMatchedPatternId ist Property. Re-Mapping
  braucht entweder Polling-Adapter oder Tracker-Action-Surface-Erweiterung.
  Reducer-M3-Szenarien laufen ohne das Signal grün; Add-on in M4.4/M4.5.

Tests (+16, 123 total V2.Core.Tests):
- EspPhase first-emit / repeat-dedup / distinct-values / null-skip
- UserSessionCompleted fire-once
- AppState Theory (4 terminal states → 2 kinds)
- Non-terminal skipped; per-app fire-once
- Different apps independent; null-app ignored
- Prior-Action chain-preserve
- Dispose restores prior handler
- Null-args throw

Testsuite: 1228 → 1244.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 16:56:40 +02:00
parent 3c1fca8089
commit e97eb08ec5
2 changed files with 462 additions and 0 deletions
@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
{
public sealed class ImeLogTrackerAdapterTests
{
private static readonly DateTime Fixed = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
private sealed class Fixture : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
public AgentLogger Logger { get; }
public ImeLogTracker Tracker { get; }
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
public VirtualClock Clock { get; } = new VirtualClock(Fixed);
public Fixture()
{
Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
Tracker = new ImeLogTracker(
logFolder: Tmp.Path,
patterns: new List<ImeLogPattern>(),
logger: Logger);
}
public void Dispose()
{
Tracker.Dispose();
Tmp.Dispose();
}
}
[Fact]
public void EspPhaseChanged_first_phase_emits_signal_with_phase_in_payload()
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerEspPhaseFromTest("DeviceSetup");
var posted = Assert.Single(f.Ingress.Posted);
Assert.Equal(DecisionSignalKind.EspPhaseChanged, posted.Kind);
Assert.Equal("ImeLogTracker", posted.SourceOrigin);
Assert.Equal("DeviceSetup", posted.Payload![SignalPayloadKeys.EspPhase]);
}
[Fact]
public void EspPhaseChanged_same_phase_repeated_is_deduped_idempotency()
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerEspPhaseFromTest("DeviceSetup");
adapter.TriggerEspPhaseFromTest("DeviceSetup");
adapter.TriggerEspPhaseFromTest("DeviceSetup");
Assert.Single(f.Ingress.Posted);
}
[Fact]
public void EspPhaseChanged_distinct_phases_emit_separate_signals()
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerEspPhaseFromTest("DeviceSetup");
adapter.TriggerEspPhaseFromTest("AccountSetup");
adapter.TriggerEspPhaseFromTest("AccountSetup"); // dedup
adapter.TriggerEspPhaseFromTest("FinalizingSetup");
Assert.Equal(3, f.Ingress.Posted.Count);
Assert.Equal("DeviceSetup", f.Ingress.Posted[0].Payload![SignalPayloadKeys.EspPhase]);
Assert.Equal("AccountSetup", f.Ingress.Posted[1].Payload![SignalPayloadKeys.EspPhase]);
Assert.Equal("FinalizingSetup", f.Ingress.Posted[2].Payload![SignalPayloadKeys.EspPhase]);
}
[Fact]
public void EspPhaseChanged_null_or_empty_phase_is_skipped()
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerEspPhaseFromTest(null!);
adapter.TriggerEspPhaseFromTest("");
Assert.Empty(f.Ingress.Posted);
}
[Fact]
public void UserSessionCompleted_emits_ImeUserSessionCompleted_fire_once()
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerUserSessionCompletedFromTest();
adapter.TriggerUserSessionCompletedFromTest();
var posted = Assert.Single(f.Ingress.Posted);
Assert.Equal(DecisionSignalKind.ImeUserSessionCompleted, posted.Kind);
}
[Theory]
[InlineData(AppInstallationState.Installed, DecisionSignalKind.AppInstallCompleted)]
[InlineData(AppInstallationState.Skipped, DecisionSignalKind.AppInstallCompleted)]
[InlineData(AppInstallationState.Postponed, DecisionSignalKind.AppInstallCompleted)]
[InlineData(AppInstallationState.Error, DecisionSignalKind.AppInstallFailed)]
public void AppStateChange_to_terminal_state_emits_correct_signal_kind(
AppInstallationState newState, DecisionSignalKind expectedKind)
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState($"app-{newState}", listPos: 0);
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, newState);
var posted = Assert.Single(f.Ingress.Posted);
Assert.Equal(expectedKind, posted.Kind);
Assert.Equal($"app-{newState}", posted.Payload!["appId"]);
Assert.Equal(newState.ToString(), posted.Payload["newState"]);
}
[Fact]
public void AppStateChange_to_non_terminal_state_does_not_emit()
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("app-1", 0);
adapter.TriggerAppStateFromTest(app, AppInstallationState.Unknown, AppInstallationState.InProgress);
adapter.TriggerAppStateFromTest(app, AppInstallationState.InProgress, AppInstallationState.Downloading);
adapter.TriggerAppStateFromTest(app, AppInstallationState.Downloading, AppInstallationState.Installing);
Assert.Empty(f.Ingress.Posted);
}
[Fact]
public void AppStateChange_terminal_per_app_is_fire_once()
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
var app = new AppPackageState("app-1", 0);
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installing, AppInstallationState.Installed);
// Even if the tracker re-fires (shouldn't happen, but defend) — still one post.
adapter.TriggerAppStateFromTest(app, AppInstallationState.Installed, AppInstallationState.Error);
Assert.Single(f.Ingress.Posted);
}
[Fact]
public void AppStateChange_different_apps_emit_independently()
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerAppStateFromTest(new AppPackageState("a", 0), AppInstallationState.Installing, AppInstallationState.Installed);
adapter.TriggerAppStateFromTest(new AppPackageState("b", 1), AppInstallationState.Installing, AppInstallationState.Error);
adapter.TriggerAppStateFromTest(new AppPackageState("c", 2), AppInstallationState.Installing, AppInstallationState.Skipped);
Assert.Equal(3, f.Ingress.Posted.Count);
Assert.Contains(f.Ingress.Posted, p => p.Payload!["appId"] == "a" && p.Kind == DecisionSignalKind.AppInstallCompleted);
Assert.Contains(f.Ingress.Posted, p => p.Payload!["appId"] == "b" && p.Kind == DecisionSignalKind.AppInstallFailed);
Assert.Contains(f.Ingress.Posted, p => p.Payload!["appId"] == "c" && p.Kind == DecisionSignalKind.AppInstallCompleted);
}
[Fact]
public void AppStateChange_null_app_is_ignored()
{
using var f = new Fixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerAppStateFromTest(null!, AppInstallationState.Installing, AppInstallationState.Installed);
Assert.Empty(f.Ingress.Posted);
}
[Fact]
public void Adapter_preserves_prior_Action_handlers_chain_invoke()
{
using var f = new Fixture();
int priorEspCalls = 0;
int priorUserCalls = 0;
f.Tracker.OnEspPhaseChanged = _ => priorEspCalls++;
f.Tracker.OnUserSessionCompleted = () => priorUserCalls++;
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
// Call through the tracker's Action (simulating what the tracker would do).
f.Tracker.OnEspPhaseChanged("DeviceSetup");
f.Tracker.OnUserSessionCompleted();
Assert.Equal(1, priorEspCalls);
Assert.Equal(1, priorUserCalls);
Assert.Equal(2, f.Ingress.Posted.Count);
}
[Fact]
public void Dispose_restores_prior_Action_handlers()
{
using var f = new Fixture();
int priorCalls = 0;
Action<string> priorAction = _ => priorCalls++;
f.Tracker.OnEspPhaseChanged = priorAction;
var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
Assert.NotSame(priorAction, f.Tracker.OnEspPhaseChanged);
adapter.Dispose();
Assert.Same(priorAction, f.Tracker.OnEspPhaseChanged);
}
[Fact]
public void Ctor_null_args_throw()
{
using var f = new Fixture();
Assert.Throws<ArgumentNullException>(() => new ImeLogTrackerAdapter(null!, f.Ingress, f.Clock));
Assert.Throws<ArgumentNullException>(() => new ImeLogTrackerAdapter(f.Tracker, null!, f.Clock));
Assert.Throws<ArgumentNullException>(() => new ImeLogTrackerAdapter(f.Tracker, f.Ingress, null!));
}
}
}
@@ -0,0 +1,230 @@
#nullable enable
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
/// <summary>
/// Adapter for <see cref="ImeLogTracker"/> → mehrere DecisionSignalKinds.
/// Plan §2.1a / §2.2.
/// <para>
/// ImeLogTracker nutzt Action-Property-Callbacks (nicht Events). Adapter <b>ersetzt</b>
/// diese Action-Props — Orchestrator darf die nicht parallel belegen. Dispose restauriert
/// die vorherigen Action-Werte (nur für Clean-Shutdown relevant; normalerweise ist der
/// Tracker selbst kurz vor Dispose).
/// </para>
/// <para>
/// Mapping:
/// <list type="bullet">
/// <item><c>OnEspPhaseChanged(phase)</c> → <see cref="DecisionSignalKind.EspPhaseChanged"/>
/// (dedup per distinct phase value — Reducer Plan §2.1a idempotenz-Anforderung).</item>
/// <item><c>OnUserSessionCompleted()</c> → <see cref="DecisionSignalKind.ImeUserSessionCompleted"/> (fire-once).</item>
/// <item><c>OnAppStateChanged(app, old, new)</c> → <see cref="DecisionSignalKind.AppInstallCompleted"/>
/// (state=Installed/Skipped/Postponed) oder <see cref="DecisionSignalKind.AppInstallFailed"/> (state=Error).
/// Dedup pro (AppId, terminal-state-tuple).</item>
/// </list>
/// </para>
/// <para>
/// <b>Nicht Teil von M4.3.4:</b> <c>WhiteGloveSealingPatternDetected</c> — ImeLogTracker
/// hat aktuell keinen öffentlichen Pattern-Match-Hook; Re-Mapping via LastMatchedPatternId
/// braucht entweder Polling-Adapter oder Modifikation der Tracker-Action-Surface. Dokumentiert
/// als M4-Follow-Up; Reducer behandelt es derzeit korrekt auch ohne diesen Signal-Typ (alle
/// M3-Szenarien grün).
/// </para>
/// </summary>
internal sealed class ImeLogTrackerAdapter : IDisposable
{
private readonly ImeLogTracker _tracker;
private readonly ISignalIngressSink _ingress;
private readonly IClock _clock;
private readonly object _lock = new object();
// Previous action-handlers — restored on Dispose so we don't leave a dead callback
// hanging on the tracker.
private readonly Action<string>? _prevOnEspPhaseChanged;
private readonly Action? _prevOnUserSessionCompleted;
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState>? _prevOnAppStateChanged;
// Our own delegate instances — stored once so Dispose can compare by reference.
private readonly Action<string> _ourOnEspPhaseChanged;
private readonly Action _ourOnUserSessionCompleted;
private readonly Action<AppPackageState, AppInstallationState, AppInstallationState> _ourOnAppStateChanged;
// Dedup state.
private string? _lastEspPhase;
private bool _userSessionCompletedPosted;
private readonly HashSet<string> _appsAlreadyPostedTerminal = new HashSet<string>(StringComparer.Ordinal);
public ImeLogTrackerAdapter(
ImeLogTracker tracker,
ISignalIngressSink ingress,
IClock clock)
{
_tracker = tracker ?? throw new ArgumentNullException(nameof(tracker));
_ingress = ingress ?? throw new ArgumentNullException(nameof(ingress));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
// Chain-preserve: save any previously-wired handlers and invoke them from our
// dispatcher. Lets the orchestrator co-wire diagnostic callbacks (e.g. logging)
// without being blocked by the adapter.
_prevOnEspPhaseChanged = _tracker.OnEspPhaseChanged;
_prevOnUserSessionCompleted = _tracker.OnUserSessionCompleted;
_prevOnAppStateChanged = _tracker.OnAppStateChanged;
// Store our delegate instances once — implicit method-group conversions
// create a new delegate each time, which would break Dispose's reference check.
_ourOnEspPhaseChanged = OnEspPhaseChanged;
_ourOnUserSessionCompleted = OnUserSessionCompleted;
_ourOnAppStateChanged = OnAppStateChanged;
_tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged;
_tracker.OnUserSessionCompleted = _ourOnUserSessionCompleted;
_tracker.OnAppStateChanged = _ourOnAppStateChanged;
}
public void Dispose()
{
// Restore only if we're still the current handler; otherwise leave it alone
// (someone else re-wired after us and owns it now).
if (ReferenceEquals(_tracker.OnEspPhaseChanged, _ourOnEspPhaseChanged))
_tracker.OnEspPhaseChanged = _prevOnEspPhaseChanged;
if (ReferenceEquals(_tracker.OnUserSessionCompleted, _ourOnUserSessionCompleted))
_tracker.OnUserSessionCompleted = _prevOnUserSessionCompleted;
if (ReferenceEquals(_tracker.OnAppStateChanged, _ourOnAppStateChanged))
_tracker.OnAppStateChanged = _prevOnAppStateChanged;
}
private void OnEspPhaseChanged(string phase)
{
_prevOnEspPhaseChanged?.Invoke(phase);
EmitEspPhase(phase);
}
private void OnUserSessionCompleted()
{
_prevOnUserSessionCompleted?.Invoke();
EmitUserSessionCompleted();
}
private void OnAppStateChanged(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
{
_prevOnAppStateChanged?.Invoke(app, oldState, newState);
EmitAppState(app, oldState, newState);
}
internal void TriggerEspPhaseFromTest(string phase) => EmitEspPhase(phase);
internal void TriggerUserSessionCompletedFromTest() => EmitUserSessionCompleted();
internal void TriggerAppStateFromTest(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) =>
EmitAppState(app, oldState, newState);
private void EmitEspPhase(string phase)
{
if (string.IsNullOrEmpty(phase)) return;
lock (_lock)
{
if (string.Equals(_lastEspPhase, phase, StringComparison.Ordinal))
{
return; // Idempotent — phase unchanged.
}
_lastEspPhase = phase;
}
_ingress.Post(
kind: DecisionSignalKind.EspPhaseChanged,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: "ImeLogTracker",
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "ime-log-tracker-v1",
summary: $"IME log phase transition → {phase}",
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
{
["detectionSource"] = "IME log regex pattern",
["phase"] = phase,
}),
payload: new Dictionary<string, string>(StringComparer.Ordinal)
{
[SignalPayloadKeys.EspPhase] = phase,
});
}
private void EmitUserSessionCompleted()
{
lock (_lock)
{
if (_userSessionCompletedPosted) return;
_userSessionCompletedPosted = true;
}
_ingress.Post(
kind: DecisionSignalKind.ImeUserSessionCompleted,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: "ImeLogTracker",
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "ime-log-tracker-v1",
summary: "IME user session completed (all user-scope apps finished)",
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
{
["detectionSource"] = "IME log pattern (UserSessionCompleted)",
}));
}
private void EmitAppState(AppPackageState app, AppInstallationState oldState, AppInstallationState newState)
{
if (app == null || string.IsNullOrEmpty(app.Id)) return;
var terminalKind = ClassifyTerminalState(newState);
if (terminalKind == null) return; // Not a terminal state — skip.
lock (_lock)
{
if (!_appsAlreadyPostedTerminal.Add(app.Id))
{
return; // Already posted terminal state for this app.
}
}
_ingress.Post(
kind: terminalKind.Value,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: "ImeLogTracker",
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "ime-log-tracker-v1",
summary: $"App {app.Id} → {newState}",
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
{
["appId"] = app.Id,
["appName"] = app.Name ?? string.Empty,
["previousState"] = oldState.ToString(),
["newState"] = newState.ToString(),
}),
payload: new Dictionary<string, string>(StringComparer.Ordinal)
{
["appId"] = app.Id,
["newState"] = newState.ToString(),
});
}
private static DecisionSignalKind? ClassifyTerminalState(AppInstallationState state)
{
switch (state)
{
case AppInstallationState.Installed:
case AppInstallationState.Skipped:
case AppInstallationState.Postponed:
return DecisionSignalKind.AppInstallCompleted;
case AppInstallationState.Error:
return DecisionSignalKind.AppInstallFailed;
default:
return null;
}
}
}
}