mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
+182
-2
@@ -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]
|
||||
|
||||
+130
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
-4
@@ -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>
|
||||
|
||||
+146
@@ -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
|
||||
|
||||
+13
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user