diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs index b9aba67a..6193c5db 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallDetectorTests.cs @@ -31,16 +31,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office public VirtualClock Clock { get; } public OfficeInstallDetector Sut { get; } public OfficeC2RSnapshot Current { get; set; } + public OfficeInstallStatePersistence? Persistence { get; } private readonly TempDirectory _tmp; - public Rig(OfficeC2RSnapshot initial) + public Rig(OfficeC2RSnapshot initial, bool withPersistence = false) { _tmp = new TempDirectory(); Sink = new FakeSignalIngressSink(); Clock = new VirtualClock(At); Current = initial; + var logger = NewLogger(_tmp.Path); + Persistence = withPersistence ? new OfficeInstallStatePersistence(_tmp.Path, logger) : null; var post = new InformationalEventPost(Sink, Clock); - Sut = new OfficeInstallDetector("S1", "T1", post, NewLogger(_tmp.Path), Clock) + Sut = new OfficeInstallDetector("S1", "T1", post, logger, Clock, + statePersistence: Persistence) { SnapshotProvider = () => Current, }; @@ -420,6 +424,182 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office Assert.False(OfficeInstallDetector.CoreBinariesPresentOnDisk(System.IO.Path.Combine(tmp.Path, "does-not-exist"), logger)); } + // ---------------------------------------------------------------- persistence across restarts + + [Fact] + public void Started_persists_active_state_with_trigger_and_start_time() + { + using var rig = new Rig(ActiveStreaming(), withPersistence: true); + + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 2, FileSize = 1000, TotalBytesDownloaded = 100 }); + + var state = rig.Persistence!.Load(); + Assert.NotNull(state); + Assert.Equal(OfficeInstallStateData.StateActive, state!.State); + Assert.Equal(At, state.StartedAtUtc); + Assert.Equal("do", state.StartedTrigger); + Assert.Equal(100, state.PeakDo!.TotalBytesDownloaded); + } + + [Fact] + public void Completed_persists_terminal_state() + { + using var rig = new Rig(ActiveStreaming(), withPersistence: true); + rig.Sut.OnWorkerStarted(); + + rig.Current = CompletedIdle(); + rig.Sut.CoreBinariesProbe = _ => true; + Assert.Equal(OfficeInstallDetector.CompletionOutcome.Completed, rig.Sut.TryFinalizeCompletion()); + + var state = rig.Persistence!.Load(); + Assert.Equal(OfficeInstallStateData.StateCompleted, state!.State); + Assert.True(state.IsTerminal); + } + + [Fact] + public void Failed_persists_terminal_state() + { + using var rig = new Rig(ActiveStreaming(), withPersistence: true); + rig.Sut.OnWorkerStarted(); + + var withError = ActiveStreaming(); + withError.ErrorCode = "0x80070005"; + rig.Current = withError; + rig.Sut.OnRegistryChanged(); + + var state = rig.Persistence!.Load(); + Assert.Equal(OfficeInstallStateData.StateFailed, state!.State); + Assert.True(state.IsTerminal); + } + + [Fact] + public void AbandonSilently_does_not_persist_a_terminal_state() + { + // AbandonSilently fires on every host dispose/shutdown. Persisting it as terminal would + // block the next agent run from resuming the open lifecycle — and the resume is exactly + // what delivers a completion missed across a mid-install reboot. + using var rig = new Rig(ActiveStreaming(), withPersistence: true); + rig.Sut.OnWorkerStarted(); + + rig.Sut.AbandonSilently(); + + var state = rig.Persistence!.Load(); + Assert.Equal(OfficeInstallStateData.StateActive, state!.State); + Assert.False(state.IsTerminal); + } + + [Fact] + public void Active_peak_growth_is_persisted_with_throttle() + { + using var rig = new Rig(ActiveStreaming(), withPersistence: true); + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 2, FileSize = 1000, TotalBytesDownloaded = 100 }); // started + persisted + + // Within the 30s throttle window: peak grows in memory but the file is not rewritten. + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 2, FileSize = 1000, TotalBytesDownloaded = 300 }); + Assert.Equal(100, rig.Persistence!.Load()!.PeakDo!.TotalBytesDownloaded); + + // After the throttle window the next grown peak is persisted. + rig.Clock.Advance(TimeSpan.FromSeconds(31)); + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 2, FileSize = 1000, TotalBytesDownloaded = 600 }); + Assert.Equal(600, rig.Persistence.Load()!.PeakDo!.TotalBytesDownloaded); + } + + [Fact] + public void ResumeActive_completes_without_second_started_using_original_start_time_and_peak() + { + // Agent restart after a mid-install reboot: started went out in the previous run. The + // resumed lifecycle must not emit a second started, and the completion proven in this + // run reports the true total duration + the persisted DO summary. + using var rig = new Rig(CompletedIdle(), withPersistence: true); + + rig.Sut.ResumeActive(new OfficeInstallStateData + { + State = OfficeInstallStateData.StateActive, + StartedAtUtc = At.AddMinutes(-10), + StartedTrigger = "do", + PeakDo = new OfficeDoPeakData + { + JobCount = 3, + FileSize = 1200, + TotalBytesDownloaded = 1000, + BytesFromHttp = 1000, + BytesFromCacheServer = 800, + DownloadMode = 1, + }, + }); + Assert.Empty(rig.OfficeEvents()); // no second started + + // New start triggers must not re-open a window either — the lifecycle is already open. + rig.Sut.OnWorkerStarted(); + Assert.Empty(rig.OfficeEvents()); + + rig.Sut.CoreBinariesProbe = _ => true; + Assert.Equal(OfficeInstallDetector.CompletionOutcome.Completed, rig.Sut.TryFinalizeCompletion()); + + var completed = Assert.Single(rig.OfficeEvents()); + Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, EventType(completed)); + Assert.Equal(600, Data(completed)["durationSeconds"]); // 10 min across the restart + Assert.Equal("do", Data(completed)["startedTrigger"]); + var summary = Assert.IsType>(Data(completed)["doSummary"]); + Assert.Equal(1000L, summary["totalBytesDownloaded"]); + Assert.Equal(800L, summary["bytesFromCacheServer"]); + Assert.Equal(200L, summary["bytesFromCdn"]); + Assert.Equal(OfficeInstallStateData.StateCompleted, rig.Persistence!.Load()!.State); + } + + [Fact] + public void ResumeActive_surfaces_installation_path_for_synchronous_completion() + { + // Office finished installing while the agent was down: the resume reads a fresh snapshot + // and surfaces the InstallationPath; the host's binary watcher (wired here via the + // callback) finds the binaries immediately and completes the lifecycle synchronously — + // the previously missed completed is delivered, with no started this run. + using var tmp = new TempDirectory(); + var sink = new FakeSignalIngressSink(); + var clock = new VirtualClock(At); + var post = new InformationalEventPost(sink, clock); + + OfficeInstallDetector sut = null!; + sut = new OfficeInstallDetector("S1", "T1", post, NewLogger(tmp.Path), clock, + onInstallationPathObserved: _ => + { + sut.CoreBinariesProbe = __ => true; + sut.TryFinalizeCompletion(); + }); + var snap = CompletedIdle(); + snap.InstallationPath = @"C:\Program Files\Microsoft Office"; + sut.SnapshotProvider = () => snap; + + sut.ResumeActive(new OfficeInstallStateData + { + State = OfficeInstallStateData.StateActive, + StartedAtUtc = At.AddMinutes(-5), + StartedTrigger = "registry", + }); + + var office = sink.Posted + .Where(p => p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et != null && et.StartsWith("office_install_", StringComparison.Ordinal)) + .ToList(); + var completed = Assert.Single(office); + Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, completed.Payload![SignalPayloadKeys.EventType]); + } + + [Fact] + public void ResumeActive_ignores_terminal_or_invalid_state() + { + using var rig = new Rig(ActiveStreaming(), withPersistence: true); + + rig.Sut.ResumeActive(new OfficeInstallStateData { State = OfficeInstallStateData.StateCompleted }); + rig.Sut.ResumeActive(new OfficeInstallStateData { State = null }); + + // Still Idle — a real start trigger opens a fresh lifecycle normally. + rig.Sut.OnWorkerStarted(); + var started = Assert.Single(rig.OfficeEvents()); + Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(started)); + } + // ---------------------------------------------------------------- ctor [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallStatePersistenceTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallStatePersistenceTests.cs new file mode 100644 index 00000000..05270f44 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Office/OfficeInstallStatePersistenceTests.cs @@ -0,0 +1,130 @@ +#nullable enable +using System; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office +{ + /// + /// OfficeInstallStatePersistence — the office-install-state.json file that carries the + /// detector lifecycle across agent restarts/reboots. Fail-soft: missing/corrupt files load + /// as null (fresh start), saves never throw. + /// + public sealed class OfficeInstallStatePersistenceTests + { + private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info); + + [Fact] + public void Save_then_load_roundtrips_all_fields() + { + using var tmp = new TempDirectory(); + var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path)); + var startedAt = new DateTime(2026, 6, 8, 12, 0, 0, DateTimeKind.Utc); + + sut.Save(new OfficeInstallStateData + { + State = OfficeInstallStateData.StateActive, + StartedAtUtc = startedAt, + StartedTrigger = "do", + PeakDo = new OfficeDoPeakData + { + JobCount = 3, + FileSize = 1200, + TotalBytesDownloaded = 1000, + BytesFromPeers = 0, + BytesFromHttp = 1000, + BytesFromCacheServer = 800, + DownloadMode = 1, + }, + }); + + var loaded = sut.Load(); + Assert.NotNull(loaded); + Assert.Equal(OfficeInstallStateData.StateActive, loaded!.State); + Assert.False(loaded.IsTerminal); + Assert.Equal(startedAt, loaded.StartedAtUtc); + Assert.Equal("do", loaded.StartedTrigger); + Assert.NotNull(loaded.PeakDo); + Assert.Equal(3, loaded.PeakDo!.JobCount); + Assert.Equal(1200, loaded.PeakDo.FileSize); + Assert.Equal(1000, loaded.PeakDo.TotalBytesDownloaded); + Assert.Equal(1000, loaded.PeakDo.BytesFromHttp); + Assert.Equal(800, loaded.PeakDo.BytesFromCacheServer); + Assert.Equal(1, loaded.PeakDo.DownloadMode); + } + + [Fact] + public void Save_overwrites_a_previous_state() + { + using var tmp = new TempDirectory(); + var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path)); + + sut.Save(new OfficeInstallStateData { State = OfficeInstallStateData.StateActive }); + sut.Save(new OfficeInstallStateData { State = OfficeInstallStateData.StateCompleted }); + + var loaded = sut.Load(); + Assert.Equal(OfficeInstallStateData.StateCompleted, loaded!.State); + Assert.True(loaded.IsTerminal); + } + + [Fact] + public void Load_returns_null_when_no_file_exists() + { + using var tmp = new TempDirectory(); + var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path)); + + Assert.Null(sut.Load()); + } + + [Theory] + [InlineData("not json at all {{{")] + [InlineData("null")] + [InlineData("{}")] // deserializes but State is empty → invalid + public void Load_returns_null_for_corrupt_or_empty_state(string content) + { + using var tmp = new TempDirectory(); + var sut = new OfficeInstallStatePersistence(tmp.Path, NewLogger(tmp.Path)); + File.WriteAllText(sut.StateFilePath, content); + + Assert.Null(sut.Load()); + } + + [Theory] + [InlineData(OfficeInstallStateData.StateActive, false)] + [InlineData(OfficeInstallStateData.StateCompleted, true)] + [InlineData(OfficeInstallStateData.StateFailed, true)] + public void IsTerminal_only_for_completed_and_failed(string state, bool expected) + { + Assert.Equal(expected, new OfficeInstallStateData { State = state }.IsTerminal); + } + + [Fact] + public void OfficeDoPeakData_maps_to_and_from_sample() + { + var sample = new OfficeDoSample + { + JobCount = 2, + FileSize = 500, + TotalBytesDownloaded = 400, + BytesFromPeers = 50, + BytesFromHttp = 350, + BytesFromCacheServer = 300, + DownloadMode = 3, + }; + + var roundtripped = OfficeDoPeakData.FromSample(sample)!.ToSample(); + + Assert.Equal(sample.JobCount, roundtripped.JobCount); + Assert.Equal(sample.FileSize, roundtripped.FileSize); + Assert.Equal(sample.TotalBytesDownloaded, roundtripped.TotalBytesDownloaded); + Assert.Equal(sample.BytesFromPeers, roundtripped.BytesFromPeers); + Assert.Equal(sample.BytesFromHttp, roundtripped.BytesFromHttp); + Assert.Equal(sample.BytesFromCacheServer, roundtripped.BytesFromCacheServer); + Assert.Equal(sample.DownloadMode, roundtripped.DownloadMode); + Assert.Null(OfficeDoPeakData.FromSample(null)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs index 0f544cc1..f9147253 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallDetector.cs @@ -64,14 +64,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office // exclude products (e.g. no Outlook), so requiring all would miss legitimate completions. internal static readonly string[] CoreBinaries = { "WINWORD.EXE", "EXCEL.EXE", "POWERPNT.EXE", "OUTLOOK.EXE" }; + // While Active, a grown DO peak is re-persisted at most this often — keeps the doSummary + // data of a mid-install restart fresh without writing the state file on every 3s DO poll. + private const int PeakPersistThrottleSeconds = 30; + private readonly string _sessionId; private readonly string _tenantId; private readonly InformationalEventPost _post; private readonly AgentLogger _logger; private readonly IClock _clock; private readonly Action? _onInstallationPathObserved; + private readonly OfficeInstallStatePersistence? _statePersistence; private readonly object _lock = new object(); private bool _pathObservedRaised; + private DateTime? _lastStatePersistUtc; // Test seam: when set, used instead of reading the live registry/process state. Production // never assigns it. Surfaced to the test assembly via InternalsVisibleTo. @@ -98,7 +104,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office InformationalEventPost post, AgentLogger logger, IClock clock, - Action? onInstallationPathObserved = null) + Action? onInstallationPathObserved = null, + OfficeInstallStatePersistence? statePersistence = null) { _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); @@ -106,6 +113,32 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); _onInstallationPathObserved = onInstallationPathObserved; + _statePersistence = statePersistence; + } + + /// + /// Restores a persisted open lifecycle after an agent restart (an enrollment commonly spans + /// several reboots): started was already emitted in a previous run, so none is re-emitted + /// here. The original start time and DO peak are restored, so a completion proven in THIS + /// run reports the true total duration and doSummary. A fresh snapshot is read to surface + /// the InstallationPath, which lets the host arm the binary watcher — when Office finished + /// installing while the agent was down, that completes the lifecycle synchronously. + /// + internal void ResumeActive(OfficeInstallStateData state) + { + if (state == null || state.State != OfficeInstallStateData.StateActive) return; + lock (_lock) + { + if (_state != DetectorState.Idle) return; + _state = DetectorState.Active; + _startedAtUtc = state.StartedAtUtc ?? _clock.UtcNow; + _startedTrigger = state.StartedTrigger; + if (state.PeakDo != null) _peakDo = state.PeakDo.ToSample(); + _logger.Info($"[{SourceName}] resumed open install lifecycle from persisted state (started {_startedAtUtc:O}, trigger={_startedTrigger ?? "?"})"); + + var snap = ReadSnapshotSafe(); + if (snap != null) ObserveInstallationPath(snap); + } } /// @@ -177,8 +210,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office lock (_lock) { _lastDo = sample; - if (_peakDo == null || sample.TotalBytesDownloaded > _peakDo.TotalBytesDownloaded) - _peakDo = sample; + var peakGrew = _peakDo == null || sample.TotalBytesDownloaded > _peakDo.TotalBytesDownloaded; + if (peakGrew) _peakDo = sample; if (_state == DetectorState.Terminal) return; if (_state == DetectorState.Idle) @@ -187,8 +220,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office var snapStart = ReadSnapshotSafe(); if (snapStart == null) return; BeginIfIdle("do", snapStart); + return; + } + + // Active: no progress event — the sample only updates the DO summary data. A grown + // peak is re-persisted (throttled) so a mid-install agent restart keeps the doSummary. + if (peakGrew && _statePersistence != null + && (!_lastStatePersistUtc.HasValue + || (_clock.UtcNow - _lastStatePersistUtc.Value).TotalSeconds >= PeakPersistThrottleSeconds)) + { + PersistState(OfficeInstallStateData.StateActive); } - // Active: no progress event — the sample only updates the DO summary data. } } @@ -219,6 +261,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office { _state = DetectorState.Terminal; EmitLifecycle(Constants.EventTypes.OfficeInstallCompleted, snap, EventSeverity.Info, "Completed", isTerminal: true, coreBinariesPresent: true); + PersistState(OfficeInstallStateData.StateCompleted); return CompletionOutcome.Completed; } @@ -260,6 +303,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office // Emitting started first guarantees the correct order (started → completed); otherwise a // pre-installed Office produced completed-before-started (field session a7525e97). EmitLifecycle(Constants.EventTypes.OfficeInstallStarted, snap, EventSeverity.Info, PhaseOf(snap), isTerminal: false); + // Persist Active BEFORE ObserveInstallationPath: when Office is already on disk the + // host's binary watcher completes the lifecycle synchronously from inside that callback + // (and persists Completed) — persisting Active afterwards would overwrite the terminal. + PersistState(OfficeInstallStateData.StateActive); ObserveInstallationPath(snap); } @@ -280,6 +327,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office if (_state == DetectorState.Terminal) return; _state = DetectorState.Terminal; EmitLifecycle(Constants.EventTypes.OfficeInstallFailed, snap, EventSeverity.Error, "Failed", isTerminal: true); + PersistState(OfficeInstallStateData.StateFailed); + } + + /// + /// Persists the lifecycle state (fail-soft). Active and the two terminals are persisted; + /// deliberately is NOT — it fires on every dispose/shutdown + /// and persisting it as terminal would block the next run from resuming the open lifecycle + /// (the resume is what delivers a completion missed across a mid-install reboot). + /// Caller holds the lock. + /// + private void PersistState(string state) + { + if (_statePersistence == null) return; + _lastStatePersistUtc = _clock.UtcNow; + _statePersistence.Save(new OfficeInstallStateData + { + State = state, + StartedAtUtc = _startedAtUtc, + StartedTrigger = _startedTrigger, + PeakDo = OfficeDoPeakData.FromSample(_peakDo), + }); } /// diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs new file mode 100644 index 00000000..ea10391d --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs @@ -0,0 +1,146 @@ +#nullable enable +using System; +using System.IO; +using AutopilotMonitor.Agent.V2.Core.Logging; +using Newtonsoft.Json; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office +{ + /// + /// Persists the lifecycle state across agent restarts + /// (one enrollment commonly spans several reboots). Without it every restart re-armed the + /// detector from Idle and all three start triggers fire falsely on an already-installed + /// Office: the Scenario\INSTALL registry key persists post-install, OfficeC2RClient.exe + /// runs again for update checks, and Office updates stream from the same CDN — each restart + /// then emitted a duplicate started+completed pair (~0s duration). + /// + /// Semantics: Active means "started was emitted, no terminal yet" — the next run resumes + /// the open lifecycle (no second started; a missed completion is delivered with the original + /// start time). Completed/Failed mean the terminal event went out — the next run + /// does not arm the detector at all. is + /// deliberately NOT persisted as terminal: it fires on every dispose/shutdown and would + /// otherwise block the resume that catches the completion after a mid-install reboot. + /// + /// Fail-soft like ImeTrackerStatePersistence: I/O errors never throw; a missing or + /// corrupt file loads as null (fresh start — worst case is today's duplicate-pair behavior). + /// + public class OfficeInstallStatePersistence + { + private readonly string _stateDirectory; + private readonly string _stateFilePath; + private readonly AgentLogger _logger; + + public OfficeInstallStatePersistence(string stateDirectory, AgentLogger logger) + { + _stateDirectory = Environment.ExpandEnvironmentVariables(stateDirectory); + _stateFilePath = Path.Combine(_stateDirectory, "office-install-state.json"); + _logger = logger; + } + + public string StateFilePath => _stateFilePath; + + /// Loads the persisted lifecycle state, or null when absent/unreadable. + public OfficeInstallStateData? Load() + { + if (!File.Exists(_stateFilePath)) return null; + try + { + var json = File.ReadAllText(_stateFilePath); + var state = JsonConvert.DeserializeObject(json); + if (state == null || string.IsNullOrEmpty(state.State)) + { + _logger.Warning($"[{OfficeInstallDetector.SourceName}] persisted state file was empty or invalid — starting fresh"); + return null; + } + return state; + } + catch (Exception ex) + { + _logger.Warning($"[{OfficeInstallDetector.SourceName}] failed to load persisted state, starting fresh: {ex.Message}"); + return null; + } + } + + /// Persists the lifecycle state (atomic write via temp file). Never throws. + public void Save(OfficeInstallStateData state) + { + try + { + Directory.CreateDirectory(_stateDirectory); + var tempPath = _stateFilePath + ".tmp"; + File.WriteAllText(tempPath, JsonConvert.SerializeObject(state)); + if (File.Exists(_stateFilePath)) + { + File.Replace(tempPath, _stateFilePath, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + else + { + File.Move(tempPath, _stateFilePath); + } + } + catch (Exception ex) + { + _logger.Debug($"[{OfficeInstallDetector.SourceName}] failed to save state: {ex.Message}"); + } + } + } + + // ---- DTOs for state serialization ---- + + public class OfficeInstallStateData + { + public const string StateActive = "Active"; + public const string StateCompleted = "Completed"; + public const string StateFailed = "Failed"; + + /// , or . + public string? State { get; set; } + + public DateTime? StartedAtUtc { get; set; } + public string? StartedTrigger { get; set; } + + /// Highest-bytes DO sample seen — basis for the doSummary on a resumed completion. + public OfficeDoPeakData? PeakDo { get; set; } + + [JsonIgnore] + public bool IsTerminal => State == StateCompleted || State == StateFailed; + } + + /// Persisted mirror of the fields the completed doSummary needs. + public class OfficeDoPeakData + { + public int JobCount { get; set; } + public long FileSize { get; set; } + public long TotalBytesDownloaded { get; set; } + public long BytesFromPeers { get; set; } + public long BytesFromHttp { get; set; } + public long BytesFromCacheServer { get; set; } + public int DownloadMode { get; set; } + + public static OfficeDoPeakData? FromSample(OfficeDoSample? sample) + { + if (sample == null) return null; + return new OfficeDoPeakData + { + JobCount = sample.JobCount, + FileSize = sample.FileSize, + TotalBytesDownloaded = sample.TotalBytesDownloaded, + BytesFromPeers = sample.BytesFromPeers, + BytesFromHttp = sample.BytesFromHttp, + BytesFromCacheServer = sample.BytesFromCacheServer, + DownloadMode = sample.DownloadMode, + }; + } + + public OfficeDoSample ToSample() => new OfficeDoSample + { + JobCount = JobCount, + FileSize = FileSize, + TotalBytesDownloaded = TotalBytesDownloaded, + BytesFromPeers = BytesFromPeers, + BytesFromHttp = BytesFromHttp, + BytesFromCacheServer = BytesFromCacheServer, + DownloadMode = DownloadMode, + }; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index e68f49d2..08311535 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office; using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic; using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport; using AutopilotMonitor.Agent.V2.Core.SignalAdapters; @@ -275,13 +276,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration OfficeInstallDetectorHost? officeHost = null; if (collectors.EnableOfficeInstallDetector) { - officeHost = new OfficeInstallDetectorHost( - sessionId: sessionId, - tenantId: tenantId, - ingress: ingress, - clock: clock, - logger: logger, - settleSeconds: collectors.OfficeInstallSettleSeconds); + // The lifecycle state is persisted across agent restarts (an enrollment spans + // several reboots, and Scenario\INSTALL persists post-install / OfficeC2RClient.exe + // runs again for update checks / Office updates stream from the same CDN — all three + // start triggers would falsely re-open the window and emit a duplicate + // started+completed pair every restart). A persisted terminal means the install was + // already reported → don't arm the detector at all this run; a persisted Active is + // resumed inside the host (no second started, missed completion delivered late). + var officeStatePersistence = new OfficeInstallStatePersistence(_stateDirectory, logger); + var officeState = officeStatePersistence.Load(); + if (officeState != null && officeState.IsTerminal) + { + logger.Info($"[{OfficeInstallDetector.SourceName}] persisted lifecycle is already terminal ({officeState.State}) — detector not armed this run"); + } + else + { + officeHost = new OfficeInstallDetectorHost( + sessionId: sessionId, + tenantId: tenantId, + ingress: ingress, + clock: clock, + logger: logger, + settleSeconds: collectors.OfficeInstallSettleSeconds, + statePersistence: officeStatePersistence, + resumeState: officeState); + } } // M4.6.γ — Delivery-Optimization telemetry. Dormant-by-default: polls only when the IME log diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs index 29bf7333..ea9dcf4c 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OfficeInstallDetectorHost.cs @@ -45,6 +45,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration private readonly AgentLogger _logger; private readonly object _lock = new object(); + private readonly OfficeInstallStateData? _resumeState; private RegistryChangeWatcher? _registryWatcher; private OfficeBinaryWatcher? _binaryWatcher; private bool _pathObserved; // InstallationPath known → stop poking the DO collector @@ -64,15 +65,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration ISignalIngressSink ingress, IClock clock, AgentLogger logger, - int settleSeconds) + int settleSeconds, + OfficeInstallStatePersistence? statePersistence = null, + OfficeInstallStateData? resumeState = null) { if (ingress == null) throw new ArgumentNullException(nameof(ingress)); if (clock == null) throw new ArgumentNullException(nameof(clock)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _resumeState = resumeState; var post = new InformationalEventPost(ingress, clock); _detector = new OfficeInstallDetector(sessionId, tenantId, post, logger, clock, - onInstallationPathObserved: OnInstallationPathObserved); + onInstallationPathObserved: OnInstallationPathObserved, + statePersistence: statePersistence); _processWatcher = new OfficeProcessWatcher(logger, settleSeconds); _processWatcher.Started += OnWorkerStarted; } @@ -96,6 +101,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration _registryWatcher.Start(); } _processWatcher.Start(); + + // Resume a lifecycle left open by a previous agent run (enrollments span reboots) AFTER + // the watchers are armed: the resume surfaces the InstallationPath, which arms the binary + // watcher — when Office finished installing while the agent was down, its initial scan + // completes the lifecycle synchronously and the teardown disposes the watchers again. + if (_resumeState != null) _detector.ResumeActive(_resumeState); } private void OnWorkerStarted(object? sender, EventArgs e) => _detector.OnWorkerStarted();