fix(v2core): M4.5.a restore ImeTrackerStatePersistence (peripheral capability, not dead code)

M4.5.a hat ImeTrackerStatePersistence faelschlicherweise als Legacy
geloescht. Korrektur: die Klasse persistiert den IME-Collector-internal
State, nicht DecisionEngine-State:

  - LogFilePositionTracker: Byte-Offset pro IME-Logfile (ohne das
    re-parst der Tracker nach Restart von Byte 0 → Event-Spam)
  - SeenAppIds: Phase-Isolation (Device-Phase-Apps duerfen nicht in
    AccountSetup-Phase bleeden)
  - IgnoreList: App-IDs aus abgeschlossenen Phasen, die weiterhin in
    Logs auftauchen koennen
  - Packages (AppPackageStateList): vollstaendiger Install-Progress,
    DO-Telemetry, ErrorPatterns, AppType, AttemptNumber — essentiell
    fuer App-Download-Monitor, Install-Monitor, App-Summaries
  - Phase-Tracking (CurrentPhaseOrder, LastEspPhaseDetected, etc.)

SignalLog-Replay deckt das NICHT ab — der SignalLog persistiert
DecisionSignals (SessionStarted, EspPhaseChanged, HelloResolved, …),
nicht per-App-Install-Progress oder Log-File-Positions.

Restore:
  - ImeTrackerStatePersistence.cs wieder aus git history
  - ImeLogTracker.cs: _statePersistence-Feld + Ctor-Init +
    LoadState/SaveState/DeleteState-Bodies aus 8ca845c^ restored
    (nur die beiden dead using-Imports auf Flows/Completion bleiben
    entfernt, da deren Namespaces tatsaechlich geloescht sind)

Build + Tests gruen (286/287, known pre-existing Flake bleibt fuer
M4.5.c).

Danke an User fuer den Catch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-20 22:27:23 +02:00
parent 8ca845c9cb
commit 925fc707cc
2 changed files with 315 additions and 16 deletions
@@ -72,9 +72,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private CancellationTokenSource _cts;
private bool _allAppsCompletedFired;
// M4.5.a: ImeTrackerStatePersistence removed. V2 relies on SignalLog-replay + the
// SignalAdapter's fire-once dedup for resume semantics; fresh-start on restart is
// intentional. See plan §4.x M4.5 Legacy-Cleanup.
// State persistence: saves tracker state to disk so agent restart continues
// from the exact log position without re-parsing or re-building ignore lists.
private readonly ImeTrackerStatePersistence _statePersistence;
private bool _stateDirty;
// Standard GUID capture pattern used as {GUID} placeholder in patterns
@@ -148,9 +148,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_matchLogPath = matchLogPath;
_packageStates = new AppPackageStateList(logger);
// M4.5.a: state-persistence removed; stateDirectory retained in signature for
// backwards-compat with existing callers/tests but intentionally unused.
_ = stateDirectory;
// State persistence setup
if (!string.IsNullOrEmpty(stateDirectory))
{
_statePersistence = new ImeTrackerStatePersistence(stateDirectory, logger);
}
if (!string.IsNullOrEmpty(_matchLogPath))
{
@@ -282,27 +284,164 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
_pollingTask = null;
}
#region State Persistence (M4.5.a: no-op stubs — legacy ImeTrackerStatePersistence removed)
// V2 resume semantics come from SignalLog-replay + SignalAdapter fire-once dedup.
// These stubs preserve the call-sites (LoadState / SaveState / DeleteState) across
// the polling loop + event handlers so the tracker compiles unchanged outside this
// region. See plan §4.x M4.5 Legacy-Cleanup.
#region State Persistence
/// <summary>
/// Loads persisted state from disk. Called on Start() to restore tracker state
/// after an agent restart, so parsing continues exactly where it left off.
/// </summary>
private void LoadState()
{
// no-op — fresh start on restart
if (_statePersistence == null) return;
var state = _statePersistence.Load();
if (state == null) return;
// Restore phase tracking
_currentPhaseOrder = state.CurrentPhaseOrder;
_lastEspPhaseDetected = state.LastEspPhaseDetected;
_allAppsCompletedFired = state.AllAppsCompletedFired;
_logPhaseIsCurrentPhase = state.LogPhaseIsCurrentPhase;
// Restore seen app IDs
_seenAppIds.Clear();
if (state.SeenAppIds != null)
{
foreach (var id in state.SeenAppIds)
_seenAppIds.Add(id);
}
// Restore ignore list
if (state.IgnoreList != null)
{
foreach (var id in state.IgnoreList)
_packageStates.AddToIgnoreList(id);
}
// Restore current package ID
_packageStates.CurrentPackageId = state.CurrentPackageId;
// Restore package states
if (state.Packages != null)
{
_packageStates.Clear();
foreach (var p in state.Packages)
{
var pkg = AppPackageState.Restore(
p.Id, p.ListPos, p.Name,
(AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted,
p.DependsOn != null ? new HashSet<string>(p.DependsOn) : new HashSet<string>(),
(AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen,
p.ProgressPercent, p.BytesDownloaded, p.BytesTotal,
errorPatternId: p.ErrorPatternId, errorDetail: p.ErrorDetail, errorCode: p.ErrorCode,
exitCode: p.ExitCode, hresultFromWin32: p.HResultFromWin32,
doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded,
doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching,
doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers,
doBytesFromInternetPeers: p.DoBytesFromInternetPeers, doDownloadMode: p.DoDownloadMode,
doDownloadDuration: p.DoDownloadDuration, doBytesFromHttp: p.DoBytesFromHttp,
hasDoTelemetry: p.HasDoTelemetry,
appVersion: p.AppVersion, appType: p.AppType,
attemptNumber: p.AttemptNumber, detectionResult: p.DetectionResult);
_packageStates.Add(pkg);
}
}
// Restore file positions
if (state.FilePositions != null)
{
foreach (var kvp in state.FilePositions)
{
var fullPath = Path.Combine(_logFolder, kvp.Key);
_positionTracker.RestorePosition(fullPath, kvp.Value.Position, kvp.Value.LastKnownSize);
}
}
// Re-activate patterns based on restored phase state
ActivatePatterns(_logPhaseIsCurrentPhase, force: true);
_logger.Info($"ImeLogTracker: state restored - phase: {_lastEspPhaseDetected ?? "(none)"} (order: {_currentPhaseOrder}), " +
$"ignore list: {_packageStates.IgnoreList.Count}, packages: {_packageStates.Count}, " +
$"file positions: {state.FilePositions?.Count ?? 0}");
}
/// <summary>
/// Persists current tracker state to disk as JSON.
/// Called after each polling cycle when state has changed.
/// </summary>
private void SaveState()
{
// no-op — nothing to persist
if (_statePersistence == null) return;
// Build state DTO
var state = new ImeTrackerStateData
{
CurrentPhaseOrder = _currentPhaseOrder,
LastEspPhaseDetected = _lastEspPhaseDetected,
AllAppsCompletedFired = _allAppsCompletedFired,
LogPhaseIsCurrentPhase = _logPhaseIsCurrentPhase,
SeenAppIds = _seenAppIds.ToList(),
IgnoreList = _packageStates.IgnoreList.ToList(),
CurrentPackageId = _packageStates.CurrentPackageId,
Packages = _packageStates.Select(p => new PackageStateData
{
Id = p.Id,
ListPos = p.ListPos,
Name = p.Name,
RunAs = (int)p.RunAs,
Intent = (int)p.Intent,
Targeted = (int)p.Targeted,
DependsOn = p.DependsOn?.ToList() ?? new List<string>(),
InstallationState = (int)p.InstallationState,
DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen,
ProgressPercent = p.ProgressPercent,
BytesDownloaded = p.BytesDownloaded,
BytesTotal = p.BytesTotal,
ErrorPatternId = p.ErrorPatternId,
ErrorDetail = p.ErrorDetail,
ErrorCode = p.ErrorCode,
ExitCode = p.ExitCode,
HResultFromWin32 = p.HResultFromWin32,
DoFileSize = p.DoFileSize,
DoTotalBytesDownloaded = p.DoTotalBytesDownloaded,
DoBytesFromPeers = p.DoBytesFromPeers,
DoPercentPeerCaching = p.DoPercentPeerCaching,
DoBytesFromLanPeers = p.DoBytesFromLanPeers,
DoBytesFromGroupPeers = p.DoBytesFromGroupPeers,
DoBytesFromInternetPeers = p.DoBytesFromInternetPeers,
DoDownloadMode = p.DoDownloadMode,
DoDownloadDuration = p.DoDownloadDuration,
DoBytesFromHttp = p.DoBytesFromHttp,
HasDoTelemetry = p.HasDoTelemetry,
AppVersion = p.AppVersion,
AppType = p.AppType,
AttemptNumber = p.AttemptNumber,
DetectionResult = p.DetectionResult
}).ToList(),
FilePositions = new Dictionary<string, FilePositionData>()
};
// Store file positions by filename only (log folder is known)
foreach (var kvp in _positionTracker.GetAllPositions())
{
var fileName = Path.GetFileName(kvp.Key);
state.FilePositions[fileName] = new FilePositionData
{
Position = kvp.Value.Position,
LastKnownSize = kvp.Value.LastKnownSize
};
}
_statePersistence.Save(state);
}
/// <summary>Kept as public no-op for API compatibility; callers treat it as a hint.</summary>
/// <summary>
/// Deletes persisted state file. Called on enrollment complete to ensure
/// a fresh state on the next enrollment cycle.
/// </summary>
public void DeleteState()
{
// no-op
_statePersistence?.Delete();
}
#endregion
@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
{
/// <summary>
/// Handles persistence of ImeLogTracker state to disk (JSON serialization).
/// Extracted from ImeLogTracker to separate I/O concerns from log parsing.
/// </summary>
public class ImeTrackerStatePersistence
{
private readonly string _stateDirectory;
private readonly string _stateFilePath;
private readonly AgentLogger _logger;
public ImeTrackerStatePersistence(string stateDirectory, AgentLogger logger)
{
_stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory);
_stateFilePath = Path.Combine(_stateDirectory, "ime-tracker-state.json");
_logger = logger;
}
public string StateFilePath => _stateFilePath;
/// <summary>
/// Loads persisted state from disk. Returns null if no state file exists or on error.
/// </summary>
public ImeTrackerStateData Load()
{
if (!File.Exists(_stateFilePath))
{
_logger.Info("ImeLogTracker: no persisted state found (fresh enrollment)");
return null;
}
try
{
var json = File.ReadAllText(_stateFilePath);
var state = JsonConvert.DeserializeObject<ImeTrackerStateData>(json);
if (state == null)
{
_logger.Warning("ImeLogTracker: persisted state file was empty or invalid");
return null;
}
return state;
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: failed to load persisted state, starting fresh: {ex.Message}");
return null;
}
}
/// <summary>
/// Persists tracker state to disk as JSON (atomic write via temp file).
/// </summary>
public void Save(ImeTrackerStateData state)
{
try
{
Directory.CreateDirectory(_stateDirectory);
var tempPath = _stateFilePath + ".tmp";
File.WriteAllText(tempPath, JsonConvert.SerializeObject(state, Formatting.Indented));
File.Copy(tempPath, _stateFilePath, overwrite: true);
try { File.Delete(tempPath); } catch { }
}
catch (Exception ex)
{
_logger.Debug($"ImeLogTracker: failed to save state: {ex.Message}");
}
}
/// <summary>
/// Deletes persisted state file.
/// </summary>
public void Delete()
{
try
{
if (File.Exists(_stateFilePath))
{
File.Delete(_stateFilePath);
_logger.Info("ImeLogTracker: persisted state deleted");
}
}
catch (Exception ex)
{
_logger.Debug($"ImeLogTracker: failed to delete state file: {ex.Message}");
}
}
}
// ---- DTOs for state serialization ----
public class ImeTrackerStateData
{
public int CurrentPhaseOrder { get; set; }
public string LastEspPhaseDetected { get; set; }
public bool AllAppsCompletedFired { get; set; }
public bool LogPhaseIsCurrentPhase { get; set; }
public List<string> SeenAppIds { get; set; }
public List<string> IgnoreList { get; set; }
public string CurrentPackageId { get; set; }
public List<PackageStateData> Packages { get; set; }
public Dictionary<string, FilePositionData> FilePositions { get; set; }
}
public class PackageStateData
{
public string Id { get; set; }
public int ListPos { get; set; }
public string Name { get; set; }
public int RunAs { get; set; }
public int Intent { get; set; }
public int Targeted { get; set; }
public List<string> DependsOn { get; set; }
public int InstallationState { get; set; }
public bool DownloadingOrInstallingSeen { get; set; }
public int? ProgressPercent { get; set; }
public long BytesDownloaded { get; set; }
public long BytesTotal { get; set; }
// Error context
public string ErrorPatternId { get; set; }
public string ErrorDetail { get; set; }
public string ErrorCode { get; set; }
// Installer result codes
public string ExitCode { get; set; }
public string HResultFromWin32 { get; set; }
// Delivery Optimization telemetry
public long DoFileSize { get; set; }
public long DoTotalBytesDownloaded { get; set; }
public long DoBytesFromPeers { get; set; }
public int DoPercentPeerCaching { get; set; }
public long DoBytesFromLanPeers { get; set; }
public long DoBytesFromGroupPeers { get; set; }
public long DoBytesFromInternetPeers { get; set; }
public int DoDownloadMode { get; set; } = -1;
public string DoDownloadDuration { get; set; }
public long DoBytesFromHttp { get; set; }
public bool HasDoTelemetry { get; set; }
// App metadata (captured from IME logs for App Dashboard reporting)
public string AppVersion { get; set; }
public string AppType { get; set; }
public int AttemptNumber { get; set; }
public string DetectionResult { get; set; }
}
public class FilePositionData
{
public long Position { get; set; }
public long LastKnownSize { get; set; }
}
}