diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs index 4ad27ab0..5d9ec9a8 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/SignalAdapters/RealmJoinWatcherAdapterTests.cs @@ -224,6 +224,64 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters Assert.Equal("0", info.Payload["lastExitCode"]); } + [Fact] + public void Package_watchers_stay_disarmed_until_phase_reaches_RunningThreshold() + { + // Pre-RJ window: IME may still be writing to HKLM\SOFTWARE\RealmJoin while the + // RJ service key is already present but DeploymentPhase is still Blank (0). + // Package watchers must NOT arm yet — otherwise IME's in-flight package writes + // would be misattributed to RJ's lifecycle. + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + // Service-appearance fires Detected without a phase value — must NOT arm. + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: null); + Assert.False(f.Watcher.PackageWatchersArmedForTest); + + // Phase = 0 (Blank) — still no arm; RJ is installed but not deploying. + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0); + Assert.False(f.Watcher.PackageWatchersArmedForTest); + + // Phase transitions to 100 (RunningFirstDeployment) — package watchers MUST arm now. + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 100); + Assert.True(f.Watcher.PackageWatchersArmedForTest); + } + + [Fact] + public void Package_watchers_stay_armed_once_phase_subsequently_drops_back_below_threshold() + { + // Set-once semantics — even if a later phase reading were below threshold + // (defensive against partial / racy registry writes), the watcher does not + // disarm and re-introduce a window where post-running events could be lost. + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 100); + Assert.True(f.Watcher.PackageWatchersArmedForTest); + + // Hypothetical regression: phase reads as 0 again — must not flip the flag. + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0); + Assert.True(f.Watcher.PackageWatchersArmedForTest); + } + + [Fact] + public void Package_watchers_arm_immediately_when_first_phase_observed_is_completed() + { + // Agent boots into a session where RJ is already at CompletedFirstDeployment + // (110) — e.g. pre-installed on an image, or recovered after a long absence. + // The first phase observation is already past threshold, so package watchers + // arm in that same notify pass; the historic package rows enumerated by + // CheckMachinePackages then surface as started+completed pairs. + using var f = new Fixture(); + using var adapter = new RealmJoinWatcherAdapter(f.Watcher, f.Ingress, f.Clock); + + f.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 110); + + Assert.True(f.Watcher.PackageWatchersArmedForTest); + // Resolved fires on the same pass. + Assert.Contains(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinResolved); + } + [Fact] public void Watcher_fires_PackageStarted_even_when_DisplayName_is_missing() { diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs index d133b5df..23c7e4e6 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinInfo.cs @@ -66,6 +66,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals // Resolved-phase value per the RJ enum (RealmJoin.Core.SoftwarePackaging.DeploymentPhase). public const int PhaseCompletedFirstDeployment = 110; + /// + /// Minimum DWORD value indicating RJ has moved out of Blank (0) into an active + /// or terminal deployment phase. Below this the SOFTWARE\RealmJoin subtree may still + /// be written by adjacent IME packages — package watchers stay disarmed until RJ owns + /// the keys. + /// + public const int PhaseRunningThresholdMin = 100; + /// /// Map a raw DeploymentPhase DWORD to its RJ-internal enum name. Mirrors /// RealmJoin.Core.SoftwarePackaging.DeploymentPhase as documented at feature diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs index b8e1440f..ea166329 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/RealmJoinWatcher.cs @@ -118,10 +118,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals private readonly HashSet _hkuPackagesCompleted = new HashSet(StringComparer.OrdinalIgnoreCase); private string? _hkuSid; - // Set once the Services\realmjoin gate fires. Until then, HKLM\SOFTWARE\RealmJoin and - // HKU\\SOFTWARE\RealmJoin remain unarmed — RJ presence is the prerequisite for - // looking at those keys at all. - private bool _realmJoinServiceDetected; + // Set once the first non-Blank DeploymentPhase (>= 100) is observed. Package watchers + // (HKLM\SOFTWARE\RealmJoin + HKU\\SOFTWARE\RealmJoin) only arm at that point — + // earlier observation would mix IME's in-flight package writes (which share the same + // subtree) into RJ's lifecycle. + private bool _packageWatchersArmed; private bool _disposed; public event EventHandler? RealmJoinDetected; @@ -151,10 +152,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals /// resolves a real user owner and /// produces the SID. Idempotent. /// - /// The actual HKU watcher only arms once the Services\realmjoin gate fires — - /// either it has already fired (immediate arm) or it fires later - /// (deferred arm from ). On devices - /// without RJ the SID is recorded but no HKU watcher is ever created. + /// The actual HKU watcher only arms once the package-watcher arming threshold has + /// fired (RJ observed at or + /// higher). Either it has already fired (immediate arm) or fires later + /// (deferred arm from ). On devices without RJ + /// or where RJ never leaves Blank, the SID is recorded but no HKU watcher is created. /// /// public void ArmHku(string sid) @@ -167,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals { if (_hkuSid != null) return; _hkuSid = sid; - armNow = _realmJoinServiceDetected; + armNow = _packageWatchersArmed; } _logger.Info($"RealmJoinWatcher: recorded SID {sid} for HKU watching (armNow={armNow})"); @@ -337,7 +339,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals private void AttachServiceRealmjoinSubtreeWatcher() { - bool armUserToo; lock (_stateLock) { if (_serviceRealmjoinWatcher != null) return; @@ -361,23 +362,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals { _logger.Warning($"RealmJoinWatcher: failed to attach Services\\realmjoin watcher: {ex.Message}"); } - - _realmJoinServiceDetected = true; - armUserToo = _hkuSid != null; } // The appearance of Services\realmjoin is itself the RJ-detected signal — - // surface it even if Parameters\DeploymentPhase isn't populated yet. + // surface it even if Parameters\DeploymentPhase isn't populated yet. The package + // watchers (HKLM + HKU SOFTWARE\RealmJoin) intentionally stay disarmed here: + // NotifyRealmJoinPresence arms them once DeploymentPhase >= PhaseRunningThresholdMin + // is observed. Before that the same subtree may still receive in-flight IME + // package writes that are not part of RJ's lifecycle. NotifyRealmJoinPresence(phase: null); CheckParameters(); - - // RJ is now confirmed installed — arm the package watchers. - EnsureMachineRealmJoinStage(); - if (armUserToo) - { - CheckUserPackages(); - EnsureUserRealmJoinStage(); - } } private void AttachMachineRealmJoinSubtreeWatcher() @@ -469,6 +463,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals bool firePhaseChange = false; bool fireResolved = false; int? prevPhase = null; + bool armPackageWatchers = false; + bool armUserPackageWatcherToo = false; lock (_stateLock) { @@ -488,6 +484,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals fireResolved = true; _resolvedFired = true; } + // Arm the package watchers once RJ leaves Blank (phase >= 100). Before that, + // IME may still be writing to HKLM\SOFTWARE\RealmJoin during the pre-RJ + // install window and we'd misattribute those rows to RJ's lifecycle. + if (!_packageWatchersArmed && phase.Value >= RealmJoinInfo.PhaseRunningThresholdMin) + { + _packageWatchersArmed = true; + armPackageWatchers = true; + armUserPackageWatcherToo = _hkuSid != null; + } } } @@ -515,6 +520,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals try { RealmJoinResolved?.Invoke(this, new RealmJoinResolvedEventArgs(phase.Value)); } catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinResolved handler threw", ex); } } + + if (armPackageWatchers) + { + _logger.Info($"RealmJoinWatcher: phase={phase} crossed RunningThreshold — arming package watchers (HKLM + HKU={armUserPackageWatcherToo})"); + EnsureMachineRealmJoinStage(); + if (armUserPackageWatcherToo) + { + CheckUserPackages(); + EnsureUserRealmJoinStage(); + } + } } private void CheckMachinePackages() @@ -649,5 +665,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals internal void TriggerUserPackageObservationFromTest(RealmJoinPackageSnapshot snap) => MaybeFirePackageEvents(RealmJoinPackageScope.User, snap, _hkuPackagesStarted, _hkuPackagesCompleted); + + internal void TriggerNotifyRealmJoinPresenceFromTest(int? phase) => NotifyRealmJoinPresence(phase); + + internal bool PackageWatchersArmedForTest + { + get { lock (_stateLock) { return _packageWatchersArmed; } } + } } }