fix(agent-v2): persist Office install lifecycle state across agent restarts

The OfficeInstallDetector state was in-memory only, so every agent
restart (an enrollment spans several reboots) re-armed the detector
from Idle and all three start triggers fired falsely on an
already-installed Office: Scenario\INSTALL 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) via the binary watcher's
initial scan; a mid-install reboot also reset the start time.

New OfficeInstallStatePersistence (office-install-state.json in the
state directory, fail-soft, atomic write) carries the lifecycle:

- persisted terminal (Completed/Failed) -> the factory does not arm
  the detector at all on the next run (no WMI/RegNotify watchers)
- persisted Active -> the host resumes the open lifecycle without a
  second started; a completion proven in this run reports the true
  total duration plus the persisted DO peak as doSummary (also
  catches completions missed across a mid-install reboot)
- AbandonSilently deliberately does NOT persist a terminal: it fires
  on every dispose and would block that resume
- DO peak growth re-persists throttled (30s) while Active

V2.Core tests 1383/1383 green (14 new); V2 runtime builds clean.
No ConfigVersion bump (no new remote setting); inert until redeploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-12 18:16:36 +02:00
parent 6529d972cd
commit ea4bb1c8ef
6 changed files with 569 additions and 15 deletions
@@ -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<Dictionary<string, object>>(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]
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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));
}
}
}
@@ -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<string?>? _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<string?>? onInstallationPathObserved = null)
Action<string?>? 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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
@@ -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);
}
/// <summary>
/// Persists the lifecycle state (fail-soft). Active and the two terminals are persisted;
/// <see cref="AbandonSilently"/> 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.
/// </summary>
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),
});
}
/// <summary>
@@ -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
{
/// <summary>
/// Persists the <see cref="OfficeInstallDetector"/> 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 <c>Scenario\INSTALL</c> 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).
/// <para>
/// Semantics: <c>Active</c> 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). <c>Completed</c>/<c>Failed</c> mean the terminal event went out — the next run
/// does not arm the detector at all. <see cref="OfficeInstallDetector.AbandonSilently"/> 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.
/// </para>
/// Fail-soft like <c>ImeTrackerStatePersistence</c>: I/O errors never throw; a missing or
/// corrupt file loads as null (fresh start — worst case is today's duplicate-pair behavior).
/// </summary>
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;
/// <summary>Loads the persisted lifecycle state, or null when absent/unreadable.</summary>
public OfficeInstallStateData? Load()
{
if (!File.Exists(_stateFilePath)) return null;
try
{
var json = File.ReadAllText(_stateFilePath);
var state = JsonConvert.DeserializeObject<OfficeInstallStateData>(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;
}
}
/// <summary>Persists the lifecycle state (atomic write via temp file). Never throws.</summary>
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";
/// <summary><see cref="StateActive"/>, <see cref="StateCompleted"/> or <see cref="StateFailed"/>.</summary>
public string? State { get; set; }
public DateTime? StartedAtUtc { get; set; }
public string? StartedTrigger { get; set; }
/// <summary>Highest-bytes DO sample seen — basis for the doSummary on a resumed completion.</summary>
public OfficeDoPeakData? PeakDo { get; set; }
[JsonIgnore]
public bool IsTerminal => State == StateCompleted || State == StateFailed;
}
/// <summary>Persisted mirror of the <see cref="OfficeDoSample"/> fields the completed doSummary needs.</summary>
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,
};
}
}
@@ -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
@@ -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();