feat(agent): RealmJoin self-update is observed and reported as realmjoin_autoupdate_detected — realmjoin_detected read the version once from RealmJoin.exe, but the tray AppUpdater often replaces the MSI-shipped binary on its first start minutes after detection (gktatooine 946ccbd6: 4.21.4 -> 4.21.18, whole deployment ran on the new build while the timeline and RealmJoinFacts kept 4.21.4); the updater's only registry write is HKLM\...\Uninstall\RealmJoin (DisplayVersion/InstallDate), so RealmJoinWatcher arms a RegNotifyChangeKeyValue watcher there once Detected fired (appearance-watch on the Uninstall parent until the tray creates the entry), re-reads the binary version after a 2 s debounce (DisplayVersion fallback while the file is locked, channel from baseline) and emits the Info event {previousVersion,newVersion,releaseChannel} plus the typed RealmJoinAutoUpdateDetected signal, whose reducer overrides RealmJoinFacts.ProductVersion/ReleaseChannel (WithVersionOverride) and touches neither gate, deadline nor LastActivityUtc; unreadable baseline at detection is seeded silently; portal device card prefers the updated version ("auto-updated from X"); event registered in Constants/resource-catalog/shared-manifests with replay fixture and watcher/adapter/reducer tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-30 20:02:39 +02:00
parent 251e82a45a
commit 3c8ef02191
17 changed files with 595 additions and 9 deletions
+15 -1
View File
@@ -8,7 +8,7 @@ tags:
- decision-engine
- reducer
- completion
timestamp: 2026-07-11T00:00:00+02:00
timestamp: 2026-08-30T00:00:00+02:00
---
# V2 Agent — Decision Engine (DecisionCore)
@@ -175,6 +175,20 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
The idle case (detected, no deployment activity ever) times out at 60 min
unchanged, and phase 200/210 activity never extends.
**Self-update observation** (gktatooine session 946ccbd6): the version on
`RealmJoinDetected` is read once from `RealmJoin.exe`, but the tray's `AppUpdater`
often replaces the MSI-shipped binary on its first start — minutes after detection,
before the deployment begins. `RealmJoinWatcher` therefore arms a
`RegNotifyChangeKeyValue` watcher on `HKLM\...\Uninstall\RealmJoin` (the only
registry write of the updater; appearance-watch on the `Uninstall` parent when the
entry does not exist yet) once Detected fired, re-reads the binary version after a
2 s debounce (registry `DisplayVersion` as fallback while the file is locked) and
posts the typed `RealmJoinAutoUpdateDetected` signal plus the
`realmjoin_autoupdate_detected` timeline event when the version differs from the
baseline. The reducer overrides `RealmJoinFacts.ProductVersion` / `ReleaseChannel`
(`WithVersionOverride` — the one legitimate later write past the set-once helpers);
it never touches the gate, the deadline, or `LastActivityUtc`. An unreadable
baseline at detection is seeded silently by the first successful re-read.
A closed gate also outranks the 30-minute `AdvisoryCompletion` backstop: that
handler re-arms (trigger suffix `:CompletionGateHolding`) instead of failing while
the RealmJoin gate is closed *and* its own deadline is still armed, because the gate
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-08-30 (13)
* **Update**: `agent/decision-engine.md` — RealmJoin self-update observation. `realmjoin_detected` reported the MSI-shipped build (4.21.4) although the tray auto-updated to 4.21.18 nine minutes later and ran the whole deployment on it (gktatooine session 946ccbd6). `RealmJoinWatcher` now watches `HKLM\...\Uninstall\RealmJoin` (the updater's only registry write) after detection, re-reads the binary version on change and emits `realmjoin_autoupdate_detected` (`previousVersion`, `newVersion`, `releaseChannel`); the typed `RealmJoinAutoUpdateDetected` signal overrides `RealmJoinFacts.ProductVersion`/`ReleaseChannel` and nothing else. Portal device card shows the updated version with an "auto-updated from" note.
## 2026-08-30 (12)
* **Update**: `web/portal-navigation-prefetch.md` — hosting citation now points at `deploy-web.yml`: the portal is built in CI (setup-node 22, `npm ci`, `next build`) and uploaded with the pinned SWA CLI; the Azure/static-web-apps-deploy action, its Oryx in-container build and the per-PR preview environments are gone. All non-GitHub-owned actions are SHA-pinned; the release and rules auto-commit steps use first-party `gh`/`git`.
@@ -600,5 +600,138 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.DoesNotContain(ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinPackageCompleted);
}
// ---- auto-update (Uninstall\RealmJoin) -----------------------------------------------
private sealed class VersionSource
{
public RealmJoinVersionInfo Info = RealmJoinVersionInfo.Empty;
public string? DisplayVersion;
public RealmJoinVersionInfo Read() => Info;
public string? ReadDisplayVersion() => DisplayVersion;
}
private sealed class AutoUpdateHarness : IDisposable
{
private readonly TempDirectory _tmp = new TempDirectory();
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
public VersionSource Versions { get; } = new VersionSource();
public RealmJoinWatcher Watcher { get; }
public RealmJoinWatcherAdapter Adapter { get; }
public AutoUpdateHarness()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var registry = new FakeRealmJoinRegistry();
Watcher = new RealmJoinWatcher(
logger,
enumeratePackageIds: registry.Enumerate,
tryReadPackage: registry.TryRead,
keyExists: registry.KeyExists,
readVersionInfo: Versions.Read,
readUninstallDisplayVersion: Versions.ReadDisplayVersion);
Adapter = new RealmJoinWatcherAdapter(Watcher, Ingress, new VirtualClock(Fixed));
}
public void Dispose()
{
Adapter.Dispose();
Watcher.Dispose();
_tmp.Dispose();
}
}
[Fact]
public void AutoUpdate_fires_once_with_previous_and_new_version_when_binary_version_changes_after_detection()
{
// gktatooine session 946ccbd6: realmjoin_detected read 4.21.4 from the MSI-shipped
// binary; nine minutes later the tray AppUpdater swapped it for 4.21.18 and rewrote
// Uninstall\RealmJoin. The Detected read is the baseline; the re-read after the
// Uninstall-key change must surface exactly one auto-update event.
using var h = new AutoUpdateHarness();
h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary");
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest);
h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary");
h.Watcher.TriggerCheckAutoUpdateFromTest();
var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.4", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]);
Assert.Equal("4.21.18", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ProductVersion]);
Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]);
var info = Assert.Single(h.Ingress.Posted, p =>
p.Kind == DecisionSignalKind.InformationalEvent
&& p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == SharedEventTypes.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.4", info.Payload!["previousVersion"]);
Assert.Equal("4.21.18", info.Payload["newVersion"]);
Assert.Equal("canary", info.Payload["releaseChannel"]);
Assert.Contains("4.21.4 -> 4.21.18", info.Payload[SignalPayloadKeys.Message]);
// Same version again (updater rewrote more values) — no duplicate.
h.Watcher.TriggerCheckAutoUpdateFromTest();
Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest);
}
[Fact]
public void AutoUpdate_stays_silent_when_version_is_unchanged_or_unreadable()
{
using var h = new AutoUpdateHarness();
h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "release");
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
// Unchanged (e.g. the tray creating the Uninstall entry on first start).
h.Watcher.TriggerCheckAutoUpdateFromTest();
// Unreadable (binary locked mid-swap, registry value not there yet) — baseline kept.
h.Versions.Info = RealmJoinVersionInfo.Empty;
h.Versions.DisplayVersion = null;
h.Watcher.TriggerCheckAutoUpdateFromTest();
Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.4", h.Watcher.BaselineVersionForTest);
}
[Fact]
public void AutoUpdate_falls_back_to_Uninstall_DisplayVersion_and_keeps_baseline_channel()
{
using var h = new AutoUpdateHarness();
h.Versions.Info = new RealmJoinVersionInfo("4.21.4", "canary");
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
h.Versions.Info = RealmJoinVersionInfo.Empty; // RealmJoin.exe still held by the updater
h.Versions.DisplayVersion = "4.21.18"; // registry already rewritten
h.Watcher.TriggerCheckAutoUpdateFromTest();
var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.ProductVersion]);
// DisplayVersion carries no channel tag — the Detected channel is the best evidence.
Assert.Equal("canary", signal.Payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel]);
}
[Fact]
public void AutoUpdate_seeds_baseline_silently_when_Detected_could_not_read_the_binary()
{
using var h = new AutoUpdateHarness();
h.Versions.Info = RealmJoinVersionInfo.Empty;
h.Watcher.TriggerNotifyRealmJoinPresenceFromTest(phase: 0);
Assert.Null(h.Watcher.BaselineVersionForTest);
// First successful read has nothing to compare against — no phantom update.
h.Versions.Info = new RealmJoinVersionInfo("4.21.18", "canary");
h.Watcher.TriggerCheckAutoUpdateFromTest();
Assert.DoesNotContain(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.18", h.Watcher.BaselineVersionForTest);
// A later genuine change is still reported against the seeded baseline.
h.Versions.Info = new RealmJoinVersionInfo("4.21.19", "canary");
h.Watcher.TriggerCheckAutoUpdateFromTest();
var signal = Assert.Single(h.Ingress.Posted, p => p.Kind == DecisionSignalKind.RealmJoinAutoUpdateDetected);
Assert.Equal("4.21.18", signal.Payload![DecisionEngine.RealmJoinPayloadKeys.PreviousVersion]);
}
}
}
@@ -102,6 +102,49 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <summary>Defensive fallback for legacy 32-bit installs.</summary>
public const string RealmJoinExePathX86 = @"C:\Program Files (x86)\RealmJoin\RealmJoin.exe";
/// <summary>
/// Always-existing parent of the RJ Add/Remove-Programs entry — the appearance-watch
/// anchor when <see cref="UninstallRealmJoinPath"/> does not exist yet (the tray creates
/// the entry on its first start, after the service key is already present).
/// </summary>
public const string UninstallRootPath = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
/// <summary>Sub-key name of the RJ entry under <see cref="UninstallRootPath"/>.</summary>
public const string UninstallRealmJoinKeyName = "RealmJoin";
/// <summary>
/// RJ's Add/Remove-Programs entry. The in-app updater (<c>RealmJoin.Core.AppUpdater</c>)
/// rewrites this key (<c>DisplayVersion</c>, <c>InstallDate</c>) in the same second it
/// swaps <c>RealmJoin.exe</c> — the only registry write that reflects an RJ self-update.
/// Neither <c>SOFTWARE\RealmJoin</c> nor <c>Services\realmjoin\Parameters</c> carry a version.
/// </summary>
public const string UninstallRealmJoinPath = UninstallRootPath + @"\" + UninstallRealmJoinKeyName;
/// <summary>Bare version string under <see cref="UninstallRealmJoinPath"/>, e.g. <c>4.21.18</c> (no channel tag).</summary>
public const string DisplayVersionValueName = "DisplayVersion";
/// <summary>
/// Best-effort read of <c>DisplayVersion</c> under <see cref="UninstallRealmJoinPath"/>.
/// Fallback for the auto-update watcher when <c>RealmJoin.exe</c> is momentarily locked by
/// the updater; carries no release channel. <c>null</c> when absent or unreadable.
/// </summary>
public static string? TryReadUninstallDisplayVersion()
{
try
{
using (var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
using (var key = baseKey.OpenSubKey(UninstallRealmJoinPath, writable: false))
{
var raw = key?.GetValue(DisplayVersionValueName) as string;
return string.IsNullOrWhiteSpace(raw) ? null : raw!.Trim();
}
}
catch
{
return null;
}
}
/// <summary>
/// Release-channel name reported when the version string carries no SemVer prerelease
/// tag. Per the RJ developer, only beta/canary builds are tagged — untagged == stable.
@@ -35,6 +35,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
/// <summary>
/// RJ replaced its own binary after detection (tray <c>AppUpdater</c> "New version found …,
/// update required" on first start). <see cref="PreviousVersion"/> is the version reported
/// on <see cref="RealmJoinDetectedEventArgs"/> (or the last auto-update), <see cref="NewVersion"/>
/// the version now installed. Observability-only — the deployment lifecycle is unaffected.
/// </summary>
public sealed class RealmJoinAutoUpdateDetectedEventArgs : EventArgs
{
public string PreviousVersion { get; }
public string NewVersion { get; }
/// <summary>Channel of the new build; <c>null</c> when only the registry fallback was readable.</summary>
public string? ReleaseChannel { get; }
public RealmJoinAutoUpdateDetectedEventArgs(string previousVersion, string newVersion, string? releaseChannel)
{
PreviousVersion = previousVersion;
NewVersion = newVersion;
ReleaseChannel = releaseChannel;
}
}
public sealed class RealmJoinResolvedEventArgs : EventArgs
{
public int DeploymentPhase { get; }
@@ -104,13 +125,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
internal delegate IReadOnlyList<string> EnumeratePackageIdsDelegate(RegistryHive hive, string packagesPath);
internal delegate bool TryReadPackageDelegate(RegistryHive hive, string packagesPath, string packageId, out RealmJoinPackageSnapshot snapshot);
internal delegate bool KeyExistsDelegate(RegistryHive hive, string subPath);
internal delegate RealmJoinVersionInfo ReadVersionInfoDelegate();
internal delegate string? ReadUninstallDisplayVersionDelegate();
/// <summary>
/// Debounce for the Uninstall-key watcher. Longer than <see cref="DebounceMilliseconds"/>:
/// the updater rewrites several values and swaps <c>RealmJoin.exe</c> in the same window,
/// so the version re-read should land after the binary is back in place.
/// </summary>
internal const int AutoUpdateDebounceMilliseconds = 2000;
private readonly AgentLogger _logger;
private readonly EnumeratePackageIdsDelegate _enumeratePackageIds;
private readonly TryReadPackageDelegate _tryReadPackage;
private readonly KeyExistsDelegate _keyExists;
private readonly ReadVersionInfoDelegate _readVersionInfo;
private readonly ReadUninstallDisplayVersionDelegate _readUninstallDisplayVersion;
private readonly object _stateLock = new object();
// Auto-update stage (armed once Detected fired, held until Stop).
private RegistryWatcher? _uninstallAppearanceWatcher;
private RegistryWatcher? _uninstallWatcher;
private Timer? _uninstallDebounce;
// Version baseline the next Uninstall-key change is compared against. Seeded from the
// Detected read; null when the binary was unreadable then — the first successful
// re-read seeds it silently instead of reporting a phantom update.
private string? _baselineVersion;
private string? _baselineChannel;
// Stage-1 parent-watchers (disposed on appearance).
private RegistryWatcher? _servicesAppearanceWatcher;
private RegistryWatcher? _machineSoftwareAppearanceWatcher;
@@ -149,6 +191,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
public event EventHandler<RealmJoinPhaseChangedEventArgs>? RealmJoinPhaseChanged;
public event EventHandler<RealmJoinPackageEventArgs>? RealmJoinPackageStarted;
public event EventHandler<RealmJoinPackageEventArgs>? RealmJoinPackageCompleted;
public event EventHandler<RealmJoinAutoUpdateDetectedEventArgs>? RealmJoinAutoUpdateDetected;
public RealmJoinWatcher(AgentLogger logger)
: this(logger, RealmJoinInfo.EnumeratePackageIds, RealmJoinInfo.TryReadPackage, KeyExistsViaRegistry)
@@ -159,12 +202,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
AgentLogger logger,
EnumeratePackageIdsDelegate enumeratePackageIds,
TryReadPackageDelegate tryReadPackage,
KeyExistsDelegate keyExists)
KeyExistsDelegate keyExists,
ReadVersionInfoDelegate? readVersionInfo = null,
ReadUninstallDisplayVersionDelegate? readUninstallDisplayVersion = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_enumeratePackageIds = enumeratePackageIds ?? throw new ArgumentNullException(nameof(enumeratePackageIds));
_tryReadPackage = tryReadPackage ?? throw new ArgumentNullException(nameof(tryReadPackage));
_keyExists = keyExists ?? throw new ArgumentNullException(nameof(keyExists));
_readVersionInfo = readVersionInfo ?? RealmJoinInfo.TryReadRealmJoinVersionInfo;
_readUninstallDisplayVersion = readUninstallDisplayVersion ?? RealmJoinInfo.TryReadUninstallDisplayVersion;
}
public void Start()
@@ -224,14 +271,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
DisposeAndNull(ref _servicesAppearanceWatcher);
DisposeAndNull(ref _machineSoftwareAppearanceWatcher);
DisposeAndNull(ref _userSoftwareAppearanceWatcher);
DisposeAndNull(ref _uninstallAppearanceWatcher);
_serviceRealmjoinDebounce?.Dispose(); _serviceRealmjoinDebounce = null;
_machineRealmJoinDebounce?.Dispose(); _machineRealmJoinDebounce = null;
_userRealmJoinDebounce?.Dispose(); _userRealmJoinDebounce = null;
_uninstallDebounce?.Dispose(); _uninstallDebounce = null;
DisposeAndNull(ref _serviceRealmjoinWatcher);
DisposeAndNull(ref _machineRealmJoinWatcher);
DisposeAndNull(ref _userRealmJoinWatcher);
DisposeAndNull(ref _uninstallWatcher);
}
_logger.Info($"RealmJoinWatcher: stopped ({reason})");
}
@@ -555,13 +605,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
if (fireDetected)
{
var initial = phase ?? 0;
// Read RJ version + release channel best-effort (fail-soft to null) ONLY when
// the Detected event actually fires — single read per session lifetime keeps
// any potential file-system cost to a single hit.
var versionInfo = RealmJoinInfo.TryReadRealmJoinVersionInfo();
// Read RJ version + release channel best-effort (fail-soft to null) when the
// Detected event fires. The result seeds the auto-update baseline; later reads
// only happen when the Uninstall key changes (see CheckAutoUpdate).
var versionInfo = _readVersionInfo();
lock (_stateLock)
{
_baselineVersion = versionInfo.ProductVersion;
_baselineChannel = versionInfo.ReleaseChannel;
}
_logger.Info($"RealmJoinWatcher: RJ detected (phase={initial}, productVersion={versionInfo.ProductVersion ?? "<unknown>"}, releaseChannel={versionInfo.ReleaseChannel ?? "<unknown>"})");
try { RealmJoinDetected?.Invoke(this, new RealmJoinDetectedEventArgs(initial, versionInfo.ProductVersion, versionInfo.ReleaseChannel)); }
catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinDetected handler threw", ex); }
EnsureUninstallStage();
}
if (firePhaseChange && prevPhase.HasValue && phase.HasValue)
@@ -593,6 +650,115 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
// ---- auto-update stage (Uninstall\RealmJoin) ---------------------------------------
/// <summary>
/// Arm the RJ self-update observer. The tray's <c>AppUpdater</c> rewrites
/// <see cref="RealmJoinInfo.UninstallRealmJoinPath"/> whenever it swaps the binary — on a
/// first start with a stale MSI payload this happens minutes after Detected, so the
/// version reported on <see cref="RealmJoinDetected"/> would otherwise be wrong for the
/// rest of the session. Same appearance-then-subtree pattern as the other stages; the
/// entry is created by the tray on first start, i.e. it may not exist at Detected time.
/// </summary>
private void EnsureUninstallStage()
{
if (_disposed) return;
if (_keyExists(RegistryHive.LocalMachine, RealmJoinInfo.UninstallRealmJoinPath))
{
AttachUninstallWatcher();
return;
}
ArmAppearanceParentWatcher(
role: "uninstall",
hive: RegistryHive.LocalMachine,
parentPath: RealmJoinInfo.UninstallRootPath,
targetSubKeyName: RealmJoinInfo.UninstallRealmJoinKeyName,
targetFullPath: RealmJoinInfo.UninstallRealmJoinPath,
slot: w => { lock (_stateLock) { _uninstallAppearanceWatcher = w; } },
clearSlot: () => { lock (_stateLock) { _uninstallAppearanceWatcher = null; } },
onAppeared: AttachUninstallWatcher);
}
private void AttachUninstallWatcher()
{
lock (_stateLock)
{
if (_disposed || _uninstallWatcher != null) return;
try
{
_uninstallDebounce = new Timer(_ => CheckAutoUpdate(), null, Timeout.Infinite, Timeout.Infinite);
_uninstallWatcher = new RegistryWatcher(
RegistryHive.LocalMachine,
RealmJoinInfo.UninstallRealmJoinPath,
watchSubtree: false,
view: RegistryView.Registry64,
filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet,
trace: msg => _logger.Trace($"RealmJoinWatcher(uninstall): {msg}"));
_uninstallWatcher.Changed += (s, e) => _uninstallDebounce?.Change(AutoUpdateDebounceMilliseconds, Timeout.Infinite);
_uninstallWatcher.Error += (s, ex) => _logger.Warning($"RealmJoinWatcher(uninstall): {ex.Message}");
_uninstallWatcher.Start();
_logger.Info("RealmJoinWatcher: attached auto-update watcher on Uninstall\\RealmJoin");
}
catch (Exception ex)
{
_logger.Warning($"RealmJoinWatcher: failed to attach Uninstall\\RealmJoin watcher: {ex.Message}");
}
}
// The key may already carry a newer version than the Detected read (update raced
// the appearance), so sweep once on attach — same as the other stages.
CheckAutoUpdate();
}
/// <summary>
/// Re-read the installed RJ version and fire <see cref="RealmJoinAutoUpdateDetected"/>
/// when it differs from the baseline. Primary source is the binary's file-version
/// resource (carries the channel); the Uninstall key's <c>DisplayVersion</c> is the
/// fallback while the updater still holds the file. Unreadable ⇒ no event, baseline kept.
/// </summary>
private void CheckAutoUpdate()
{
try
{
var info = _readVersionInfo();
var newVersion = info.ProductVersion;
var newChannel = info.ReleaseChannel;
if (string.IsNullOrEmpty(newVersion))
{
newVersion = _readUninstallDisplayVersion();
newChannel = null;
if (string.IsNullOrEmpty(newVersion)) return;
}
string? previousVersion;
string? channelForEvent;
lock (_stateLock)
{
previousVersion = _baselineVersion;
if (previousVersion == null)
{
// Detected could not read the binary — seed silently, nothing to compare.
_baselineVersion = newVersion;
_baselineChannel = newChannel;
return;
}
if (string.Equals(previousVersion, newVersion, StringComparison.OrdinalIgnoreCase)) return;
channelForEvent = newChannel ?? _baselineChannel;
_baselineVersion = newVersion;
_baselineChannel = channelForEvent;
}
_logger.Info($"RealmJoinWatcher: RJ auto-update detected ({previousVersion} -> {newVersion}, releaseChannel={channelForEvent ?? "<unknown>"})");
try { RealmJoinAutoUpdateDetected?.Invoke(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion!, channelForEvent)); }
catch (Exception ex) { _logger.Error("RealmJoinWatcher: RealmJoinAutoUpdateDetected handler threw", ex); }
}
catch (Exception ex)
{
_logger.Warning($"RealmJoinWatcher: CheckAutoUpdate threw: {ex.Message}");
}
}
private void CheckMachinePackages()
{
CheckPackages(
@@ -766,6 +932,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
MaybeFirePackageEvents(RealmJoinPackageScope.User, snap, _hkuPackagesStarted, _hkuPackagesCompleted);
internal void TriggerNotifyRealmJoinPresenceFromTest(int? phase) => NotifyRealmJoinPresence(phase);
internal void TriggerCheckAutoUpdateFromTest() => CheckAutoUpdate();
internal string? BaselineVersionForTest { get { lock (_stateLock) { return _baselineVersion; } } }
// Test seam — directly seed the dedup sets without touching the registry, so that a
// subsequent MaybeFirePackageEvents observation can verify suppression behavior.
@@ -21,6 +21,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
{
private const string SourceLabel = "RealmJoinWatcher";
private const string RegistryKeyHint = @"HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters";
private const string UninstallRegistryKeyHint = @"HKLM\" + RealmJoinInfo.UninstallRealmJoinPath;
private readonly RealmJoinWatcher _watcher;
private readonly ISignalIngressSink _ingress;
@@ -39,6 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_watcher.RealmJoinPhaseChanged += OnPhaseChanged;
_watcher.RealmJoinPackageStarted += OnPackageStarted;
_watcher.RealmJoinPackageCompleted += OnPackageCompleted;
_watcher.RealmJoinAutoUpdateDetected += OnAutoUpdateDetected;
}
public void Dispose()
@@ -48,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_watcher.RealmJoinPhaseChanged -= OnPhaseChanged;
_watcher.RealmJoinPackageStarted -= OnPackageStarted;
_watcher.RealmJoinPackageCompleted -= OnPackageCompleted;
_watcher.RealmJoinAutoUpdateDetected -= OnAutoUpdateDetected;
}
// Test seams
@@ -57,6 +60,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
internal void TriggerPhaseChangedFromTest(int prev, int curr) => OnPhaseChanged(this, new RealmJoinPhaseChangedEventArgs(prev, curr));
internal void TriggerPackageStartedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageStarted(this, new RealmJoinPackageEventArgs(scope, snap));
internal void TriggerPackageCompletedFromTest(string scope, RealmJoinPackageSnapshot snap) => OnPackageCompleted(this, new RealmJoinPackageEventArgs(scope, snap));
internal void TriggerAutoUpdateDetectedFromTest(string previousVersion, string newVersion, string? releaseChannel) =>
OnAutoUpdateDetected(this, new RealmJoinAutoUpdateDetectedEventArgs(previousVersion, newVersion, releaseChannel));
private void OnDetected(object? sender, RealmJoinDetectedEventArgs e)
{
@@ -177,6 +182,56 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
occurredAtUtc: _clock.UtcNow);
}
private void OnAutoUpdateDetected(object? sender, RealmJoinAutoUpdateDetectedEventArgs e)
{
// Dual-emission: (1) the typed signal overrides the persisted version facts so
// final-status / replay carry the build that ran the deployment; (2) the timeline
// entry makes the self-update visible next to realmjoin_detected.
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = e.PreviousVersion,
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = e.NewVersion,
};
if (!string.IsNullOrEmpty(e.ReleaseChannel))
{
payload[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = e.ReleaseChannel!;
}
_ingress.Post(
kind: DecisionSignalKind.RealmJoinAutoUpdateDetected,
occurredAtUtc: _clock.UtcNow,
sourceOrigin: SourceLabel,
evidence: new Evidence(
kind: EvidenceKind.Derived,
identifier: "realmjoin-autoupdate-detected-v1",
summary: $"RealmJoin self-update observed ({e.PreviousVersion} -> {e.NewVersion}, releaseChannel={e.ReleaseChannel ?? "<unknown>"})",
derivationInputs: new Dictionary<string, string>(StringComparer.Ordinal)
{
["registryKey"] = UninstallRegistryKeyHint,
}),
payload: payload);
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["previousVersion"] = e.PreviousVersion,
["newVersion"] = e.NewVersion,
["registryKey"] = UninstallRegistryKeyHint,
};
if (!string.IsNullOrEmpty(e.ReleaseChannel))
{
data["releaseChannel"] = e.ReleaseChannel!;
}
var channelTail = string.IsNullOrEmpty(e.ReleaseChannel) ? string.Empty : $", channel={e.ReleaseChannel}";
_post.Emit(
eventType: SharedConstants.EventTypes.RealmJoinAutoUpdateDetected,
source: SourceLabel,
message: $"RealmJoin auto-updated {e.PreviousVersion} -> {e.NewVersion}{channelTail}",
severity: EventSeverity.Info,
immediateUpload: true,
data: data,
occurredAtUtc: _clock.UtcNow);
}
private void OnPackageStarted(object? sender, RealmJoinPackageEventArgs e)
{
var displayName = TruncateDisplayName(e.DisplayName);
@@ -837,5 +837,73 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.True(data.ContainsKey("realmjoinDetectedUtc"));
Assert.True(data.ContainsKey("realmjoinResolvedUtc"));
}
[Fact]
public void RealmJoinAutoUpdateDetected_overrides_version_facts_without_touching_gate_or_deadline()
{
var engine = new DecisionEngine();
var state = PrimeClassicAwaitingDesktop(engine);
var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0",
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4",
[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "canary",
}));
var deadlineBefore = Assert.Single(detected.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
// Channel omitted (registry DisplayVersion fallback) — the Detected channel must survive.
var updated = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4",
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.18",
}));
Assert.Null(updated.Transition.DeadEndReason);
Assert.Equal("4.21.18", updated.NewState.RealmJoinFacts.ProductVersion!.Value);
Assert.Equal("canary", updated.NewState.RealmJoinFacts.ReleaseChannel!.Value);
Assert.Equal(detected.NewState.Stage, updated.NewState.Stage);
Assert.False(DecisionEngine.RealmJoinGateOpen(updated.NewState));
var deadlineAfter = Assert.Single(updated.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
Assert.Equal(deadlineBefore.DueAtUtc, deadlineAfter.DueAtUtc);
Assert.Null(updated.NewState.RealmJoinFacts.LastActivityUtc);
Assert.Empty(updated.Effects);
// Channel supplied — overrides too.
var again = engine.Reduce(updated.NewState, MakeSignal(7, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(15),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.18",
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.22.0",
[DecisionEngine.RealmJoinPayloadKeys.ReleaseChannel] = "release",
}));
Assert.Equal("4.22.0", again.NewState.RealmJoinFacts.ProductVersion!.Value);
Assert.Equal("release", again.NewState.RealmJoinFacts.ReleaseChannel!.Value);
}
[Fact]
public void RealmJoinAutoUpdateDetected_without_version_dead_ends_and_keeps_facts()
{
var engine = new DecisionEngine();
var state = PrimeClassicAwaitingDesktop(engine);
var detected = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0",
[DecisionEngine.RealmJoinPayloadKeys.ProductVersion] = "4.21.4",
}));
var step = engine.Reduce(detected.NewState, MakeSignal(6, DecisionSignalKind.RealmJoinAutoUpdateDetected, T0.AddMinutes(14),
new Dictionary<string, string>
{
[DecisionEngine.RealmJoinPayloadKeys.PreviousVersion] = "4.21.4",
}));
Assert.Equal("realmjoin_autoupdate_missing_version", step.Transition.DeadEndReason);
Assert.Equal("4.21.4", step.NewState.RealmJoinFacts.ProductVersion!.Value);
}
}
}
@@ -128,6 +128,7 @@ export const EVENT_TYPES_CATALOG = {
'realmjoin_first_deployment_incomplete',
'realmjoin_package_started',
'realmjoin_package_completed',
'realmjoin_autoupdate_detected',
],
deployment_events: [
'modern_deployment_log',
@@ -55,6 +55,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
// — the SemVer prerelease tag, absent tag == stable release).
public const string ProductVersion = "productVersion";
public const string ReleaseChannel = "releaseChannel";
// Set on RealmJoinAutoUpdateDetected: the version the facts carried before the
// self-update (ProductVersion/ReleaseChannel then describe the NEW build).
public const string PreviousVersion = "previousVersion";
}
/// <summary>
@@ -387,6 +390,47 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// the <c>realmjoin_first_deployment_incomplete</c> Warning, cancels the hard-timeout
/// deadline and releases the completion gate — for us RJ is finished at that point.
/// </summary>
/// <summary>
/// RJ self-update observed after detection. Overrides the set-once version facts so
/// <see cref="RealmJoinFacts.ProductVersion"/> / <see cref="RealmJoinFacts.ReleaseChannel"/>
/// describe the build that actually ran the deployment. Pure bookkeeping: no gate, no
/// deadline, no activity credit (the update precedes the deployment, it is not
/// deployment progress). Missing <c>productVersion</c> dead-ends.
/// </summary>
private DecisionStep HandleRealmJoinAutoUpdateDetectedV1(DecisionState state, DecisionSignal signal)
{
var newVersion = TryReadString(signal, RealmJoinPayloadKeys.ProductVersion);
if (string.IsNullOrEmpty(newVersion))
{
var deadEnd = BumpStepBookkeeping(state, signal);
var deadEndTransition = BuildDeadEndTransition(
state: state,
signal: signal,
nextStepIndex: deadEnd.StepIndex,
trigger: nameof(DecisionSignalKind.RealmJoinAutoUpdateDetected),
deadEndReason: "realmjoin_autoupdate_missing_version");
return new DecisionStep(deadEnd, deadEndTransition, Array.Empty<DecisionEffect>());
}
var nextStep = state.StepIndex + 1;
var builder = state.ToBuilder()
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
builder.RealmJoinFacts = state.RealmJoinFacts.WithVersionOverride(
newVersion!,
TryReadString(signal, RealmJoinPayloadKeys.ReleaseChannel),
signal.SessionSignalOrdinal);
var bookkept = builder.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: state.Stage,
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.RealmJoinAutoUpdateDetected));
return new DecisionStep(bookkept, transition, Array.Empty<DecisionEffect>());
}
private DecisionStep HandleRealmJoinPhaseChangedV1(DecisionState state, DecisionSignal signal)
{
var currentPhase = TryReadPhase(signal);
@@ -144,6 +144,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
(DecisionSignalKind.RealmJoinDetected, 1) => HandleRealmJoinDetectedV1(state, signal),
(DecisionSignalKind.RealmJoinResolved, 1) => HandleRealmJoinResolvedV1(state, signal),
(DecisionSignalKind.RealmJoinPhaseChanged, 1) => HandleRealmJoinPhaseChangedV1(state, signal),
(DecisionSignalKind.RealmJoinAutoUpdateDetected, 1) => HandleRealmJoinAutoUpdateDetectedV1(state, signal),
(DecisionSignalKind.RealmJoinPackageStarted, 1) => HandleRealmJoinPackageStartedV1(state, signal),
(DecisionSignalKind.RealmJoinPackageCompleted, 1) => HandleRealmJoinPackageCompletedV1(state, signal),
// Note: RealmJoinTimeout is a deadline-only kind (no direct adapter signal) —
@@ -125,5 +125,11 @@ namespace AutopilotMonitor.DecisionCore.Signals
// the run as a secondary-user deployment, which writes 200/210 and NEVER 110 — the
// gate would otherwise stay closed until the 60-min hard timeout.
RealmJoinPhaseChanged,
// RJ replaced its own binary after detection (tray AppUpdater on first start: "New
// version found …, update required" → service restarts on the new build minutes after
// RealmJoinDetected captured the MSI-shipped version). Bookkeeping-only: overrides
// RealmJoinFacts.ProductVersion/ReleaseChannel so the persisted facts match what
// actually ran the deployment. Never touches the gate or the timeout deadline.
RealmJoinAutoUpdateDetected,
}
}
@@ -167,6 +167,29 @@ namespace AutopilotMonitor.DecisionCore.State
lastActivityUtc: LastActivityUtc);
}
/// <summary>
/// Auto-update override of the set-once version facts (<see cref="WithProductVersion"/> /
/// <see cref="WithReleaseChannel"/> deliberately refuse replays of Detected; a self-update
/// is the one legitimate later write). Empty <paramref name="productVersion"/> is a no-op;
/// a <c>null</c> <paramref name="releaseChannel"/> keeps the existing channel fact.
/// </summary>
public RealmJoinFacts WithVersionOverride(string productVersion, string? releaseChannel, long sourceSignalOrdinal)
{
if (string.IsNullOrEmpty(productVersion)) return this;
return new RealmJoinFacts(
detectedUtc: DetectedUtc,
resolvedUtc: ResolvedUtc,
lastDeploymentPhase: LastDeploymentPhase,
outcome: Outcome,
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
productVersion: new SignalFact<string>(productVersion, sourceSignalOrdinal),
releaseChannel: string.IsNullOrEmpty(releaseChannel)
? ReleaseChannel
: new SignalFact<string>(releaseChannel!, sourceSignalOrdinal),
packages: Packages,
lastActivityUtc: LastActivityUtc);
}
public RealmJoinFacts WithResolved(DateTime utc, int phase, long sourceSignalOrdinal)
{
if (ResolvedUtc != null) return this; // set-once
@@ -547,6 +547,11 @@ namespace AutopilotMonitor.Shared
public const string RealmJoinFirstDeploymentIncomplete = "realmjoin_first_deployment_incomplete";
public const string RealmJoinPackageStarted = "realmjoin_package_started";
public const string RealmJoinPackageCompleted = "realmjoin_package_completed";
// Info: RJ replaced its own binary after realmjoin_detected (tray AppUpdater on
// first start). Data: previousVersion, newVersion, releaseChannel. Source of truth
// for the version that ran the deployment — realmjoin_detected carries the
// MSI-shipped build.
public const string RealmJoinAutoUpdateDetected = "realmjoin_autoupdate_detected";
// =====================================================================
// Single-source consolidation (2026-05-29): event-type string literals
@@ -21,6 +21,7 @@ interface ProxyConfigData { [key: string]: unknown; proxyType?: string; type?: s
interface AutopilotProfileData { [key: string]: unknown; PolicyDownloadDate?: string; AutopilotCreationDate?: string; autopilotModeLabel?: string; domainJoinMethodLabel?: string }
interface ImeVersionData { [key: string]: unknown; version?: string; agentVersion?: string }
interface RealmJoinInfoData { [key: string]: unknown; productVersion?: string; releaseChannel?: string }
interface RealmJoinAutoUpdateData { [key: string]: unknown; previousVersion?: string; newVersion?: string; releaseChannel?: string }
interface TpmStatusData { [key: string]: unknown; specVersion?: string }
interface BitLockerStatusData { [key: string]: unknown; volumes?: unknown[] }
interface DeviceLocationData { [key: string]: unknown; country?: string; Country?: string; timezone?: string; Timezone?: string }
@@ -189,7 +190,17 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
session?.isCloudPc === true || `${autopilotProfileMissingEvent?.isCloudPc}` === "true";
const aadJoinStatus = getEventData("aad_join_status");
const imeVersion = getEventData<ImeVersionData>("ime_agent_version");
const realmJoinInfo = getEventData<RealmJoinInfoData>("realmjoin_detected");
// realmjoin_detected carries the MSI-shipped build; the tray often self-updates minutes later
// (realmjoin_autoupdate_detected). The latest auto-update is the version that ran the deployment.
const realmJoinDetected = getEventData<RealmJoinInfoData>("realmjoin_detected");
const realmJoinAutoUpdate = getEventData<RealmJoinAutoUpdateData>("realmjoin_autoupdate_detected");
const realmJoinInfo: RealmJoinInfoData | null = realmJoinAutoUpdate?.newVersion
? { productVersion: realmJoinAutoUpdate.newVersion, releaseChannel: realmJoinAutoUpdate.releaseChannel ?? realmJoinDetected?.releaseChannel }
: realmJoinDetected;
const realmJoinInitialVersion =
realmJoinAutoUpdate?.newVersion && realmJoinDetected?.productVersion && realmJoinDetected.productVersion !== realmJoinAutoUpdate.newVersion
? realmJoinDetected.productVersion
: null;
const bitLockerStatus = getEventData<BitLockerStatusData>("bitlocker_status");
const secureBootStatus = getEventData("secureboot_status");
const tpmStatus = getEventData<TpmStatusData>("tpm_status");
@@ -478,9 +489,10 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session
<DetailRow
label="RealmJoin Agent Version"
value={
realmJoinInfo.releaseChannel && realmJoinInfo.releaseChannel !== "release"
(realmJoinInfo.releaseChannel && realmJoinInfo.releaseChannel !== "release"
? `${realmJoinInfo.productVersion} (${realmJoinInfo.releaseChannel})`
: realmJoinInfo.productVersion
: realmJoinInfo.productVersion!) +
(realmJoinInitialVersion ? ` — auto-updated from ${realmJoinInitialVersion}` : "")
}
/>
)}
@@ -623,6 +623,7 @@ export const SHARED_MANIFEST = {
"prior_run_died_with_state",
"provisioning_package_scan",
"proxy_configuration",
"realmjoin_autoupdate_detected",
"realmjoin_detected",
"realmjoin_first_deployment_incomplete",
"realmjoin_package_completed",
@@ -619,6 +619,7 @@
"prior_run_died_with_state",
"provisioning_package_scan",
"proxy_configuration",
"realmjoin_autoupdate_detected",
"realmjoin_detected",
"realmjoin_first_deployment_incomplete",
"realmjoin_package_completed",
@@ -0,0 +1,7 @@
# RealmJoinAutoUpdateDetected v1 — RealmJoin self-update after detection.
# Posted by RealmJoinWatcherAdapter when the HKLM\...\Uninstall\RealmJoin key changes and the
# re-read RealmJoin.exe version differs from the one reported on RealmJoinDetected (tray
# AppUpdater on first start; gktatooine session 946ccbd6: 4.21.4 -> 4.21.18 nine minutes after
# detection). Reducer: HandleRealmJoinAutoUpdateDetectedV1 → overrides
# RealmJoinFacts.ProductVersion / ReleaseChannel. Bookkeeping-only: no gate, no deadline.
{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"RealmJoinAutoUpdateDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-08-30T13:33:27.0000000Z","SourceOrigin":"RealmJoinWatcher","Evidence":{"Kind":"Derived","Identifier":"realmjoin-autoupdate-detected-v1","Summary":"RealmJoin self-update observed (4.21.4 -> 4.21.18, releaseChannel=canary)","DerivationInputs":{"registryKey":"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\RealmJoin"}},"Payload":{"previousVersion":"4.21.4","productVersion":"4.21.18","releaseChannel":"canary"}}