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 eb3a8a32..0ec18b81 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 @@ -122,7 +122,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office Assert.Equal("Info", Severity(started)); var data = Data(started); - Assert.Equal("Streaming", data["phase"]); + Assert.Equal("Installing", data["phase"]); Assert.Equal("INSTALL", data["scenario"]); Assert.Contains("O365ProPlusRetail", (List)data["products"]); Assert.Equal("Current", data["channel"]); @@ -220,71 +220,45 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office Assert.Equal(2, events.Count); // started + one progress only } - // ---------------------------------------------------------------- completed + // ---------------------------------------------------------------- terminal (conservative v1) [Fact] - public void Worker_stopped_with_streaming_finished_emits_completed_with_version_and_duration() + public void Worker_stopped_emits_no_terminal_event() { + // Conservative v1: the transient worker's exit is NOT install completion — no completed/ + // failed is emitted (field session 8353e03b false-failed). started/progress only. using var rig = new Rig(ActiveStreaming()); - rig.Sut.OnWorkerStarted(); // started - rig.Clock.Advance(TimeSpan.FromSeconds(412)); - rig.Current = CompletedIdle("16.0.17628.20144"); - rig.Sut.OnWorkerStopped(); // completed - - var events = rig.OfficeEvents(); - Assert.Equal(2, events.Count); - var completed = events[1]; - Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, EventType(completed)); - Assert.Equal("Info", Severity(completed)); - - var data = Data(completed); - Assert.Equal("Completed", data["phase"]); - Assert.Equal("16.0.17628.20144", data["versionReached"]); - Assert.Equal(412, data["durationSeconds"]); - } - - [Fact] - public void Terminal_state_latches_no_further_events() - { - using var rig = new Rig(ActiveStreaming()); - - rig.Sut.OnWorkerStarted(); + rig.Sut.OnWorkerStarted(); // started rig.Current = CompletedIdle(); - rig.Sut.OnWorkerStopped(); // completed - rig.Current = ActiveStreaming(); - rig.Sut.OnWorkerStarted(); // must NOT re-trigger (one lifecycle) - rig.Sut.OnRegistryChanged(); + rig.Sut.OnWorkerStopped(); // must NOT emit a terminal var events = rig.OfficeEvents(); - Assert.Equal(2, events.Count); - Assert.Equal(Constants.EventTypes.OfficeInstallCompleted, EventType(events[1])); + Assert.Single(events); + Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(events[0])); } - // ---------------------------------------------------------------- failed - [Fact] - public void Worker_stopped_without_streaming_finished_emits_failed_warning() + public void After_worker_stopped_further_events_are_ignored() { using var rig = new Rig(ActiveStreaming()); - rig.Sut.OnWorkerStarted(); + rig.Sut.OnWorkerStarted(); // started + rig.Sut.OnWorkerStopped(); // stop tracking (no terminal) - rig.Current = new OfficeC2RSnapshot - { - ConfigurationKeyPresent = true, - StreamingFinished = false, - ActiveScenarioPresent = false, - OfficeC2RClientRunning = false, - }; - rig.Sut.OnWorkerStopped(); // failed (abandoned) + // A brand-new worker / registry change must not re-trigger (one lifecycle). + rig.Current = ActiveStreaming(); + rig.Sut.OnWorkerStarted(); + rig.Sut.OnRegistryChanged(); + rig.Sut.OnOfficeDoSample(new OfficeDoSample { JobCount = 1, FileSize = 100, TotalBytesDownloaded = 50 }); var events = rig.OfficeEvents(); - Assert.Equal(2, events.Count); - Assert.Equal(Constants.EventTypes.OfficeInstallFailed, EventType(events[1])); - Assert.Equal("Warning", Severity(events[1])); + Assert.Single(events); + Assert.Equal(Constants.EventTypes.OfficeInstallStarted, EventType(events[0])); } + // ---------------------------------------------------------------- failed (error code only) + [Fact] public void Observed_error_code_during_progress_emits_failed_error_with_code() { 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 b1fe5d79..40f96acb 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 @@ -30,9 +30,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office /// — aggregated Delivery-Optimization stats for Office's CDN /// download (from the extended DeliveryOptimizationCollector). DO has no push API, so it is /// sampled while the worker is alive; a byte advance folds a real download-% into progress. - /// — last worker exited () → - /// emit / - /// with version + duration + DO summary. + /// — last worker exited. NOT treated as completion (the + /// worker is a transient launcher; the C2R service installs on for minutes). Conservative v1: + /// stop tracking, emit no terminal. Completion/failure (via Scenario\INSTALL\TasksState) is + /// deferred to the live-install capture iteration. Only an explicit numeric error code finalizes + /// as . /// /// /// @@ -134,15 +136,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office } } - /// Last Office C2R worker exited — resolve terminal completed / failed. + /// + /// Last Office C2R worker exited. The worker (OfficeC2RClient.exe) is a transient launcher — + /// in the field (session 8353e03b) it lived only ~2s and kicked off the operation, while the + /// OfficeClickToRun.exe service ran the actual install for minutes afterwards. Its exit is + /// therefore NOT install completion. Emitting failed on worker-exit was a false-positive, so + /// we do NOT finalize here — we simply stop tracking and emit nothing. + /// + /// Completion/failure detection is deferred (conservative v1): the registry has NO + /// StreamingFinished value and Scenario\INSTALL persists post-install, so neither + /// is a usable signal. The real completion signal is Scenario\INSTALL\TasksState + /// (per-task TASKSTATE_COMPLETED / _INPROGRESS / failure); the in-progress/failed + /// wording is being captured from a live install before it is wired up — see office-followup. + /// + /// public void OnWorkerStopped() { lock (_lock) { - if (_state != DetectorState.Active) return; - var snap = ReadSnapshotSafe(); - if (snap == null) snap = new OfficeC2RSnapshot(); - Finalize(snap); + if (_state == DetectorState.Active) _state = DetectorState.Terminal; // stop; no terminal event } } @@ -152,9 +164,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office private void EvaluateActive(OfficeC2RSnapshot snap) { + // Conservative v1: the only terminal we trust is an explicit numeric error code. Completion + // is NOT claimed (TasksState-based detection lands with the live-install capture). if (!string.IsNullOrEmpty(snap.ErrorCode)) { - Finalize(snap); + FinalizeFailed(snap); return; } @@ -166,33 +180,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office } } - private void Finalize(OfficeC2RSnapshot snap) + private void FinalizeFailed(OfficeC2RSnapshot snap) { if (_state == DetectorState.Terminal) return; _state = DetectorState.Terminal; - - string eventType; - EventSeverity severity; - string phase; - if (!string.IsNullOrEmpty(snap.ErrorCode)) - { - eventType = Constants.EventTypes.OfficeInstallFailed; severity = EventSeverity.Error; phase = "Failed"; - } - else if (snap.StreamingFinished) - { - eventType = Constants.EventTypes.OfficeInstallCompleted; severity = EventSeverity.Info; phase = "Completed"; - } - else - { - // Worker gone without StreamingFinished → abandoned install. - eventType = Constants.EventTypes.OfficeInstallFailed; severity = EventSeverity.Warning; phase = "Failed"; - } - - EmitLifecycle(eventType, snap, severity, phase, isTerminal: true); + EmitLifecycle(Constants.EventTypes.OfficeInstallFailed, snap, EventSeverity.Error, "Failed", isTerminal: true); } - private static string PhaseOf(OfficeC2RSnapshot snap) - => snap.StreamingFinished ? "Finalizing" : "Streaming"; + // Conservative v1: StreamingFinished does not exist in the C2R registry, so we cannot label + // Streaming vs Finalizing — report a single "Installing" phase until TasksState-based phase + // detection is added. + private static string PhaseOf(OfficeC2RSnapshot snap) => "Installing"; /// /// Stable signature of everything we treat as "progress". A change in any of these (registry @@ -203,8 +201,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Office { var parts = new List { - "phase=" + PhaseOf(snap), - "streaming=" + snap.StreamingFinished, "version=" + (snap.VersionToReport ?? string.Empty), "scenario=" + (snap.ActiveScenarioName ?? string.Empty), }; diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs index c494d52d..bad8ba30 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs @@ -469,16 +469,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic $"{progressCount} progress updates, {matchCount} new matches ({_enrichedAppIds.Count} total enriched)"); } + // Office C2R content CDN hosts — version-independent (a FileId build marker like "_16_0_" would + // break on older/newer Office versions). Field-validated against session 8353e03b: C2R uses BOTH + // the primary CDN officecdn.microsoft.com (registry OriginalCDNDomain) and the content CDN + // f.c2r.ts.cdn.office.net (registry FailoverDomain / PreferredCDNPrefix). + private static readonly string[] OfficeCdnHosts = { "cdn.office.net", "officecdn.microsoft.com" }; + /// - /// True when a DO job's SourceURL points at the Office CDN (Office C2R content), i.e. a job - /// that is not an IME Win32 app. TODO(office-followup): validate the exact SourceURL/FileId - /// shape against a real enrollment's do-status.jsonl and tighten if needed. + /// True when a DO job's SourceURL points at an Office C2R content CDN (not an IME Win32 app). + /// Matched purely by the stable CDN host so it survives Office version changes. /// private static bool IsOfficeCdnJob(string sourceUrl) { if (string.IsNullOrEmpty(sourceUrl)) return false; - return sourceUrl.IndexOf("officecdn", StringComparison.OrdinalIgnoreCase) >= 0 - || sourceUrl.IndexOf("officeclient.microsoft.com", StringComparison.OrdinalIgnoreCase) >= 0; + foreach (var host in OfficeCdnHosts) + if (sourceUrl.IndexOf(host, StringComparison.OrdinalIgnoreCase) >= 0) return true; + return false; } // -----------------------------------------------------------------------